DeFi composability — Uniswap V3/V4, Aave V3, ERC-4626 vaults, Chainlink oracles, and how to compose them
Every DeFi protocol is a lego piece. Flash loan from Aave, swap on Uniswap, deposit into a vault — all in one transaction. This composability is what makes Ethereum DeFi structurally different from TradFi. It is also what makes it dangerous.
Live since January 2025. The AMM is no longer a fixed formula — hooks let you inject custom logic at every lifecycle point.
PoolManager (singleton)
├── Pool A (ETH/USDC, 0.3% fee, Hook X)
├── Pool B (ETH/USDC, 0.05% fee, no hook)
└── Pool C (WBTC/ETH, 1% fee, Hook Y)
All pools live inside a single PoolManager contract. No more deploying pair contracts. This uses a singleton pattern with transient storage for massive gas savings.
| Hook | When It Fires | Use Case |
|---|---|---|
beforeInitialize | Pool creation | Validate pool parameters |
afterInitialize | Pool created | Initialize hook state |
beforeSwap | Before every swap | Custom fees, circuit breakers |
afterSwap | After every swap | MEV capture, analytics |
beforeAddLiquidity | Before LP deposit | Restrict who can LP |
afterAddLiquidity | After LP deposit | Auto-compounding |
beforeRemoveLiquidity | Before LP withdrawal | Withdrawal fees, timelocks |
afterRemoveLiquidity | After LP withdrawal | Cleanup |
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {BaseHook} from "@uniswap/v4-periphery/src/base/hooks/BaseHook.sol";
import {Hooks} from "@uniswap/v4-core/src/libraries/Hooks.sol";
import {IPoolManager} from "@uniswap/v4-core/src/interfaces/IPoolManager.sol";
import {PoolKey} from "@uniswap/v4-core/src/types/PoolKey.sol";
import {BeforeSwapDelta, BeforeSwapDeltaLibrary} from "@uniswap/v4-core/src/types/BeforeSwapDelta.sol";
/// @notice A hook that charges a dynamic fee based on volatility
contract DynamicFeeHook is BaseHook {
uint256 public constant HIGH_VOL_FEE = 100; // 1% in bps
uint256 public constant LOW_VOL_FEE = 5; // 0.05% in bps
constructor(IPoolManager _poolManager) BaseHook(_poolManager) {}
function getHookPermissions() public pure override returns (Hooks.Permissions memory) {
return Hooks.Permissions({
beforeInitialize: false,
afterInitialize: false,
beforeAddLiquidity: false,
afterAddLiquidity: false,
beforeRemoveLiquidity: false,
afterRemoveLiquidity: false,
beforeSwap: true, // We need this
afterSwap: false,
beforeDonate: false,
afterDonate: false,
beforeSwapReturnDelta: false,
afterSwapReturnDelta: false,
afterAddLiquidityReturnDelta: false,
afterRemoveLiquidityReturnDelta: false
});
}
function beforeSwap(
address,
PoolKey calldata key,
IPoolManager.SwapParams calldata params,
bytes calldata
) external override returns (bytes4, BeforeSwapDelta, uint24) {
uint24 fee = _isHighVolatility()
? uint24(HIGH_VOL_FEE)
: uint24(LOW_VOL_FEE);
return (BaseHook.beforeSwap.selector, BeforeSwapDeltaLibrary.ZERO_DELTA, fee);
}
function _isHighVolatility() internal view returns (bool) {
// Your volatility detection logic here
return false;
}
}
import {PoolKey} from "@uniswap/v4-core/src/types/PoolKey.sol";
import {Currency} from "@uniswap/v4-core/src/types/Currency.sol";
import {IPoolManager} from "@uniswap/v4-core/src/interfaces/IPoolManager.sol";
PoolKey memory key = PoolKey({
currency0: Currency.wrap(address(token0)),
currency1: Currency.wrap(address(token1)),
fee: 3000, // 0.3%
tickSpacing: 60,
hooks: IHooks(hookAddress) // Your hook contract
});
// Initialize the pool
IPoolManager(POOL_MANAGER).initialize(key, sqrtPriceX96);
The hook contract address must encode which callbacks it uses. The last 14 bits of the address determine which hooks are active. Use CREATE2 with salt mining to deploy to a valid address.
| Contract | Address (All Chains via CREATE2) |
|---|---|
| PoolManager | 0x000000000004444c5dc75cB358380D2e3dE08A90 |
Verify at docs.uniswap.org.
Still the most battle-tested and widely deployed DEX. V3's concentrated liquidity lets LPs focus their capital in specific price ranges for up to 4000x better capital efficiency on stablecoin pairs.
import {ISwapRouter} from "@uniswap/v3-periphery/contracts/interfaces/ISwapRouter.sol";
function swapExactInput(
address tokenIn,
address tokenOut,
uint256 amountIn,
uint256 amountOutMinimum // NEVER set to 0
) external returns (uint256 amountOut) {
IERC20(tokenIn).approve(UNISWAP_V3_ROUTER, amountIn);
ISwapRouter.ExactInputSingleParams memory params = ISwapRouter.ExactInputSingleParams({
tokenIn: tokenIn,
tokenOut: tokenOut,
fee: 3000, // 0.3% pool
recipient: msg.sender,
deadline: block.timestamp + 300, // 5 minutes — NEVER use block.timestamp alone
amountIn: amountIn,
amountOutMinimum: amountOutMinimum,
sqrtPriceLimitX96: 0
});
amountOut = ISwapRouter(UNISWAP_V3_ROUTER).exactInputSingle(params);
}
function swapMultiHop(uint256 amountIn, uint256 amountOutMin) external returns (uint256) {
// Path: WBTC -> ETH -> USDC (encoded as token-fee-token-fee-token)
bytes memory path = abi.encodePacked(
WBTC,
uint24(3000), // WBTC/ETH 0.3% pool
WETH,
uint24(500), // ETH/USDC 0.05% pool
USDC
);
ISwapRouter.ExactInputParams memory params = ISwapRouter.ExactInputParams({
path: path,
recipient: msg.sender,
deadline: block.timestamp + 300,
amountIn: amountIn,
amountOutMinimum: amountOutMin // Calculate offchain, NEVER 0
});
return ISwapRouter(UNISWAP_V3_ROUTER).exactInput(params);
}
| Contract | Address |
|---|---|
| SwapRouter | 0xE592427A0AEce92De3Edee1F18E0157C05861564 |
| SwapRouter02 | 0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45 |
| NonfungiblePositionManager | 0xC36442b4a4522E871399CD717aBDD847Ab11FE88 |
| Factory | 0x1F98431c8aD98523631AE4a59f267346ea31F984 |
| Quoter V2 | 0x61fFE014bA17989E743c5F6cB21bF9697530B21e |
| Contract | Address |
|---|---|
| SwapRouter02 | 0x2626664c2603336E57B271c5C0b26F421741e481 |
| Factory | 0x33128a8fC17869897dcE68Ed026d694621f6FDfD |
| Contract | Address |
|---|---|
| SwapRouter | 0xE592427A0AEce92De3Edee1F18E0157C05861564 |
| SwapRouter02 | 0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45 |
| Factory | 0x1F98431c8aD98523631AE4a59f267346ea31F984 |
Warning: Always verify at docs.uniswap.org/contracts/v3/reference/deployments.
The dominant lending protocol. Supply collateral, borrow assets, or flash loan unlimited capital for a single transaction.
import {IPool} from "@aave/v3-core/contracts/interfaces/IPool.sol";
address constant AAVE_POOL = 0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2; // Ethereum
// Supply collateral
function supplyToAave(address asset, uint256 amount) external {
IERC20(asset).approve(AAVE_POOL, amount);
IPool(AAVE_POOL).supply(
asset,
amount,
msg.sender, // onBehalfOf
0 // referralCode
);
}
// Borrow against collateral
function borrowFromAave(address asset, uint256 amount) external {
IPool(AAVE_POOL).borrow(
asset,
amount,
2, // interestRateMode: 1=stable, 2=variable
0, // referralCode
msg.sender // onBehalfOf
);
}
// Repay
function repayAave(address asset, uint256 amount) external {
IERC20(asset).approve(AAVE_POOL, amount);
IPool(AAVE_POOL).repay(
asset,
amount,
2, // interestRateMode
msg.sender // onBehalfOf
);
}
Borrow any amount with zero collateral. Must repay principal + 0.05% fee in the same transaction.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {IPool} from "@aave/v3-core/contracts/interfaces/IPool.sol";
import {IFlashLoanSimpleReceiver} from "@aave/v3-core/contracts/flashloan/base/FlashLoanSimpleReceiver.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
contract FlashLoanArb is IFlashLoanSimpleReceiver {
using SafeERC20 for IERC20;
IPool public immutable POOL;
address public immutable OWNER;
constructor(address pool) {
POOL = IPool(pool);
OWNER = msg.sender;
}
function executeArbitrage(address asset, uint256 amount, bytes calldata params) external {
require(msg.sender == OWNER, "Not owner");
POOL.flashLoanSimple(address(this), asset, amount, params, 0);
}
function executeOperation(
address asset,
uint256 amount,
uint256 premium, // The fee (0.05% of amount)
address initiator,
bytes calldata params
) external override returns (bool) {
require(msg.sender == address(POOL), "Not pool");
require(initiator == address(this), "Not self-initiated");
// === YOUR LOGIC HERE ===
// You have `amount` of `asset` to work with.
// Swap on DEX A, swap back on DEX B, arbitrage, liquidate, etc.
// === REPAY ===
uint256 totalDebt = amount + premium;
IERC20(asset).safeApprove(address(POOL), totalDebt);
return true;
}
function ADDRESSES_PROVIDER() external view override returns (IPoolAddressesProvider) {
return IPoolAddressesProvider(POOL.ADDRESSES_PROVIDER());
}
}
function executeMultiFlashLoan() external {
address[] memory assets = new address[](2);
assets[0] = USDC; // 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48
assets[1] = WETH; // 0xC02aaA39b223FE8D0A0e5CB4F27eAD9083C756Cc
uint256[] memory amounts = new uint256[](2);
amounts[0] = 1_000_000e6; // 1M USDC
amounts[1] = 500 ether; // 500 ETH
uint256[] memory modes = new uint256[](2);
modes[0] = 0; // 0 = flash loan (must repay)
modes[1] = 0;
IPool(AAVE_POOL).flashLoan(
address(this),
assets,
amounts,
modes,
address(this),
"",
0
);
}
| Contract | Ethereum | Base | Arbitrum |
|---|---|---|---|
| Pool | 0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2 | 0xA238Dd80C259a72e81d7e4664a9801593F98d1c5 | 0x794a61358D6845594F94dc1DB02A252b5b4814aD |
| PoolAddressesProvider | 0x2f39d218133AFaB8F2B819B1066c7E434Ad94E9e | 0xe20fCBdBfFC4Dd138cE8b2E6FBb6CB49777ad64D | 0xa97684ead0e402dC232d5A977953DF7ECBaB3CDb |
Warning: Verify at docs.aave.com/developers/deployed-contracts.
The universal deposit-withdraw interface. See the standards skill for the full interface. Here we focus on building and composing vaults.
import {ERC4626} from "@openzeppelin/contracts/token/ERC20/extensions/ERC4626.sol";
import {ERC20, IERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
contract YieldVault is ERC4626 {
using SafeERC20 for IERC20;
address constant AAVE_POOL = 0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2;
constructor(IERC20 _asset)
ERC4626(_asset)
ERC20("Yield Vault Shares", "yvUSDC")
{}
/// @notice Total assets includes deposited + earned yield
function totalAssets() public view override returns (uint256) {
return IERC20(asset()).balanceOf(address(this));
}
/// @notice Deploy assets into yield strategy on deposit
function _deposit(address caller, address receiver, uint256 assets, uint256 shares) internal override {
super._deposit(caller, receiver, assets, shares);
_deployToStrategy(assets);
}
/// @notice Withdraw from strategy before sending to user
function _withdraw(
address caller, address receiver, address owner, uint256 assets, uint256 shares
) internal override {
_withdrawFromStrategy(assets);
super._withdraw(caller, receiver, owner, assets, shares);
}
function _deployToStrategy(uint256 amount) internal {
IERC20(asset()).forceApprove(AAVE_POOL, amount);
IPool(AAVE_POOL).supply(asset(), amount, address(this), 0);
}
function _withdrawFromStrategy(uint256 amount) internal {
IPool(AAVE_POOL).withdraw(asset(), amount, address(this));
}
// Inflation attack protection
function _decimalsOffset() internal pure override returns (uint8) {
return 3;
}
}
ERC-4626 vault shares are ERC-20 tokens. You can compose them:
// Vault-of-vaults: diversified yield
contract MetaVault is ERC4626 {
IERC4626[] public underlyingVaults;
function totalAssets() public view override returns (uint256) {
uint256 total;
for (uint256 i = 0; i < underlyingVaults.length; i++) {
uint256 shares = underlyingVaults[i].balanceOf(address(this));
total += underlyingVaults[i].convertToAssets(shares);
}
return total;
}
}
convertToShares(assets) and convertToAssets(shares) are the core accounting functions.deposit and mint round against the depositor. withdraw and redeem round against the withdrawer. The vault always wins rounding.import {AggregatorV3Interface} from "@chainlink/contracts/src/v0.8/interfaces/AggregatorV3Interface.sol";
function getPrice(address feed) internal view returns (uint256) {
(
uint80 roundId,
int256 answer,
,
uint256 updatedAt,
uint80 answeredInRound
) = AggregatorV3Interface(feed).latestRoundData();
require(answer > 0, "Negative price");
require(updatedAt > block.timestamp - 3600, "Stale price");
require(answeredInRound >= roundId, "Stale round");
return uint256(answer); // 8 decimals
}
This is where most bugs happen. Chainlink returns 8 decimals. Tokens have varying decimals.
/// @notice Get the USD value of a token amount, normalized to 18 decimals
function getUsdValue(
address token,
uint256 amount,
address priceFeed
) public view returns (uint256) {
uint256 price = getPrice(priceFeed); // 8 decimals
uint8 tokenDecimals = IERC20Metadata(token).decimals();
// Normalize to 18 decimals:
// amount (tokenDecimals) * price (8 decimals) / 10^tokenDecimals * 10^18 / 10^8
return (amount * price * 1e18) / (10 ** tokenDecimals * 1e8);
}
| Pair | Address | Decimals | Heartbeat |
|---|---|---|---|
| ETH/USD | 0x5f4eC3Df9cbd43714FE2740f5E3616155c5b8419 | 8 | 1 hour |
| BTC/USD | 0xF4030086522a5bEEa4988F8cA5B36dbC97BeE88c | 8 | 1 hour |
| USDC/USD | 0x8fFfFfd4AfB6115b954Bd326cbe7B4BA576818f6 | 8 | 24 hours |
| DAI/USD | 0xAed0c38402a5d19df6E4c03F4E2DceD6e29c1ee9 | 8 | 1 hour |
| LINK/USD | 0x2c1d072e956AFFC0D435Cb7AC38EF18d24d9127c | 8 | 1 hour |
| Pair | Address | Decimals |
|---|---|---|
| ETH/USD | 0x71041dddad3595F9CEd3DcCFBe3D1F4b0a16Bb70 | 8 |
| USDC/USD | 0x7e860098F58bBFC8648a4311b374B1D669a2bc6B | 8 |
Warning: Feed addresses differ per chain. Verify at data.chain.link.
For onchain randomness: lotteries, NFT reveals, gaming.
import {VRFConsumerBaseV2Plus} from "@chainlink/contracts/src/v0.8/vrf/dev/VRFConsumerBaseV2Plus.sol";
import {VRFV2PlusClient} from "@chainlink/contracts/src/v0.8/vrf/dev/libraries/VRFV2PlusClient.sol";
contract RandomNFT is VRFConsumerBaseV2Plus {
uint256 public s_subscriptionId;
bytes32 public constant KEY_HASH =
0x787d74caea10b2b357790d5b5247c2f63d1d91572a9846f780606e4d953677ae;
mapping(uint256 => address) public requestToSender;
constructor(uint256 subscriptionId, address vrfCoordinator)
VRFConsumerBaseV2Plus(vrfCoordinator)
{
s_subscriptionId = subscriptionId;
}
function requestRandomMint() external returns (uint256 requestId) {
requestId = s_vrfCoordinator.requestRandomWords(
VRFV2PlusClient.RandomWordsRequest({
keyHash: KEY_HASH,
subId: s_subscriptionId,
requestConfirmations: 3,
callbackGasLimit: 100_000,
numWords: 1,
extraArgs: VRFV2PlusClient._argsToBytes(
VRFV2PlusClient.ExtraArgsV1({nativePayment: false})
)
})
);
requestToSender[requestId] = msg.sender;
}
function fulfillRandomWords(uint256 requestId, uint256[] calldata randomWords) internal override {
address minter = requestToSender[requestId];
uint256 tokenId = randomWords[0] % 10_000;
_mint(minter, tokenId);
}
}
Aave Flash Loan → 1M USDC
→ Swap USDC→ETH on Uniswap (ETH is cheaper here)
→ Swap ETH→USDC on Sushiswap (ETH is more expensive here)
→ Repay 1M USDC + 500 USDC fee to Aave
→ Keep profit
function executeOperation(
address asset,
uint256 amount,
uint256 premium,
address,
bytes calldata
) external returns (bool) {
// 1. Swap USDC -> WETH on Uniswap
IERC20(USDC).approve(UNI_ROUTER, amount);
uint256 wethAmount = ISwapRouter(UNI_ROUTER).exactInputSingle(
ISwapRouter.ExactInputSingleParams({
tokenIn: USDC, tokenOut: WETH, fee: 3000,
recipient: address(this), deadline: block.timestamp,
amountIn: amount, amountOutMinimum: 0, sqrtPriceLimitX96: 0
})
);
// 2. Swap WETH -> USDC on another DEX
IERC20(WETH).approve(OTHER_ROUTER, wethAmount);
uint256 usdcBack = IOtherRouter(OTHER_ROUTER).swap(WETH, USDC, wethAmount);
// 3. Repay flash loan + fee
require(usdcBack > amount + premium, "No profit");
IERC20(USDC).approve(address(POOL), amount + premium);
return true;
}
1. Deposit 10,000 USDC as collateral on Aave
2. Borrow 7,000 USDC (70% LTV)
3. Deposit 7,000 USDC into a yield vault earning 8% APY
4. Aave borrow rate: 3% APY
5. Net yield on borrowed capital: 5%
6. Effective yield: (10,000 * 0% + 7,000 * 5%) / 10,000 = 3.5% extra
Risk: If vault yield drops below borrow cost, you pay to farm. If collateral value drops, liquidation.
User deposits USDC → Vault supplies to Aave → Earns aUSDC yield
→ Vault LPs on Uniswap → Earns swap fees
→ Combined yield returned to vault shareholders
Chainlink Automation monitors vault allocation
→ When allocation drifts >5% from target
→ Keeper calls vault.rebalance()
→ Vault withdraws from underweight strategy
→ Vault deposits into overweight strategy
Smart contracts do not have cron jobs. Chainlink Automation (formerly Keepers) or Gelato provide the "poke" to trigger rebalancing, harvesting, or liquidation.
contract ComposabilityForkTest is Test {
address constant AAVE_POOL = 0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2;
address constant UNI_ROUTER = 0xE592427A0AEce92De3Edee1F18E0157C05861564;
address constant USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48;
address constant WETH = 0xC02aaA39b223FE8D0A0e5CB4F27eAD9083C756Cc;
function setUp() external {
vm.createSelectFork("mainnet", 19_000_000);
}
function testFork_supplyAndBorrow() external {
address user = makeAddr("user");
deal(WETH, user, 10 ether);
vm.startPrank(user);
// Supply WETH as collateral
IERC20(WETH).approve(AAVE_POOL, 10 ether);
IPool(AAVE_POOL).supply(WETH, 10 ether, user, 0);
// Borrow USDC against it
IPool(AAVE_POOL).borrow(USDC, 5_000e6, 2, 0, user);
vm.stopPrank();
assertEq(IERC20(USDC).balanceOf(user), 5_000e6);
}
function testFork_swapOnUniswapV3() external {
address user = makeAddr("user");
deal(USDC, user, 10_000e6);
vm.startPrank(user);
IERC20(USDC).approve(UNI_ROUTER, 10_000e6);
ISwapRouter.ExactInputSingleParams memory params = ISwapRouter.ExactInputSingleParams({
tokenIn: USDC,
tokenOut: WETH,
fee: 3000,
recipient: user,
deadline: block.timestamp + 300,
amountIn: 10_000e6,
amountOutMinimum: 1 ether, // Expect at least 1 ETH
sqrtPriceLimitX96: 0
});
uint256 wethOut = ISwapRouter(UNI_ROUTER).exactInputSingle(params);
vm.stopPrank();
assertGt(wethOut, 1 ether, "Should receive at least 1 ETH");
}
}
Your Vault
└── Aave V3 (lending)
└── Chainlink (oracle)
└── Uniswap V3 (swaps)
└── ERC-4626 yield source
└── Another protocol
└── Another oracle
For each dependency, ask:
| Operation | Mainnet Gas | L2 Cost (est.) |
|---|---|---|
| ERC-20 approve | ~46,000 | $0.01 |
| Uniswap V3 swap | ~150,000 | $0.03 |
| Aave supply | ~250,000 | $0.05 |
| Aave flash loan (full cycle) | ~500,000 | $0.10 |
| 3-protocol composition | ~800,000+ | $0.15-0.50 |
On mainnet at 30 gwei, a 3-protocol composition costs $6-20. On Base or Arbitrum, $0.15-0.50. This cost difference is why composability patterns are production-ready on L2s.
| Token | Address | Decimals |
|---|---|---|
| WETH | 0xC02aaA39b223FE8D0A0e5CB4F27eAD9083C756Cc | 18 |
| USDC | 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 | 6 |
| USDT | 0xdAC17F958D2ee523a2206206994597C13D831ec7 | 6 |
| DAI | 0x6B175474E89094C44Da98b954EedeAC495271d0F | 18 |
| WBTC | 0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599 | 8 |
| LINK | 0x514910771AF9Ca656af840dff83E8264EcF986CA | 18 |
| Token | Address | Decimals |
|---|---|---|
| WETH | 0x4200000000000000000000000000000000000006 | 18 |
| USDC | 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913 | 6 |
| cbETH | 0x2Ae3F1Ec7F1F5012CFEab0185bfc7aa3cf0DEc22 | 18 |
LLM training data for protocol addresses is frequently outdated. Before using any address in production:
cast call to verify.# Verify a contract is the Aave V3 Pool
cast call 0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2 \
"ADDRESSES_PROVIDER()(address)" \
--rpc-url $MAINNET_RPC_URL
# Verify a Chainlink feed returns the expected pair
cast call 0x5f4eC3Df9cbd43714FE2740f5E3616155c5b8419 \
"description()(string)" \
--rpc-url $MAINNET_RPC_URL
# Should return "ETH / USD"
standards — ERC-20, ERC-721, ERC-4626 interfaces these protocols usesecurity — Flash loan attack defense, oracle manipulation, MEV protectiontesting — Fork tests against the protocols covered herecontract-addresses — Full address list for all major protocols