Skip to content

Instantly share code, notes, and snippets.

@MaksimDrozd
Created January 27, 2026 15:00
Show Gist options
  • Select an option

  • Save MaksimDrozd/620023dbea322faae336eade4af51454 to your computer and use it in GitHub Desktop.

Select an option

Save MaksimDrozd/620023dbea322faae336eade4af51454 to your computer and use it in GitHub Desktop.
#!/usr/bin/env ts-node
/**
* Complete CrossCurve bridge flow script
*
* This script demonstrates the full flow from CrossCurve API configuration
* to building calldata, creating user operations, and sending them to the chain.
*
* Swap: Native ETH from Base mainnet to Native ETH on Arbitrum mainnet
*
* Usage:
* ts-node scripts/crosscurve-full-flow.ts
*
* Required environment variables or config:
* - OWNER_PRIVATE_KEY: Private key of the AA wallet owner (EOA)
* - AA_ADDRESS: Smart Account (Kernel) address
* - BUNDLER_RPC_ARBITRUM: Bundler RPC URL for Arbitrum (if using bundler)
* - ENTRYPOINT_OWNER_PRIVATE_KEY: Private key for EntryPoint owner (if not using bundler)
*/
import axios from 'axios';
import { BigNumber, constants, ethers } from 'ethers';
// ============================================================================
// CONFIGURATION SECTION
// ============================================================================
const CONFIG = {
// CrossCurve API Configuration
crossCurve: {
baseUrl: process.env.CROSSCURVE_BASE_URL || 'https://pusher-cdp.x.ubtk.dev',
apiKey:
process.env.CROSSCURVE_API_KEY || 'test-sdk-test-sdk-test-sdk-standard',
endpoints: {
routing: '/routing/scan',
txCreate: '/tx/create',
},
defaultSlippage: 0.5,
},
// Chain Configuration
chains: {
base: {
chainId: 8453, // Base mainnet
rpc:
process.env.BASE_RPC ||
'https://go.getblock.io/e5bafd5ef9d546ef85521b5b3a09f27a',
nativeToken: constants.AddressZero, // Native ETH
nativeSymbol: 'ETH',
nativeDecimals: 18,
},
arbitrum: {
chainId: 42161, // Arbitrum One mainnet
rpc:
process.env.ARBITRUM_RPC ||
'https://go.getblock.io/3eb2408d7595404cac0fdc18823f09c3',
nativeToken: constants.AddressZero, // Native ETH
nativeSymbol: 'ETH',
nativeDecimals: 18,
},
optimism: {
chainId: 10, // Optimism mainnet
rpc:
process.env.OPTIMISM_RPC ||
'https://go.getblock.io/c515386aaa4944d58fb10697b46819b5',
nativeToken: constants.AddressZero, // Native ETH
nativeSymbol: 'ETH',
nativeDecimals: 18,
},
},
// Account Abstraction Configuration
aa: {
// TODO: Set your AA address here or via env
aaAddress:
process.env.AA_ADDRESS || '0x176f9d5cd42303f9Bb0a1DfEE5A36fE5Eb2F1A9f', // Smart Account (Kernel) address
// TODO: Set owner private key here or via env
ownerPrivateKey:
process.env.OWNER_PRIVATE_KEY ||
'0xe4e5602ceaae714dadbd3d46b9c51f843ab265d5f53ab66ec20e30d46737870d', // EOA private key that owns the AA
// Entry Point Configuration
entryPointAddress:
process.env.ENTRYPOINT_ADDRESS ||
'0x0000000071727De22E5E9d8BAf0edAc6f37da032', // ERC-4337 EntryPoint v0.7
// Nonce key (random number for nonce management)
nonceKey: Math.floor(Math.random() * Number.MAX_SAFE_INTEGER),
},
// Swap Configuration
swap: {
amountIn: process.env.SWAP_AMOUNT || '0.0001', // Amount in ETH (will be converted to wei)
tokenIn: constants.AddressZero, // Native ETH
tokenOut: constants.AddressZero, // Native ETH
slippage: 0.5, // 0.5%
},
// Gas Configuration (from fee.constants.ts)
gas: {
callGasLimit: '0x1E8480', // 2000000 (increased for complex bridge operations)
verificationGasLimit: '0x249f0', // 150000
preVerificationGas: '0x30d40', // 200000
},
// EntryPoint Configuration
entryPoint: {
// TODO: Set private key for sending handleOps transaction
// This should be a wallet that has ETH to pay for gas
ownerPrivateKey: process.env.ENTRYPOINT_OWNER_PRIVATE_KEY,
},
} as const;
// ============================================================================
// TYPE DEFINITIONS
// ============================================================================
interface CrossChainSwapConfig {
chainIdIn: number;
chainIdOut: number;
tokenIn: string;
tokenOut: string;
amountIn: string;
slippage: number;
}
interface RoutingScanBody {
from: string;
params: {
chainIdIn: number;
chainIdOut: number;
tokenIn: string;
tokenOut: string;
amountIn: string;
};
slippage: number;
}
interface RoutingScanResult {
route: Array<{
type: string;
limits?: { min: string; max: string };
}>;
deliveryFee?: { amount: string };
signature?: string;
[key: string]: any;
}
interface CreateTxParams {
from: string;
recipient: string;
routing: RoutingScanResult & { signature: string };
buildCalldata?: boolean;
}
interface StartPopulatedTx {
to: string;
abi: string;
args: unknown[];
value: string;
}
interface SimpleTx {
to: string;
value: string;
data: string;
}
type CreateTxResponse = StartPopulatedTx | SimpleTx;
interface SignaturePack {
executionPrice: string;
deadline: string;
v: string;
r: string;
s: string;
}
interface KernelCallDataParams {
toAddress: string;
amount: string;
data: string;
}
interface IUserOperation {
sender: string;
nonce: string;
callData: string;
callGasLimit: string;
verificationGasLimit: string;
preVerificationGas: string;
maxFeePerGas: string;
maxPriorityFeePerGas: string;
signature: string;
initCode?: string;
factory?: string | null;
factoryData?: string;
paymasterAndData?: string;
}
interface IPackedUserOperation {
sender: string;
nonce: string;
initCode: string;
callData: string;
accountGasLimits: string;
preVerificationGas: string;
gasFees: string;
paymasterAndData: string;
signature: string;
}
// ============================================================================
// UTILITY FUNCTIONS
// ============================================================================
function validateConfig(): void {
const errors: string[] = [];
if (!CONFIG.aa.aaAddress) {
errors.push('AA_ADDRESS is required');
}
if (!CONFIG.aa.ownerPrivateKey) {
errors.push('OWNER_PRIVATE_KEY is required (for signing user operation)');
}
if (!CONFIG.entryPoint.ownerPrivateKey) {
errors.push(
'ENTRYPOINT_OWNER_PRIVATE_KEY is required (for sending handleOps transaction)',
);
}
if (errors.length > 0) {
console.error('Configuration errors:');
errors.forEach((err) => console.error(` - ${err}`));
console.error(
'\nPlease set the required environment variables or update CONFIG in the script.',
);
process.exit(1);
}
}
// ============================================================================
// CROSSCURVE API CLIENT
// ============================================================================
class CrossCurveAPIClient {
private baseUrl: string;
private apiKey: string;
constructor(baseUrl: string, apiKey: string) {
this.baseUrl = baseUrl;
this.apiKey = apiKey;
}
private getHeaders(): Record<string, string> {
return {
'Content-Type': 'application/json',
'api-key': this.apiKey,
};
}
async getRoutes(
config: CrossChainSwapConfig,
from: string,
): Promise<RoutingScanResult[]> {
console.log(
`[CrossCurve] Getting routes: ${config.chainIdIn} -> ${config.chainIdOut}`,
);
console.log(
`[CrossCurve] Amount: ${config.amountIn}, Token: ${config.tokenIn}`,
);
const requestBody: RoutingScanBody = {
from,
params: {
chainIdIn: config.chainIdIn,
chainIdOut: config.chainIdOut,
tokenIn: config.tokenIn,
tokenOut: config.tokenOut,
amountIn: config.amountIn,
},
slippage: config.slippage,
};
const url = `${this.baseUrl}${CONFIG.crossCurve.endpoints.routing}`;
const response = await axios.post<RoutingScanResult[]>(url, requestBody, {
headers: this.getHeaders(),
});
console.log(`[CrossCurve] Found ${response.data.length} routes`);
return response.data;
}
async createTransaction(
route: RoutingScanResult,
fromAddress: string,
buildCalldata = false,
): Promise<CreateTxResponse> {
console.log(
`[CrossCurve] Creating transaction, buildCalldata=${buildCalldata}`,
);
if (!route.signature) {
throw new Error('Route signature is required');
}
const requestBody: CreateTxParams = {
from: fromAddress,
recipient: fromAddress,
routing: route as RoutingScanResult & { signature: string },
buildCalldata,
};
const url = `${this.baseUrl}${CONFIG.crossCurve.endpoints.txCreate}`;
const response = await axios.post<CreateTxResponse>(url, requestBody, {
headers: this.getHeaders(),
});
console.log(`[CrossCurve] Transaction created: to=${response.data.to}`);
return response.data;
}
}
// ============================================================================
// CALLDATA BUILDER
// ============================================================================
class CalldataBuilder {
private kernelInterface: ethers.utils.Interface;
constructor() {
this.kernelInterface = new ethers.utils.Interface([
'function execute(bytes32 mode, bytes executionCalldata) external payable returns (bytes[] memory)',
]);
}
isNativeToken(tokenAddress: string): boolean {
return (
tokenAddress.toLowerCase() === constants.AddressZero.toLowerCase() ||
tokenAddress === constants.AddressZero
);
}
buildKernelCallData(params: KernelCallDataParams[]): string {
const CALLTYPE_BATCH = '0x01';
const EXECTYPE_DEFAULT = '0x00';
// Encode batch execute
const batchExecuteData = this.encodeBatch(
params.map((param) => ({
target: param.toAddress,
value: param.amount,
callData: param.data || '0x',
})),
);
// Create exec mode: bytes32 structure
// Structure: [callType (1 byte), execType (1 byte), modeSelector (4 bytes), payload (26 bytes)] = 32 bytes
const callTypeBytes = ethers.utils.arrayify(CALLTYPE_BATCH);
const execTypeBytes = ethers.utils.arrayify(EXECTYPE_DEFAULT);
const modeSelectorBytes = ethers.utils.arrayify('0x00000000');
const payloadBytes = ethers.utils.arrayify(constants.HashZero).slice(0, 26);
const execMode = ethers.utils.hexlify(
ethers.utils.concat([
callTypeBytes,
execTypeBytes,
modeSelectorBytes,
payloadBytes,
]),
);
// Encode kernel execute call
const kernelExecuteCallData = this.kernelInterface.encodeFunctionData(
'execute',
[execMode, batchExecuteData],
);
return kernelExecuteCallData;
}
private encodeBatch(
executions: { target: string; value: string; callData: string }[],
): string {
const batchAbi = ['tuple(address target, uint256 value, bytes callData)[]'];
return ethers.utils.defaultAbiCoder.encode(batchAbi, [executions]);
}
}
// ============================================================================
// USER OPERATION BUILDER
// ============================================================================
class HandleOpsBuilder {
private entryPointInterface: ethers.utils.Interface;
constructor() {
this.entryPointInterface = new ethers.utils.Interface([
'function handleOps(tuple(address sender, uint256 nonce, bytes initCode, bytes callData, bytes32 accountGasLimits, uint256 preVerificationGas, bytes32 gasFees, bytes paymasterAndData, bytes signature)[] ops, address beneficiary)',
]);
}
buildHandleOpsCalldata(
ops: IPackedUserOperation[],
beneficiary: string,
): string {
return this.entryPointInterface.encodeFunctionData('handleOps', [
ops,
beneficiary,
]);
}
}
class UserOperationBuilder {
private entryPointAddress: string;
private entryPointInterface: ethers.utils.Interface;
constructor(entryPointAddress: string) {
this.entryPointAddress = entryPointAddress;
this.entryPointInterface = new ethers.utils.Interface([
'function getNonce(address sender, uint192 key) external view returns (uint256 nonce)',
]);
}
async createBaseUserOp(
sender: string,
callData: string,
rpcProvider: ethers.providers.JsonRpcProvider,
nonceKey: number,
maxFeePerGas: string,
maxPriorityFeePerGas: string,
initCode = '0x',
): Promise<IUserOperation> {
const entryPointContract = new ethers.Contract(
this.entryPointAddress,
this.entryPointInterface,
rpcProvider,
);
const nonce = await entryPointContract.getNonce(sender, nonceKey);
return {
sender,
nonce: ethers.utils.hexlify(nonce),
callData,
callGasLimit: CONFIG.gas.callGasLimit,
verificationGasLimit: CONFIG.gas.verificationGasLimit,
preVerificationGas: CONFIG.gas.preVerificationGas,
maxFeePerGas,
maxPriorityFeePerGas,
signature: '0x',
initCode,
};
}
packUserOp(userOp: IUserOperation, initCode: string): IPackedUserOperation {
const accountGasLimits = ethers.utils.solidityPack(
['uint128', 'uint128'],
[userOp.verificationGasLimit, userOp.callGasLimit],
);
const gasFees = ethers.utils.solidityPack(
['uint128', 'uint128'],
[userOp.maxFeePerGas, userOp.maxPriorityFeePerGas],
);
return {
sender: userOp.sender,
nonce: userOp.nonce,
initCode: initCode || '0x',
callData: userOp.callData,
accountGasLimits,
preVerificationGas: userOp.preVerificationGas,
gasFees,
paymasterAndData: userOp.paymasterAndData || '0x',
signature: userOp.signature,
};
}
calculateUserOpHash(
packedUserOp: IPackedUserOperation,
chainId: number,
): string {
const innerHash = ethers.utils.keccak256(
ethers.utils.defaultAbiCoder.encode(
[
'address',
'uint256',
'bytes32',
'bytes32',
'bytes32',
'uint256',
'bytes32',
'bytes32',
],
[
packedUserOp.sender,
packedUserOp.nonce,
ethers.utils.keccak256(packedUserOp.initCode),
ethers.utils.keccak256(packedUserOp.callData),
packedUserOp.accountGasLimits,
packedUserOp.preVerificationGas,
packedUserOp.gasFees,
ethers.utils.keccak256(packedUserOp.paymasterAndData),
],
),
);
return ethers.utils.keccak256(
ethers.utils.defaultAbiCoder.encode(
['bytes32', 'address', 'uint256'],
[innerHash, this.entryPointAddress, BigInt(chainId)],
),
);
}
}
// ============================================================================
// MAIN FLOW
// ============================================================================
async function main() {
console.log('='.repeat(80));
console.log('CrossCurve Bridge Flow: Base ETH -> Arbitrum ETH');
console.log('='.repeat(80));
console.log();
// Validate configuration
validateConfig();
// Initialize clients
const crossCurveClient = new CrossCurveAPIClient(
CONFIG.crossCurve.baseUrl,
CONFIG.crossCurve.apiKey,
);
const calldataBuilder = new CalldataBuilder();
const userOpBuilder = new UserOperationBuilder(CONFIG.aa.entryPointAddress);
const handleOpsBuilder = new HandleOpsBuilder();
// Step 1: Prepare swap configuration
console.log('[Step 1] Preparing swap configuration...');
const amountInWei = ethers.utils.parseEther(CONFIG.swap.amountIn);
const swapConfig: CrossChainSwapConfig = {
chainIdIn: CONFIG.chains.base.chainId,
chainIdOut: CONFIG.chains.arbitrum.chainId,
tokenIn: CONFIG.swap.tokenIn,
tokenOut: CONFIG.swap.tokenOut,
amountIn: amountInWei.toString(),
slippage: CONFIG.swap.slippage,
};
console.log(
` Amount: ${CONFIG.swap.amountIn} ETH (${amountInWei.toString()} wei)`,
);
console.log(` From: Base (${swapConfig.chainIdIn})`);
console.log(` To: Arbitrum (${swapConfig.chainIdOut})`);
console.log();
// Step 2: Get routes from CrossCurve API
console.log('[Step 2] Getting routes from CrossCurve API...');
const routes = await crossCurveClient.getRoutes(
swapConfig,
CONFIG.aa.aaAddress,
);
if (routes.length === 0) {
throw new Error('No routes available');
}
const bestRoute = routes[0];
console.log(` Found ${routes.length} route(s)`);
console.log(' πŸ“‹ Route Details:');
console.log(
` Delivery Fee: ${bestRoute.deliveryFee?.amount || '0'} wei (${ethers.utils.formatEther(bestRoute.deliveryFee?.amount || '0')} ETH)`,
);
console.log(
` Expected Finality: ${bestRoute.expectedFinalitySeconds || 'N/A'} seconds`,
);
console.log(` Slippage: ${bestRoute.slippage || 'N/A'}%`);
console.log(` Price Impact: ${bestRoute.priceImpact || 'N/A'}%`);
console.log(
` Amount Out: ${bestRoute.amountOut || 'N/A'} wei (${bestRoute.amountOut ? ethers.utils.formatEther(bestRoute.amountOut) : 'N/A'} ETH)`,
);
console.log(
` Amount In: ${bestRoute.amountIn || 'N/A'} wei (${bestRoute.amountIn ? ethers.utils.formatEther(bestRoute.amountIn) : 'N/A'} ETH)`,
);
if (bestRoute.route && Array.isArray(bestRoute.route)) {
console.log(` Route Steps: ${bestRoute.route.length}`);
}
if (bestRoute.signature) {
console.log(` Signature: ${bestRoute.signature.substring(0, 20)}...`);
}
console.log();
// Step 3: Create transaction via CrossCurve API
console.log('[Step 3] Creating transaction via CrossCurve API...');
const transactionData = await crossCurveClient.createTransaction(
bestRoute,
CONFIG.aa.aaAddress,
false, // buildCalldata: false - we build it ourselves
);
// Validate we got ABI+args format
if (
!('abi' in transactionData) ||
!('args' in transactionData) ||
!transactionData.abi ||
!Array.isArray(transactionData.args)
) {
throw new Error('Expected ABI+args format from CrossCurve API');
}
const txData = transactionData as StartPopulatedTx;
console.log(' πŸ“„ Transaction Details:');
console.log(` Target: ${txData.to}`);
console.log(
` Value: ${txData.value} wei (${ethers.utils.formatEther(txData.value)} ETH)`,
);
console.log(` ABI: ${txData.abi}`);
console.log(` Args Count: ${txData.args.length}`);
console.log(' Args:');
txData.args.forEach((arg, index) => {
if (typeof arg === 'object' && arg !== null) {
console.log(` [${index}]: ${JSON.stringify(arg, null, 2)}`);
} else {
console.log(` [${index}]: ${arg}`);
}
});
console.log();
// Step 4: Extract execution price
console.log('[Step 4] Extracting execution price...');
let executionPrice = BigNumber.from(0);
if (bestRoute.deliveryFee?.amount) {
executionPrice = BigNumber.from(bestRoute.deliveryFee.amount);
}
// Also try to extract from args[2] (receipt/invoice)
const invoiceReceipt = txData.args[2] as
| SignaturePack
| { invoice?: SignaturePack };
if (invoiceReceipt) {
const invoice =
'invoice' in invoiceReceipt
? invoiceReceipt.invoice
: (invoiceReceipt as SignaturePack);
if (invoice?.executionPrice) {
executionPrice = BigNumber.from(invoice.executionPrice);
}
}
console.log(` Execution price: ${executionPrice.toString()} wei`);
console.log();
// Step 5: Build calldata
console.log('[Step 5] Building kernel calldata...');
const operations: KernelCallDataParams[] = [];
// For native token, we need to send value with the transaction
const amountIn = BigNumber.from(swapConfig.amountIn);
const correctValue = amountIn.add(executionPrice);
// Encode function call from ABI+args
const iface = new ethers.utils.Interface([txData.abi]);
const functionName = txData.abi.match(/function (\w+)/)?.[1];
if (!functionName) {
throw new Error('Failed to extract function name from ABI');
}
// Process args - convert SignaturePack to tuple array format
// Match the exact logic from crosscurve-calldata.service.ts
const processedArgs = txData.args.map((arg: unknown, index: number) => {
if (index === 2 && arg && typeof arg === 'object') {
const receipt = arg as
| SignaturePack
| {
invoice?: SignaturePack;
feeShare?: string;
feeShareRecipient?: string;
feeToken?: string;
};
if ('invoice' in receipt && receipt.invoice) {
// New format: { invoice: SignaturePack, feeShare, feeShareRecipient, feeToken }
// Return as array: [signature tuple array, feeShare, feeShareRecipient, feeToken]
// The signature tuple should be [executionPrice, deadline, v, r, s]
const invoice = receipt.invoice;
const feeShare = receipt.feeShare || '0';
const feeShareRecipient =
receipt.feeShareRecipient || constants.AddressZero;
const feeToken = receipt.feeToken || constants.AddressZero;
console.log(' πŸ”§ Processing args[2] (invoice format):');
console.log(
` Execution Price: ${invoice.executionPrice} wei (${ethers.utils.formatEther(invoice.executionPrice)} ETH)`,
);
console.log(
` Deadline: ${invoice.deadline} (${new Date(Number(invoice.deadline) * 1000).toISOString()})`,
);
console.log(` Signature v: ${invoice.v}`);
console.log(` Signature r: ${invoice.r.substring(0, 20)}...`);
console.log(` Signature s: ${invoice.s.substring(0, 20)}...`);
console.log(
` Fee Share: ${feeShare} wei (${ethers.utils.formatEther(feeShare)} ETH)`,
);
console.log(` Fee Share Recipient: ${feeShareRecipient}`);
console.log(` Fee Token: ${feeToken}`);
console.log();
return [
[
invoice.executionPrice,
invoice.deadline,
invoice.v,
invoice.r,
invoice.s,
],
feeShare,
feeShareRecipient,
feeToken,
];
} else if ('executionPrice' in receipt) {
// Old format: SignaturePack directly - return as tuple array
const sig = receipt as SignaturePack;
console.log(' πŸ”§ Processing args[2] (old format):');
console.log(
` Execution Price: ${sig.executionPrice} wei (${ethers.utils.formatEther(sig.executionPrice)} ETH)`,
);
console.log(
` Deadline: ${sig.deadline} (${new Date(Number(sig.deadline) * 1000).toISOString()})`,
);
console.log(` Signature v: ${sig.v}`);
console.log(` Signature r: ${sig.r.substring(0, 20)}...`);
console.log(` Signature s: ${sig.s.substring(0, 20)}...`);
console.log();
return [sig.executionPrice, sig.deadline, sig.v, sig.r, sig.s];
}
}
return arg;
});
const bridgeCallData = iface.encodeFunctionData(functionName, processedArgs);
// Add bridge operation
operations.push({
toAddress: txData.to,
amount: ethers.utils.formatUnits(correctValue, 0),
data: bridgeCallData,
});
// Build kernel calldata
const kernelCallData = calldataBuilder.buildKernelCallData(operations);
console.log(' πŸ“¦ Calldata Summary:');
console.log(` Function: ${functionName}`);
console.log(` Operations count: ${operations.length}`);
operations.forEach((op, index) => {
console.log(` Operation [${index}]:`);
console.log(` To: ${op.toAddress}`);
console.log(
` Amount: ${op.amount} wei (${ethers.utils.formatEther(op.amount)} ETH)`,
);
console.log(` Data length: ${op.data.length} bytes`);
});
console.log(` Kernel calldata length: ${kernelCallData.length} bytes`);
console.log(
` Amount In: ${amountIn.toString()} wei (${ethers.utils.formatEther(amountIn.toString())} ETH)`,
);
console.log(
` Execution Price: ${executionPrice.toString()} wei (${ethers.utils.formatEther(executionPrice.toString())} ETH)`,
);
console.log(
` Total Value: ${correctValue.toString()} wei (${ethers.utils.formatEther(correctValue.toString())} ETH)`,
);
console.log();
// Step 6: Create user operation
console.log('[Step 6] Creating user operation...');
const rpcProvider = new ethers.providers.JsonRpcProvider(
CONFIG.chains.base.rpc,
);
// Get current gas prices before creating user operation
const feeData = await rpcProvider.getFeeData();
const maxFeePerGas = feeData.maxFeePerGas || BigNumber.from('0x3b9aca00'); // Fallback to 1 gwei
const maxPriorityFeePerGas =
feeData.maxPriorityFeePerGas || BigNumber.from('0x3b9aca00'); // Fallback to 1 gwei
console.log(' β›½ Gas Price Details (from feeData):');
console.log(
` maxFeePerGas: ${maxFeePerGas.toString()} wei (${ethers.utils.formatUnits(maxFeePerGas, 'gwei')} gwei)`,
);
console.log(
` maxPriorityFeePerGas: ${maxPriorityFeePerGas.toString()} wei (${ethers.utils.formatUnits(maxPriorityFeePerGas, 'gwei')} gwei)`,
);
if (feeData.gasPrice) {
console.log(
` gasPrice: ${feeData.gasPrice.toString()} wei (${ethers.utils.formatUnits(feeData.gasPrice, 'gwei')} gwei)`,
);
}
console.log();
// Estimate callGasLimit dynamically
console.log(' β›½ Estimating callGasLimit...');
let estimatedCallGasLimit: BigNumber;
try {
// Estimate gas for the execution call
const gasEstimate = await rpcProvider.estimateGas({
from: CONFIG.aa.entryPointAddress,
to: CONFIG.aa.aaAddress,
data: kernelCallData,
value: correctValue,
});
// Add 50% buffer for safety (bridge operations can be complex)
estimatedCallGasLimit = gasEstimate.mul(150).div(100);
console.log(
` Estimated gas: ${gasEstimate.toString()} wei (${gasEstimate.toString()})`,
);
console.log(
` With 50% buffer: ${estimatedCallGasLimit.toString()} wei (${estimatedCallGasLimit.toString()})`,
);
} catch (error) {
console.log(
` ⚠️ Gas estimation failed, using default: ${CONFIG.gas.callGasLimit}`,
);
console.log(
` Error: ${error instanceof Error ? error.message : String(error)}`,
);
estimatedCallGasLimit = BigNumber.from(CONFIG.gas.callGasLimit);
}
console.log();
const userOp = await userOpBuilder.createBaseUserOp(
CONFIG.aa.aaAddress,
kernelCallData,
rpcProvider,
CONFIG.aa.nonceKey,
maxFeePerGas.toHexString(),
maxPriorityFeePerGas.toHexString(),
'0x', // initCode (empty for existing account)
);
// Override callGasLimit with estimated value
userOp.callGasLimit = estimatedCallGasLimit.toHexString();
console.log(` Sender: ${userOp.sender}`);
console.log(` Nonce: ${userOp.nonce}`);
console.log(` Call data length: ${userOp.callData.length} bytes`);
console.log(
` Call gas limit: ${userOp.callGasLimit} (${BigNumber.from(userOp.callGasLimit).toString()})`,
);
console.log(
` Verification gas limit: ${userOp.verificationGasLimit} (${BigNumber.from(userOp.verificationGasLimit).toString()})`,
);
console.log(
` Pre-verification gas: ${userOp.preVerificationGas} (${BigNumber.from(userOp.preVerificationGas).toString()})`,
);
console.log();
// Step 7: Pack user operation
console.log('[Step 7] Packing user operation...');
const packedUserOp = userOpBuilder.packUserOp(userOp, '0x'); // initCode (empty for existing account)
console.log(` Packed user operation created`);
console.log();
// Step 8: Calculate user operation hash
console.log('[Step 8] Calculating user operation hash...');
const userOpHash = userOpBuilder.calculateUserOpHash(
packedUserOp,
CONFIG.chains.base.chainId,
);
console.log(` UserOp hash: ${userOpHash}`);
console.log();
// Step 9: Sign user operation
console.log('[Step 9] Signing user operation...');
const ownerWallet = new ethers.Wallet(CONFIG.aa.ownerPrivateKey);
const signature = await ownerWallet.signMessage(
ethers.utils.arrayify(userOpHash),
);
userOp.signature = signature;
packedUserOp.signature = signature;
console.log(` Signature: ${signature.substring(0, 20)}...`);
console.log();
console.log(' πŸ“¦ Final User Operation:');
const userOpFormatted = {
...userOp,
callGasLimit: `${userOp.callGasLimit} (${BigNumber.from(userOp.callGasLimit).toString()} wei)`,
verificationGasLimit: `${userOp.verificationGasLimit} (${BigNumber.from(userOp.verificationGasLimit).toString()} wei)`,
preVerificationGas: `${userOp.preVerificationGas} (${BigNumber.from(userOp.preVerificationGas).toString()} wei)`,
maxFeePerGas: `${userOp.maxFeePerGas} (${ethers.utils.formatUnits(userOp.maxFeePerGas, 'gwei')} gwei)`,
maxPriorityFeePerGas: `${userOp.maxPriorityFeePerGas} (${ethers.utils.formatUnits(userOp.maxPriorityFeePerGas, 'gwei')} gwei)`,
};
console.log(JSON.stringify(userOpFormatted, null, 2));
console.log(' πŸ“¦ Final Packed User Operation:');
console.log(JSON.stringify(packedUserOp, null, 2));
console.log();
// Step 10: Send user operation via handleOps
console.log('[Step 10] Sending user operation via handleOps...');
const entryPointOwnerWallet = new ethers.Wallet(
CONFIG.entryPoint.ownerPrivateKey,
rpcProvider,
);
const beneficiary = entryPointOwnerWallet.address;
console.log(` Beneficiary: ${beneficiary} (will receive gas refund)`);
// Build handleOps calldata
const handleOpsCalldata = handleOpsBuilder.buildHandleOpsCalldata(
[packedUserOp],
beneficiary,
);
console.log(` HandleOps calldata length: ${handleOpsCalldata.length} bytes`);
console.log();
console.log(' πŸ“€ HandleOps Transaction Details:');
console.log(` From: ${beneficiary}`);
console.log(` To: ${CONFIG.aa.entryPointAddress}`);
console.log(` Value: 0`);
console.log(` Calldata: ${handleOpsCalldata}`);
console.log();
// Estimate gas
const gasEstimate = await rpcProvider.estimateGas({
to: CONFIG.aa.entryPointAddress,
from: beneficiary,
data: handleOpsCalldata,
});
console.log(' β›½ Gas Estimate:');
console.log(` Estimated gas: ${gasEstimate.toString()} wei`);
const estimatedGasCost = gasEstimate.mul(maxFeePerGas);
console.log(
` Estimated cost: ${estimatedGasCost.toString()} wei (${ethers.utils.formatEther(estimatedGasCost.toString())} ETH)`,
);
console.log();
// Send transaction
const tx = await entryPointOwnerWallet.sendTransaction({
to: CONFIG.aa.entryPointAddress,
data: handleOpsCalldata,
gasLimit: gasEstimate.mul(120).div(100), // Add 20% buffer
maxFeePerGas,
maxPriorityFeePerGas,
});
console.log(` βœ… Transaction sent!`);
console.log(` Transaction hash: ${tx.hash}`);
console.log(` Waiting for confirmation...`);
const txReceipt = await tx.wait();
console.log(` βœ… Transaction confirmed in block ${txReceipt.blockNumber}`);
console.log(` Gas used: ${txReceipt.gasUsed.toString()}`);
console.log();
console.log('='.repeat(80));
console.log('Flow completed!');
console.log('='.repeat(80));
}
// Run the script
main().catch((error) => {
console.error('Error:', error);
process.exit(1);
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment