What is ERC-4337?
ERC-4337 is the account abstraction standard that enables smart contract wallets to:
Send transactions without being externally owned accounts (EOAs)
Pay gas in ERC-20 tokens (with paymasters)
Batch multiple operations atomically
Implement custom validation logic
Borrow Recovery uses ERC-4337 to execute rescue operations (repay, withdraw, transfer) on Kernel loan wallets.
UserOperation Structure
From lib/accountAbstraction/userOpHashV07.ts:
export type UserOperationV07 = {
sender : Address ; // Kernel wallet address
nonce : bigint ; // Sequential nonce
callData : Hex ; // Encoded Kernel execute() call
callGasLimit : bigint ; // Gas for execution
verificationGasLimit : bigint ; // Gas for signature validation
preVerificationGas : bigint ; // Gas for bundler overhead
maxFeePerGas : bigint ; // Max gas price
maxPriorityFeePerGas : bigint ; // Miner tip
signature : Hex ; // Owner's signature
// Optional fields (not used in recovery app):
factory ?: Address | undefined ; // Factory for deployment
factoryData ?: Hex | undefined ; // Factory calldata
paymaster ?: Address | undefined ; // Paymaster address
paymasterData ?: Hex | undefined ; // Paymaster data
paymasterVerificationGasLimit ?: bigint ; // Paymaster verification gas
paymasterPostOpGasLimit ?: bigint ; // Paymaster post-op gas
};
The recovery app does not use paymasters - users pay gas directly from their Kernel wallet.
UserOperation Lifecycle
The full lifecycle of a UserOperation is:
1. Construct → Build UserOp with calldata
2. Estimate Gas → Get gas limits from bundler
3. Get Gas Price → Fetch current gas pricing
4. Compute Hash → Calculate UserOp hash
5. Sign Hash → Sign with wallet
6. Submit → Send to bundler
7. Bundled → Bundler includes in tx
8. Executed → EntryPoint processes on-chain
The entire flow is implemented in lib/accountAbstraction/submitUserOpV07.ts.
Gas Estimation
Before submission, the app estimates gas requirements:
type EstimateGasResult = {
callGasLimit : Hex ;
verificationGasLimit : Hex ;
preVerificationGas : Hex ;
};
const estimate = await jsonRpcFetch < EstimateGasResult >(
bundlerUrl ,
"eth_estimateUserOperationGas" ,
[ toRpcOp ( opBase ), ENTRYPOINT_V07_ADDRESS ]
);
From lib/accountAbstraction/submitUserOpV07.ts:301.
Gas Buffering
The app adds a 50% buffer to estimates to handle:
Interest accrual between estimation and execution
Gas price volatility
State changes in DeFi protocols
const addBuffer = ( value : bigint ) : bigint => ( value * 150 n ) / 100 n ;
const callGasLimit = addBuffer ( parseHexQuantity ( estimate . callGasLimit ));
const verificationGasLimit = addBuffer ( parseHexQuantity ( estimate . verificationGasLimit ));
const preVerificationGas = addBuffer ( parseHexQuantity ( estimate . preVerificationGas ));
From lib/accountAbstraction/submitUserOpV07.ts:332-353.
If estimation fails or returns zero, the app falls back to conservative defaults:
FALLBACK_CALL_GAS_LIMIT = 80,000
FALLBACK_VERIFICATION_GAS_LIMIT = 250,000
FALLBACK_PRE_VERIFICATION_GAS = 40,000
Gas Pricing
The app uses a multi-tier approach to get gas prices:
1. Bundler-Specific Methods
First, try bundler-specific methods:
async function getBundlerUserOpGasPrice (
bundlerUrl : string
) : Promise < UserOpGasPrice | null > {
try {
// Try ZeroDev method
const response = await jsonRpcFetch (
bundlerUrl ,
"zd_getUserOperationGasPrice" ,
[]
);
return parseBundlerUserOpGasPrice ( response );
} catch {
try {
// Try Pimlico method
const response = await jsonRpcFetch (
bundlerUrl ,
"pimlico_getUserOperationGasPrice" ,
[]
);
return parseBundlerUserOpGasPrice ( response );
} catch {
return null ;
}
}
}
From lib/accountAbstraction/submitUserOpV07.ts:90-102.
2. Chain RPC Fallback
If bundler methods fail, fall back to chain RPC:
const gasPriceHex = await jsonRpcFetch < Hex >( chainRpcUrl , "eth_gasPrice" );
maxFeePerGas = parseHexQuantity ( gasPriceHex , "gasPrice" );
try {
const tipHex = await jsonRpcFetch < Hex >( chainRpcUrl , "eth_maxPriorityFeePerGas" );
maxPriorityFeePerGas = parseHexQuantity ( tipHex , "maxPriorityFeePerGas" );
} catch {
maxPriorityFeePerGas = maxFeePerGas ;
}
From lib/accountAbstraction/submitUserOpV07.ts:259-268.
3. Price Bumping
Add 10-20% buffer to handle price increases:
// From bundler: 10% bump
return {
maxFeePerGas: ( maxFeePerGas * 110 n ) / 100 n ,
maxPriorityFeePerGas: ( maxPriorityFeePerGas * 110 n ) / 100 n ,
};
// From chain RPC: 20% bump
maxFeePerGas = ( maxFeePerGas * 120 n ) / 100 n ;
From lib/accountAbstraction/submitUserOpV07.ts:85-86 and :270.
Nonce Management
Kernel v3 uses a special nonce encoding for EntryPoint v0.7:
const KERNEL_V07_NONCE_KEY_MODE_DEFAULT : Hex = "0x00" ;
const KERNEL_V07_NONCE_KEY_TYPE_SUDO : Hex = "0x00" ;
const MAX_KERNEL_NONCE_SUBKEY = 0xffff n ;
function encodeKernelV07NonceKey (
validatorAddress : Address ,
nonceSubKey : bigint = 0 n
) : bigint {
const encoded = pad (
concatHex ([
KERNEL_V07_NONCE_KEY_MODE_DEFAULT ,
KERNEL_V07_NONCE_KEY_TYPE_SUDO ,
validatorAddress ,
toHex ( nonceSubKey , { size: 2 }),
]),
{ size: 24 }
);
return BigInt ( encoded );
}
From lib/accountAbstraction/submitUserOpV07.ts:145-159.
Reading Current Nonce
The app reads nonces from EntryPoint:
async function readKernelNonce ( parameters : {
chainRpcUrl : string ;
request : RpcRequest ;
kernelAddress : Address ;
nonceKey : bigint ;
}) : Promise < bigint > {
const { chainRpcUrl , request , kernelAddress , nonceKey } = parameters ;
const data = encodeEntryPointGetNonce ({ sender: kernelAddress , key: nonceKey });
// Try both "latest" and "pending" blocks
const targets = [ "latest" , "pending" ] as const ;
const observed : bigint [] = [];
for ( const blockTag of targets ) {
try {
const res = await jsonRpcFetch < Hex >( chainRpcUrl , "eth_call" , [
{ to: ENTRYPOINT_V07_ADDRESS , data },
blockTag ,
]);
observed . push ( decodeEntryPointGetNonce ( res ));
} catch {
// Continue to next provider
}
}
// Return highest observed nonce
return observed . reduce (( max , value ) => ( value > max ? value : max ), observed [ 0 ]);
}
From lib/accountAbstraction/submitUserOpV07.ts:161-193.
The app checks both “latest” and “pending” to handle race conditions where a UserOp is submitted but not yet mined.
UserOperation Hash
Before signing, the app computes the UserOp hash:
import { getUserOperationHash } from "viem/account-abstraction" ;
export function getUserOperationHashV07 ( parameters : {
userOperation : UserOperationV07 ;
entryPointAddress : Address ;
chainId : number ;
}) : Hex {
const { userOperation , entryPointAddress , chainId } = parameters ;
return getUserOperationHash ({
chainId ,
entryPointAddress ,
entryPointVersion: "0.7" ,
userOperation ,
});
}
From lib/accountAbstraction/userOpHashV07.ts:23-35.
The hash includes:
All UserOp fields (sender, nonce, callData, gas limits, etc.)
EntryPoint address
Chain ID
This prevents replay attacks across chains and EntryPoint versions.
Signing UserOperations
The app tries multiple signature methods for wallet compatibility:
async function signUserOperationHash ( parameters : {
request : RpcRequest ;
owner : Address ;
userOpHash : Hex ;
}) : Promise < Hex > {
const { request , owner , userOpHash } = parameters ;
const signingAttempts = [
{ method: "personal_sign" , params: [ userOpHash , owner ] },
{ method: "personal_sign" , params: [ owner , userOpHash ] }, // Reversed params
{ method: "eth_sign" , params: [ owner , userOpHash ] },
];
for ( const attempt of signingAttempts ) {
try {
return await request ( attempt . method , attempt . params );
} catch ( error ) {
if ( isUserRejectedError ( error )) throw error ;
if ( ! isSignatureMethodCompatibilityError ( error )) throw error ;
// Continue to next method
}
}
throw new Error ( "Failed to sign UserOperation hash." );
}
From lib/accountAbstraction/submitUserOpV07.ts:195-220.
Different wallets implement personal_sign with different parameter orders, so the app tries both.
Submission and Retry Logic
The app includes sophisticated retry logic:
Nonce Retry
If submission fails with AA25 (invalid nonce), refresh and retry:
if ( isInvalidAccountNonceError ( sendError ) && ! nonceRetryUsed ) {
nonceRetryUsed = true ;
onStatus ?.( "Nonce changed before submission. Refreshing and retrying…" );
await new Promise (( resolve ) => setTimeout ( resolve , 1200 ));
nonce = await readKernelNonce ({ /* ... */ });
// Re-estimate and retry
}
From lib/accountAbstraction/submitUserOpV07.ts:409-423.
Gas Price Retry
If bundler requires higher gas price, bump and retry:
const minRequiredMaxFeePerGas = extractMinRequiredMaxFeePerGas ( sendError );
if ( minRequiredMaxFeePerGas !== null && ! gasRetryUsed ) {
const minBumpedFee = ( minRequiredMaxFeePerGas * 110 n ) / 100 n ;
const currentBumpedFee = ( opForSend . maxFeePerGas * 110 n ) / 100 n ;
const bumpedMaxFeePerGas = minBumpedFee > currentBumpedFee
? minBumpedFee
: currentBumpedFee ;
opForSend = {
... opForSend ,
maxFeePerGas: bumpedMaxFeePerGas ,
maxPriorityFeePerGas: /* adjusted */ ,
};
gasRetryUsed = true ;
continue ; // Retry
}
From lib/accountAbstraction/submitUserOpV07.ts:391-407.
EntryPoint v0.7
Borrow Recovery uses EntryPoint v0.7 , the latest ERC-4337 version:
import { ENTRYPOINT_V07_ADDRESS } from "@/lib/protocols/entryPoint" ;
// All UserOps are sent to this address
const entryPointAddress = ENTRYPOINT_V07_ADDRESS ;
EntryPoint v0.7 improvements:
Better gas efficiency
Enhanced validation rules
Native paymaster support
Bundler Integration
The app supports ZeroDev bundlers via configuration:
Project ID format : abc123... (app constructs URL)
Full URL format : https://rpc.zerodev.app/api/v3/{projectId}/chain/{chainId}
From the user’s perspective:
// User enters:
"abc123def456"
// App constructs:
const bundlerUrl = `https://rpc.zerodev.app/api/v3/ ${ projectId } /chain/ ${ chainId } ` ;
The bundler handles:
Gas estimation
UserOp validation
Bundling multiple UserOps
Submission to mempool
Benefits for Recovery
Using ERC-4337 provides:
Atomic batching : Approve + repay in one UserOp
No gas holding : Wallet can execute with minimal ETH
Standard interface : Works with any bundler
Enhanced validation : Kernel can enforce additional rules
Next Steps
Kernel Accounts Learn about the Kernel smart accounts
Protocol Integration See how UserOps interact with protocols