diff --git a/eslint.config.mjs b/eslint.config.mjs index 31953fdb..0fcbde37 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -6,4 +6,4 @@ import tseslint from 'typescript-eslint'; export default tseslint.config( eslint.configs.recommended, tseslint.configs.recommended, -); \ No newline at end of file +); diff --git a/package.json b/package.json index 6c0643a7..829fe190 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,9 @@ "onCommand:ethcode.contract.call", "onCommand:ethcode.transaction.gas.set", "onCommand:ethcode.transaction.gas.prices", - "onCommand:ethcode.rental.create" + "onCommand:ethcode.rental.create", + "onCommand:ethcode.foundry.load", + "onCommand:ethcode.hardhat.load" ], "main": "./extension/build/extension.js", "extensionDependencies": [ @@ -157,6 +159,14 @@ { "command": "ethcode.createERC4907Contract", "title": "Ethcode: Create ERC4907 Contract" + }, + { + "command": "ethcode.foundry.load", + "title": "Ethcode: Load Foundry Contracts" + }, + { + "command": "ethcode.hardhat.load", + "title": "Ethcode: Load Hardhat Contracts" } ], "keybindings": [ diff --git a/src/extension.ts b/src/extension.ts index 330dc5e9..cf8d5ed3 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -18,13 +18,18 @@ import { import { createERC4907Contract, parseBatchCompiledJSON, + parseFoundryCompiledJSON, + parseHardhatCompiledJSON, parseCompiledJSONPayload, - selectContract + selectContract, + parseFoundryConfig } from './utils' +import { isFoundryProject, isHardhatProject } from './utils/functions' import { provider, status, wallet, contract } from './api' import { events } from './api/events' import { event } from './api/api' import { type API } from './types' +import * as toml from 'toml' export async function activate (context: ExtensionContext): Promise { const disposables = [ @@ -154,6 +159,24 @@ export async function activate (context: ExtensionContext): Promise { logger.success('Welcome to Ethcode!') + }), + + // Load Foundry contracts + commands.registerCommand('ethcode.foundry.load', async () => { + try { + await parseFoundryCompiledJSON(context) + } catch (error) { + logger.error(`Error loading Foundry contracts: ${error}`) + } + }), + + // Load Hardhat contracts + commands.registerCommand('ethcode.hardhat.load', async () => { + try { + await parseHardhatCompiledJSON(context) + } catch (error) { + logger.error(`Error loading Hardhat contracts: ${error}`) + } }) ] @@ -235,25 +258,61 @@ export async function activate (context: ExtensionContext): Promise { + // Create dynamic file watcher based on project type + const createDynamicWatcher = async () => { + const watchPatterns: string[] = [] + + try { + // Check for Foundry project using centralized parser + const foundryConfig = await parseFoundryConfig() + if (foundryConfig) { + const { outDir } = foundryConfig + watchPatterns.push(`${outDir}/**/*.json`) + logger.log(`Foundry project detected, watching: ${outDir}/**/*.json`) + } + + // Check for Hardhat project + if (isHardhatProject(path_[0].uri.fsPath)) { + watchPatterns.push('artifacts/**/*.json') + logger.log('Hardhat project detected, watching: artifacts/**/*.json') + } + + // Fallback patterns for other frameworks + if (watchPatterns.length === 0) { + watchPatterns.push('{artifacts, build, out, cache, out-*/**/*.json}') + logger.log('No specific framework detected, using fallback patterns') + } + + } catch (error) { + logger.error(`Error setting up file watcher: ${error}`) + // Fallback to original pattern + watchPatterns.push('{artifacts, build, out, cache, out-*/**/*.json}') + } + + // Create watcher with dynamic patterns + return workspace.createFileSystemWatcher( + new RelativePattern(path_[0].uri.fsPath, watchPatterns.join(',')) + ) + } + + const watcher = await createDynamicWatcher() + + watcher.onDidCreate(async (uri: any) => { await parseBatchCompiledJSON(context) const contracts = context.workspaceState.get('contracts') as string[] if (contracts === undefined || contracts.length === 0) return [] event.contracts.fire(Object.keys(contracts)) }) - watcher.onDidChange(async (uri) => { + watcher.onDidChange(async (uri: any) => { await parseBatchCompiledJSON(context) const contracts = context.workspaceState.get('contracts') as string[] if (contracts === undefined || contracts.length === 0) return [] event.contracts.fire(Object.keys(contracts)) }) - watcher.onDidDelete(async (uri) => { + watcher.onDidDelete(async (uri: any) => { const contracts = context.workspaceState.get('contracts') as string[] if (contracts === undefined || contracts.length === 0) return [] event.contracts.fire(Object.keys(contracts)) diff --git a/src/types/output.ts b/src/types/output.ts index f28dd9aa..d69e8792 100644 --- a/src/types/output.ts +++ b/src/types/output.ts @@ -1,4 +1,5 @@ import { type AbiItem } from './types' +import { logger } from '../lib' export interface HardHatCompiledOutput { contractName: string @@ -18,6 +19,40 @@ export interface RemixCompiledOutput { abi: readonly AbiItem[] } +export interface FoundryCompiledOutput { + abi: readonly AbiItem[] + bytecode: { + object: string + sourceMap: string + linkReferences: Record>> + } + deployedBytecode: { + object: string + sourceMap: string + linkReferences: Record>> + } + metadata: string + ir: string + irOptimized: string + storageLayout: any + evm: { + assembly: string + bytecode: { + object: string + sourceMap: string + linkReferences: Record>> + } + deployedBytecode: { + object: string + sourceMap: string + linkReferences: Record>> + } + methodIdentifiers: Record + gasEstimates: any + } + ewasm: any +} + interface GasEstimate { confidence: number maxFeePerGas: number @@ -34,9 +69,10 @@ export interface GasEstimateOutput { export interface CompiledJSONOutput { name?: string // contract name path?: string // local path of the contract - contractType: number // 0: null, 1: hardhat output, 2: remix output + contractType: number // 0: null, 1: hardhat output, 2: remix output, 3: foundry output hardhatOutput?: HardHatCompiledOutput remixOutput?: RemixCompiledOutput + foundryOutput?: FoundryCompiledOutput } export const getAbi = (output: CompiledJSONOutput): any => { @@ -44,7 +80,11 @@ export const getAbi = (output: CompiledJSONOutput): any => { if (output.contractType === 1) return output.hardhatOutput?.abi - return output.remixOutput?.abi + if (output.contractType === 2) return output.remixOutput?.abi + + if (output.contractType === 3) return output.foundryOutput?.abi + + return [] } export const getByteCode = ( @@ -59,21 +99,43 @@ export const getByteCode = ( return bytecode.startsWith('0x') ? bytecode : `0x${bytecode}` } - // Remix format - const bytecode = output.remixOutput?.data.bytecode.object - if (!bytecode) { - console.log('Remix bytecode is undefined or null') - return undefined + if (output.contractType === 2) { + // Remix format + const bytecode = output.remixOutput?.data.bytecode.object + if (!bytecode) { + logger.log('Remix bytecode is undefined or null') + return undefined + } + + logger.log(`Original Remix bytecode: ${bytecode.substring(0, 20)}...`) + logger.log(`Bytecode starts with 0x: ${bytecode.startsWith('0x')}`) + + // Ensure 0x prefix for Remix format + const result = bytecode.startsWith('0x') ? bytecode : `0x${bytecode}` + logger.log(`Final bytecode: ${result.substring(0, 20)}...`) + + return result } - - console.log(`Original Remix bytecode: ${bytecode.substring(0, 20)}...`) - console.log(`Bytecode starts with 0x: ${bytecode.startsWith('0x')}`) - - // Ensure 0x prefix for Remix format - const result = bytecode.startsWith('0x') ? bytecode : `0x${bytecode}` - console.log(`Final bytecode: ${result.substring(0, 20)}...`) - - return result + + if (output.contractType === 3) { + // Foundry format + const bytecode = output.foundryOutput?.bytecode.object + if (!bytecode) { + logger.log('Foundry bytecode is undefined or null') + return undefined + } + + logger.log(`Original Foundry bytecode: ${bytecode.substring(0, 20)}...`) + logger.log(`Bytecode starts with 0x: ${bytecode.startsWith('0x')}`) + + // Ensure 0x prefix for Foundry format + const result = bytecode.startsWith('0x') ? bytecode : `0x${bytecode}` + logger.log(`Final Foundry bytecode: ${result.substring(0, 20)}...`) + + return result + } + + return undefined } export interface BytecodeObject { diff --git a/src/utils/contracts.ts b/src/utils/contracts.ts index 9196161f..e23458e6 100644 --- a/src/utils/contracts.ts +++ b/src/utils/contracts.ts @@ -4,6 +4,88 @@ import * as fs from 'fs' import * as toml from 'toml' import { logger } from '../lib' import { type CompiledJSONOutput, type IFunctionQP } from '../types' + +/** + * Centralized Foundry configuration parser + * Reads foundry.toml from the current workspace and extracts configuration + */ +const parseFoundryConfig = async (): Promise<{ outDir: string; config: any } | null> => { + try { + if (!workspace.workspaceFolders || workspace.workspaceFolders.length === 0) { + return null + } + + const workspacePath = workspace.workspaceFolders[0].uri.fsPath + const foundryConfigPath = path.join(workspacePath, 'foundry.toml') + + if (!fs.existsSync(foundryConfigPath)) { + return null + } + + const configFile = fs.readFileSync(foundryConfigPath, 'utf8') + + // Try to parse with the TOML library first + let foundryConfig: any + try { + foundryConfig = toml.parse(configFile) + } catch (tomlError) { + // If TOML parsing fails, try a simple regex-based approach for just the output directory + logger.log(`TOML parsing failed, using fallback parser: ${tomlError}`) + foundryConfig = parseFoundryConfigFallback(configFile) + } + + // Extract output directory with proper precedence + let outDir = 'out' // default Foundry output directory + + if (foundryConfig.profile && foundryConfig.profile.default && foundryConfig.profile.default.out) { + outDir = foundryConfig.profile.default.out + } else if (foundryConfig.out) { + outDir = foundryConfig.out + } + + logger.log(`Foundry configuration loaded: output directory = ${outDir}`) + + return { + outDir, + config: foundryConfig + } + } catch (error) { + logger.error(`Error parsing foundry.toml: ${error}`) + return null + } +} + +/** + * Fallback parser for foundry.toml when TOML library fails + * Uses regex to extract just the output directory + */ +const parseFoundryConfigFallback = (configContent: string): any => { + const config: any = {} + + // Look for [profile.default] section + const profileDefaultMatch = configContent.match(/\[profile\.default\][\s\S]*?(?=\[|$)/) + if (profileDefaultMatch) { + const profileSection = profileDefaultMatch[0] + + // Extract out directory from profile.default section + const outMatch = profileSection.match(/out\s*=\s*['"]([^'"]+)['"]/) + if (outMatch) { + config.profile = { + default: { + out: outMatch[1] + } + } + } + } + + // Also check for root-level out setting + const rootOutMatch = configContent.match(/^out\s*=\s*['"]([^'"]+)['"]/m) + if (rootOutMatch && !config.profile) { + config.out = rootOutMatch[1] + } + + return config +} import { createERC4907ContractFile, createERC4907ContractInterface, @@ -19,6 +101,283 @@ import { } from './functions' import { ERC4907ContractUrls } from '../contracts/ERC4907/ERC4907' +/** + * Parse Foundry compiled JSON contracts specifically + * This function is dedicated to loading Foundry compiled contracts with proper error handling + */ +const parseFoundryCompiledJSON = async (context: ExtensionContext): Promise => { + if (workspace.workspaceFolders === undefined) { + logger.error(new Error('Please open your solidity project to vscode')) + return + } + + logger.log('Loading compiled contracts...') + + try { + // Check if this is a Foundry project + const isFoundry = await isFoundryProject() + if (!isFoundry) { + logger.error(new Error('This is not a Foundry project. Please run this command in a Foundry project directory or ensure foundry.toml exists.')) + return + } + + // Parse foundry.toml configuration + const foundryConfig = await parseFoundryConfig() + if (!foundryConfig) { + logger.error(new Error('Foundry configuration file (foundry.toml) not found. Please ensure this is a valid Foundry project.')) + return + } + + const { outDir } = foundryConfig + logger.log(`Foundry output directory: ${outDir}`) + + // Check if output directory exists + const path_ = workspace.workspaceFolders[0].uri.fsPath + const outPath = path.join(path_, outDir) + + if (!fs.existsSync(outPath)) { + logger.error(new Error(`Foundry output directory '${outDir}' not found. Please compile your contracts first using 'forge build'`)) + return + } + + // Initialize contracts storage + void context.workspaceState.update('contracts', {}) + + // Load all JSON files from the output directory + const jsonFiles = getDirectoriesRecursive(outPath, 0) + logger.log(`Found ${jsonFiles.length} total JSON files in ${outDir} directory`) + + const contractFiles = jsonFiles.filter((filePath: string) => { + const fileName = path.parse(filePath).base + const nameWithoutExt = fileName.substring(0, fileName.length - 5) + + // Skip files with dots in name (library files) + if (nameWithoutExt.includes('.')) { + logger.log(`Skipping ${fileName}: Library file (contains dots)`) + return false + } + + // Skip files that are likely not contracts (build info, cache, etc.) + if (nameWithoutExt.length === 16 && /^[a-f0-9]{16}$/.test(nameWithoutExt)) { + // This looks like a hash/ID file, likely not a contract + logger.log(`Skipping ${fileName}: Hash/ID file (16-character hex)`) + return false + } + + // Skip common non-contract files + const skipPatterns = ['build-info', 'cache', 'metadata', 'storage-layout'] + if (skipPatterns.some(pattern => fileName.toLowerCase().includes(pattern))) { + logger.log(`Skipping ${fileName}: Non-contract file (contains ${skipPatterns.find(p => fileName.toLowerCase().includes(p))})`) + return false + } + + return true + }) + + if (contractFiles.length === 0) { + logger.error(new Error(`No compiled contract files found in '${outDir}' directory. Please compile your contracts using 'forge build'`)) + return + } + + logger.log(`Found ${contractFiles.length} compiled contract files (filtered from ${jsonFiles.length} total JSON files)`) + + // Parse each contract file + let loadedContracts = 0 + for (const filePath of contractFiles) { + try { + const fileName = path.parse(filePath).base + const contractName = fileName.substring(0, fileName.length - 5) + + logger.log(`Parsing Foundry contract: ${contractName}`) + + const fileData = fs.readFileSync(filePath, 'utf8') + const jsonData = JSON.parse(fileData) + + // Validate Foundry format - check for both possible structures + if (!jsonData.abi) { + logger.log(`Skipping ${contractName}: Missing ABI field`) + continue + } + + // Check if this is a Foundry format (has evm field) or Hardhat format (has bytecode field) + if (!jsonData.evm && !jsonData.bytecode) { + logger.log(`Skipping ${contractName}: Not a valid compiled contract format (missing evm or bytecode)`) + continue + } + + // Determine contract type based on format + let contractType: number + let outputData: any + + if (jsonData.evm) { + // Foundry format + contractType = 3 + outputData = jsonData + } else if (jsonData.bytecode) { + // Hardhat format - convert to Foundry format for compatibility + contractType = 3 + outputData = { + abi: jsonData.abi, + bytecode: { + object: jsonData.bytecode, + sourceMap: '', + linkReferences: {} + }, + deployedBytecode: { + object: jsonData.deployedBytecode || jsonData.bytecode, + sourceMap: '', + linkReferences: {} + }, + evm: { + bytecode: { + object: jsonData.bytecode, + sourceMap: '', + linkReferences: {} + }, + deployedBytecode: { + object: jsonData.deployedBytecode || jsonData.bytecode, + sourceMap: '', + linkReferences: {} + } + } + } + } else { + logger.log(`Skipping ${contractName}: Unknown contract format`) + continue + } + + const output: CompiledJSONOutput = { + contractType: contractType, + foundryOutput: outputData, + path: path.dirname(filePath), + name: contractName + } + + // Store contract in workspace state + let contracts: any = context.workspaceState.get('contracts') + if (!contracts) contracts = {} + + contracts[contractName] = output + void context.workspaceState.update('contracts', contracts) + + const formatType = jsonData.evm ? 'Foundry' : 'Hardhat' + logger.success(`Successfully loaded ${formatType} contract: ${contractName}`) + loadedContracts++ + + } catch (parseError) { + logger.error(new Error(`Failed to parse contract file ${filePath}: ${parseError}`)) + } + } + + if (loadedContracts === 0) { + logger.error(new Error('No valid compiled contracts were loaded. Please check your compilation output.')) + } else { + logger.success(`Successfully loaded ${loadedContracts} compiled contracts`) + } + + } catch (error) { + logger.error(new Error(`Error loading Foundry contracts: ${error}`)) + } +} + +/** + * Parse Hardhat compiled JSON contracts specifically + * This function is dedicated to loading Hardhat compiled contracts with proper error handling + */ +const parseHardhatCompiledJSON = async (context: ExtensionContext): Promise => { + if (workspace.workspaceFolders === undefined) { + logger.error(new Error('Please open your solidity project to vscode')) + return + } + + logger.log('Loading Hardhat compiled contracts...') + + try { + const path_ = workspace.workspaceFolders[0].uri.fsPath + + // Check if this is a Hardhat project + if (!isHardhatProject(path_)) { + logger.error(new Error('This is not a Hardhat project. Please run this command in a Hardhat project directory.')) + return + } + + // Check if artifacts directory exists + const artifactsPath = path.join(path_, 'artifacts', 'contracts') + if (!fs.existsSync(artifactsPath)) { + logger.error(new Error("Hardhat artifacts directory not found. Please compile your contracts first using 'npx hardhat compile'")) + return + } + + // Initialize contracts storage + void context.workspaceState.update('contracts', {}) + + // Load all JSON files from the artifacts directory + const jsonFiles = getDirectoriesRecursive(artifactsPath, 0) + const contractFiles = jsonFiles.filter((filePath: string) => { + const fileName = path.parse(filePath).base + const nameWithoutExt = fileName.substring(0, fileName.length - 5) + // Filter out files with dots in name (avoiding library files) + return !nameWithoutExt.includes('.') + }) + + if (contractFiles.length === 0) { + logger.error(new Error("No compiled contract files found in 'artifacts/contracts' directory. Please compile your contracts using 'npx hardhat compile'")) + return + } + + logger.log(`Found ${contractFiles.length} compiled contract files`) + + // Parse each contract file + let loadedContracts = 0 + for (const filePath of contractFiles) { + try { + const fileName = path.parse(filePath).base + const contractName = fileName.substring(0, fileName.length - 5) + + logger.log(`Parsing Hardhat contract: ${contractName}`) + + const fileData = fs.readFileSync(filePath, 'utf8') + const jsonData = JSON.parse(fileData) + + // Validate Hardhat format + if (!jsonData.bytecode) { + logger.log(`Skipping ${contractName}: Not a valid Hardhat compiled contract format`) + continue + } + + const output: CompiledJSONOutput = { + contractType: 1, // Hardhat format + hardhatOutput: jsonData, + path: path.dirname(filePath), + name: contractName + } + + // Store contract in workspace state + let contracts: any = context.workspaceState.get('contracts') + if (!contracts) contracts = {} + + contracts[contractName] = output + void context.workspaceState.update('contracts', contracts) + + logger.success(`Successfully loaded Hardhat contract: ${contractName}`) + loadedContracts++ + + } catch (parseError) { + logger.error(new Error(`Failed to parse contract file ${filePath}: ${parseError}`)) + } + } + + if (loadedContracts === 0) { + logger.error(new Error('No valid Hardhat contracts were loaded. Please check your compilation output.')) + } else { + logger.success(`Successfully loaded ${loadedContracts} Hardhat contracts`) + } + + } catch (error) { + logger.error(new Error(`Error loading Hardhat contracts: ${error}`)) + } +} + const parseBatchCompiledJSON = async (context: ExtensionContext): Promise => { if (workspace.workspaceFolders === undefined) { logger.error(new Error('Please open your solidity project to vscode')) @@ -92,10 +451,15 @@ const getCompiledJsonObject = (_jsonPayload: any): CompiledJSONOutput => { output.contractType = 2 output.remixOutput = data logger.log('Loaded Remix compiled json output.') + } else if (data.abi !== undefined && data.evm !== undefined) { + // Foundry format + output.contractType = 3 + output.foundryOutput = data + logger.log('Loaded Foundry compiled json output.') } } catch (e) { // eslint-disable-next-line no-console - console.log(e) + logger.error(e) } return output @@ -109,13 +473,22 @@ const loadAllCompiledJsonOutputs: any = async (path_: string) => { let allFiles if (await isFoundryProject()) { - const foundryConfigFile = await workspace.findFiles('**/foundry.toml', '**/{node_modules,lib}/**') - const file = await workspace.fs.readFile(foundryConfigFile[0]) - const foundryConfig = toml.parse(file.toString()) - allFiles = getDirectoriesRecursive( - path.join(path_, foundryConfig.profile.default.out), // for USDC project output dir is artifacts/foundry - 0 - ) + const foundryConfig = await parseFoundryConfig() + if (foundryConfig) { + const { outDir } = foundryConfig + logger.log(`Foundry output directory: ${outDir}`) + allFiles = getDirectoriesRecursive( + path.join(path_, outDir), + 0 + ) + } else { + // Fallback to default Foundry output directory + logger.log('Foundry project detected but no foundry.toml found, using default output directory') + allFiles = getDirectoriesRecursive( + path.join(path_, 'out'), + 0 + ) + } } else if (isHardhatProject(path_)) { allFiles = getDirectoriesRecursive( path.join(path_, 'artifacts', 'contracts'), @@ -213,7 +586,10 @@ const createERC4907Contract: any = async (context: ExtensionContext) => { export { parseBatchCompiledJSON, + parseFoundryCompiledJSON, + parseHardhatCompiledJSON, parseCompiledJSONPayload, selectContract, - createERC4907Contract + createERC4907Contract, + parseFoundryConfig } diff --git a/test_files/foundry/foundry.toml b/test_files/foundry/foundry.toml new file mode 100644 index 00000000..ead57fac --- /dev/null +++ b/test_files/foundry/foundry.toml @@ -0,0 +1,15 @@ +[profile.default] +src = "src" +out = "out" +libs = ["lib"] +solc = "0.8.19" + +[profile.default.fuzz] +runs = 1000 + +[profile.default.invariant] +runs = 1000 +depth = 15 +fail_on_revert = false + +# See more config options https://github.com/foundry-rs/foundry/blob/master/crates/config/README.md#all-options \ No newline at end of file diff --git a/test_files/foundry/src/Counter.sol b/test_files/foundry/src/Counter.sol new file mode 100644 index 00000000..e51cef12 --- /dev/null +++ b/test_files/foundry/src/Counter.sol @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.19; + +contract Counter { + uint256 public number; + + function setNumber(uint256 newNumber) public { + number = newNumber; + } + + function increment() public { + number++; + } +} \ No newline at end of file