Back to Blog
Aave V3 — DeFi Protocol's Code Explained. Part 1 — Pool.sol
DeFiSmart ContractsAaveAudit

Aave V3 — DeFi Protocol's Code Explained. Part 1 — Pool.sol

8 min
Continuing the saga of DeFi Protocol by code, here I have started the first part of the walk-through Aave V3 protocol's smart contracts.
It is super helpful to dive into the code of such popular protocols because first of all you'll understand it much better and second because all new protocols forked from Aave will be based in the same code and if you end up auditing one of them, you'll already be familiar and it will make you save time.
In this article we are going to cover:
  • Introduction & Architecture
  • Usages
  • Pool.sol main functions:
    • supply()
    • withdraw()
    • borrow()
    • repay()

What is Aave?

Aave is a decentralised non-custodial liquidity market protocol where users can participate as suppliers or borrowers. Suppliers provide liquidity to the market to earn a passive income, while borrowers are able to borrow unexpected expenses, leveraging their holdings.
Liquidity is at the heart of the Aave Protocol as it enables the protocol's operations and user experience.
The liquidity of the protocol is measured by the availability of assets for basic protocol operations such as borrowing assets backed by collateral and claiming supplied assets along with accrued yield. A lack of liquidity will block operations.

Architecture

The Aave Protocol V3 contracts are divided in two repositories:
aave-v3-core — Hosts core protocol V3 contracts that contains the logic for supply, borrow, liquidation, flashloan, a/s/v tokens, portal, pool configuration, oracles and interest rate strategies.
Core protocol contracts fall in 4 categories:
  • Configuration
  • Pool logic
  • Tokenization
  • Misc
aave-v3-periphery — In this repository you will find the contracts related to rewards, ui data provider, incentive data provider, wallet balance provider and WETH gateway.
There are 2 categories of contracts:
  • Rewards
  • Misc

What can we do with Aave protocol?

Supply & Earn. By supplying, you will earn passive income based on the market borrowing demand.
Borrow. Additionally, supplying assets allows you to borrow by using your supplied assets as a collateral.

Pool.sol Smart Contract

I'm going to focus this Part 1 of the Aave V3 series on this contract, so that we can see first how it's composed one of the higher level smart contract and how it links with other low level contracts.
The Pool.sol contract is the main user facing contract of the protocol. It exposes the liquidity management methods.
Pool.sol is owned by the PoolAddressesProvider of the specific market. All admin functions are callable by the PoolConfigurator contract, which is defined in PoolAddressesProvider.
I have created this diagram which shows how Pool's main functions interact with low level functions.
Pool.sol function interactions diagram
Something interesting to mention here is that in the Pool contract itself it will mainly be calling to either internal functions, such as executeSupply() inside the contract SupplyLogic.sol or libraries like DataTypes which holds and stores the function's main parameters in structs.

function supply()

1function supply(
2 address asset,
3 uint256 amount,
4 address onBehalfOf,
5 uint16 referralCode
6) external;
  • asset: address of the asset being supplied to the pool.
  • amount: amount of asset being supplied.
  • onBehalfOf: address that will receive the corresponding aTokens. Only the onBehalfOf address will be able to withdraw asset from the pool.
  • referralCode: unique code for 3rd party referral program integration. Use 0 for no referral.
Supplies an amount of underlying asset into the reserve, receiving in return overlying aTokens. E.g. User supplies 100 USDC and gets in return 100 aUSDC.
Inside the function we find only the call to the internal function executeSupply() inside SupplyLogic.sol:
1SupplyLogic.executeSupply(
2 _reserves,
3 _reservesList,
4 _usersConfig[onBehalfOf],
5 DataTypes.ExecuteSupplyParams({
6 asset: asset,
7 amount: amount,
8 onBehalfOf: onBehalfOf,
9 referralCode: referralCode
10 })
11);
Don't get overwhelmed by seeing this ExecuteSupplyParams call inside the function's parameter. It's simply passing a list of parameters wrapped in a struct.
Now, looking inside executeSupply() we can split it in three parts:

1. Updates and validations

1reserve.updateState(reserveCache);
2
3ValidationLogic.validateSupply(reserveCache, reserve, params.amount);
4
5reserve.updateInterestRates(reserveCache, params.asset, params.amount, 0);
First it will update the reserves with the provided reserveData from the specific asset passed as argument.
Right after, it will pass this data to validation where the main checks will be that this asset fulfils the following:
1require(isActive, Errors.RESERVE_INACTIVE);
2require(!isPaused, Errors.RESERVE_PAUSED);
3require(!isFrozen, Errors.RESERVE_FROZEN);
And last thing to update is the interest rates with updateInterestRates() which receives as parameters, the reserves cached, the specific asset and the amount of the asset.

2. The main action — supply and mint

The supply of the ERC20 is done by using a safeTransferFrom function and the mint of the aTokens:
1IERC20(params.asset).safeTransferFrom(
2 msg.sender,
3 reserveCache.aTokenAddress,
4 params.amount
5);
6
7IAToken(reserveCache.aTokenAddress).mint(
8 msg.sender,
9 params.onBehalfOf,
10 params.amount,
11 reserveCache.nextLiquidityIndex
12);

3. Setting the collateral value

This will only happen if it is the first time the sender makes a supply. That's why we have the condition to be set if isFirstSupply, the validation before modifying anything and finally the setter:
1if (isFirstSupply) {
2 if (ValidationLogic.validateUseAsCollateral()) {
3 userConfig.setUsingAsCollateral(reserve.id, true);
4 }
5}

function withdraw()

1function withdraw(
2 address asset,
3 uint256 amount,
4 address to
5) external returns (uint256);
Withdraws an amount of underlying asset from the reserve, burning the equivalent aTokens owned. E.g. User has 100 aUSDC, calls withdraw() and receives 100 USDC, burning the 100 aUSDC.
Likewise supply, withdraw directly calls to the internal function executeWithdraw inside SupplyLogic.sol:
1SupplyLogic.executeWithdraw(
2 _reserves,
3 _reservesList,
4 _eModeCategories,
5 _usersConfig[msg.sender],
6 DataTypes.ExecuteWithdrawParams({
7 asset: asset,
8 amount: amount,
9 to: to,
10 reservesCount: _reservesCount,
11 oracle: ADDRESSES_PROVIDER.getPriceOracle(),
12 userEModeCategory: _usersEModeCategory[msg.sender]
13 })
14);
Inside executeWithdraw there are two main parts:

1. Update and validate

1ValidationLogic.validateWithdraw(reserveCache, amountToWithdraw, userBalance);
2
3reserve.updateInterestRates(reserveCache, params.asset, 0, amountToWithdraw);

Get the DeFi Protocol Security Checklist

15 vulnerabilities every DeFi team should check before mainnet. Used by 40+ protocols.

No spam. Unsubscribe anytime.

As with executeSupply it validates the provided parameters in order to update interest rates.
Then it checks isUsingAsCollateral() and if it is true and the amount desired to withdraw is as high as the userBalance then it will cancel the existing collateral:
1if (isCollateral && amountToWithdraw == userBalance) {
2 userConfig.setUsingAsCollateral(reserve.id, false);
3}

2. Burn the aTokens

Burns aTokens from user and sends the equivalent amount of underlying to the address specified in params.to:
1IAToken(reserveCache.aTokenAddress).burn(
2 msg.sender,
3 params.to,
4 amountToWithdraw,
5 reserveCache.nextLiquidityIndex
6);

function borrow()

1function borrow(
2 address asset,
3 uint256 amount,
4 uint256 interestRateMode,
5 uint16 referralCode,
6 address onBehalfOf
7) external;
Allows users to borrow a specific amount of the reserve underlying asset, provided that the borrower already supplied enough collateral, or was given enough allowance by a credit delegator on the corresponding debt token (StableDebtToken or VariableDebtToken).
E.g. User borrows 100 USDC passing as onBehalfOf his own address, receiving the 100 USDC in his wallet and 100 stable/variable debt tokens, depending on the interestRateMode.
Inside borrow it directly calls the internal executeBorrow function inside BorrowLogic.sol:
1BorrowLogic.executeBorrow(
2 _reserves,
3 _reservesList,
4 _eModeCategories,
5 _usersConfig[onBehalfOf],
6 DataTypes.ExecuteBorrowParams({
7 asset: asset,
8 user: msg.sender,
9 onBehalfOf: onBehalfOf,
10 amount: amount,
11 interestRateMode: DataTypes.InterestRateMode(interestRateMode),
12 referralCode: referralCode,
13 releaseUnderlying: true,
14 maxStableRateBorrowSizePercent: _maxStableRateBorrowSizePercent,
15 reservesCount: _reservesCount,
16 oracle: ADDRESSES_PROVIDER.getPriceOracle(),
17 userEModeCategory: _usersEModeCategory[onBehalfOf],
18 priceOracleSentinel: ADDRESSES_PROVIDER.getPriceOracleSentinel()
19 })
20);
One thing to highlight is the interestRateMode. It is an enum that determines which debt token will be minted:
1enum InterestRateMode {
2 NONE,
3 STABLE,
4 VARIABLE
5}
If STABLE it will execute:
1IStableDebtToken(reserveCache.stableDebtTokenAddress).mint(
2 params.user,
3 params.onBehalfOf,
4 params.amount,
5 currentStableRate
6);
If VARIABLE:
1IVariableDebtToken(reserveCache.variableDebtTokenAddress).mint(
2 params.user,
3 params.onBehalfOf,
4 params.amount,
5 reserveCache.nextVariableBorrowIndex
6);
Once the tokens are minted, the actual transfer of the underlying asset to the user happens:
1IAToken(reserveCache.aTokenAddress).transferUnderlyingTo(
2 params.user,
3 params.amount
4);

function repay()

1function repay(
2 address asset,
3 uint256 amount,
4 uint256 interestRateMode,
5 address onBehalfOf
6) external returns (uint256);
Repays a borrowed amount on a specific reserve, burning the equivalent debt tokens owned. E.g. User repays 100 USDC, burning 100 variable/stable debt tokens of the onBehalfOf address.
As in the rest of functions, repay calls its internal function executeRepay inside BorrowLogic.sol:
1BorrowLogic.executeRepay(
2 _reserves,
3 _reservesList,
4 _usersConfig[onBehalfOf],
5 DataTypes.ExecuteRepayParams({
6 asset: asset,
7 amount: amount,
8 interestRateMode: DataTypes.InterestRateMode(interestRateMode),
9 onBehalfOf: onBehalfOf,
10 useATokens: false
11 })
12);
Note useATokens is set to false here — there is another method called repayWithATokens() which allows a user to repay with aTokens without leaving dust from interest.
The user repays its debt by either burning its stable/variable debt token:
1IStableDebtToken(reserveCache.stableDebtTokenAddress).burn(
2 params.onBehalfOf,
3 paybackAmount
4);
5
6IVariableDebtToken(reserveCache.variableDebtTokenAddress).burn(
7 params.onBehalfOf,
8 paybackAmount,
9 reserveCache.nextVariableBorrowIndex
10);
Or by burning the aTokens received when providing liquidity through the supply() function:
1IAToken(reserveCache.aTokenAddress).burn(
2 msg.sender,
3 reserveCache.aTokenAddress,
4 paybackAmount,
5 reserveCache.nextLiquidityIndex
6);
The key difference:
  • Using aTokens to repay: the transaction is finished — there is no additional transfer, because as mentioned in withdraw(), aTokens represent the user's supplied equivalent.
  • Otherwise: the IERC20().safeTransferFrom() executes to transfer the specified amount from msg.sender:
1IERC20(params.asset).safeTransferFrom(
2 msg.sender,
3 reserveCache.aTokenAddress,
4 paybackAmount
5);
6
7IAToken(reserveCache.aTokenAddress).handleRepayment(
8 msg.sender,
9 params.onBehalfOf,
10 paybackAmount
11);

Get in touch

Understanding protocols like Aave at the code level is what sets thorough auditors apart. At Zealynx, we bring this same depth to every engagement — dissecting core logic, library interactions, and edge cases across DeFi's most forked codebases. Reach out to discuss how we can secure your protocol.

Get the DeFi Protocol Security Checklist

15 vulnerabilities every DeFi team should check before mainnet. Used by 40+ protocols.

No spam. Unsubscribe anytime.

oog
zealynx

Smart Contract Security Digest

Monthly exploit breakdowns, audit checklists, and DeFi security research — straight to your inbox

© 2026 Zealynx