Zero-Capital Liquidations via Aerodrome Callback Swaps on Base
A borrower's collateral drops below the required threshold. Someone needs to liquidate the loan. The catch: you need to hold the debt token upfront. What if you didn't?
This post walks through how I built a zero-capital liquidation system on Base, using Aerodrome Slipstream to convert seized collateral into repayment tokens, all in a single atomic transaction.
Table of Contents
- Why do liquidations need capital?
- What if you could liquidate with zero tokens?
- How does the callback pattern work?
- Breaking down the on-chain executor
- Aerodrome Slipstream: single-hop vs multi-hop
- The full transaction lifecycle
- Profit mechanics: where does the money come from?
- What this is NOT: flash loans vs callbacks
- Deploying on Base: costs and tradeoffs
- The bot side: encoding swap data off-chain
Why do liquidations need capital?
What happens when a borrower's collateral value drops below the liquidation threshold, and why does the liquidator need to hold tokens in the first place?
In a standard liquidation flow, the liquidator sends debt tokens to the protocol to cover the borrower's outstanding loan. In return, the protocol seizes a portion of the borrower's collateral and transfers it to the liquidator. The collateral seized is worth more than the debt repaid (scaled up by a liquidation bonus percentage), so the liquidator can sell it on the open market and pocket the surplus.
The problem: you need to hold the debt token before you can liquidate. If a loan has 1,000 USDC of outstanding debt, you need 1,000 USDC sitting in your wallet. That's a capital requirement. It locks out smaller operators and concentrates liquidation activity among well-funded bots.
In Floe's intent-based lending model, loans are matched peer-to-peer (no shared pools, no share tokens). But the liquidation capital problem is the same. Liquidators still need to front the debt token to repay the lender.
What if you could liquidate with zero tokens?
What if the protocol sent you the collateral first, you swapped it for debt tokens, and then repaid, all in one transaction?
That's the callback pattern. The key insight: if the protocol transfers collateral to your contract before demanding repayment, you can use that collateral to obtain the repayment tokens on a DEX. This works because Ethereum transactions are atomic. Either every step succeeds, or everything reverts. The protocol never loses funds because if the repayment doesn't happen, the collateral transfer also reverts.
This is not a flash loan. There's no borrowing from a liquidity pool, no flash loan fee, no third-party pool dependency. The protocol itself is the source of the collateral. I'll dig into the distinction in section 8.
How does the callback pattern work?
How do you convince a lending protocol to send you collateral before you've paid anything?
You implement a callback interface. The protocol calls a known function on your contract after transferring collateral but before pulling repayment. Your contract does whatever it needs to (swap, bridge, whatever) and then approves the protocol to pull the debt tokens.
Here's the interface:
interface ILiquidationCallback {
/// @notice Called after collateral has been transferred to msg.sender but before
/// loan token payment is pulled. Use this to swap collateral to loan tokens.
/// @param loanId The ID of the loan being liquidated
/// @param collateralToken The address of the collateral token received
/// @param collateralAmount The amount of collateral tokens received
/// @param loanToken The address of the loan token that must be repaid
/// @param repaymentRequired The total amount of loan tokens that will be pulled after this callback
/// @param data Arbitrary data passed through from the liquidateWithCallback caller
function onLiquidationCallback(
uint256 loanId,
address collateralToken,
uint256 collateralAmount,
address loanToken,
uint256 repaymentRequired,
bytes calldata data
) external;
}
The step-by-step flow:
- The liquidator's bot calls
liquidateWithCallback(loanId, repayAmount, maxTotalRepayment, data)on the protocol - The protocol validates that the loan is unhealthy (current LTV exceeds the liquidation threshold, or the loan is past its duration)
- The protocol calculates how much collateral to seize (debt value + liquidation bonus)
- The protocol transfers collateral to the callback contract (optimistic transfer)
- The protocol calls
onLiquidationCallback()on the callback contract - The callback contract swaps the collateral for debt tokens on a DEX
- The callback contract approves the protocol to pull the repayment amount
- The protocol calls
safeTransferFromto pull repayment from the callback contract: one transfer to the lender (principal + interest) and, if applicable, a second transfer to the protocol fee recipient. The protocol never holds the tokens; they go directly from the executor to the recipients. - If any transfer fails (insufficient balance or approval), the entire transaction reverts
The protocol uses the checks-effects-interactions pattern: it updates internal state (marks principal as repaid, reduces loan amounts) before making external calls. This prevents reentrancy.
Breaking down the on-chain executor
What does the actual contract look like?
The LiquidationCallbackExecutor is an immutable periphery contract. It has two immutable addresses set at deployment, two entry points (liquidate and onLiquidationCallback), and two utility functions (rescueTokens and rescueETH for sweeping profits and stuck tokens).
Immutable state:
/// @notice The Floe lending protocol contract (LendingIntentMatcherUpgradeable proxy)
address public immutable LENDING_PROTOCOL;
/// @notice Aerodrome Slipstream SwapRouter on Base
address public immutable SWAP_ROUTER;
constructor(address _lendingProtocol, address _swapRouter, address _owner) Ownable(_owner) {
require(_lendingProtocol != address(0), "zero lending protocol");
require(_swapRouter != address(0), "zero swap router");
LENDING_PROTOCOL = _lendingProtocol;
SWAP_ROUTER = _swapRouter;
}
LENDING_PROTOCOL and SWAP_ROUTER are set once at deployment and can never change. No proxy, no upgradeable storage. If the swap logic needs to change (new DEX, new route), you redeploy a fresh contract. On Base, that costs about $0.01.
Entry point, liquidate():
function liquidate(
uint256 loanId,
uint256 repayAmount,
uint256 maxTotalRepayment,
bytes calldata swapData
) external onlyOwner {
emit LiquidationInitiated(loanId, repayAmount, maxTotalRepayment, swapData.length);
ILendingIntentMatcher(LENDING_PROTOCOL).liquidateWithCallback(
loanId, repayAmount, maxTotalRepayment, swapData
);
emit LiquidationCompleted(loanId, repayAmount, maxTotalRepayment);
}
The parameters:
loanId: which loan to liquidaterepayAmount: how much of the loan's principal to repay (can be partial or full)maxTotalRepayment: the actual amount the protocol pulls isrepayAmountplus accrued interest plus a protocol fee. Since interest accrues continuously (block by block), the exact total can drift between when the bot submits the tx and when it executes.maxTotalRepaymentcaps that total, so the tx reverts if interest accrual pushed it higher than expected.swapData: encoded swap parameters (single-hop vs multi-hop, tick spacing, path) that get forwarded to the callback
Only the contract owner (the bot's EOA wallet) can call this. It forwards everything to the protocol's liquidateWithCallback(), which triggers the collateral transfer and callback sequence.
The callback, onLiquidationCallback():
function onLiquidationCallback(
uint256 loanId, // which loan is being liquidated
address collateralToken, // token the protocol just sent us (e.g. WETH)
uint256 collateralAmount, // how much collateral we received
address loanToken, // token we need to repay (e.g. USDC)
uint256 repaymentRequired, // exact amount of loanToken the protocol will pull after this returns
bytes calldata data // our encoded swap params (passed through from liquidate())
) external override {
// Only the lending protocol can call this. Without this check, anyone could
// call onLiquidationCallback() and trick the executor into swapping tokens.
require(msg.sender == LENDING_PROTOCOL, "only lending protocol");
// Decode the swap route info that the bot encoded off-chain.
// isMultiHop: false = direct swap (e.g. WETH->USDC), true = routed (e.g. cbBTC->USDC->USDT)
// tickSpacing: identifies which Aerodrome pool to use (only for single-hop)
// path: packed route bytes (only for multi-hop)
(bool isMultiHop, int24 tickSpacing, bytes memory path) =
abi.decode(data, (bool, int24, bytes));
// Allow the Aerodrome SwapRouter to spend our collateral.
// forceApprove is an OpenZeppelin SafeERC20 helper. Some tokens (like USDT)
// revert if you call approve() when the current allowance is already non-zero.
// forceApprove handles this by resetting to 0 first if needed.
IERC20(collateralToken).forceApprove(SWAP_ROUTER, collateralAmount);
// Swap all collateral for loan tokens via Aerodrome.
// amountOutMinimum = repaymentRequired means: if the swap can't produce
// enough to cover repayment, revert the entire transaction.
uint256 amountOut;
if (isMultiHop) {
// Multi-hop: swap through intermediate token(s)
// e.g. cbBTC -> USDC -> USDT using a packed path
amountOut = IAerodromeSwapRouter(SWAP_ROUTER).exactInput(
IAerodromeSwapRouter.ExactInputParams({
path: path, // tightly packed bytes: [token, tickSpacing, token, tickSpacing, token]
// e.g. cbBTC -> (tick 2000) -> USDC -> (tick 1) -> USDT
recipient: address(this), // send output tokens back to this contract
deadline: block.timestamp, // only valid this block (no MEV window)
amountIn: collateralAmount, // swap all collateral
amountOutMinimum: repaymentRequired // must cover repayment or revert
})
);
} else {
// Single-hop: direct swap through one pool
// e.g. WETH -> USDC in the tickSpacing=100 pool
amountOut = IAerodromeSwapRouter(SWAP_ROUTER).exactInputSingle(
IAerodromeSwapRouter.ExactInputSingleParams({
tokenIn: collateralToken, // selling this token
tokenOut: loanToken, // buying this token
tickSpacing: tickSpacing, // identifies which Aerodrome pool
recipient: address(this), // send output here
deadline: block.timestamp, // valid this block only
amountIn: collateralAmount, // swap all collateral
amountOutMinimum: repaymentRequired,// must cover repayment or revert
sqrtPriceLimitX96: 0 // no price limit (amountOutMinimum handles it)
})
);
}
// Any surplus from the swap is profit. It stays in the contract
// until the bot sweeps it later via rescueTokens().
uint256 profit = amountOut > repaymentRequired ? amountOut - repaymentRequired : 0;
// Approve the protocol to pull exactly repaymentRequired of loan tokens.
// When this function returns, the protocol calls safeTransferFrom() on us.
// If this approval is less than repaymentRequired, that call reverts,
// which reverts everything including the collateral transfer.
IERC20(loanToken).forceApprove(LENDING_PROTOCOL, repaymentRequired);
}
Security properties:
msg.sender == LENDING_PROTOCOLin the callback: only the protocol can trigger the swap logic. A random contract can't callonLiquidationCallback()and trick the executor into swapping tokens.onlyOwneronliquidate(): only the bot's wallet can initiate liquidations.- Immutable design: no proxy, no admin functions that change behavior. The contract does one thing and does it the same way every time.
The full verified source is on Basescan.
Aerodrome Slipstream: single-hop vs multi-hop
Why Aerodrome, and what's the difference between a single-hop and multi-hop swap?
Aerodrome is the dominant DEX on Base with deep concentrated liquidity pools (Slipstream). Concentrated liquidity means LPs can focus their capital in specific price ranges, resulting in better execution for swappers.
In concentrated liquidity DEXs, the price range is divided into discrete price points called ticks. LPs choose which tick range to provide liquidity in. Tick spacing controls how granular those price points are: a smaller tick spacing (like 1) means ticks are very close together (fine-grained, low fee, used for stablecoin pairs), while a larger tick spacing (like 100 or 2000) means ticks are spaced further apart (coarser, higher fee, used for volatile pairs like WETH/USDC).
On Aerodrome, tickSpacing is what identifies a specific pool for a given token pair. The same token pair can have multiple pools with different tick spacings, each with different fee structures and liquidity profiles.
Single-hop is a direct swap between two tokens. You specify tokenIn, tokenOut, and tickSpacing, and the SwapRouter routes through one pool. This uses exactInputSingle().
Multi-hop is a routed swap through an intermediate token. For example, swapping cbBTC to USDT might go cbBTC → USDC → USDT if there's no direct cbBTC/USDT pool with sufficient liquidity. This uses exactInput() with a packed path of addresses and tick spacings.
if (isMultiHop) {
amountOut = IAerodromeSwapRouter(SWAP_ROUTER).exactInput(
IAerodromeSwapRouter.ExactInputParams({
path: path,
recipient: address(this),
deadline: block.timestamp,
amountIn: collateralAmount,
amountOutMinimum: repaymentRequired
})
);
} else {
amountOut = IAerodromeSwapRouter(SWAP_ROUTER).exactInputSingle(
IAerodromeSwapRouter.ExactInputSingleParams({
tokenIn: collateralToken,
tokenOut: loanToken,
tickSpacing: tickSpacing,
recipient: address(this),
deadline: block.timestamp,
amountIn: collateralAmount,
amountOutMinimum: repaymentRequired,
sqrtPriceLimitX96: 0
})
);
}
Three parameters worth noting:
amountOutMinimum: repaymentRequiredmakes the swap revert if the output doesn't cover the repayment. This is the slippage protection. If market conditions change between when the bot submits the tx and when it executes, the swap fails safely.deadline: block.timestampmeans the swap is only valid in the current block. No MEV window for sandwich attacks.sqrtPriceLimitX96: 0: in concentrated liquidity pools, you can set a price boundary that stops the swap if the pool's price moves past it (encoded as a Q64.96 fixed-point square root of the price ratio). Setting it to 0 means "no limit, let the swap execute at whatever price the pool is at." I don't need this becauseamountOutMinimumalready reverts the tx if the output is too low.
The full transaction lifecycle
What actually happens in a single Base block (~2 seconds) between the bot submitting a transaction and the liquidation completing?
Let's walk through a concrete example. Say there's a loan with 0.044 WETH collateral and 100 USDC debt, and WETH is trading at $2,500.
Token transfer sequence:
- Protocol transfers 0.044 WETH collateral to the executor contract
- Executor approves 0.044 WETH to Aerodrome SwapRouter, which swaps it through the WETH/USDC pool (tickSpacing: 100)
- SwapRouter sends 109.50 USDC back to executor (after swap fees)
- Executor calls
forceApproveto grant the protocol an allowance of 100.50 USDC (the fullrepaymentRequired). This doesn't move any tokens; it authorizes the protocol to spend the executor's USDC in the next step. - Protocol calls
safeTransferFromto pull the 100.50 USDC from the executor in two transfers: one to the lender (principal + interest portion) and one to the protocol fee recipient (protocol fee portion). ERC20'stransferFromchecks that the caller (the protocol) has allowance from the source (the executor), which is why step 4 approves the protocol. The protocol never holds the tokens; they go directly from the executor to each recipient. - 9.00 USDC remains in the executor contract as profit
Everything is atomic. If the swap in step 2 can't produce enough USDC due to slippage, the entire transaction reverts. The protocol never loses collateral, and the liquidator never loses anything except gas.
Gas cost for the full transaction: ~800k gas on Base, which is roughly $0.006 at current L2 gas prices.
Profit mechanics: where does the money come from?
If the liquidator doesn't put up capital, where does the profit come from?
The profit comes from the liquidation bonus. When a loan is unhealthy, the protocol seizes more collateral than the debt is worth. This surplus incentivizes liquidators to keep the protocol solvent.
A worked example:
| Item | Value |
|---|---|
| Loan debt (principal + interest + fee) | 100.50 USDC |
| Collateral seized | 0.044 WETH |
| WETH price | $2,500 |
| Collateral market value | $110.00 |
| Swap output (after DEX fees) | 109.50 USDC |
| Total repayment pulled | 100.50 USDC |
| Profit | 9.00 USDC |
The profit formula: swapOutput - repaymentRequired.
The profit stays in the executor contract until the bot owner calls rescueTokens() to sweep it to their wallet. This is a deliberate design choice: keeping tokens in the contract avoids an extra transfer on every liquidation, saving gas.
Risk profile: If collateral price drops between when the bot identifies the opportunity and when the tx executes, the swap's amountOutMinimum check fails, the entire tx reverts, and the liquidator loses only gas (~$0.006). No capital at risk.
What this is NOT: flash loans vs callbacks
Isn't this just a flash loan with extra steps?
No. The mechanism is different.
Flash loans: You borrow tokens from a liquidity pool (Aave, Balancer, etc.), use them for whatever operation you need, then repay the borrowed amount plus a fee in the same transaction. If you don't repay, the tx reverts.
Callbacks: No borrowing at all. The lending protocol sends you collateral it already seized from the borrower. You convert it on a DEX and approve the protocol to pull repayment. No pool interaction, no borrow, no fee.
Key differences:
| Flash Loans | Callbacks | |
|---|---|---|
| Source of funds | Liquidity pool (Aave, Balancer) | Protocol's seized collateral |
| Fee | ~0.05% (Aave V3), 0% (Balancer) | None |
| Pool dependency | Pool must have enough liquidity to lend | No lending pool needed |
| External calls | Borrow from pool + repay to pool + your operation | Your operation only |
| Gas cost | Higher (extra pool interactions) | Lower (fewer external calls) |
You could use flash loans for liquidation. Borrow USDC from Aave, repay the loan, receive collateral, sell collateral, repay Aave. It works. But callbacks are cheaper (no flash loan fee, fewer external calls) and simpler (one fewer protocol to interact with, one fewer failure point).
Both patterns rely on atomic execution for safety. The difference is where the initial funds come from.
Deploying on Base: costs and tradeoffs
Why Base, and what does it cost to deploy and run this?
Base is an L2 with sub-cent transaction costs. The deployment cost for the LiquidationCallbackExecutor was under $0.01. Each liquidation transaction costs roughly $0.006 in gas.
The contract uses an immutable design: no proxy, no upgradeable storage, no admin keys that can change behavior (except owner for the rescueTokens sweep). This means:
- No storage slot overhead from proxy patterns
- No risk of storage collisions or upgrade bugs
- No governance attack surface
The tradeoff: if the swap logic needs to change (new DEX integration, new route), you deploy a fresh contract. On Base, that's ~$0.01 and takes one transaction. The bot updates its config to point to the new executor address, and the old contract can be drained with rescueTokens().
Solidity 0.8.20+ uses the PUSH0 opcode (Shanghai EVM). Base supports Shanghai, so this works without issues.
The bot side: encoding swap data off-chain
How does the bot decide which swap route to use?
The bot maintains a configuration of known token pairs and their corresponding Aerodrome pool tick spacings. When it identifies a liquidation opportunity, it looks up the collateral/loan token pair and encodes the appropriate swap parameters.
On-chain contracts:
The executor interacts with two Aerodrome contracts on Base:
- SwapRouter (
0xBE6D8f0d05cC4be24d5167a3eF062215bE6D18a5): executes the actual swaps viaexactInputSingle()andexactInput() - QuoterV2 (
0x254cF9E1E6e233aa1AC962CB9B05b2cFeAAe15b0): used off-chain by the bot to simulate swaps and estimate output amounts before submitting transactions
Supported tokens:
| Token | Address | Type |
|---|---|---|
| WETH | 0x4200000000000000000000000000000000000006 | Collateral |
| cbBTC | 0xcbB7C0000aB88B473b1f5aFd9ef808440eed33Bf | Collateral |
| USDC | 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913 | Loan token |
| USDT | 0xfde4C96c8593536E31F229EA8f37b2ADa2699bb2 | Loan token |
Configured swap routes:
| Route | Hops | Pool(s) | Tick Spacing |
|---|---|---|---|
| WETH → USDC | Single | CL100 | 100 |
| cbBTC → USDC | Single | CL2000 | 2000 |
| cbBTC → USDT | Multi (via USDC) | CL2000 + CL1 | 2000, 1 |
| WETH → USDT | Multi (via USDC) | CL100 + CL1 | 100, 1 |
How I determined each route: I checked which Aerodrome Slipstream pools exist for each token pair and picked the one with the deepest liquidity. WETH/USDC and cbBTC/USDC both have direct Slipstream pools with significant TVL, so those are single-hop. For USDT pairs (cbBTC → USDT, WETH → USDT), there are no direct Slipstream pools with meaningful liquidity on Aerodrome, so I route through USDC as an intermediate: the first leg uses the deep collateral/USDC pool, and the second leg uses the USDC/USDT CL1 stablecoin pool (tick spacing 1, the tightest possible, appropriate for a stablecoin-to-stablecoin swap).
private encodeSwapData(collateralToken: string, loanToken: string): string {
const route = getSwapRoute(collateralToken, loanToken);
if (route.isMultiHop) {
// Multi-hop: encode packed path (tokenIn + tickSpacing1 + intermediate + tickSpacing2 + tokenOut)
const path = ethers.solidityPacked(
['address', 'int24', 'address', 'int24', 'address'],
[collateralToken, route.tickSpacing1!, route.intermediateToken!, route.tickSpacing2!, loanToken]
);
return ethers.AbiCoder.defaultAbiCoder().encode(
['bool', 'int24', 'bytes'],
[true, 0, path]
);
} else {
// Single-hop: tickSpacing identifies the Aerodrome CL pool, path is empty
return ethers.AbiCoder.defaultAbiCoder().encode(
['bool', 'int24', 'bytes'],
[false, route.tickSpacing, '0x']
);
}
}
The encoded data format is abi.encode(bool isMultiHop, int24 tickSpacing, bytes path). For single-hop swaps, tickSpacing identifies which Aerodrome pool to use and path is empty (0x). For multi-hop swaps, tickSpacing is ignored (set to 0) and path contains the tightly packed route: [tokenIn, tick1, intermediate, tick2, tokenOut].
Route selection happens off-chain because on-chain route discovery is expensive. Querying multiple pools to find the best path would add gas cost to every liquidation. Instead, the bot pre-computes routes from a config file. Adding support for a new token pair means adding one entry to the route config.
The on-chain contract doesn't care how the route was chosen. It receives the encoded swap data, decodes it, and executes. This separation of concerns keeps the contract simple and the bot flexible.
The full contract source is verified at 0x11342cF82370FaCfea89a5b57ecF84A483E54964 on Base mainnet.