HardhatDeFi
15,000 USDC
View results
Submission Details
Severity: low
Invalid

Misplaced Ownership of Position Tokens Leads to Permanent Liquidity Lock and Denial of Service.

Summary

The AaveDIVAWrapper contract includes a function called addLiquidity, which allows users to add _collateralAmount of liquidity to an existing DIVA Protocol pool identified by the provided _poolId. As part of this process, long position tokens are sent to _longRecipient, while short position tokens are sent to _shortRecipient. Additionally, users can remove liquidity using the removeLiquidity function, which removes liquidity from the specified _poolId by burning an equal amount (_positionTokenAmount) of valid long and short tokens and subsequently transferring the corresponding collateral tokens (e.g., USDC) to the designated _recipient.

To achieve this, AaveDIVAWrapper::addLiquidity internally calls AaveDIVAWrapperCore::_addLiquidity, and AaveDIVAWrapper::removeLiquidity calls AaveDIVAWrapperCore::_removeLiquidity. However, a chronological bug exists between these two functions, potentially leading to a Denial of Service (DoS) for liquidity providers.

In the AaveDIVAWrapperCore::_addLiquidity function, the msg.sender (liquidity provider) has the ability to specify recipients for the position tokens long tokens are sent to _longRecipient, and short tokens to _shortRecipient. While msg.sender initially provides liquidity, they lose ownership of the position tokens upon distribution, as there is no mechanism ensuring that msg.sender retains any reclaimable rights or approvals over them. Consequently, when msg.sender attempts to call removeLiquidity, the transaction fails due to a lack of ownership of the required position tokens, since the recipients (long and short token holders) must approve the transfer or transfer the position tokens back to msg.sender but may refuse to do so.

This vulnerability can effectively lock liquidity in the protocol, preventing providers from reclaiming their collateral and leading to a Denial of Service (DoS) scenario where liquidity providers are unable to exit their positions. Only recipients of the respective position tokens can redeem position tokens.

Vulnerability Details

Code Snippet:

AaveDIVAWrapperCore::_addLiquidity: Reference Link

function _addLiquidity(bytes32 _poolId, uint256 _collateralAmount, address _longRecipient, address _shortRecipient)
internal
{
// Verify that the collateral token used in the DIVA Protocol pool corresponds to a registered
// collateral token in the AaveDIVAWrapper contract. Returns zero address if the wToken is not registered.
IDIVA.Pool memory _pool = IDIVA(_diva).getPoolParameters(_poolId);
address _collateralToken = _wTokenToCollateralToken[_pool.collateralToken];
// Confirm that the collateral token is registered. This check is performed early
// to ensure an immediate and graceful revert rather than allowing execution to continue until the `mint`
// operation at the end of the `_handleTokenOperations` function, which would then fail when attempting to call
// the `mint` function on address(0).
if (_collateralToken == address(0)) {
revert CollateralTokenNotRegistered();
}
// Transfer collateral token from caller to this contract, supply to Aave, and mint wTokens
// to this contract.
_handleTokenOperations(_collateralToken, _collateralAmount, _pool.collateralToken);
// Add liquidity to the DIVA Protocol pool associated with the provided `_poolId`
// using the wToken and send the position tokens to the provided recipients.
@> IDIVA(_diva).addLiquidity(_poolId, _collateralAmount, _longRecipient, _shortRecipient);
}

Code Snippet:

AaveDIVAWrapperCore::_removeLiquidity: Reference Link

function _removeLiquidity(bytes32 _poolId, uint256 _positionTokenAmount, address _recipient)
internal
returns (uint256)
{
// Query pool parameters to obtain the collateral token as well as the
// short and long token addresses.
IDIVA.Pool memory _pool = IDIVA(_diva).getPoolParameters(_poolId);
// Early check that the pool's collateral token is associated with a registered collateral token.
// This ensures an immediate and graceful revert.
if (_wTokenToCollateralToken[_pool.collateralToken] == address(0)) {
revert CollateralTokenNotRegistered();
}
IERC20Metadata _shortTokenContract = IERC20Metadata(_pool.shortToken);
IERC20Metadata _longTokenContract = IERC20Metadata(_pool.longToken);
IERC20Metadata _collateralTokenContract = IERC20Metadata(_pool.collateralToken);
// Use the user's min short/long token balance if `_positionTokenAmount` equals `type(uint256).max`.
// That corresponds to the maximum amount that the user can remove from the pool.
uint256 _userBalanceShort = _shortTokenContract.balanceOf(msg.sender);
uint256 _userBalanceLong = _longTokenContract.balanceOf(msg.sender);
uint256 _positionTokenAmountToRemove = _positionTokenAmount;
if (_positionTokenAmount == type(uint256).max) {
_positionTokenAmountToRemove = _userBalanceShort > _userBalanceLong ? _userBalanceLong : _userBalanceShort;
}
// Transfer short and long tokens from user to this contract. Requires prior user approval on both tokens.
// No need to use `safeTransferFrom` here as short and long tokens in DIVA Protocol are standard ERC20 tokens
// using OpenZeppelin's ERC20 implementation.
@> _shortTokenContract.transferFrom(
@> msg.sender,
/**
* from
*/
address(this),
/**
* to
*/
_positionTokenAmountToRemove
);
@> _longTokenContract.transferFrom(
@> msg.sender,
/**
* from
*/
address(this),
/**
* to
*/
_positionTokenAmountToRemove
);
// Remove liquidity on DIVA Protocol to receive wTokens, and calculate the returned wToken amount (net of DIVA fees)
// as DIVA Protocol's removeLiquidity function does not return the amount of collateral token received.
uint256 _wTokenBalanceBeforeRemoveLiquidity = _collateralTokenContract.balanceOf(address(this));
@> IDIVA(_diva).removeLiquidity(_poolId, _positionTokenAmountToRemove);
uint256 _wTokenAmountReturned =
_collateralTokenContract.balanceOf(address(this)) - _wTokenBalanceBeforeRemoveLiquidity;
// Conscious decision to omit an early zero amount check here as it will either revert inside `removeLiquidity` due to
// zero DIVA fees (if DIVA fee pct != 0) or in the subsequent call to Aave's `withdraw` function inside `_redeemWTokenPrivate`.
// Withdraw collateral token from Aave, burn wTokens owned by this contract and transfer collateral token to `_recipient`.
uint256 _amountReturned = _redeemWTokenPrivate(
_pool.collateralToken, // wToken
_wTokenAmountReturned,
_recipient,
address(this)
);
return _amountReturned;
}

It is entirely reasonable for the msg.sender (liquidity provider) to have the ability to specify a recipient when removing liquidity, allowing them to designate who will receive the collateral upon withdrawal. This flexibility ensures that liquidity providers can manage their assets efficiently, directing funds to different addresses as needed.

However, a critical flaw arises from the fact that the liquidity provider does not retain any ownership record or control over the position tokens once they have been distributed to the designated long and short token recipients. Since removing liquidity requires burning an equal amount of long and short position tokens, the liquidity provider becomes entirely dependent on the cooperation of the token recipients to return or approve the use of those tokens.

If these recipients refuse to provide approval or become inactive, the liquidity provider is effectively blocked from reclaiming their collateral, leading to a potential liquidity lock. This oversight introduces a Denial of Service (DoS) risk, where users are unable to exit their positions simply because the recipients of their position tokens do not grant the necessary permissions.

To mitigate this issue, the protocol should implement a mechanism that either ensures liquidity providers maintain some form of reclaimable control over their distributed position tokens or introduces an alternative means for liquidity withdrawal that does not rely entirely on recipient approval. Without such safeguards, liquidity providers risk permanent loss of access to their locked funds, making the system unreliable and vulnerable to manipulation by malicious actors.

Let's analyze the problem with the help of a scenario:

The Problem: Let's assume that

  1. Alice provides liquidity and, in return, receives both long and short position tokens.

  2. Alice decides to transfer:

    • Long tokens → Bob

    • Short tokens → Charlie

  3. Now, Bob and Charlie each own the respective tokens, and they've ownership and control the approval rights over their tokens.

  4. Alice wants to remove liquidity, but to do so, she must return an equal amount of long and short tokens to the contract.

  5. Bob and Charlie refuse to approve Alice to spend their tokens.

  6. Alice is now stuck—she cannot retrieve her original liquidity because she does not have both long and short tokens.

Proof of Concept

To demonstrate this vulnerability, a Proof of Concept (PoC) is provided below.

Note: The PoC is written using the Foundry tool.

  1. Step 1: Make a foundry project and put all the contracts in the src directory.

  2. Step 2: Create a test folder and mocks folder in the test and src directories respectively (or we already have a mocks folder in src).

  3. Step 3: Create all necessary mocks

  4. step 4: Create a test file with any name in the test directory.

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {Test, console} from "forge-std/Test.sol";
import {AaveMock} from "../src/mocks/AaveMock.m.sol";
import {ATokenMock} from "../src/mocks/ATokenMock.m.sol";
import {DivaMock} from "../src/mocks/DivaMock.m.sol";
import {MockERC20} from "../src/mocks/MockERC20.sol";
import {MockLongToken} from "../src/mocks/MockLongToken.m.sol";
import {MockShortToken} from "../src/mocks/MockShortToken.m.sol";
import {MockUSDT} from "../src/mocks/MockUSDTEthereum.sol";
import {AaveDIVAWrapper} from "../src/AaveDIVAWrapper.sol";
import {WToken} from "../src/WToken.sol";
import {IAaveDIVAWrapper} from "../src/interfaces/IAaveDIVAWrapper.sol";
contract AaveDivaWrapperTest is Test {
AaveDIVAWrapper aaveDivaWrapper;
DivaMock divaMock;
AaveMock aaveMock;
MockUSDT usdtMock;
address AAVE_DIVA_WRAPPER_OWNER = makeAddr("AAVE_DIVA_WRAPPER_OWNER");
address ALICE = makeAddr("ALICE");
address BOB = makeAddr("BOB");
address CHARLIE = makeAddr("CHARLIE");
address INTRUDER = makeAddr("INTRUDER");
address CHAINLINK_DATA_PROVIDER = makeAddr("CHAINLINK_DATA_PROVIDER");
address ERC721_PERMISSIONED_TOKEN = makeAddr("ERC721_PERMISSIONED_TOKEN");
function setUp() public {
aaveMock = new AaveMock();
divaMock = new DivaMock();
usdtMock = new MockUSDT();
aaveMock.setAaveAllowedTokens(address(usdtMock));
vm.startPrank(AAVE_DIVA_WRAPPER_OWNER);
// AaveDIVAWrapper constructor
// constructor(address _aaveV3Pool, address _diva, address _owner)
aaveDivaWrapper = new AaveDIVAWrapper(address(aaveMock), address(divaMock), AAVE_DIVA_WRAPPER_OWNER);
vm.stopPrank();
}
}
  1. Step 5: Now, Put the following Test PoC in the test file adjacent to the setUp function.

function testLiquidityProviderLocksLiquidity() public {
uint256 gradient = (5 * (10 ** (usdtMock.decimals() - 1)));
uint256 collateralAmount = (100 * (10 ** usdtMock.decimals()));
IAaveDIVAWrapper.PoolParams memory _poolParams = IAaveDIVAWrapper.PoolParams({
referenceAsset: "USDT/USD",
expiryTime: uint96(block.timestamp + 2 hours),
floor: 100,
inflection: 150,
cap: 200,
gradient: gradient,
collateralAmount: collateralAmount,
collateralToken: address(usdtMock),
dataProvider: CHAINLINK_DATA_PROVIDER,
capacity: type(uint256).max,
longRecipient: ALICE,
shortRecipient: ALICE,
permissionedERC721Token: ERC721_PERMISSIONED_TOKEN
});
vm.startPrank(AAVE_DIVA_WRAPPER_OWNER);
address wUSDTToken = aaveDivaWrapper.registerCollateralToken(address(usdtMock));
vm.stopPrank();
usdtMock.mint(ALICE, collateralAmount);
vm.startPrank(ALICE);
usdtMock.approve(address(aaveDivaWrapper), collateralAmount);
bytes32 poolId = aaveDivaWrapper.createContingentPool(_poolParams);
vm.stopPrank();
usdtMock.mint(ALICE, collateralAmount);
vm.startPrank(ALICE);
usdtMock.approve(address(aaveDivaWrapper), 0);
usdtMock.approve(address(aaveDivaWrapper), collateralAmount);
// Long Recipient - BOB
// Short Recipient - CHARLIE
aaveDivaWrapper.addLiquidity(poolId, collateralAmount, BOB, CHARLIE);
vm.stopPrank();
MockLongToken longToken = MockLongToken(divaMock.getPoolParameters(poolId).longToken);
MockShortToken shortToken = MockShortToken(divaMock.getPoolParameters(poolId).shortToken);
address aTokenAddress = aaveMock.getReserveData(address(usdtMock)).aTokenAddress;
ATokenMock aToken = ATokenMock(aTokenAddress);
WToken wToken = WToken(wUSDTToken);
console.log("aaveDivaWrapper aToken balance : ", aToken.balanceOf(address(aaveDivaWrapper)));
console.log("aaveDivaWrapper usdt balance : ", usdtMock.balanceOf(address(aaveDivaWrapper)));
console.log("aaveDivaWrapper wToken balance : ", wToken.balanceOf(address(aaveDivaWrapper)));
console.log("aaveDivaWrapper longToken balance : ", longToken.balanceOf(address(aaveDivaWrapper)));
console.log("aaveDivaWrapper shortToken balance: ", shortToken.balanceOf(address(aaveDivaWrapper)));
console.log("-----------------------------------");
console.log("aaveMock aToken balance : ", aToken.balanceOf(address(aaveMock)));
console.log("aaveMock usdt balance : ", usdtMock.balanceOf(address(aaveMock)));
console.log("aaveMock wToken balance : ", wToken.balanceOf(address(aaveMock)));
console.log("aaveMock longToken balance : ", longToken.balanceOf(address(aaveMock)));
console.log("aaveMock shortToken balance : ", shortToken.balanceOf(address(aaveMock)));
console.log("-----------------------------------");
console.log("diva aToken balance : ", aToken.balanceOf(address(divaMock)));
console.log("diva usdt balance : ", usdtMock.balanceOf(address(divaMock)));
console.log("diva wToken balance : ", wToken.balanceOf(address(divaMock)));
console.log("diva longToken balance : ", longToken.balanceOf(address(divaMock)));
console.log("diva shortToken balance : ", shortToken.balanceOf(address(divaMock)));
console.log("-----------------------------------");
console.log("alice aToken balance : ", aToken.balanceOf(ALICE));
console.log("alice usdt balance : ", usdtMock.balanceOf(ALICE));
console.log("alice wToken balance : ", wToken.balanceOf(ALICE));
console.log("alice longToken balance : ", longToken.balanceOf(ALICE));
console.log("alice shortToken balance : ", shortToken.balanceOf(ALICE));
console.log("-----------------------------------");
console.log("BOB aToken balance : ", aToken.balanceOf(BOB));
console.log("BOB usdt balance : ", usdtMock.balanceOf(BOB));
console.log("BOB wToken balance : ", wToken.balanceOf(BOB));
console.log("BOB longToken balance : ", longToken.balanceOf(BOB));
console.log("BOB shortToken balance : ", shortToken.balanceOf(BOB));
console.log("-----------------------------------");
console.log("CHARLIE aToken balance : ", aToken.balanceOf(CHARLIE));
console.log("CHARLIE usdt balance : ", usdtMock.balanceOf(CHARLIE));
console.log("CHARLIE wToken balance : ", wToken.balanceOf(CHARLIE));
console.log("CHARLIE longToken balance : ", longToken.balanceOf(CHARLIE));
console.log("CHARLIE shortToken balance : ", shortToken.balanceOf(CHARLIE));
// time travel
vm.warp(block.timestamp + 1 days);
vm.startPrank(ALICE);
// BOB refuses to transfer long tokens back to alice
// longToken.transfer(ALICE, longToken.balanceOf(BOB));
// CHARLIE refuses to transfer short tokens back to alice
// shortToken.transfer(ALICE, shortToken.balanceOf(CHARLIE));
vm.expectRevert(
abi.encodeWithSelector(
bytes4(keccak256(abi.encodePacked("ERC20InsufficientAllowance(address,uint256,uint256)"))),
address(aaveDivaWrapper),
0,
longToken.balanceOf(BOB)
)
);
aaveDivaWrapper.removeLiquidity(poolId, type(uint256).max, ALICE);
vm.stopPrank();
}
  1. Step 6: Now, to run the test, run the following commands in the terminal in sequence:

forge test --mt testLiquidityProviderLocksLiquidity -vvvv
  1. Step 7: See the output of the test in the terminal, the output should be similar to the below one:

[⠊] Compiling...
No files changed, compilation skipped
Ran 1 test for test/AaveDivaWrapperTest.t.sol:AaveDivaWrapperTest
[PASS] testLiquidityProviderLocksLiquidity() (gas: 3590682)
Logs:
aaveDivaWrapper aToken balance : 200000000
aaveDivaWrapper usdt balance : 0
aaveDivaWrapper wToken balance : 0
aaveDivaWrapper longToken balance : 0
aaveDivaWrapper shortToken balance: 0
-----------------------------------
aaveMock aToken balance : 0
aaveMock usdt balance : 200000000
aaveMock wToken balance : 0
aaveMock longToken balance : 0
aaveMock shortToken balance : 0
-----------------------------------
diva aToken balance : 0
diva usdt balance : 0
diva wToken balance : 200000000
diva longToken balance : 0
diva shortToken balance : 0
-----------------------------------
alice aToken balance : 0
alice usdt balance : 0
alice wToken balance : 0
alice longToken balance : 100000000
alice shortToken balance : 100000000
-----------------------------------
BOB aToken balance : 0
BOB usdt balance : 0
BOB wToken balance : 0
BOB longToken balance : 100000000
BOB shortToken balance : 0
-----------------------------------
CHARLIE aToken balance : 0
CHARLIE usdt balance : 0
CHARLIE wToken balance : 0
CHARLIE longToken balance : 0
CHARLIE shortToken balance : 100000000
Traces:
[3817982] AaveDivaWrapperTest::testLiquidityProviderLocksLiquidity()
├─ [266] MockUSDT::decimals() [staticcall]
│ └─ ← [Return] 6
├─ [266] MockUSDT::decimals() [staticcall]
│ └─ ← [Return] 6
├─ [0] VM::startPrank(AAVE_DIVA_WRAPPER_OWNER: [0xE3956835e40e4731A03596C87534501ac1656f1E])
│ └─ ← [Return]
.
.
.
├─ [540] ATokenMock::balanceOf(AaveDIVAWrapper: [0x93Cf436DE8BF36C3d42Ab9dDD57631eCe44F9986]) [staticcall]
│ └─ ← [Return] 200000000 [2e8]
├─ [0] console::log("aaveDivaWrapper aToken balance : ", 200000000 [2e8]) [staticcall]
│ └─ ← [Stop]
├─ [562] MockUSDT::balanceOf(AaveDIVAWrapper: [0x93Cf436DE8BF36C3d42Ab9dDD57631eCe44F9986]) [staticcall]
│ └─ ← [Return] 0
├─ [0] console::log("aaveDivaWrapper usdt balance : ", 0) [staticcall]
│ └─ ← [Stop]
├─ [540] WToken::balanceOf(AaveDIVAWrapper: [0x93Cf436DE8BF36C3d42Ab9dDD57631eCe44F9986]) [staticcall]
│ └─ ← [Return] 0
├─ [0] console::log("aaveDivaWrapper wToken balance : ", 0) [staticcall]
│ └─ ← [Stop]
├─ [2540] MockLongToken::balanceOf(AaveDIVAWrapper: [0x93Cf436DE8BF36C3d42Ab9dDD57631eCe44F9986]) [staticcall]
│ └─ ← [Return] 0
├─ [0] console::log("aaveDivaWrapper longToken balance : ", 0) [staticcall]
│ └─ ← [Stop]
├─ [2540] MockShortToken::balanceOf(AaveDIVAWrapper: [0x93Cf436DE8BF36C3d42Ab9dDD57631eCe44F9986]) [staticcall]
│ └─ ← [Return] 0
├─ [0] console::log("aaveDivaWrapper shortToken balance: ", 0) [staticcall]
│ └─ ← [Stop]
├─ [0] console::log("-----------------------------------") [staticcall]
│ └─ ← [Stop]
├─ [2540] ATokenMock::balanceOf(AaveMock: [0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f]) [staticcall]
│ └─ ← [Return] 0
├─ [0] console::log("aaveMock aToken balance : ", 0) [staticcall]
│ └─ ← [Stop]
├─ [562] MockUSDT::balanceOf(AaveMock: [0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f]) [staticcall]
│ └─ ← [Return] 200000000 [2e8]
├─ [0] console::log("aaveMock usdt balance : ", 200000000 [2e8]) [staticcall]
│ └─ ← [Stop]
├─ [2540] WToken::balanceOf(AaveMock: [0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f]) [staticcall]
│ └─ ← [Return] 0
├─ [0] console::log("aaveMock wToken balance : ", 0) [staticcall]
│ └─ ← [Stop]
├─ [2540] MockLongToken::balanceOf(AaveMock: [0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f]) [staticcall]
│ └─ ← [Return] 0
├─ [0] console::log("aaveMock longToken balance : ", 0) [staticcall]
│ └─ ← [Stop]
├─ [2540] MockShortToken::balanceOf(AaveMock: [0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f]) [staticcall]
│ └─ ← [Return] 0
├─ [0] console::log("aaveMock shortToken balance : ", 0) [staticcall]
│ └─ ← [Stop]
├─ [0] console::log("-----------------------------------") [staticcall]
│ └─ ← [Stop]
├─ [2540] ATokenMock::balanceOf(DivaMock: [0x2e234DAe75C793f67A35089C9d99245E1C58470b]) [staticcall]
│ └─ ← [Return] 0
├─ [0] console::log("diva aToken balance : ", 0) [staticcall]
│ └─ ← [Stop]
├─ [2562] MockUSDT::balanceOf(DivaMock: [0x2e234DAe75C793f67A35089C9d99245E1C58470b]) [staticcall]
│ └─ ← [Return] 0
├─ [0] console::log("diva usdt balance : ", 0) [staticcall]
│ └─ ← [Stop]
├─ [540] WToken::balanceOf(DivaMock: [0x2e234DAe75C793f67A35089C9d99245E1C58470b]) [staticcall]
│ └─ ← [Return] 200000000 [2e8]
├─ [0] console::log("diva wToken balance : ", 200000000 [2e8]) [staticcall]
│ └─ ← [Stop]
├─ [540] MockLongToken::balanceOf(DivaMock: [0x2e234DAe75C793f67A35089C9d99245E1C58470b]) [staticcall]
│ └─ ← [Return] 0
├─ [0] console::log("diva longToken balance : ", 0) [staticcall]
│ └─ ← [Stop]
├─ [540] MockShortToken::balanceOf(DivaMock: [0x2e234DAe75C793f67A35089C9d99245E1C58470b]) [staticcall]
│ └─ ← [Return] 0
├─ [0] console::log("diva shortToken balance : ", 0) [staticcall]
│ └─ ← [Stop]
├─ [0] console::log("-----------------------------------") [staticcall]
│ └─ ← [Stop]
├─ [2540] ATokenMock::balanceOf(ALICE: [0xef211076B8d8b46797E09c9a374Fb4Cdc1dF0916]) [staticcall]
│ └─ ← [Return] 0
├─ [0] console::log("alice aToken balance : ", 0) [staticcall]
│ └─ ← [Stop]
├─ [562] MockUSDT::balanceOf(ALICE: [0xef211076B8d8b46797E09c9a374Fb4Cdc1dF0916]) [staticcall]
│ └─ ← [Return] 0
├─ [0] console::log("alice usdt balance : ", 0) [staticcall]
│ └─ ← [Stop]
├─ [2540] WToken::balanceOf(ALICE: [0xef211076B8d8b46797E09c9a374Fb4Cdc1dF0916]) [staticcall]
│ └─ ← [Return] 0
├─ [0] console::log("alice wToken balance : ", 0) [staticcall]
│ └─ ← [Stop]
├─ [540] MockLongToken::balanceOf(ALICE: [0xef211076B8d8b46797E09c9a374Fb4Cdc1dF0916]) [staticcall]
│ └─ ← [Return] 100000000 [1e8]
├─ [0] console::log("alice longToken balance : ", 100000000 [1e8]) [staticcall]
│ └─ ← [Stop]
├─ [540] MockShortToken::balanceOf(ALICE: [0xef211076B8d8b46797E09c9a374Fb4Cdc1dF0916]) [staticcall]
│ └─ ← [Return] 100000000 [1e8]
├─ [0] console::log("alice shortToken balance : ", 100000000 [1e8]) [staticcall]
│ └─ ← [Stop]
├─ [0] console::log("-----------------------------------") [staticcall]
│ └─ ← [Stop]
├─ [2540] ATokenMock::balanceOf(BOB: [0xa53b369bDCbe05dcBB96d6550C924741902d2615]) [staticcall]
│ └─ ← [Return] 0
├─ [0] console::log("BOB aToken balance : ", 0) [staticcall]
│ └─ ← [Stop]
├─ [2562] MockUSDT::balanceOf(BOB: [0xa53b369bDCbe05dcBB96d6550C924741902d2615]) [staticcall]
│ └─ ← [Return] 0
├─ [0] console::log("BOB usdt balance : ", 0) [staticcall]
│ └─ ← [Stop]
├─ [2540] WToken::balanceOf(BOB: [0xa53b369bDCbe05dcBB96d6550C924741902d2615]) [staticcall]
│ └─ ← [Return] 0
├─ [0] console::log("BOB wToken balance : ", 0) [staticcall]
│ └─ ← [Stop]
├─ [540] MockLongToken::balanceOf(BOB: [0xa53b369bDCbe05dcBB96d6550C924741902d2615]) [staticcall]
│ └─ ← [Return] 100000000 [1e8]
├─ [0] console::log("BOB longToken balance : ", 100000000 [1e8]) [staticcall]
│ └─ ← [Stop]
├─ [2540] MockShortToken::balanceOf(BOB: [0xa53b369bDCbe05dcBB96d6550C924741902d2615]) [staticcall]
│ └─ ← [Return] 0
├─ [0] console::log("BOB shortToken balance : ", 0) [staticcall]
│ └─ ← [Stop]
├─ [0] console::log("-----------------------------------") [staticcall]
│ └─ ← [Stop]
├─ [2540] ATokenMock::balanceOf(CHARLIE: [0x05D932dE3D050b91d0E14ac70fc4a0CC06D45af9]) [staticcall]
│ └─ ← [Return] 0
├─ [0] console::log("CHARLIE aToken balance : ", 0) [staticcall]
│ └─ ← [Stop]
├─ [2562] MockUSDT::balanceOf(CHARLIE: [0x05D932dE3D050b91d0E14ac70fc4a0CC06D45af9]) [staticcall]
│ └─ ← [Return] 0
├─ [0] console::log("CHARLIE usdt balance : ", 0) [staticcall]
│ └─ ← [Stop]
├─ [2540] WToken::balanceOf(CHARLIE: [0x05D932dE3D050b91d0E14ac70fc4a0CC06D45af9]) [staticcall]
│ └─ ← [Return] 0
├─ [0] console::log("CHARLIE wToken balance : ", 0) [staticcall]
│ └─ ← [Stop]
├─ [2540] MockLongToken::balanceOf(CHARLIE: [0x05D932dE3D050b91d0E14ac70fc4a0CC06D45af9]) [staticcall]
│ └─ ← [Return] 0
├─ [0] console::log("CHARLIE longToken balance : ", 0) [staticcall]
│ └─ ← [Stop]
├─ [540] MockShortToken::balanceOf(CHARLIE: [0x05D932dE3D050b91d0E14ac70fc4a0CC06D45af9]) [staticcall]
│ └─ ← [Return] 100000000 [1e8]
├─ [0] console::log("CHARLIE shortToken balance : ", 100000000 [1e8]) [staticcall]
│ └─ ← [Stop]
├─ [0] VM::warp(86401 [8.64e4])
│ └─ ← [Return]
├─ [0] VM::startPrank(ALICE: [0xef211076B8d8b46797E09c9a374Fb4Cdc1dF0916])
│ └─ ← [Return]
├─ [540] MockLongToken::balanceOf(BOB: [0xa53b369bDCbe05dcBB96d6550C924741902d2615]) [staticcall]
│ └─ ← [Return] 100000000 [1e8]
├─ [0] VM::expectRevert(ERC20InsufficientAllowance(0x93Cf436DE8BF36C3d42Ab9dDD57631eCe44F9986, 0, 100000000 [1e8]))
│ └─ ← [Return]
├─ [16244] AaveDIVAWrapper::removeLiquidity(0x6e5b574bbb039056fe6dab15818fd206ed5e73dc75e9b454bd29d5ee9fd6142b, 115792089237316195423570985008687907853269984665640564039457584007913129639935 [1.157e77], ALICE: [0xef211076B8d8b46797E09c9a374Fb4Cdc1dF0916])
│ ├─ [4624] DivaMock::getPoolParameters(0x6e5b574bbb039056fe6dab15818fd206ed5e73dc75e9b454bd29d5ee9fd6142b) [staticcall]
│ │ └─ ← [Return] Pool({ floor: 100, inflection: 150, cap: 200, gradient: 500000 [5e5], collateralBalance: 200000000 [2e8], finalReferenceValue: 0, capacity: 115792089237316195423570985008687907853269984665640564039457584007913129639935 [1.157e77], statusTimestamp: 1, shortToken: 0x8d2C17FAd02B7bb64139109c6533b7C2b9CADb81, payoutShort: 0, longToken: 0xffD4505B3452Dc22f8473616d50503bA9E1710Ac, payoutLong: 0, collateralToken: 0x966D842bc56C5c4443591D3bb0B565d06c473D4B, expiryTime: 7201, dataProvider: 0x1e6974FFaa047925EfcADEc7382f1652E3e06d95, indexFees: 0, indexSettlementPeriods: 0, statusFinalReferenceValue: 0, referenceAsset: "USDT/USD" })
│ ├─ [540] MockShortToken::balanceOf(ALICE: [0xef211076B8d8b46797E09c9a374Fb4Cdc1dF0916]) [staticcall]
│ │ └─ ← [Return] 100000000 [1e8]
│ ├─ [540] MockLongToken::balanceOf(ALICE: [0xef211076B8d8b46797E09c9a374Fb4Cdc1dF0916]) [staticcall]
│ │ └─ ← [Return] 100000000 [1e8]
│ ├─ [2974] MockShortToken::transferFrom(ALICE: [0xef211076B8d8b46797E09c9a374Fb4Cdc1dF0916], AaveDIVAWrapper: [0x93Cf436DE8BF36C3d42Ab9dDD57631eCe44F9986], 100000000 [1e8])
│ │ └─ ← [Revert] ERC20InsufficientAllowance(0x93Cf436DE8BF36C3d42Ab9dDD57631eCe44F9986, 0, 100000000 [1e8])
│ └─ ← [Revert] ERC20InsufficientAllowance(0x93Cf436DE8BF36C3d42Ab9dDD57631eCe44F9986, 0, 100000000 [1e8])
├─ [0] VM::stopPrank()
│ └─ ← [Return]
└─ ← [Stop]
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 3.48ms (1.47ms CPU time)
Ran 1 test suite in 1.67s (3.48ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)

As demonstrated in the output, test passes and removeLiquidity function reverts with error ERC20InsufficientAllowance.

Impact

  • Why This is an Issue?

    • Denial of Service (DoS) Risk: Alice cannot remove liquidity unless she somehow gets back both position tokens.

    • Trust Assumption: The system assumes Bob and Charlie will cooperate, which is not guaranteed. They can be eloped anytime

    • Funds at Risk: Alice might never be able to reclaim her collateral due to malicious behavior or even simple negligence by Bob or Charlie.

  • Why This is a Medium Issue?

    • The protocol provides a function to redeem position tokens: AaveDIVAWrapper::redeemPositionToken, which internally calls AaveDIVAWrapperCore::_redeemPositionToken. This function allows position token holders to redeem collateral independently, reducing the severity of the issue.

    • The core problem lies in the trustworthiness of the long and short token recipients. Since the liquidity provider loses direct ownership of position tokens upon transferring them, they become fully dependent on the recipients’ cooperation to approve transfers when attempting to remove liquidity.

    • However, this issue does not result in an immediate or permanent loss of funds—the liquidity provider’s collateral is still retrievable as long as the token recipients act in good faith. The risk is primarily one of accessibility and trust, rather than outright loss or protocol failure.

    • Due to the existence of the redeemPositionToken function and the fact that the issue stems from user behavior rather than a direct protocol vulnerability, this should be classified as a Medium-severity issue rather than a High or Critical one.

    • If the protocol is designed to delegate the responsibility of removing liquidity to the recipients of long and short position tokens, the removeLiquidity function still does not work as intended. The issue arises because if the long and short token recipients are different entities, neither of them will possess both token types. Since removing liquidity requires burning an equal amount of long and short tokens, the transaction will fail due to an insufficient balance of either token.

    • Furthermore, even if the function does not revert due to a zero-value transfer, it will not produce the expected outcome, as the fundamental requirement of having both long and short tokens is not met.

    Code Reference: AaveDIVAWrapperCore::_removeLiquidity

    ...
    uint256 _userBalanceShort = _shortTokenContract.balanceOf(msg.sender);
    uint256 _userBalanceLong = _longTokenContract.balanceOf(msg.sender);
    uint256 _positionTokenAmountToRemove = _positionTokenAmount;
    if (_positionTokenAmount == type(uint256).max) {
    @> // if msg.sender doesn't have both tokens amount then either of tokens
    // balance will be zero and that will be chosen to tranfer
    @> _positionTokenAmountToRemove = _userBalanceShort > _userBalanceLong
    ? _userBalanceLong
    : _userBalanceShort;
    }
    // Transfer short and long tokens from user to this contract. Requires prior user approval on both tokens.
    // No need to use `safeTransferFrom` here as short and long tokens in DIVA Protocol are standard ERC20 tokens
    // using OpenZeppelin's ERC20 implementation.
    _shortTokenContract.transferFrom(msg.sender, address(this), _positionTokenAmountToRemove);
    _longTokenContract.transferFrom(msg.sender, address(this), _positionTokenAmountToRemove);
    ...
    • Key Issue: The removeLiquidity function only works when the recipient of both long and short tokens is the same entity—either the liquidity provider themselves or a single recipient holding both token types. If different recipients receive the long and short tokens separately, the function becomes non-functional, as neither party possesses the necessary balance to execute the transfer.

    • Since removeLiquidity is a crucial function that reverses the effects of addLiquidity, it must be fully functional and reliable. Therefore, it is essential to resolve this oversight by implementing a mechanism that ensures liquidity can always be removed, regardless of how position tokens are distributed.

Tools Used

  • Manual Review

  • Foundry

Recommendations

To mitigate this issue, a few possible solutions exist:

  • Implement an "Atomic Transfer Mechanism"

    • Instead of directly sending position tokens to different recipients, implement a single atomic transaction that guarantees Alice can retrieve her liquidity.

    Example: The addLiquidity function could include an internal accounting system where Alice retains a right to reclaim tokens instead of immediately transferring ownership.

  • Approval to recipients

    • The Liquidity Provider may authorize Position Token recipients to spend Position Tokens on their behalf. Additionally, there should be a confirmation mechanism that verifies if the Liquidity Provider has sufficient Position Tokens. If not, the logic should check whether the recipients have already redeemed Position Tokens. If they have, the system should revert the transaction with an appropriate message.

  • Create an "Emergency Exit" Function

    • If Liquidity Provider is unable to retrieve their tokens, the protocol could allow an alternative withdrawal mechanism:

    • After a timelock or governance approval, Liquidity Provider could forcibly reclaim an equivalent value of collateral and a forcibly forfeiture from recipients approval.

    • This would prevent funds from being permanently locked.

  • Set msg.sender as both the longTokenRecipient and shortTokenRecipient.

    • Since both long and short position tokens are ERC20-based tokens, it is logical to assign msg.sender as the initial recipient. When an ERC20 token is owned by a user, they have full control over transferring or distributing it as they see fit. This flexibility eliminates the need to pre-define separate recipients at the time of adding liquidity.

    • By ensuring that msg.sender initially receives both position tokens, the liquidity provider retains complete ownership and control, allowing them to later transfer or allocate the tokens to different recipients at their discretion.

      • Why is this important?

      • If different recipients are pre-selected for long and short tokens, it immediately removes control from the liquidity provider, potentially leading to liquidity lock scenarios where they are unable to remove liquidity if the recipients refuse to return the tokens or approve transfers.

      • By assigning both position tokens to msg.sender by default, the liquidity provider retains ownership and can manage their assets efficiently without any unintended restrictions.

Updates

Lead Judging Commences

bube Lead Judge 9 months ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement
Assigned finding tags:

[Invalid] Inconsistent recipient addresses

The `addLiquidity` allows the short and long recipients to be different addresses. Then if a given user has only one of the position tokens, he calls `redeemPositionToken` to redeem position token amount, if this user has amount of the both token types, he can call `removeLiquidity` and in that way an equal amount of short and long tokens will be burned.

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.