Aave V4 Liquidations
Liquidate unhealthy positions to earn liquidation bonuses and help maintain protocol stability.
Identify Target Position
First, identify a position with a health factor below 1.0 that is eligible for liquidation.
const position: UserPosition = {
__typename: "UserPosition",
id: "aGVsbG8",
user: "0x456…",
healthFactor: {
value: BigDecimal(0.85), // Below 1.0 - eligible for liquidation
// …
},
spoke: {
address: "0x123…",
chain: {
chainId: 1,
name: "Ethereum",
},
// …
},
// …
};
Finding positions with a health factor below 1.0 is out of scope here. See Health Factor.
Select Liquidation Targets
A liquidation targets one debt reserve to repay and one collateral reserve to seize, including the liquidation bonus.
Only liquidate when the liquidation bonus exceeds gas costs.
1. Choose the Debt Position
First, identify which debt position to target by looking at the user’s borrow positions. Consider targeting debts with larger amounts.
Fetch borrow positions from User Borrows, then pick the specific position you want to liquidate:
const debtPosition: UserBorrowItem = {
reserve: {
id: "SGVsbG8h",
onChainId: "42",
asset: {
underlying: { address: "0xa0b8…", info: { symbol: "USDC", name: "USD Coin" } },
},
// …
},
debt: {
amount: {
value: BigDecimal(2000),
},
exchange: {
value: BigDecimal(2000),
// …
},
// …
},
// …
};
If less than $1,000 USD of debt would remain in the target reserve, you must liquidate all of it.
2. Choose the Collateral Position
Next, identify which collateral position to target by examining the user’s supply positions that are enabled as collateral. Only supplies with is can be targeted for liquidation.
Choose collateral in the token you prefer to receive as your liquidation bonus by fetching the supply positions from User Supplies:
const collateral: UserSupplyItem = {
reserve: {
id: "V29ybGQ=",
onChainId: "43",
asset: {
underlying: { address: "0xc02a…", info: { symbol: "WETH", name: "Wrapped Ether" } },
},
// …
},
isCollateral: true, // Can be liquidated
withdrawable: {
amount: {
value: BigDecimal(1.5),
// …
},
// …
},
// …
};
In Aave V4, liquidation bonuses follow a Dutch auction where lower health factors result in higher liquidation bonuses.
Liquidate the Position
Step-by-Step with AaveKit
After selecting the debt and collateral reserves, choose the repayment amount. You can send an exact value or let the protocol determine the needed amount by using max, restoring the position to a healthy state (HF ≥ 1.0).
To liquidate an unhealthy position with AaveKit, follow these steps.
1. Configure Wallet Integration
In React, first instantiate the hooks for the wallet library of your choice. This wallet will pay the debt tokens and receive the seized collateral.
use: used to send ERC-20 approval and liquidation transactionsSend Transaction use: used to sign ERC-20 permits when availableSign Typed Data
import { useWalletClient } from "wagmi";
import { useSendTransaction, useSignTypedData } from "@aave/react/viem";
// …
const { data: wallet } = useWalletClient();
const [sendTransaction] = useSendTransaction(wallet);
const [signTypedData] = useSignTypedData(wallet);
2. Define the Liquidation Flow
Then, in React, use the use hook to prepare the liquidation operation.
import { useLiquidatePosition } from "@aave/react";
const [liquidatePosition, { loading, error }] = useLiquidatePosition(
(plan, { cancel }) => {
switch (plan.__typename) {
case "TransactionRequest":
return sendTransaction(plan);
case "Erc20Approval":
// If token supports EIP-2612 permits, sign permit (recommended)
if (plan.bySignature) {
return signTypedData(plan.bySignature);
}
// Otherwise use traditional approval transaction
return sendTransaction(plan.byTransaction);
case "PreContractActionRequired":
return sendTransaction(plan.transaction);
}
},
);
import { useLiquidatePosition } from "@aave/react";
const [status, setStatus] = useState("");
const [liquidatePosition, { loading, error }] = useLiquidatePosition(
(plan, { cancel }) => {
switch (plan.__typename) {
case "TransactionRequest":
setStatus("Please sign the Liquidation transaction in your wallet");
return sendTransaction(plan).andTee(() => setStatus("Liquidating…"));
case "Erc20Approval":
if (plan.bySignature) {
setStatus("Please sign the Permit in your wallet");
return signTypedData(plan.bySignature).andTee(() =>
setStatus("Approving…"),
);
}
setStatus("Please sign the Approval transaction in your wallet");
return sendTransaction(plan.byTransaction).andTee(() =>
setStatus("Approving…"),
);
case "PreContractActionRequired":
setStatus(
"Please sign the Pre-Contract Action transaction in your wallet",
);
return sendTransaction(plan.transaction);
}
},
);
import { useLiquidatePosition } from "@aave/react";
const [liquidatePosition, { loading, error }] = useLiquidatePosition(
(plan, { cancel }) => {
if (
window.confirm("Are you sure you want to liquidate this position?") ===
false
) {
return cancel("User cancelled the liquidation");
}
switch (plan.__typename) {
case "TransactionRequest":
return sendTransaction(plan);
case "Erc20Approval":
if (plan.bySignature) {
return signTypedData(plan.bySignature);
}
return sendTransaction(plan.byTransaction);
case "PreContractActionRequired":
return sendTransaction(plan.transaction);
}
},
);
In the Erc20 case, the by field is only available if the token supports EIP-2612 permits.
For tokens that need an allowance reset, like USDT on Ethereum
Mainnet,
the hook calls your callback twice: reset to 0, then set the new value.
by is null for these.
3. Execute the Liquidation
Then, execute the liquidation operation.
- TypeScript: Use the
liquidateaction to create the transaction request for liquidating the unhealthy position.Position - GraphQL: Use the
liquidatequery to prepare the liquidation execution plan. Specify an exact debt amount to cover, or usePosition amount: { max: true }to bring the position back to a healthy state.
Set receive to receive the seized collateral as supply shares instead of the underlying asset (defaults to false).
import {
bigDecimal,
evmAddress,
type UserBorrowItem,
type UserPosition,
type UserSupplyItem,
} from "@aave/react";
const execute = async (
position: UserPosition,
debt: UserBorrowItem,
collateral: UserSupplyItem,
) => {
const result = await liquidatePosition({
collateral: collateral.reserve.id,
debt: debt.reserve.id,
amount: {
exact: { value: bigDecimal("1000") }, // 1000 USDC
},
liquidator: evmAddress(wallet.account.address), // User's address
user: position.user,
});
// …
};
import {
evmAddress,
type UserPosition,
type UserBorrowItem,
type UserSupplyItem,
} from "@aave/react";
const execute = async (
position: UserPosition,
debt: UserBorrowItem,
collateral: UserSupplyItem,
) => {
const result = await liquidatePosition({
collateral: collateral.reserve.id,
debt: debt.reserve.id,
amount: {
max: true,
},
liquidator: evmAddress(wallet.account.address), // User's address
user: position.user,
});
// …
};
import { liquidatePosition } from "@aave/client/actions";
import { bigDecimal, evmAddress } from "@aave/client";
import { client } from "./client";
const result = await liquidatePosition(client, {
collateral: collateral.reserve.id,
debt: debtPosition.reserve.id,
amount: {
exact: { value: bigDecimal("1000") }, // 1000 USDC
},
liquidator: evmAddress(wallet.account.address),
user: position.user,
});
// …
import { liquidatePosition } from "@aave/client/actions";
import { evmAddress } from "@aave/client";
import { client } from "./client";
const result = await liquidatePosition(client, {
collateral: collateral.reserve.id,
debt: debtPosition.reserve.id,
amount: { max: true },
liquidator: evmAddress(wallet.account.address),
user: position.user,
});
// …
import { AaveClient } from "@aave/client";
export const client = AaveClient.create();
query ($request: LiquidatePositionRequest!) {
liquidatePosition(request: $request) {
__typename
... on TransactionRequest {
...TransactionRequest
}
... on Erc20ApprovalRequired {
reason
requiredAmount {
...DecimalNumber
}
currentAllowance {
...DecimalNumber
}
approvals {
byTransaction {
...TransactionRequest
}
bySignature {
...PermitTypedData
}
}
originalTransaction {
...TransactionRequest
}
}
... on InsufficientBalanceError {
required {
...DecimalNumber
}
available {
...DecimalNumber
}
}
}
}
fragment TransactionRequest on TransactionRequest {
to
from
data
value
chainId
operations
}
fragment PermitTypedData on PermitTypedData {
types
primaryType
domain {
name
version
chainId
verifyingContract
}
message
}
In GraphQL, below is an example of a Liquidate object.
{
"request": {
"collateral": "V29ybGQ=",
"debt": "SGVsbG8h",
"amount": {
"exact": {
"value": "1000"
}
},
"liquidator": "0x789…",
"user": "0x456…"
}
}
{
"request": {
"collateral": "V29ybGQ=",
"debt": "SGVsbG8h",
"amount": {
"max": true
},
"liquidator": "0x789…",
"user": "0x456…",
"receiveShares": true
}
}
{
"request": {
"collateral": "V29ybGQ=",
"debt": "SGVsbG8h",
"amount": {
"exact": {
"value": "1000",
"permitSig": {
"deadline": 1700000000,
"value": "0xabcdef…"
}
}
},
"liquidator": "0x789…",
"user": "0x456…"
}
}
In TypeScript, to handle ERC-20 permit-based approval, wrap the liquidate call with permit for the wallet library of your choice.
import { liquidatePosition } from "@aave/client/actions";
import { permitWith } from "@aave/client/viem";
import { bigDecimal, evmAddress } from "@aave/client";
import { client } from "./client";
import { wallet } from "./wallet";
const result = await permitWith(wallet, (permitSig) =>
liquidatePosition(client, {
collateral: collateral.reserve.id,
debt: debtPosition.reserve.id,
amount: {
exact: { value: bigDecimal("1000"), permitSig },
},
liquidator: evmAddress(wallet.account.address),
user: position.user,
}),
);
// …
import { createWalletClient, http, type Hex } from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { mainnet } from "viem/chains";
export const wallet = createWalletClient({
account: privateKeyToAccount(process.env.PRIVATE_KEY as Hex),
chain: mainnet,
transport: http(),
});
The permit helper:
- Calls the
liquidateaction without a permit signaturePosition - If the response is
Erc20with permit signature available, signs the permit and callsApproval Required liquidateagain with the signature to get an updated execution planPosition - Otherwise, returns the execution plan unchanged (no permit needed or token doesn’t support permits)
For tokens that need an allowance reset, like USDT on Ethereum
Mainnet,
permit skips the permit and the next step handles both approvals.
4. Process the Execution Plan
In TypeScript, use send for the wallet library of your choice to send the transactions in the execution plan, and await indexing with client.. This wallet will pay the debt tokens and receive the seized collateral.
import { liquidatePosition } from "@aave/client/actions";
import { sendWith } from "@aave/client/viem";
import { client } from "./client";
import { wallet } from "./wallet";
const result = await liquidatePosition(client, {
// …
})
.andThen(sendWith(wallet))
.andThen(client.waitForTransaction);
import { liquidatePosition } from "@aave/client/actions";
import { bigDecimal, evmAddress } from "@aave/client";
import { permitWith, sendWith } from "@aave/client/viem";
import { client } from "./client";
import { wallet } from "./wallet";
const result = await permitWith(wallet, (permitSig) =>
liquidatePosition(client, {
collateral: collateral.reserve.id,
debt: debtPosition.reserve.id,
amount: {
exact: { value: bigDecimal("1000"), permitSig },
},
liquidator: evmAddress(wallet.account.address),
user: position.user,
}),
)
.andThen(sendWith(wallet))
.andThen(client.waitForTransaction);
import { createWalletClient, http, type Hex } from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { mainnet } from "viem/chains";
export const wallet = createWalletClient({
account: privateKeyToAccount(process.env.PRIVATE_KEY as Hex),
chain: mainnet,
transport: http(),
});
send handles all required approvals, including the allowance reset for
tokens like USDT.
In GraphQL, if the response is a Transaction, you can send it directly using the wallet associated with the from address.
{
"data": {
"liquidatePosition": {
"__typename": "TransactionRequest",
"to": "0x123…",
"from": "0x789…",
"data": "0xc2fa746c…",
"value": "0",
"chainId": 1,
"operations": ["LIQUIDATION"]
}
}
}
If the response is Erc20, you have two options:
Transaction-based approval
- Send each transaction in the
approvalsarray in sequence - Then send
originalTransaction
Permit-based approval
This is available only when approvals contains a single Erc20
item.
- Sign the
approvals[0].typed data with the sender’s account.by Signature - Call the
liquidatequery again with the permit signaturePosition - The response will be a
Transactionyou can then send directlyRequest
{
"data": {
"liquidatePosition": {
"__typename": "Erc20ApprovalRequired",
"reason": "Insufficient allowance for ERC-20 token transfer",
"requiredAmount": {
"value": "1000.000000",
"decimals": 6
},
"currentAllowance": {
"value": "0.000000",
"decimals": 6
},
"approvals": [
{
"byTransaction": {
"to": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
"from": "0x789…",
"data": "0x095ea7b3000000000000000000000000878…",
"value": "0",
"chainId": 1,
"operations": null
},
"bySignature": {
"types": { /* … */ },
"primaryType": "Permit",
"domain": { /* … */ },
"message": { /* … */ }
}
}
],
"originalTransaction": {
"to": "0x123…",
"from": "0x789…",
"data": "0xc2fa746c…",
"value": "0",
"chainId": 1,
"operations": ["LIQUIDATION"]
}
}
}
}
{
"data": {
"liquidatePosition": {
"__typename": "Erc20ApprovalRequired",
"reason": "Token requires allowance reset before new approval",
"requiredAmount": {
"value": "1000.000000",
"decimals": 6
},
"currentAllowance": {
"value": "500.000000",
"decimals": 6
},
"approvals": [
{
"byTransaction": {
"to": "0xdAC17F958D2ee523a2206206994597C13D831ec7",
"from": "0x789…",
"data": "0x095ea7b3000000000000000000000000878…0",
"value": "0",
"chainId": 1,
"operations": null
},
"bySignature": null
},
{
"byTransaction": {
"to": "0xdAC17F958D2ee523a2206206994597C13D831ec7",
"from": "0x789…",
"data": "0x095ea7b3000000000000000000000000878…1000",
"value": "0",
"chainId": 1,
"operations": null
},
"bySignature": null
}
],
"originalTransaction": {
"to": "0x123…",
"from": "0x789…",
"data": "0xc2fa746c…",
"value": "0",
"chainId": 1,
"operations": ["LIQUIDATION"]
}
}
}
}
With multiple approvals in Erc20, send each by
in order, then original. For USDT on Ethereum
Mainnet,
the first resets the allowance to 0 and the second sets the required amount.
If the response is an Insufficient, inform the user that they don’t have enough funds to perform the liquidation.
{
"data": {
"liquidatePosition": {
"__typename": "InsufficientBalanceError",
"required": {
"value": "1000.000000",
"decimals": 6
},
"available": {
"value": "500.000000",
"decimals": 6
}
}
}
}
5. Handle the Result
Finally, in React and TypeScript, handle the result.
const execute = async (/* … */) => {
const result = await liquidatePosition(/* … */);
if (result.isErr()) {
switch (result.error.name) {
case "CancelError":
// The user cancelled the operation
return;
case "SigningError":
console.error(
`Failed to sign the transaction: ${result.error.message}`,
);
break;
case "TimeoutError":
console.error(`Transaction timed out: ${result.error.message}`);
break;
case "TransactionError":
console.error(`Transaction failed: ${result.error.message}`);
break;
case "ValidationError":
console.error(
"Insufficient balance:",
`required: ${result.error.cause.required.value.toDisplayString(2)}`,
`available: ${result.error.cause.available.value.toDisplayString(2)}`,
);
break;
case "UnexpectedError":
console.error(result.error.message);
break;
}
return;
}
console.log("Liquidation successful with hash:", result.value.txHash);
};
if (result.isErr()) {
switch (result.error.name) {
case "CancelError":
// The user cancelled the operation
return;
case "SigningError":
console.error(`Failed to sign the transaction: ${result.error.message}`);
break;
case "TimeoutError":
console.error(`Transaction timed out: ${result.error.message}`);
break;
case "TransactionError":
console.error(`Transaction failed: ${result.error.message}`);
break;
case "ValidationError":
console.error(
"Insufficient balance:",
`required: ${result.error.cause.required.value.toDisplayString(2)}`,
`available: ${result.error.cause.available.value.toDisplayString(2)}`,
);
break;
case "UnexpectedError":
console.error(result.error.message);
break;
}
} else {
console.log("Liquidation successful with hash:", result.value.txHash);
}
Step-by-Step with Solidity
Start with the spoke address and target user identifying the position:
address spokeAddress = 0x123…;
address targetUser = 0x456…;
And with the on-chain reserve IDs for the collateral and debt:
uint256 collateralReserveId = 43;
uint256 debtReserveId = 42;
Choose the amount of debt to cover:
uint256 debtToCover = 1000e6; // 1000 USDC
uint256 debtToCover = type(uint256).max;
And follow these steps to liquidate the position onchain:
1. Import the Spoke Interface
First, create an instance of the ISpoke interface.
import { ISpoke } from "aave-v4/src/spoke/interfaces/ISpoke.sol";
ISpoke spoke = ISpoke(spokeAddress);
2. Approve Token Transfer
Then, retrieve the debt reserve’s underlying token and approve the Spoke to transfer the debt tokens.
import { IERC20 } from "aave-v4/src/dependencies/openzeppelin/IERC20.sol";
ISpoke.Reserve memory debtReserve = spoke.getReserve(debtReserveId);
IERC20 token = IERC20(debtReserve.underlying);
token.approve(address(spoke), debtToCover);
3. Execute Liquidation
Finally, call the liquidation function on the Spoke contract, specifying the liquidation parameters.
spoke.liquidationCall(
collateralReserveId,
debtReserveId,
targetUser,
debtToCover,
false
);
Complete Example
Here are complete Solidity examples for checking position health and executing liquidation:
import { ISpoke } from "aave-v4/src/spoke/interfaces/ISpoke.sol";
function canLiquidate(
address spokeAddress,
address user
) external view returns (bool, uint256 healthFactor) {
ISpoke spoke = ISpoke(spokeAddress);
ISpoke.UserAccountData memory accountData = spoke.getUserAccountData(user);
healthFactor = accountData.healthFactor;
if (accountData.totalDebtValueRay == 0) {
return (false, type(uint256).max);
}
return (healthFactor < 1e18, healthFactor);
}
import { ISpoke } from "aave-v4/src/spoke/interfaces/ISpoke.sol";
import { IERC20 } from "aave-v4/src/dependencies/openzeppelin/IERC20.sol";
function liquidatePosition(
address spokeAddress,
uint256 collateralReserveId,
uint256 debtReserveId,
address user,
uint256 debtToCover
) external {
ISpoke spoke = ISpoke(spokeAddress);
ISpoke.Reserve memory debtReserve = spoke.getReserve(debtReserveId);
IERC20 debtToken = IERC20(debtReserve.underlying);
debtToken.approve(spokeAddress, debtToCover);
spoke.liquidationCall(
collateralReserveId,
debtReserveId,
user,
debtToCover,
false
);
}