diff --git a/src/ClientConfig.ts b/src/ClientConfig.ts index 044b05a..efe0163 100644 --- a/src/ClientConfig.ts +++ b/src/ClientConfig.ts @@ -27,7 +27,7 @@ export interface ClientConfig { paymasterAPI?: PaymasterAPI /** - * The address of the factory contract that will deploy the account contract. + * The address of the factory contract that will deploy account contracts. * If this is provided, it will be used directly. * If not provided, the factory address will be fetched from the factory manager contract. */ @@ -39,4 +39,12 @@ export interface ClientConfig { * If both factoryAddress and factoryManagerAddress are not provided, an error will be thrown. */ factoryManagerAddress?: string + + /** + * The address of the CREATE2 deployer contract used for deterministic contract deployments. + * This contract provides low-level deployment functionality through Account Abstraction. + * Can be set globally here or overridden per deployment operation. + * If not provided here, must be specified in individual deployment calls. + */ + deployContractAddress?: string } diff --git a/src/ContractDeployer.ts b/src/ContractDeployer.ts new file mode 100644 index 0000000..31bd728 --- /dev/null +++ b/src/ContractDeployer.ts @@ -0,0 +1,301 @@ +import { BigNumber, Contract, ContractFactory, ethers } from 'ethers'; +import { hexlify } from 'ethers/lib/utils'; +import { ERC4337EthersProvider } from './ERC4337EthersProvider'; +import { + DeploymentError, + DeploymentErrorType, + DeploymentInput, + DeploymentResult, +} from './types/deployment'; +import Debug from 'debug'; + +const debug = Debug('aa.deploy'); + +// CREATE2 deployer contract ABI - only the methods we need +const DEPLOYER_ABI = [ + 'function deploy(bytes memory bytecode, bytes32 salt) public returns (address)', + 'function computeAddress(bytes32 bytecodeHash, bytes32 salt, address deployer) public pure returns (address)', +]; + +// Default salt to use when none is provided +const DEFAULT_SALT = '0x0000000000000000000000000000000000000000000000000000000000000001'; + +interface BundlerEstimation { + callGasLimit: number; + preVerificationGas: number; + verificationGas: number; + success: boolean; + error?: string; +} + +/** + * ContractDeployer handles smart contract deployments through a CREATE2 deployer contract + * using Account Abstraction. All deployments go through the EntryPoint. + */ +export class ContractDeployer { + private deployerContract: Contract; + + constructor( + private deployContractAddress: string, + private provider: ERC4337EthersProvider, + private signer: ethers.Signer + ) { + // Use the AA provider's signer instead of the original signer + this.deployerContract = new Contract(deployContractAddress, DEPLOYER_ABI, this.provider.getSigner()); + } + + /** + * Deploy a contract using either ContractFactory or raw bytecode + */ + async deploy( + input: DeploymentInput, + salt: string = DEFAULT_SALT, + options?: { gasLimit?: BigNumber } + ): Promise { + try { + // Get bytecode based on input type + const bytecode = this._getBytecode(input); + this._validateBytecode(bytecode); + + // Get the deployment transaction data + const deployData = this.deployerContract.interface.encodeFunctionData('deploy', [bytecode, salt]); + + // Create UserOperation details + const userOpDetails = { + target: this.deployContractAddress, + data: deployData, + value: 0, + ...(options?.gasLimit && { gasLimit: options.gasLimit }) + }; + + debug('Creating UserOperation for deployment:', { + target: this.deployContractAddress, + dataLength: deployData.length, + hasGasLimit: !!options?.gasLimit + }); + + // Create and sign the UserOperation using the smart account + const userOperation = await this.provider.smartAccountAPI.createSignedUserOp(userOpDetails); + + debug('UserOperation created:', { + sender: userOperation.sender, + nonce: userOperation.nonce.toString(), + initCode: userOperation.initCode, + callData: typeof userOperation.callData === 'string' && userOperation.callData.length > 100 ? + userOperation.callData.substring(0, 100) + '...' : + userOperation.callData, + callGasLimit: userOperation.callGasLimit.toString(), + verificationGasLimit: userOperation.verificationGasLimit.toString() + }); + + // Get transaction response for tracking + const transactionResponse = await this.provider.constructUserOpTransactionResponse(userOperation); + + // Fire and forget - let wallet handle tracking + this.provider.httpRpcClient.sendUserOpToBundler(userOperation); + debug('UserOperation submitted to bundler'); + + return { + transactionHash: transactionResponse.hash + }; + } catch (error: any) { + debug('Deployment preparation failed:', { + error: error.message, + code: error.code, + type: error.type + }); + throw error; + } + } + + private async _verifyContract() { + // Check network first + const network = await this.provider.getNetwork(); + debug('Verifying network configuration:', { + chainId: network.chainId, + name: network.name, + ensAddress: network.ensAddress + }); + + // Check if contract exists + const code = await this.provider.getCode(this.deployContractAddress); + debug('Verifying deployer contract:', { + address: this.deployContractAddress, + hasCode: code !== '0x', + codeLength: code.length, + codeSample: code === '0x' ? '0x' : code.substring(0, 64) + '...' + }); + + if (code === '0x') { + throw new DeploymentError( + DeploymentErrorType.INVALID_DEPLOYER, + `No contract found at address ${this.deployContractAddress} on network ${network.chainId}` + ); + } + + // Try to encode function calls to verify interface + try { + const testBytecode = '0x1234'; + const testSalt = '0x0000000000000000000000000000000000000000000000000000000000000001'; + + // Test deploy function + const deployData = this.deployerContract.interface.encodeFunctionData('deploy', [testBytecode, testSalt]); + + // Test computeAddress function + const computeData = this.deployerContract.interface.encodeFunctionData('computeAddress', [ + ethers.utils.keccak256(testBytecode), + testSalt, + this.deployContractAddress + ]); + + debug('Verifying deployer contract interface:', { + address: this.deployContractAddress, + hasCode: code !== '0x', + functions: { + deploy: deployData.substring(0, 64) + '...', + computeAddress: computeData.substring(0, 64) + '...' + } + }); + + // Try to make a static call to verify the function exists + const staticContract = new Contract(this.deployContractAddress, DEPLOYER_ABI, this.provider); + await staticContract.callStatic.computeAddress( + ethers.utils.keccak256(testBytecode), + testSalt, + this.deployContractAddress + ); + } catch (error) { + debug('Contract interface verification failed:', error); + throw new DeploymentError( + DeploymentErrorType.INVALID_DEPLOYER, + `Contract at ${this.deployContractAddress} does not match expected interface` + ); + } + } + + /** + * Predict the address where a contract will be deployed + */ + async predictAddress( + input: DeploymentInput, + salt: string = DEFAULT_SALT + ): Promise { + const bytecode = this._getBytecode(input); + this._validateBytecode(bytecode); + + // Verify contract first + await this._verifyContract(); + + const bytecodeHash = ethers.utils.keccak256(bytecode); + + debug('Predicting deployment address:', { + bytecodeHash, + salt, + factoryAddress: this.deployContractAddress, + chainId: (await this.provider.getNetwork()).chainId + }); + + // Create a contract instance with the provider (not signer) for static calls + const staticContract = new Contract(this.deployContractAddress, DEPLOYER_ABI, this.provider); + + // Make a static call to computeAddress + try { + return await staticContract.callStatic.computeAddress( + bytecodeHash, + salt, + this.deployContractAddress // The factory itself is the deployer for CREATE2 + ); + } catch (error) { + debug('Address prediction failed:', error); + throw error; + } + } + + /** + * Validate bytecode format + */ + private _validateBytecode(bytecode: string): void { + if (!bytecode || !bytecode.startsWith('0x')) { + throw new DeploymentError( + DeploymentErrorType.INVALID_BYTECODE, + 'Invalid bytecode format' + ); + } + } + + /** + * Get bytecode from input + */ + private _getBytecode(input: DeploymentInput): string { + try { + if (input instanceof ContractFactory) { + return input.bytecode; + } + + const { bytecode, constructorArgs } = input; + if (!constructorArgs || constructorArgs.length === 0) { + return bytecode; + } + + // Create temporary factory to encode constructor args + const factory = new ContractFactory( + ['constructor(...args)'], // Minimal ABI just for constructor + bytecode, + this.signer + ); + return factory.getDeployTransaction(...constructorArgs).data as string; + } catch (error) { + throw new DeploymentError( + DeploymentErrorType.INVALID_BYTECODE, + 'Failed to process contract bytecode', + error as Error + ); + } + } + + /** + * Estimate gas for contract deployment + * Uses the same code path as actual deployment to ensure accuracy + */ + async estimateGas( + input: DeploymentInput, + salt: string = DEFAULT_SALT + ): Promise { + try { + // Get bytecode based on input type + const bytecode = this._getBytecode(input); + this._validateBytecode(bytecode); + + // Get the deployment transaction data + const deployData = this.deployerContract.interface.encodeFunctionData('deploy', [bytecode, salt]); + + // Create UserOperation details for estimation + const userOpDetails = { + target: this.deployContractAddress, + data: deployData, + value: 0 + }; + + debug('Estimating gas for deployment:', { + target: this.deployContractAddress, + dataLength: deployData.length + }); + + // Use the smart account's internal estimation + const { callGasLimit } = await this.provider.smartAccountAPI.encodeUserOpCallDataAndGasLimit(userOpDetails); + + debug('Gas estimation completed:', { + callGasLimit: callGasLimit.toString() + }); + + return callGasLimit; + } catch (error: any) { + debug('Gas estimation failed:', { + error: error.message, + code: error.code, + type: error.type + }); + throw error; + } + } +} \ No newline at end of file diff --git a/src/deploymentHelpers.ts b/src/deploymentHelpers.ts new file mode 100644 index 0000000..2cf804a --- /dev/null +++ b/src/deploymentHelpers.ts @@ -0,0 +1,108 @@ +import { BigNumber, ContractFactory, ethers } from 'ethers'; +import { ERC4337EthersProvider } from './ERC4337EthersProvider'; +import { ContractDeployer } from './ContractDeployer'; +import { DeploymentInput } from './types/deployment'; + +/** + * Create a ContractDeployer instance from an AA provider + */ +export function createContractDeployer( + provider: ERC4337EthersProvider, + deployContractAddress: string +): ContractDeployer { + return new ContractDeployer( + deployContractAddress, + provider, + provider.getSigner() + ); +} + +/** + * Deploy a contract using either ContractFactory or raw bytecode + * @param provider The AA provider to use for deployment + * @param input The deployment input (bytecode or ContractFactory) + * @param salt Optional salt for deterministic address (defaults to a standard value) + * @param deployContractAddress Optional address of the deployment contract (if not provided, uses the one from provider config) + * @param options Additional options like gasLimit + * @returns The transaction hash of the deployment + * @throws {Error} If deployContractAddress is not provided and not available in provider config + */ +export async function deployContract( + provider: ERC4337EthersProvider, + input: DeploymentInput, + salt?: string, + deployContractAddress?: string, + options?: { gasLimit?: BigNumber } +): Promise { + const address = deployContractAddress ?? provider.config.deployContractAddress; + if (!address) { + throw new Error('deployContractAddress must be provided either directly or in provider config'); + } + + const deployer = new ContractDeployer( + address, + provider, + provider.getSigner() + ); + + const result = await deployer.deploy(input, salt, options); + return result.transactionHash; +} + +/** + * Predict the address where a contract will be deployed + * @param provider The AA provider to use for prediction + * @param input The deployment input (bytecode or ContractFactory) + * @param salt Optional salt for deterministic address (defaults to a standard value) + * @param deployContractAddress Optional address of the deployment contract (if not provided, uses the one from provider config) + * @returns The predicted address where the contract will be deployed + * @throws {Error} If deployContractAddress is not provided and not available in provider config + */ +export async function predictContractAddress( + provider: ERC4337EthersProvider, + input: DeploymentInput, + salt?: string, + deployContractAddress?: string +): Promise { + const address = deployContractAddress ?? provider.config.deployContractAddress; + if (!address) { + throw new Error('deployContractAddress must be provided either directly or in provider config'); + } + + const deployer = new ContractDeployer( + address, + provider, + provider.getSigner() + ); + + return deployer.predictAddress(input, salt); +} + +/** + * Estimate gas for contract deployment + * @param provider The AA provider to use for estimation + * @param input The deployment input (bytecode or ContractFactory) + * @param salt Optional salt for deterministic address (defaults to a standard value) + * @param deployContractAddress Optional address of the deployment contract (if not provided, uses the one from provider config) + * @returns Estimated gas amount including AA overhead + * @throws {Error} If deployContractAddress is not provided and not available in provider config + */ +export async function estimateDeploymentGas( + provider: ERC4337EthersProvider, + input: DeploymentInput, + salt?: string, + deployContractAddress?: string +): Promise { + const address = deployContractAddress ?? provider.config.deployContractAddress; + if (!address) { + throw new Error('deployContractAddress must be provided either directly or in provider config'); + } + + const deployer = new ContractDeployer( + address, + provider, + provider.getSigner() + ); + + return deployer.estimateGas(input, salt); +} \ No newline at end of file diff --git a/src/types/deployment.ts b/src/types/deployment.ts new file mode 100644 index 0000000..ce721b1 --- /dev/null +++ b/src/types/deployment.ts @@ -0,0 +1,76 @@ +import { BigNumber, ContractFactory } from 'ethers'; + +/** + * Input for contract deployment operations + * Can be either raw bytecode with optional constructor args + * or an ethers ContractFactory instance + */ +export type DeploymentInput = + | { bytecode: string; constructorArgs?: any[] } + | ContractFactory; + +/** + * Contract information for deployment + */ +export interface ContractInfo { + /** Contract bytecode */ + bytecode: string; + /** Optional contract ABI - only needed if you want a Contract instance returned */ + abi?: any[]; + /** Optional constructor arguments */ + constructorArgs?: any[]; +} + +/** + * Options for contract deployment operations + */ +export interface DeploymentOptions { + /** Salt for CREATE2 deployment - if not provided, a deterministic salt will be used */ + salt?: string; + /** Optional constructor arguments for the contract */ + constructorArgs?: any[]; +} + +/** + * Result of a contract deployment operation + */ +export interface DeploymentResult { + /** Transaction hash of the deployment transaction */ + transactionHash: string; +} + +/** + * Gas estimation result for contract deployment + */ +export interface DeploymentGasEstimate { + /** Estimated gas required for deployment */ + gasLimit: BigNumber; + /** Estimated gas price */ + gasPrice: BigNumber; + /** Total estimated cost (gasLimit * gasPrice) */ + totalCost: BigNumber; +} + +/** + * Error types specific to contract deployment + */ +export enum DeploymentErrorType { + /** When the provided bytecode is invalid */ + INVALID_BYTECODE = 'INVALID_BYTECODE', + /** When CREATE2 deployer contract is invalid/not found */ + INVALID_DEPLOYER = 'INVALID_DEPLOYER' +} + +/** + * Custom error class for deployment-related errors + */ +export class DeploymentError extends Error { + constructor( + public type: DeploymentErrorType, + message: string, + public originalError?: Error + ) { + super(message); + this.name = 'DeploymentError'; + } +} \ No newline at end of file