Wise Lending - NentoR's results

Decentralized liquidity market that allows users to supply crypto assets and start earning a variable APY from borrowers.

General Information

Platform: Code4rena

Start Date: 21/02/2024

Pot Size: $200,000 USDC

Total HM: 22

Participants: 36

Period: 19 days

Judge: Trust

Total Solo HM: 12

Id: 330

League: ETH

Wise Lending

Findings Distribution

Researcher Performance

Rank: 6/36

Findings: 3

Award: $8,036.28

🌟 Selected for report: 2

🚀 Solo Findings: 1

Findings Information

🌟 Selected for report: NentoR

Labels

bug
2 (Med Risk)
sufficient quality report
primary issue
satisfactory
selected for report
M-11

Awards

5431.9558 USDC - $5,431.96

External Links

Lines of code

https://github.com/pendle-finance/pendle-core-v2-public/blob/main/contracts/router/ActionAddRemoveLiqV3.sol#L166-L172 https://github.com/pendle-finance/pendle-core-v2-public/blob/main/contracts/router/ActionAddRemoveLiqV3.sol#L444-L449 https://github.com/code-423n4/2024-02-wise-lending/blob/main/contracts/PowerFarms/PendlePowerFarm/PendlePowerFarmLeverageLogic.sol#L467-L482 https://github.com/code-423n4/2024-02-wise-lending/blob/main/contracts/PowerFarms/PendlePowerFarm/PendlePowerFarmLeverageLogic.sol#L165-L171

Vulnerability details

Impact

PendlePowerManager is incompatible with the latest deployment of PendleRouter and will cause reverts when attempting to open a farm position.

Proof of Concept

The latest deployment of the Pendle router, PendleRouterV3, is incompatible with the PendlePowerManager contract. The sponsor is expecting full compatibility with both but this is not the case here. The problem stems from the calls made to PENDLE_ROUTER.removeLiquiditySingleSy() and PENDLE_ROUTER.addLiquiditySingleSy():

function _logicOpenPosition(
    bool _isAave,
    uint256 _nftId,
    uint256 _depositAmount,
    uint256 _totalDebtBalancer,
    uint256 _allowedSpread
)
    internal
{
    // ...
    (
        uint256 netLpOut
        ,
    ) = PENDLE_ROUTER.addLiquiditySingleSy(
        {
            _receiver: address(this),
            _market: address(PENDLE_MARKET),
            _netSyIn: syReceived,
            _minLpOut: 0,
            _guessPtReceivedFromSy: ApproxParams(
                {
                    guessMin: netPtFromSwap - 100,
                    guessMax: netPtFromSwap + 100,
                    guessOffchain: 0,
                    maxIteration: 50,
                    eps: 1e15
                }
            )
        }
    );
  // ...
}

function _logicClosePosition(
    uint256 _nftId,
    uint256 _borrowShares,
    uint256 _lendingShares,
    uint256 _totalDebtBalancer,
    uint256 _allowedSpread,
    address _caller,
    bool _ethBack,
    bool _isAave
)
    private
{
    // ...
    (
        uint256 netSyOut
        ,
    ) = PENDLE_ROUTER.removeLiquiditySingleSy(
        {
            _receiver: address(this),
            _market: address(PENDLE_MARKET),
            _netLpToRemove: withdrawnLpsAmount,
            _minSyOut: 0
        }
    );
  // ...
}

The issue is that the signatures of those have changed in V3:

function addLiquiditySingleSy(
    address receiver,
    address market,
    uint256 netSyIn,
    uint256 minLpOut,
    ApproxParams calldata guessPtReceivedFromSy,
    LimitOrderData calldata limit
) external returns (uint256 netLpOut, uint256 netSyFee);

function removeLiquiditySingleSy(
    address receiver,
    address market,
    uint256 netLpToRemove,
    uint256 minSyOut,
    LimitOrderData calldata limit
) external returns (uint256 netSyOut, uint256 netSyFee);

There's a new parameter called limit that's not accounted for in the calls in the PendlePowerFarmLeverageLogic helper contract. This will lead to calls always reverting with RouterInvalidAction due to Pendle's proxy not being able to locate the selector used.

Coded POC (PendlePowerFarmControllerBase.t.sol):

function _setUpCustom(address _pendleRouter) private {
    _setProperties();

    pendleLockInstance = IPendleLock(AddressesMap[chainId].pendleLock);

    wethInstance = IWETH(AddressesMap[chainId].weth);

    wiseOracleHubInstance = WiseOracleHub(AddressesMap[chainId].oracleHub);

    aaveHubInstance = IAaveHub(AddressesMap[chainId].aaveHub);

    vm.startPrank(wiseLendingInstance.master());

    controllerTester = new PendleControllerTester(
        AddressesMap[chainId].vePendle,
        AddressesMap[chainId].pendleTokenAddress,
        AddressesMap[chainId].voterContract,
        AddressesMap[chainId].voterRewardsClaimer,
        AddressesMap[chainId].oracleHub
    );

    pendlePowerFarmTokenFactory = controllerTester.PENDLE_POWER_FARM_TOKEN_FACTORY();

    PoolManager.CreatePool memory params = PoolManager.CreatePool({
        allowBorrow: true,
        poolToken: AddressesMap[chainId].aweth,
        poolMulFactor: 17500000000000000,
        poolCollFactor: 805000000000000000,
        maxDepositAmount: 1800000000000000000000000
    });

    wiseLendingInstance.createPool(params);

    IAaveHub(AddressesMap[chainId].aaveHub).setAaveTokenAddress(
        AddressesMap[chainId].weth, AddressesMap[chainId].aweth
    );

    if (block.chainid == ETH_CHAIN_ID) {
        wiseOracleHubInstance.addOracle(
            AddressesMap[chainId].aweth,
            wiseOracleHubInstance.priceFeed(AddressesMap[chainId].weth),
            new address[](0)
        );

        wiseOracleHubInstance.recalibrate(AddressesMap[chainId].aweth);
    }

    _addPendleTokenOracle();

    _addPendleMarketOracle(
        AddressesMap[chainId].PendleMarketStEth,
        address(wiseOracleHubInstance.priceFeed(AddressesMap[chainId].weth)),
        AddressesMap[chainId].weth,
        2 ether,
        3 ether
    );

    IPendlePowerFarmToken derivativeToken =
        _addPendleMarket(AddressesMap[chainId].PendleMarketStEth, "name", "symbol", MAX_CARDINALITY);

    pendleChildLpOracleInstance = new PendleChildLpOracle(address(pendleLpOracleInstance), address(derivativeToken));

    address[] memory underlyingTokens = new address[](1);
    underlyingTokens[0] = AddressesMap[chainId].weth;

    wiseOracleHubInstance.addOracle(
        address(derivativeToken), IPriceFeed(address(pendleChildLpOracleInstance)), underlyingTokens
    );

    address[] memory underlyingTokensCurrent = new address[](0);

    wiseOracleHubInstance.addOracle(CRV_TOKEN_ADDRESS, IPriceFeed(CRV_ETH_FEED), underlyingTokensCurrent);

    wiseOracleHubInstance.recalibrate(CRV_TOKEN_ADDRESS);

    curveUsdEthOracleInstance = new CurveUsdEthOracle(IPriceFeed(ETH_USD_FEED), IPriceFeed(CRVUSD_USD_FEED));

    wiseOracleHubInstance.addOracle(
        CRVUSD_TOKEN_ADDRESS, IPriceFeed(address(curveUsdEthOracleInstance)), new address[](0)
    );

    wiseOracleHubInstance.recalibrate(CRVUSD_TOKEN_ADDRESS);

    wiseOracleHubInstance.addTwapOracle(
        CRV_TOKEN_ADDRESS,
        CRV_UNI_POOL_ADDRESS,
        CRV_UNI_POOL_TOKEN0_ADDRESS,
        CRV_UNI_POOL_TOKEN1_ADDRESS,
        UNI_V3_FEE_CRV_UNI_POOL
    );

    wiseOracleHubInstance.addTwapOracleDerivative(
        CRVUSD_TOKEN_ADDRESS,
        CRVUSD_UNI_POOL_TOKEN0_ADDRESS,
        [ETH_USDC_UNI_POOL_ADDRESS, CRVUSD_UNI_POOL_ADDRESS],
        [ETH_USDC_UNI_POOL_TOKEN0_ADDRESS, CRVUSD_UNI_POOL_TOKEN0_ADDRESS],
        [ETH_USDC_UNI_POOL_TOKEN1_ADDRESS, CRVUSD_UNI_POOL_TOKEN1_ADDRESS],
        [UNI_V3_FEE_ETH_USDC_UNI_POOL, UNI_V3_FEE_CRVUSD_UNI_POOL]
    );

    address underlyingFeed = CRVUSD_TOKEN_ADDRESS;

    _addPendleMarketOracle(
        CRVUSD_PENDLE_28MAR_2024, address(curveUsdEthOracleInstance), underlyingFeed, 0.0008 ether, 0.0016 ether
    );

    derivativeToken = _addPendleMarket(CRVUSD_PENDLE_28MAR_2024, "name", "symbol", MAX_CARDINALITY);

    pendleChildLpOracleInstance = new PendleChildLpOracle(address(pendleLpOracleInstance), address(derivativeToken));

    underlyingTokens = new address[](1);
    underlyingTokens[0] = underlyingFeed;

    wiseOracleHubInstance.addOracle(
        address(derivativeToken), IPriceFeed(address(pendleChildLpOracleInstance)), underlyingTokens
    );

    PoolManager.CreatePool[] memory createPoolArray = new PoolManager.CreatePool[](3);

    createPoolArray[0] = PoolManager.CreatePool({
        allowBorrow: true,
        poolToken: CRVUSD_TOKEN_ADDRESS,
        poolMulFactor: 17500000000000000,
        poolCollFactor: 740000000000000000,
        maxDepositAmount: 2000000000000000000000000
    });

    createPoolArray[1] = PoolManager.CreatePool({
        allowBorrow: false,
        poolToken: controllerTester.pendleChildAddress(AddressesMap[chainId].PendleMarketStEth),
        poolMulFactor: 17500000000000000,
        poolCollFactor: 740000000000000000,
        maxDepositAmount: 2000000000000000000000000
    });

    createPoolArray[2] = PoolManager.CreatePool({
        allowBorrow: false,
        poolToken: controllerTester.pendleChildAddress(CRVUSD_PENDLE_28MAR_2024),
        poolMulFactor: 17500000000000000,
        poolCollFactor: 740000000000000000,
        maxDepositAmount: 2000000000000000000000000
    });

    for (uint256 i = 0; i < createPoolArray.length; i++) {
        wiseLendingInstance.createPool(createPoolArray[i]);
    }

    powerFarmNftsInstance = new PowerFarmNFTs("", "");

    powerFarmManagerInstance = new PendlePowerManager(
        address(wiseLendingInstance),
        controllerTester.pendleChildAddress(AddressesMap[chainId].PendleMarketStEth),
        _pendleRouter,
        AddressesMap[chainId].entryAssetPendleMarketStEth,
        AddressesMap[chainId].PendleMarketStEthSy,
        AddressesMap[chainId].PendleMarketStEth,
        AddressesMap[chainId].pendleRouterStatic,
        AddressesMap[chainId].dex,
        950000000000000000,
        address(powerFarmNftsInstance)
    );

    wiseLendingInstance.setVerifiedIsolationPool(address(powerFarmManagerInstance), true);

    vm.stopPrank();

    if (block.chainid == ETH_CHAIN_ID) {
        address wethWhaleEthMain = 0x8EB8a3b98659Cce290402893d0123abb75E3ab28;

        vm.startPrank(wethWhaleEthMain);

        wethInstance.transfer(wiseLendingInstance.master(), 1000 ether);

        vm.stopPrank();
    }

    vm.startPrank(wiseLendingInstance.master());

    IERC20(AddressesMap[chainId].weth).approve(address(powerFarmManagerInstance), 1000000 ether);

    wiseSecurityInstance = IWiseSecurity(wiseLendingInstance.WISE_SECURITY());

    positionNftsInstance = IPositionNFTs(wiseLendingInstance.POSITION_NFT());
}

function testCompatibleWithRouter() public {
    address pendleRouter = 0x0000000001E4ef00d069e71d6bA041b0A16F7eA0;
    _decideChain(true);
    _setUpCustom(pendleRouter);
    _testFarmShouldEnterAndExitIntoToken();
}

function testFail_IncompatibleWithRouterV3() public {
    address pendleRouterV3 = 0x00000000005BBB0EF59571E58418F9a4357b68A0;
    _decideChain(true);
    _setUpCustom(pendleRouterV3);
    // Reverts with "RouterInvalidAction"
    _testFarmShouldEnterAndExitIntoToken();
}

Tools Used

Manual Review

Support only the latest router (V3) or add conditional checks to use the respective selector for each router version.

Assessed type

Other

#0 - c4-pre-sort

2024-03-17T14:41:41Z

GalloDaSballo marked the issue as insufficient quality report

#1 - c4-pre-sort

2024-03-17T14:42:22Z

GalloDaSballo marked the issue as remove high or low quality report

#2 - c4-pre-sort

2024-03-17T15:33:45Z

GalloDaSballo marked the issue as sufficient quality report

#3 - GalloDaSballo

2024-03-17T15:34:18Z

Worth flagging as it seems valid but may not be necessary if the sponsor is opting into using the previous deployment (which may not be deprecated)

#4 - c4-pre-sort

2024-03-17T15:34:25Z

GalloDaSballo marked the issue as primary issue

#5 - trust1995

2024-03-26T17:51:39Z

By definition a codebase can never be guaranteed to be compatible with the latest version. Requesting warden to provide evidence lack of integration with v3 achieves M+ severity.

#6 - c4-judge

2024-03-26T18:35:58Z

trust1995 marked the issue as unsatisfactory: Out of scope

#7 - 0xNentoR

2024-03-27T12:19:12Z

@trust1995 The reason why I reported this was because the sponsor told me they expect compatibility with both. The problem is that an older version of pendle router is used here and the calls for addLiquiditySingleSy() and removeLiquiditySingleSy() are basically incompatible with the new one and will revert because of a missing parameter. To understand the rationale behind this submission, let's examine the deployed contracts:

Current router version (taken from test files): https://arbiscan.io/address/0x0000000001E4ef00d069e71d6bA041b0A16F7eA0 PendleRouterV3 (latest): https://arbiscan.io/address/0x00000000005BBB0EF59571E58418F9a4357b68A0

Here's the code for both on Deth.net: Old router: https://arbiscan.deth.net/address/0xFc0617465474a6b1CA0E37ec4E67B3EEFf93bc63 New one: https://arbiscan.deth.net/address/0x00000000005BBB0EF59571E58418F9a4357b68A0

The functions can be found inActionAddRemoveLiq and ActionAddRemoveLiqV3

Old one:

  /// @dev swaps SY to PT, then adds liquidity
  function _addLiquiditySingleSy(
      address receiver,
      address market,
      IPYieldToken YT,
      uint256 netSyIn,
      uint256 minLpOut,
      ApproxParams calldata guessPtReceivedFromSy
  ) internal returns (uint256 netLpOut, uint256 netSyFee) {
      MarketState memory state = IPMarket(market).readState(address(this));
      // ...
  }

New one:

function addLiquiditySingleToken(
    address receiver,
    address market,
    uint256 minLpOut,
    ApproxParams calldata guessPtReceivedFromSy,
    TokenInput calldata input,
    LimitOrderData calldata limit
) external payable returns (uint256 netLpOut, uint256 netSyFee, uint256 netSyInterm) {
    (IStandardizedYield SY, , IPYieldToken YT) = IPMarket(market).readTokens();

    netSyInterm = _mintSyFromToken(_entry_addLiquiditySingleSy(market, limit), address(SY), 1, input);

    (netLpOut, netSyFee) = _addLiquiditySingleSy(
        receiver,
        market,
        SY,
        YT,
        netSyInterm,
        minLpOut,
        guessPtReceivedFromSy,
        limit
    );
    
    // ...
}

function _addLiquiditySingleSy(
    address receiver,
    address market,
    IStandardizedYield SY,
    IPYieldToken YT,
    uint256 netSyIn,
    uint256 minLpOut,
    ApproxParams calldata guessPtReceivedFromSy,
    LimitOrderData calldata limit
) internal returns (uint256 netLpOut, uint256 netSyFee) {
    uint256 netSyLeft = netSyIn;
    uint256 netPtReceived;

    if (!_isEmptyLimit(limit)) {
        (netSyLeft, netPtReceived, netSyFee, ) = _fillLimit(market, SY, netSyLeft, limit);
        _transferOut(address(SY), market, netSyLeft);
    }

    (uint256 netPtOutMarket, , ) = _readMarket(market).approxSwapSyToAddLiquidity(
        YT.newIndex(),
        netSyLeft,
        netPtReceived,
        block.timestamp,
        guessPtReceivedFromSy
    );
    
    // ...
}

_readMarket() comes from ActionBase, here's it's definition:

 function _readMarket(address market) internal view returns (MarketState memory) {
      return IPMarket(market).readState(address(this));
  }

You can see that both call readState() from the underlying Pendle market. Here you can find all market deployments: https://docs.pendle.finance/Developers/Deployments/Arbitrum#markets

I'll use the first one for the example. Arbiscan: https://arbiscan.io/address/0x7D49E5Adc0EAAD9C027857767638613253eF125f Deth.net: https://arbiscan.deth.net/address/0x7D49E5Adc0EAAD9C027857767638613253eF125f

If you look at the definion of readState(), you'll see the following:

function readState(address router) public view returns (MarketState memory market) {
    market.totalPt = _storage.totalPt;
    market.totalSy = _storage.totalSy;
    market.totalLp = totalSupply().Int();

    (market.treasury, market.lnFeeRateRoot, market.reserveFeePercent) = IPMarketFactory(
        factory
    ).getMarketConfig(router);

    market.scalarRoot = scalarRoot;
    market.expiry = expiry;

    market.lastLnImpliedRate = _storage.lastLnImpliedRate;
}

readState() on all markets reaches out to the factory contract it was deployed with to grab the market config. And there are two versions: MarketFactory and MarketFactoryV3. Again, both can be found in the docs but here are the links:

MarketFactory: https://arbiscan.io/address/0xf5a7De2D276dbda3EEf1b62A9E718EFf4d29dDC8 MarketFactory V3: https://arbiscan.io/address/0x2FCb47B58350cD377f94d3821e7373Df60bD9Ced

You can take a market from the docs and query Market::isValidMarket(). Using the first one, the old factory will return true whereas the new one, false.

So basically what this all means is that the protocol won't be able to use new markets. Pendle can start phasing out the old ones and migrating them over. The fix is rather easy on the protocol's end, they just need to account for the newly added parameter and can support both, if they wish, using a conditional check.

#8 - trust1995

2024-03-27T13:18:03Z

@vonMangoldt can you confirm the warden's claims around your intentions?

#9 - 0xNentoR

2024-03-29T05:38:48Z

@trust1995 Still no response from the sponsor here

#10 - trust1995

2024-03-29T07:55:22Z

Without sponsor's take the warden's claim that V3 should be compatible with the design is accepted.

#11 - c4-judge

2024-03-29T07:55:26Z

trust1995 marked the issue as satisfactory

#12 - c4-judge

2024-03-29T08:12:09Z

trust1995 marked the issue as selected for report

#13 - thebrittfactor

2024-04-29T14:35:32Z

For transparency and per conversation with the sponsors, see here for the Wise Lending team's mitigation.

Findings Information

🌟 Selected for report: NentoR

Also found by: 00xSEV, NentoR

Labels

bug
2 (Med Risk)
high quality report
edited-by-warden
:robot:_88_group
primary issue
selected for report
M-12

Awards

2444.3801 USDC - $2,444.38

External Links

Lines of code

https://github.com/code-423n4/2024-02-wise-lending/blob/main/contracts/PowerFarms/PendlePowerFarmController/PendlePowerFarmToken.sol#L98-L130 https://github.com/code-423n4/2024-02-wise-lending/blob/main/contracts/PowerFarms/PendlePowerFarmController/PendlePowerFarmToken.sol#L502-L524 https://github.com/code-423n4/2024-02-wise-lending/blob/main/contracts/PowerFarms/PendlePowerFarmController/PendlePowerFarmToken.sol#L89-L95

Vulnerability details

Impact

Random actors, malicious or not, can DOS the Pendle power farm vault by sending rewards to it through PendlePowerFarmToken::addCompoundRewards() or PendlePowerFarmController::exchangeRewardsForCompoundingWithIncentive(). This results in all users being unable to access their positions or open new ones for a set amount of time imposed by PendlePowerFarmToken::_validateSharePriceGrowth().

Proof of Concept

The PendlePowerFarmToken has a mechanism in place that protects the vault from people looping and increasing the share price:

function _validateSharePriceGrowth(
    uint256 _sharePriceNow
)
    private
    view
{
    uint256 timeDifference = block.timestamp
        - INITIAL_TIME_STAMP;

    uint256 maximum = timeDifference
        * RESTRICTION_FACTOR
        + PRECISION_FACTOR_E18;

    if (_sharePriceNow > maximum) {
        revert InvalidSharePriceGrowth();
    }
}

This is a private function invoked from the syncSupply() modifier, which is used for the following functions:

  • manualSync()
  • addCompoundRewards()
  • depositExactAmount()
  • withdrawExactShares()
  • withdrawExactAmount()

The entry point of this exploit is addCompoundRewards():

function addCompoundRewards(
    uint256 _amount
)
    external
    syncSupply
{
    if (_amount == 0) {
        revert ZeroAmount();
    }

    totalLpAssetsToDistribute += _amount;

    if (msg.sender == PENDLE_POWER_FARM_CONTROLLER) {
        return;
    }

    _safeTransferFrom(
        UNDERLYING_PENDLE_MARKET,
        msg.sender,
        PENDLE_POWER_FARM_CONTROLLER,
        _amount
    );
}

We can see that it allows users to donate their tokens to the vault to increase the totalLpAssetsToDistribute. This is then distributed among holders with subsequent calls to the aforementioned functions.

This is the code for the syncSupply() modifier:

modifier syncSupply()
{
    _triggerIndexUpdate();
    _overWriteCheck();
    _syncSupply();
    _updateRewards();
    _setLastInteraction();
    _increaseCardinalityNext();
    uint256 sharePriceBefore = _getSharePrice();
    _;
    _validateSharePriceGrowth(
        _validateSharePrice(
            sharePriceBefore
        )
    );
}

The problem here is that even though addCompoundRewards() calls it, the rewards added do not affect the share price immediately. It's still the previously deposited amount. So, the condition _sharePriceNow > maximum holds true at the time someone calls addCompoundRewards() but causes a revert with subsequent calls to functions dependent on the modifier until enough time passes for the condition to hold true again.

Coded POC (PendlePowerFarmControllerBase.t.sol):

 function testDOSVault() public normalSetup(true) {
    (IERC20 tokenReceived, uint256 balanceReceived) =
        _getTokensToPlayWith(CRVUSD_PENDLE_28MAR_2024, crvUsdMar2024LP_WHALE);

    (uint256 depositAmount, IPendlePowerFarmToken derivativeToken) =
        _prepareDeposit(CRVUSD_PENDLE_28MAR_2024, tokenReceived, balanceReceived);

    address alice = makeAddr("alice");
    address bob = makeAddr("bob");
    address charlie = makeAddr("charlie");

    IERC20 pendleLpToken = IERC20(CRVUSD_PENDLE_28MAR_2024);

    pendleLpToken.transfer(alice, 1.3e18);
    pendleLpToken.transfer(bob, 2e18);
    pendleLpToken.transfer(charlie, 1e18);

    vm.startPrank(alice);
    pendleLpToken.approve(address(derivativeToken), 1.3e18);
    derivativeToken.depositExactAmount(1.3e18);
    vm.stopPrank();

    vm.startPrank(bob);
    pendleLpToken.approve(address(derivativeToken), 2e18);
    derivativeToken.addCompoundRewards(2e18);
    vm.stopPrank();

    // DOS happens once timestamp changes
    vm.warp(block.timestamp + 1 seconds);

    // Charlie cannot deposit
    vm.startPrank(charlie);
    pendleLpToken.approve(address(derivativeToken), 1e18);
    vm.expectRevert(); // InvalidSharePriceGrowth
    derivativeToken.depositExactAmount(1 ether);
    vm.stopPrank();

    // Alice cannot withdraw
    vm.startPrank(alice);
    vm.expectRevert(); // InvalidSharePriceGrowth
    derivativeToken.withdrawExactShares(1e18);
    vm.stopPrank();

    // After 8 weeks, transactions still fail since _sharePriceNow is still greater than maximum (look at PendlePowerFarmToken::__validateSharePriceGrowth())
    vm.warp(block.timestamp + 8 weeks);

    // Alice still cannot withdraw
    vm.startPrank(alice);
    vm.expectRevert(); // InvalidSharePriceGrowth
    derivativeToken.withdrawExactShares(1e18);
    vm.stopPrank();

    // From this point onwards, maximum > _sharePriceNow
    vm.warp(block.timestamp + 9 weeks);

    // Charlie can now deposit
    vm.startPrank(charlie);
    pendleLpToken.approve(address(derivativeToken), 1e18);
    derivativeToken.depositExactAmount(1 ether);
    vm.stopPrank();

    // Alice can now deposit
    vm.startPrank(alice);
    derivativeToken.withdrawExactShares(1e18);
    vm.stopPrank();
}

Tools Used

Manual Review

I recommend turning the deposited rewards into shares at the time of calling addCompoundRewards(), so we can get _validateSharePriceGrowth() to validate it immediately and revert on the spot if needed. This will prevent the DOS and share price growth manipulation.

Assessed type

DoS

#0 - c4-pre-sort

2024-03-17T15:39:05Z

GalloDaSballo marked the issue as duplicate of #88

#1 - c4-pre-sort

2024-03-17T15:39:14Z

GalloDaSballo marked the issue as high quality report

#2 - c4-judge

2024-03-26T17:02:48Z

trust1995 marked the issue as selected for report

#3 - c4-judge

2024-03-26T17:03:17Z

trust1995 changed the severity to 3 (High Risk)

#4 - trust1995

2024-03-26T17:03:41Z

High is reasonable for temporary freeze of funds.

#5 - vm06007

2024-03-27T14:55:06Z

High is reasonable for temporary freeze of funds.

That is literally definition of a medium - temporary freeze of the funds. High is permanent freeze of funds. So dismiss the high.

#6 - vm06007

2024-03-27T14:56:47Z

https://docs.code4rena.com/awarding/judging-criteria/severity-categorization

2 — Med: Assets not at direct risk, but the function of the protocol or its availability could be impacted, or leak value with a hypothetical attack path with stated assumptions, but external requirements.

3 — High: Assets can be stolen/lost/compromised directly (or indirectly if there is a valid attack path that does not have hand-wavy hypotheticals

#7 - trust1995

2024-03-27T16:23:25Z

This is on the upper end of temporary FoF attacks, it also lasts for an extended duration. I would rule High on much weaker versions of this finding, not even close to downgrade-worthy.

#8 - 0xCiphky

2024-03-27T20:33:47Z

Hey @trust1995 This issue and #125 are the same too.

#9 - GalloDaSballo

2024-04-15T13:00:36Z

Summary of the issue

Due to a security check on LP value growth, a donation can cause the temporary inability to withdraw from a Farm Contract

Discussion

Alex: Facts: The Dos has a cost, that grows with supply The Dos can be performed permissionlessly, at any time

I'm unclear as to whether this is tied to liquidation risk, which should raise the severity

Hickuphh3: from what i see, it shouldn't affect liquidations or protocol health, only causing forced hodl with guaranteed returns. hence leaning towards M more than H

Alex: Facts: Information contained in the report mentions exclusively an inability to withdraw (funds stuck) Other facts above still apply

I agree that Medium Severity seems the most appropriate because: More supply = higher cost Dos is temporary Unclear / Missing usage to DOS a key feature

LSDan: I'm also of the opinion that medium is more appropriate here.

2 — Med: Assets not at direct risk, but the function of the protocol or its availability could be impacted, or leak value with a hypothetical attack path with stated assumptions, but external requirements. Assets are not at direct risk. The minute the DOS stops the assets are once again available Further the cost to DOS the platform is not trivial. As you both pointed out, the more assets being frozen, the higher the cost. and the attacker is donating to the users they are DOSing, leaving the user inconvenienced, but richer at the attacker's expense.

Deliberation

The severity is downgraded to Medium unanimously

Additional Context by the Lead Judge

Had the Report shown a way to weaponize this to halt liquidations, we would have had a easier time pushing for a higher severity.

However, given the Report only limiting itself to a temporary inability to withdraw, with no loss of principal, the decision was straightforward.

#10 - trust1995

2024-04-15T13:43:40Z

Judge's response

  1. I remain convinced that a permissionless, non-theoretical temporary FoF for non-negligible length (certainly days/weeks as shown) meets High severity threshold.
  2. Impact breaks a core invariant which states users can always pull out their funds. Users making monetary decisions under that assumption can certainly face loss of principal (e.g. cannot pull out to repay a due-loan, etc).

#11 - thebrittfactor

2024-04-29T14:36:06Z

For transparency and per conversation with the sponsors, see here for the Wise Lending team's mitigation.

Findings Information

🌟 Selected for report: NentoR

Also found by: 00xSEV, NentoR

Labels

bug
2 (Med Risk)
high quality report
edited-by-warden
:robot:_88_group
satisfactory
duplicate-123

Awards

2444.3801 USDC - $2,444.38

External Links

Lines of code

https://github.com/code-423n4/2024-02-wise-lending/blob/main/contracts/PowerFarms/PendlePowerFarmController/PendlePowerFarmToken.sol#L502-L524 https://github.com/code-423n4/2024-02-wise-lending/blob/main/contracts/PowerFarms/PendlePowerFarmController/PendlePowerFarmController.sol#L224-L229 https://github.com/code-423n4/2024-02-wise-lending/blob/main/contracts/PowerFarms/PendlePowerFarmController/PendlePowerFarmTokenFactory.sol#L35-L109 https://github.com/code-423n4/2024-02-wise-lending/blob/main/contracts/PowerFarms/PendlePowerFarmController/PendlePowerFarmToken.sol#L81-L130

Vulnerability details

Impact

A malicious actor can DOS a Pendle farm vault immediately after it has been deployed by depositing a small amount of wei through PendlePowerFarmToken::addCompoundRewards(). Once performed, the governance won't be able to re-deploy at a different address due to a different issue where the salt used for create2 depends on the underlying Pendle market's address and would result in the same affected address being generated.

Proof of Concept

The governance (master) holds the authority to deploy power farms for particular Pendle markets whenever necessary. This is made possible through the PendlePowerFarmController::addPendleMarket() function.

Here's the part of it we're interested in:

function addPendleMarket(
    address _pendleMarket,
    string memory _tokenName,
    string memory _symbolName,
    uint16 _maxCardinality
)
    external
    onlyMaster
{
    if (pendleChildAddress[_pendleMarket] > ZERO_ADDRESS) {
        revert AlreadySet();
    }

    address pendleChild = PENDLE_POWER_FARM_TOKEN_FACTORY.deploy(
        _pendleMarket,
        _tokenName,
        _symbolName,
        _maxCardinality
    );

    pendleChildAddress[_pendleMarket] = pendleChild;

    // ...
}

This will call into PendlePowerFarmTokenFactory and deploy a new instance of PendlePowerFarmToken using the minimal proxy pattern.

function deploy( address _underlyingPendleMarket, string memory _tokenName, string memory _symbolName, uint16 _maxCardinality ) external returns (address) { if (msg.sender != PENDLE_POWER_FARM_CONTROLLER) { revert DeployForbidden(); } return _clone( _underlyingPendleMarket, _tokenName, _symbolName, _maxCardinality ); } function _clone( address _underlyingPendleMarket, string memory _tokenName, string memory _symbolName, uint16 _maxCardinality ) private returns (address pendlePowerFarmTokenAddress) { bytes32 salt = keccak256( abi.encodePacked( _underlyingPendleMarket ) ); // (deployment code) ... PendlePowerFarmToken(pendlePowerFarmTokenAddress).initialize( _underlyingPendleMarket, PENDLE_POWER_FARM_CONTROLLER, _tokenName, _symbolName, _maxCardinality ); }

And here's the last piece of the puzzle before the farm is ready, PendlePowerFarmToken::initialize():

function initialize(
    address _underlyingPendleMarket,
    address _pendleController,
    string memory _tokenName,
    string memory _symbolName,
    uint16 _maxCardinality
)
    external
{
    if (address(PENDLE_MARKET) != address(0)) {
        revert AlreadyInitialized();
    }

    PENDLE_MARKET = IPendleMarket(
        _underlyingPendleMarket
    );

    if (PENDLE_MARKET.isExpired() == true) {
        revert MarketExpired();
    }

    PENDLE_CONTROLLER = IPendleController(
        _pendleController
    );

    MAX_CARDINALITY = _maxCardinality;

    _name = _tokenName;
    _symbol = _symbolName;

    PENDLE_POWER_FARM_CONTROLLER = _pendleController;
    UNDERLYING_PENDLE_MARKET = _underlyingPendleMarket;

    (
        address pendleSyAddress,
        ,
    ) = PENDLE_MARKET.readTokens();

    PENDLE_SY = IPendleSy(
        pendleSyAddress
    );

    _decimals = PENDLE_SY.decimals();

    lastInteraction = block.timestamp;

    _totalSupply = 1;
    underlyingLpAssetsCurrent = 1;
    mintFee = 3000;
    INITIAL_TIME_STAMP = block.timestamp;
}

As we can see, nowhere in the process do we provide an initial asset supply for the newly created power farm.

The power farm uses the following algorithm for calculating the share price (from PendlePowerFarmToken):

function previewDistribution()
    public
    view
    returns (uint256)
{
    if (totalLpAssetsToDistribute == 0) {
        return 0;
    }

    if (block.timestamp == lastInteraction) {
        return 0;
    }

    if (totalLpAssetsToDistribute < ONE_WEEK) {
        return totalLpAssetsToDistribute;
    }

    uint256 currentRate = totalLpAssetsToDistribute
        / ONE_WEEK;

    uint256 additonalAssets = currentRate
        * (block.timestamp - lastInteraction);

    if (additonalAssets > totalLpAssetsToDistribute) {
        return totalLpAssetsToDistribute;
    }

    return additonalAssets;
}

 function previewUnderlyingLpAssets()
    public
    view
    returns (uint256)
{
    return previewDistribution()
        + underlyingLpAssetsCurrent;
}

function _getSharePrice()
    private
    view
    returns (uint256)
{
    return previewUnderlyingLpAssets() * PRECISION_FACTOR_E18
        / totalSupply();
}

An attacker could call PendlePowerFarmToken::addCompoundRewards() and deflate the share price. This would cause a DOS because of the following code:

modifier syncSupply()
{
    // ...
    _;
    _validateSharePriceGrowth(
        _validateSharePrice(
            sharePriceBefore
        )
    );
}

function _validateSharePrice(
    uint256 _sharePriceBefore
)
    private
    view
    returns (uint256)
{
    uint256 sharePricenNow = _getSharePrice();

    if (sharePricenNow < _sharePriceBefore) {
        revert InvalidSharePrice();
    }

    return sharePricenNow;
}

function _validateSharePriceGrowth(
    uint256 _sharePriceNow
)
    private
    view
{
    uint256 timeDifference = block.timestamp
        - INITIAL_TIME_STAMP;

    uint256 maximum = timeDifference
        * RESTRICTION_FACTOR
        + PRECISION_FACTOR_E18;

    if (_sharePriceNow > maximum) {
        revert InvalidSharePriceGrowth();
    }
}

Bumping _sharePriceNow through the donation without any underlying assets will cause a long-term DOS on the vault. Afterward, the governance won't be able to re-deploy a power farm for the same Pendle market because the salt is hard-coded inside PendlePowerFarmTokenFactory::_clone().

Here's what the exploit path would look like:

  1. The master address deploys a new Pendle market
  2. The attacker awaits the deployment and immediately deposits some amount of wei through PendlePowerFarmToken::addCompoundRewards()
  3. All actions reliant on syncSupply become DOSed until maximum is again greater than _sharePriceNow.
  4. The master has no way to re-deploy a farm for the same market because salt is hard-coded and create2 would yield the same address. So, this Pendle market is essentially rendered unusable in the context of this protocol.

Coded POC (PendlePowerFarmControllerBase.t.sol):

function testFail_DOSAfterMarketDeployment() public normalSetup(true) {
    (IERC20 tokenReceived, uint256 balanceReceived) =
        _getTokensToPlayWith(CRVUSD_PENDLE_28MAR_2024, crvUsdMar2024LP_WHALE);
    (uint256 depositAmount, IPendlePowerFarmToken derivativeToken) =
        _prepareDeposit(CRVUSD_PENDLE_28MAR_2024, tokenReceived, balanceReceived);

    address alice = makeAddr("alice");
    tokenReceived.transfer(alice, 10e18);

    // Malicious actor deposits a reward right after deployment of market
    // (bigger compound reward => longer DOS)
    tokenReceived.approve(address(derivativeToken), 100_000 wei);
    derivativeToken.addCompoundRewards(100_000 wei);

    // Let some time pass
    vm.warp(block.timestamp + 40 weeks);

    // After 40 weeks, Vault is still under DOS and Alice cannot deposit
    vm.startPrank(alice);
    tokenReceived.approve(address(derivativeToken), tokenReceived.balanceOf(alice));
    // reverts with InvalidSharePriceGrowth
    derivativeToken.depositExactAmount(tokenReceived.balanceOf(alice));
    vm.stopPrank();
}

Logs from the depositExactAmount() call:

_sharePriceNow: 100001000000000900019000 timeDifference: 24192000 maximum: 8671232876696704000

Tools Used

Manual Review

Make the salt user supplied in addPendleMarket() and add a mechanism to remove the affected market from PendlePowerFarmController::activePendleMarkets[].

Add an initial supply of the underlying asset with each deployment. This will make the attack harder to pull off. The risk will still be there but potential denial of services through it will be much shorter in duration and unprofitable for the attacker. You can also refer to the following article to get a better idea of the vault inflation attack and case studies on how it's tackled in other protocols: https://blog.openzeppelin.com/a-novel-defense-against-erc4626-inflation-attacks

Assessed type

DoS

#0 - c4-pre-sort

2024-03-16T15:57:38Z

GalloDaSballo marked the issue as sufficient quality report

#1 - c4-pre-sort

2024-03-17T15:39:08Z

GalloDaSballo marked the issue as duplicate of #88

#2 - c4-pre-sort

2024-03-17T15:39:16Z

GalloDaSballo marked the issue as high quality report

#3 - c4-judge

2024-03-26T18:59:15Z

trust1995 marked the issue as satisfactory

#4 - c4-judge

2024-03-27T14:32:47Z

trust1995 marked the issue as not a duplicate

#5 - c4-judge

2024-03-27T14:32:58Z

trust1995 marked the issue as duplicate of #271

#6 - c4-judge

2024-03-27T14:34:53Z

trust1995 marked the issue as selected for report

#7 - c4-judge

2024-03-27T20:49:34Z

trust1995 changed the severity to 2 (Med Risk)

#8 - c4-judge

2024-03-27T20:50:17Z

trust1995 marked the issue as duplicate of #123

#9 - c4-judge

2024-03-27T20:51:45Z

trust1995 changed the severity to 3 (High Risk)

#10 - c4-judge

2024-03-27T20:59:13Z

trust1995 marked the issue as not selected for report

#11 - 0xNentoR

2024-03-28T04:51:27Z

@trust1995 The primary and this one are both written by me. I'd like to ask for this one to get grouped under #271, as it was initially.

#12 - trust1995

2024-03-28T16:45:59Z

@trust1995 The primary and this one are both written by me. I'd like to ask for this one to get grouped under #271, as it was initially.

The groupings are in line with the root cause of each group and will remain as such.

Findings Information

🌟 Selected for report: Dup1337

Also found by: 0x11singh99, NentoR, cheatc0d3, nonseodion, serial-coder, shealtielanz

Labels

bug
grade-b
insufficient quality report
QA (Quality Assurance)
Q-07

Awards

159.9366 USDC - $159.94

External Links

Low Issues

[L-1] PendlePowerManager::receive() doesn't forward ETH as specified in natspec

/**
  * @dev Standard receive functions forwarding
  * directly send ETH to the master address.
  */
receive()
	external
	payable
{
	emit ETHReceived(
		msg.value,
		msg.sender
	);
}

https://github.com/code-423n4/2024-02-wise-lending/blob/main/contracts/PowerFarms/PendlePowerFarm/PendlePowerManager.sol#L21-L29

This leads to funds sent to this contract becoming inaccessible.

[L-2] Small risk of collision with already used address in PendlePowerFarmTokenFactory::_clone().

In PendlePowerFarmTokenFactory::_clone(), there's a small risk that the create2 might create an address that already exists. Since the salt is not sender controlled, there'll be no way to deploy the particular Pendle market. Make the salt controllable by the deployer (in PendlePowerFarmController::addPendleMarket()).

bytes32 salt = keccak256(
		abi.encodePacked(
				_underlyingPendleMarket
		)
);

https://github.com/code-423n4/2024-02-wise-lending/blob/main/contracts/PowerFarms/PendlePowerFarmController/PendlePowerFarmTokenFactory.sol#L65-L69

[L-3] Immutable feeds in PendleLpOracle, PtOracleDerivative, and PtOraclePure

Chainlink feeds in PendleLpOracle, PtOracleDerivative, and PtOraclePure are immutable and cannot be changed beyond deployment. There is slight risk to it because Chainlink could decide to deprecate a particular feed and deploy a new one at a different address. The contracts have no way to change to that feed unless they all get re-deployed. It's recommended to add a way for the master address to change the feeds.

https://github.com/code-423n4/2024-02-wise-lending/blob/main/contracts/DerivativeOracles/PendleLpOracle.sol#L63 https://github.com/code-423n4/2024-02-wise-lending/blob/main/contracts/DerivativeOracles/PtOracleDerivative.sol#L49-L53 https://github.com/code-423n4/2024-02-wise-lending/blob/main/contracts/DerivativeOracles/PtOraclePure.sol#L47

Informational

[I-1] Various typos and non-existent words in the codebase

The codebase has typos and non-existent words in various places that should be fixed:

<details> <summary>Click here to expand the list</summary> <p>./contracts/MainHelper.sol:12:38 - Unknown word (devison)</p> <p>./contracts/MainHelper.sol:50:37 - Unknown word (devison)</p> <p>./contracts/MainHelper.sol:74:37 - Unknown word (devison)</p> <p>./contracts/MainHelper.sol:79:14 - Unknown word (cashout)</p> <p>./contracts/MainHelper.sol:87:17 - Unknown word (cashout)</p> <p>./contracts/MainHelper.sol:93:15 - Unknown word (cashout)</p> <p>./contracts/MainHelper.sol:109:36 - Unknown word (devison)</p> <p>./contracts/MainHelper.sol:348:36 - Unknown word (reasoanbly)</p> <p>./contracts/MainHelper.sol:369:25 - Unknown word (entrancy)</p> <p>./contracts/MainHelper.sol:580:22 - Unknown word (increas)</p> <p>./contracts/MainHelper.sol:581:28 - Unknown word (postion) fix: (position)</p> <p>./contracts/MainHelper.sol:596:28 - Unknown word (postion) fix: (position)</p> <p>./contracts/MainHelper.sol:645:14 - Unknown word (keccak)</p> <p>./contracts/MainHelper.sol:655:16 - Unknown word (keccak)</p> <p>./contracts/MainHelper.sol:731:11 - Unknown word (postion) fix: (position)</p> <p>./contracts/MainHelper.sol:792:11 - Unknown word (postion) fix: (position)</p> <p>./contracts/MainHelper.sol:812:11 - Unknown word (uncollateralized)</p> <p>./contracts/MainHelper.sol:814:16 - Unknown word (Uncollateralized)</p> <p>./contracts/MainHelper.sol:822:54 - Unknown word (Collateralized)</p> <p>./contracts/MainHelper.sol:874:17 - Unknown word (intervall)</p> <p>./contracts/MainHelper.sol:907:36 - Unknown word (maximise)</p> <p>./contracts/MainHelper.sol:1004:8 - Unknown word (unorganic)</p> <p>./contracts/MainHelper.sol:1076:27 - Unknown word (decresing)</p> <p>./contracts/MainHelper.sol:1120:31 - Unknown word (decresing)</p> <p>./contracts/PoolManager.sol:301:40 - Unknown word (NORMALISATION)</p> <p>./contracts/PositionNFTs.sol:390:22 - Unknown word (bstr)</p> <p>./contracts/PositionNFTs.sol:398:13 - Unknown word (bstr)</p> <p>./contracts/PositionNFTs.sol:407:13 - Unknown word (bstr)</p> <p>./contracts/WiseCore.sol:187:25 - Unknown word (postion) fix: (position)</p> <p>./contracts/WiseCore.sol:425:8 - Unknown word (caluclating)</p> <p>./contracts/WiseCore.sol:472:17 - Unknown word (cashout)</p> <p>./contracts/WiseCore.sol:474:35 - Unknown word (cashout)</p> <p>./contracts/WiseCore.sol:476:13 - Unknown word (cashout)</p> <p>./contracts/WiseCore.sol:487:17 - Unknown word (cashout)</p> <p>./contracts/WiseCore.sol:501:35 - Unknown word (cashout)</p> <p>./contracts/WiseCore.sol:539:46 - Unknown word (functionallity)</p> <p>./contracts/WiseCore.sol:560:35 - Unknown word (Cashout)</p> <p>./contracts/WiseCore.sol:565:31 - Unknown word (Cashout)</p> <p>./contracts/WiseCore.sol:565:69 - Unknown word (Cashout)</p> <p>./contracts/WiseCore.sol:572:31 - Unknown word (Cashout)</p> <p>./contracts/WiseCore.sol:572:64 - Unknown word (Cashout)</p> <p>./contracts/WiseCore.sol:592:55 - Unknown word (Collateralized)</p> <p>./contracts/WiseCore.sol:637:26 - Unknown word (Recieve) fix: (Receive)</p> <p>./contracts/WiseCore.sol:661:26 - Unknown word (Recieve) fix: (Receive)</p> <p>./contracts/WiseCore.sol:682:26 - Unknown word (Recieve) fix: (Receive)</p> <p>./contracts/WiseLending.sol:32:26 - Unknown word (nside)</p> <p>./contracts/WiseLending.sol:342:47 - Unknown word (Collateralized)</p> <p>./contracts/WiseLending.sol:369:47 - Unknown word (Collateralized)</p> <p>./contracts/WiseLending.sol:371:28 - Unknown word (Uncollateralized)</p> <p>./contracts/WiseLending.sol:439:43 - Unknown word (collateralized)</p> <p>./contracts/WiseLending.sol:458:43 - Unknown word (collateralized)</p> <p>./contracts/WiseLending.sol:1243:37 - Unknown word (postion) fix: (position)</p> <p>./contracts/WiseLending.sol:1270:21 - Unknown word (Recieve) fix: (Receive)</p> <p>./contracts/WiseLending.sol:1312:34 - Unknown word (liqudaiton)</p> <p>./contracts/WiseLending.sol:1337:21 - Unknown word (Recieve) fix: (Receive)</p> <p>./contracts/WiseLending.sol:1354:25 - Unknown word (Recieve) fix: (Receive)</p> <p>./contracts/WiseLending.sol:1410:13 - Unknown word (Mananger)</p> <p>./contracts/WiseLending.sol:1534:35 - Unknown word (cashout)</p> <p>./contracts/WiseLending.sol:1585:42 - Unknown word (reservating)</p> <p>./contracts/WiseLendingDeclaration.sol:182:8 - Unknown word (Orace)</p> <p>./contracts/WiseLendingDeclaration.sol:193:16 - Unknown word (Collateralized)</p> <p>./contracts/WiseLendingDeclaration.sol:243:24 - Unknown word (Recieve) fix: (Receive)</p> <p>./contracts/WiseLendingDeclaration.sol:305:8 - Unknown word (Norming)</p> <p>./contracts/WiseLendingDeclaration.sol:307:31 - Unknown word (NORMALISATION)</p> <p>./contracts/WiseLendingDeclaration.sol:317:13 - Unknown word (THRESHHOLD) fix: (THRESHOLD)</p> </details>

Recommendations

[R-1] Add onlyMaster modifier to PendlePowerFarmController::claimVoteRewards() and PendlePowerFarmController::claimArb()

PendlePowerFarmController::claimVoteRewards() and PendlePowerFarmController::claimArb() should apply the onlyMaster modifier since they deal with sending funds to that address.

https://github.com/code-423n4/2024-02-wise-lending/blob/main/contracts/PowerFarms/PendlePowerFarmController/PendlePowerFarmController.sol#L581-L598 https://github.com/code-423n4/2024-02-wise-lending/blob/main/contracts/PowerFarms/PendlePowerFarmController/PendlePowerFarmController.sol#L431-L449

#0 - c4-pre-sort

2024-03-17T10:46:02Z

GalloDaSballo marked the issue as insufficient quality report

#1 - trust1995

2024-03-26T10:51:26Z

Without promoted L-1, a little under the bar for B rating.

#2 - c4-judge

2024-03-26T10:51:29Z

trust1995 marked the issue as grade-c

#3 - 0xNentoR

2024-03-27T12:19:25Z

@trust1995 Just wanted to ask whether the grading remains the same after #164 and #129 got downgraded?

#4 - trust1995

2024-03-27T13:36:52Z

Fair enough, with those two it achieves grade-B again.

#5 - c4-judge

2024-03-27T13:36:56Z

trust1995 marked the issue as grade-b

AuditHub

A portfolio for auditors, a security profile for protocols, a hub for web3 security.

Built bymalatrax © 2024

Auditors

Browse

Contests

Browse

Get in touch

ContactTwitter