Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
247 changes: 247 additions & 0 deletions packages/foundry/contracts/MinimalRouter.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,247 @@
// SPDX-License-Identifier: GPL-3.0-or-later

pragma solidity ^0.8.24;

import { SafeCast } from "@openzeppelin/contracts/utils/math/SafeCast.sol";
import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import { Address } from "@openzeppelin/contracts/utils/Address.sol";
import { IPermit2 } from "permit2/src/interfaces/IPermit2.sol";

import { IWETH } from "@balancer-labs/v3-interfaces/contracts/solidity-utils/misc/IWETH.sol";
import { IVault } from "@balancer-labs/v3-interfaces/contracts/vault/IVault.sol";
import "@balancer-labs/v3-interfaces/contracts/vault/VaultTypes.sol";

import {
ReentrancyGuardTransient
} from "@balancer-labs/v3-solidity-utils/contracts/openzeppelin/ReentrancyGuardTransient.sol";

import { RouterCommon } from "@balancer-labs/v3-vault/contracts/RouterCommon.sol";

abstract contract MinimalRouter is RouterCommon, ReentrancyGuardTransient {
using Address for address payable;
using SafeCast for *;

/**
* @notice Data for the add liquidity hook.
* @dev Extends AddLiquidityHookParams to include a receiver.
* @param sender Account originating the add liquidity operation
* @param receiver Account to receive the BPT
* @param pool Address of the liquidity pool
* @param maxAmountsIn Maximum amounts of tokens to be added, sorted in token registration order
* @param minBptAmountOut Minimum amount of pool tokens to be received
* @param kind Type of join (e.g., single or multi-token)
* @param wethIsEth If true, incoming ETH will be wrapped to WETH and outgoing WETH will be unwrapped to ETH
* @param userData Additional (optional) data sent with the request to add liquidity
*/
struct ExtendedAddLiquidityHookParams {
address sender;
address receiver;
address pool;
uint256[] maxAmountsIn;
uint256 minBptAmountOut;
AddLiquidityKind kind;
bool wethIsEth;
bytes userData;
}

/**
* @notice Data for the remove liquidity hook.
* @dev Extends RemoveLiquidityHookParams to include a receiver.
* @param sender Account originating the remove liquidity operation
* @param receiver Account to receive the tokens
* @param pool Address of the liquidity pool
* @param minAmountsOut Minimum amounts of tokens to be received, sorted in token registration order
* @param maxBptAmountIn Maximum amount of pool tokens provided
* @param kind Type of exit (e.g., single or multi-token)
* @param wethIsEth If true, incoming ETH will be wrapped to WETH and outgoing WETH will be unwrapped to ETH
* @param userData Additional (optional) data sent with the request to remove liquidity
*/
struct ExtendedRemoveLiquidityHookParams {
address sender;
address receiver;
address pool;
uint256[] minAmountsOut;
uint256 maxBptAmountIn;
RemoveLiquidityKind kind;
bool wethIsEth;
bytes userData;
}

constructor(IVault vault, IWETH weth, IPermit2 permit2) RouterCommon(vault, weth, permit2) {
// solhint-disable-previous-line no-empty-blocks
}

/***************************************************************************
Add Liquidity
***************************************************************************/

function _addLiquidityProportional(
address pool,
address sender,
address receiver,
uint256[] memory maxAmountsIn,
uint256 exactBptAmountOut,
bool wethIsEth,
bytes memory userData
) internal saveSender returns (uint256[] memory amountsIn) {
(amountsIn, , ) = abi.decode(
_vault.unlock(
abi.encodeWithSelector(
MinimalRouter.addLiquidityHook.selector,
ExtendedAddLiquidityHookParams({
sender: sender,
receiver: receiver,
pool: pool,
maxAmountsIn: maxAmountsIn,
minBptAmountOut: exactBptAmountOut,
kind: AddLiquidityKind.PROPORTIONAL,
wethIsEth: wethIsEth,
userData: userData
})
)
),
(uint256[], uint256, bytes)
);
}

/**
* @notice Hook for adding liquidity.
* @dev Can only be called by the Vault.
* @param params Add liquidity parameters
* @return amountsIn Actual amounts in required for the join
* @return bptAmountOut BPT amount minted in exchange for the input tokens
* @return returnData Arbitrary data with encoded response from the pool
*/
function addLiquidityHook(
ExtendedAddLiquidityHookParams calldata params
)
external
nonReentrant
onlyVault
returns (uint256[] memory amountsIn, uint256 bptAmountOut, bytes memory returnData)
{
(amountsIn, bptAmountOut, returnData) = _vault.addLiquidity(
AddLiquidityParams({
pool: params.pool,
to: params.receiver,
maxAmountsIn: params.maxAmountsIn,
minBptAmountOut: params.minBptAmountOut,
kind: params.kind,
userData: params.userData
})
);

// maxAmountsIn length is checked against tokens length at the vault.
IERC20[] memory tokens = _vault.getPoolTokens(params.pool);

for (uint256 i = 0; i < tokens.length; ++i) {
IERC20 token = tokens[i];
uint256 amountIn = amountsIn[i];

// There can be only one WETH token in the pool.
if (params.wethIsEth && address(token) == address(_weth)) {
if (address(this).balance < amountIn) {
revert InsufficientEth();
}

_weth.deposit{ value: amountIn }();
_weth.transfer(address(_vault), amountIn);
_vault.settle(_weth, amountIn);
} else {
// Any value over MAX_UINT128 would revert above in `addLiquidity`, so this SafeCast shouldn't be
// necessary. Done out of an abundance of caution.
_permit2.transferFrom(params.sender, address(_vault), amountIn.toUint160(), address(token));
_vault.settle(token, amountIn);
}
}

// Send remaining ETH to the user.
_returnEth(params.sender);
}

/***************************************************************************
Remove Liquidity
***************************************************************************/

function _removeLiquidityProportional(
address pool,
address sender,
address receiver,
uint256 exactBptAmountIn,
uint256[] memory minAmountsOut,
bool wethIsEth,
bytes memory userData
) internal saveSender returns (uint256[] memory amountsOut) {
(, amountsOut, ) = abi.decode(
_vault.unlock(
abi.encodeWithSelector(
MinimalRouter.removeLiquidityHook.selector,
ExtendedRemoveLiquidityHookParams({
sender: sender,
receiver: receiver,
pool: pool,
minAmountsOut: minAmountsOut,
maxBptAmountIn: exactBptAmountIn,
kind: RemoveLiquidityKind.PROPORTIONAL,
wethIsEth: wethIsEth,
userData: userData
})
)
),
(uint256, uint256[], bytes)
);
}

/**
* @notice Hook for removing liquidity.
* @dev Can only be called by the Vault.
* @param params Remove liquidity parameters
* @return bptAmountIn BPT amount burned for the output tokens
* @return amountsOut Actual token amounts transferred in exchange for the BPT
* @return returnData Arbitrary (optional) data with an encoded response from the pool
*/
function removeLiquidityHook(
ExtendedRemoveLiquidityHookParams calldata params
)
external
nonReentrant
onlyVault
returns (uint256 bptAmountIn, uint256[] memory amountsOut, bytes memory returnData)
{
(bptAmountIn, amountsOut, returnData) = _vault.removeLiquidity(
RemoveLiquidityParams({
pool: params.pool,
from: params.sender,
maxBptAmountIn: params.maxBptAmountIn,
minAmountsOut: params.minAmountsOut,
kind: params.kind,
userData: params.userData
})
);

// minAmountsOut length is checked against tokens length at the vault.
IERC20[] memory tokens = _vault.getPoolTokens(params.pool);

uint256 ethAmountOut = 0;
for (uint256 i = 0; i < tokens.length; ++i) {
uint256 amountOut = amountsOut[i];
IERC20 token = tokens[i];

// There can be only one WETH token in the pool.
if (params.wethIsEth && address(token) == address(_weth)) {
// Send WETH here and unwrap to native ETH.
_vault.sendTo(_weth, address(this), amountOut);
_weth.withdraw(amountOut);
ethAmountOut = amountOut;
} else {
// Transfer the token to the receiver (amountOut).
_vault.sendTo(token, params.receiver, amountOut);
}
}

if (ethAmountOut > 0) {
// Send ETH to receiver.
payable(params.receiver).sendValue(ethAmountOut);
}
}
}
Loading