Angle Protocol - Invitational - auditor0517's results

A decentralized stablecoin protocol.

General Information

Platform: Code4rena

Start Date: 28/06/2023

Pot Size: $52,500 USDC

Total HM: 10

Participants: 5

Period: 9 days

Judge: hansfriese

Total Solo HM: 6

Id: 255

League: ETH

Angle Protocol

Findings Distribution

Researcher Performance

Rank: 3/5

Findings: 7

Award: $0.00

QA:
grade-a
Analysis:
grade-b

🌟 Selected for report: 6

πŸš€ Solo Findings: 3

Findings Information

🌟 Selected for report: auditor0517

Also found by: Lambda

Labels

bug
3 (High Risk)
disagree with severity
primary issue
satisfactory
selected for report
sponsor confirmed
H-01

Awards

Data not available

External Links

Lines of code

https://github.com/AngleProtocol/angle-transmuter/blob/9707ee4ed3d221e02dcfcd2ebaa4b4d38d280936/contracts/transmuter/facets/Redeemer.sol#L131

Vulnerability details

Impact

Redeemers might charge more collaterals during redemption/swap by the reentrancy attack.

Proof of Concept

Redeemers can redeem the agToken for collaterals in Redeemer contract and _redeem() burns the agToken and transfers the collaterals.

    function _redeem(
        uint256 amount,
        address to,
        uint256 deadline,
        uint256[] memory minAmountOuts,
        address[] memory forfeitTokens
    ) internal returns (address[] memory tokens, uint256[] memory amounts) {
        TransmuterStorage storage ts = s.transmuterStorage();
        if (ts.isRedemptionLive == 0) revert Paused();
        if (block.timestamp > deadline) revert TooLate();
        uint256[] memory subCollateralsTracker;
        (tokens, amounts, subCollateralsTracker) = _quoteRedemptionCurve(amount);
        // Updating the normalizer enables to simultaneously and proportionally reduce the amount
        // of stablecoins issued from each collateral without having to loop through each of them
        _updateNormalizer(amount, false);

        IAgToken(ts.agToken).burnSelf(amount, msg.sender); //@audit-info burn agToken

        address[] memory collateralListMem = ts.collateralList;
        uint256 indexCollateral;
        for (uint256 i; i < amounts.length; ++i) {
            if (amounts[i] < minAmountOuts[i]) revert TooSmallAmountOut();
            // If a token is in the `forfeitTokens` list, then it is not sent as part of the redemption process
            if (amounts[i] > 0 && LibHelpers.checkList(tokens[i], forfeitTokens) < 0) {
                Collateral storage collatInfo = ts.collaterals[collateralListMem[indexCollateral]];
                if (collatInfo.onlyWhitelisted > 0 && !LibWhitelist.checkWhitelist(collatInfo.whitelistData, to))
                    revert NotWhitelisted();
                if (collatInfo.isManaged > 0)
                    LibManager.release(tokens[i], to, amounts[i], collatInfo.managerData.config);
                else IERC20(tokens[i]).safeTransfer(to, amounts[i]); //@audit reentrancy
            }
            if (subCollateralsTracker[indexCollateral] - 1 <= i) ++indexCollateral;
        }
        emit Redeemed(amount, tokens, amounts, forfeitTokens, msg.sender, to);
    }

During the collateral transfers(direct transfer or in LibManager.release()), there might be a hook for the recipient in the case of ERC777 tokens.

Then the recipient might charge more collaterals by reentrancy like this.

  1. Let's suppose there are 2 collaterals colA and colB. The transmuter contract contains 1000 amounts of colA and colB. Alice has 20 amounts of agToken.
  2. At the first time, Alice calls redeem() with 10 amounts of agToken and she should receive 10 amounts of colA and colB.
  3. As colA is an ERC777 token, she calls redeem(10) again inside the hook after the colA transfer.
  4. During the second redemption, total collaterals will be colA = 990, colB = 1000 because colB isn't transferred in the first redemption yet.
  5. After all, Alice will receive more collaterals in the second redemption from this calculation.

I think a similar reentrancy attack might be possible during the swap as well.

Tools Used

Manual Review

I think we should add the nonReentrant modifier to the major functions like redeem()/swap().

Assessed type

Reentrancy

#0 - c4-judge

2023-07-08T11:11:09Z

hansfriese marked the issue as primary issue

#1 - c4-sponsor

2023-07-10T10:13:23Z

Picodes marked the issue as sponsor confirmed

#2 - c4-sponsor

2023-07-10T10:15:03Z

Picodes marked the issue as disagree with severity

#3 - Picodes

2023-07-10T10:15:16Z

Valid. We had this in mind but thought it was ok as we don't plan to accept collaterals with callbacks. However better than sorry and we may add the modifier.

#4 - hansfriese

2023-07-10T11:04:34Z

LibManager.release() is called during the redemption and it might have a callback although the governance doesn't accept collaterals with hooks. Because the assumption is practical enough and the users can steal collaterals directly, will keep as High.

#5 - c4-judge

2023-07-10T11:04:43Z

hansfriese marked the issue as satisfactory

#6 - c4-judge

2023-07-10T15:38:30Z

hansfriese marked the issue as selected for report

Findings Information

🌟 Selected for report: auditor0517

Also found by: Jeiwan, Udsen

Labels

bug
3 (High Risk)
disagree with severity
primary issue
satisfactory
selected for report
sponsor confirmed
H-02

Awards

Data not available

External Links

Lines of code

https://github.com/AngleProtocol/merkl-contracts/blob/1825925daef8b22d9d6c0a2bc7aab3309342e786/contracts/Distributor.sol#L234

Vulnerability details

Impact

The first disputer might lose funds although his dispute is valid.

Proof of Concept

Users can dispute the current tree using disputeTree() and the governor refunds the dispute funds if the dispute is valid in resolveDispute().

    function disputeTree(string memory reason) external {
        if (block.timestamp >= endOfDisputePeriod) revert InvalidDispute();
        IERC20(disputeToken).safeTransferFrom(msg.sender, address(this), disputeAmount);
        disputer = msg.sender;
        emit Disputed(reason);
    }

    /// @notice Resolve the ongoing dispute, if any
    /// @param valid Whether the dispute was valid
    function resolveDispute(bool valid) external onlyGovernorOrGuardian {
        if (disputer == address(0)) revert NoDispute();
        if (valid) {
            IERC20(disputeToken).safeTransfer(disputer, disputeAmount);
            // If a dispute is valid, the contract falls back to the last tree that was updated
            _revokeTree();
        } else {
            IERC20(disputeToken).safeTransfer(msg.sender, disputeAmount);
            endOfDisputePeriod = _endOfDisputePeriod(uint48(block.timestamp));
        }
        disputer = address(0);
        emit DisputeResolved(valid);
    }

But disputeTree() can be called again by another disputer although there is an active disputer and resolveDispute() refunds to the last disputer only.

In the worst case, a valid disputer might lose the dispute funds by malicious frontrunners.

  1. A valid disputer creates a dispute using disputeTree().
  2. As it's valid, the governor calls resolveDispute(valid = true) to accept the dispute and refund the funds.
  3. A malicious user calls disputeTree() by front running.
  4. Then during resolveDispute(true), the dispute funds will be sent to the second disputer and the first disputer will lose the funds although he's valid.

Tools Used

Manual Review

disputeTree() shouldn't allow another dispute when there is an active dispute already.

Assessed type

Invalid Validation

#0 - c4-judge

2023-07-08T11:12:00Z

hansfriese marked the issue as primary issue

#1 - c4-sponsor

2023-07-09T20:27:12Z

Picodes marked the issue as sponsor confirmed

#2 - c4-sponsor

2023-07-09T20:27:17Z

Picodes marked the issue as disagree with severity

#3 - Picodes

2023-07-09T20:30:20Z

Valid scenario and issue, although this is only a griefing attack, and the governance could still send back the funds to the first dispute using recoverERC20.

Considering the scenario is very unlikely as it would cost gas to the attacker for nothing, and easily fixable, I think this should be downgraded to Med

#4 - c4-judge

2023-07-10T10:06:39Z

hansfriese marked the issue as satisfactory

#5 - hansfriese

2023-07-10T10:10:19Z

Will keep as High because honest disputers may lose their funds and it requires the governance's additional work to recover.

#6 - c4-judge

2023-07-10T15:38:45Z

hansfriese marked the issue as selected for report

#7 - Picodes

2023-07-10T15:57:02Z

@hansfriese indeed but when there is a dispute it requires additional work from the governance anyway. Like the permissions for resolveDispute and recoverERC20, and recoverERC20 is even cheaper. So the trust assumptions of the disputer are exactly the same with and without this issue: he trusts the governance to send him back its deposit at some point.

This whole disputeAmount / period thing is meant to prevent spam and to force disputers to behave correctly as they will lose some funds if they don't, so someone using this attack vector also exposes himself to the governance deciding to not accept the dispute and seize the funds. Overall we will of course respect your final decision but still think med is more appropriate here

#8 - hansfriese

2023-07-10T16:48:21Z

@Picodes I totally understand your point and I'd like to mention two things.

  • The governance should refund one by one outside of the contract.
  • While checking disputeTree(), I see it doesn't emit the disputer's address and disputeAmount which would be changed later. So it wouldn't be that easy to refund in practice.

I agree it's between High and Medium and will keep as High.

Findings Information

🌟 Selected for report: Jeiwan

Also found by: auditor0517

Labels

bug
3 (High Risk)
satisfactory
upgraded by judge
duplicate-10

Awards

Data not available

External Links

Lines of code

https://github.com/AngleProtocol/merkl-contracts/blob/1825925daef8b22d9d6c0a2bc7aab3309342e786/contracts/Distributor.sol#L199

Vulnerability details

Impact

Users might claim rewards using an unconfirmed merkle root.

Proof of Concept

getMerkleRoot() returns the merkle root to claim the rewards.

    function getMerkleRoot() public view returns (bytes32) {
        if (block.timestamp >= endOfDisputePeriod) return tree.merkleRoot;
        else return lastTree.merkleRoot;
    }

It returns the last tree until endOfDisputePeriod as the current tree might be revoked due to the active dispute.

But even after the endOfDisputePeriod, the tree can be revoked if there is an active disputer and the rewards might be claimed unexpectedly.

  1. The merkle tree was updated wrongly using updateTree() and one user disputed that using disputeTree().
  2. But the governor couldn't resolve the dispute until endOfDisputePeriod due to some reasons.
  3. In this case, getMerkleRoot() will return the wrong merkle tree and claim() wouldn't work as expected.

I think it's not an intended behavior to claim rewards using the merkle tree that might be revoked.

Tools Used

Manual Review

getMerkleRoot() should return the last tree when there is an active disputer.

    function getMerkleRoot() public view returns (bytes32) {
        if (block.timestamp >= endOfDisputePeriod || disputer != address(0)) return tree.merkleRoot;
        else return lastTree.merkleRoot;
    }

Assessed type

Invalid Validation

#0 - c4-judge

2023-07-08T11:51:26Z

hansfriese changed the severity to 3 (High Risk)

#1 - c4-judge

2023-07-08T11:52:04Z

hansfriese marked the issue as duplicate of #10

#2 - c4-judge

2023-07-10T10:56:50Z

hansfriese marked the issue as satisfactory

Findings Information

🌟 Selected for report: auditor0517

Labels

bug
2 (Med Risk)
primary issue
satisfactory
selected for report
sponsor confirmed
M-01

Awards

Data not available

External Links

Lines of code

https://github.com/AngleProtocol/angle-transmuter/blob/9707ee4ed3d221e02dcfcd2ebaa4b4d38d280936/contracts/transmuter/facets/Redeemer.sol#L156-L157 https://github.com/AngleProtocol/angle-transmuter/blob/9707ee4ed3d221e02dcfcd2ebaa4b4d38d280936/contracts/transmuter/libraries/LibSetters.sol#L230-L240 https://github.com/AngleProtocol/angle-transmuter/blob/9707ee4ed3d221e02dcfcd2ebaa4b4d38d280936/contracts/transmuter/libraries/LibHelpers.sol#L77-L80

Vulnerability details

Impact

LibHelpers.piecewiseLinear reverts when the value is less than the first element of the array. This method is used in Redeemer contract and if the collateral ratio is below the first element of xRedemptionCurve, the redepmtion will revert.

Proof of Concept

In Redeemer._quoteRedemptionCurve, a penalty factor is applied when the protocol is under-collateralized using LibHelpers.piecewiseLinear.

Redeemer.sol#L156-L157

        uint64[] memory xRedemptionCurveMem = ts.xRedemptionCurve;
        penaltyFactor = uint64(LibHelpers.piecewiseLinear(collatRatio, xRedemptionCurveMem, yRedemptionCurveMem));

xRedemptionCurveMem is strictly increasing and upper bounded by BASE_9, and there's no more limitations.

LibSetters.sol

230        (action == ActionType.Redeem && (xFee[n - 1] > BASE_9 || yFee[n - 1] < 0 || yFee[n - 1] > int256(BASE_9)))
           
233        for (uint256 i = 0; i < n - 1; ++i) {
234            if (
           
240                (action == ActionType.Redeem && (xFee[i] >= xFee[i + 1] || yFee[i] < 0 || yFee[i] > int256(BASE_9)))

So collatRatio can be less than the first element of xRedemptionCurveMem. In that case, LibHelpers.findLowerBound will return 0 and LibHelpers.piecewiseLinear will revert on the following line.

LibHelpers.sol#L77-L80

    return
        yArray[indexLowerBound] +
        ((yArray[indexLowerBound + 1] - yArray[indexLowerBound]) * int64(x - xArray[indexLowerBound])) /
        int64(xArray[indexLowerBound + 1] - xArray[indexLowerBound]);

Redeemer._redeem calls _quoteRedemptionCurve, so the redemption will be blocked in this case.

Tools Used

Manual Review

We can add the following line to mitigate this issue.

    if (indexLowerBound == 0 && x < xArray[0]) return yArray[0];

Assessed type

Under/Overflow

#0 - c4-judge

2023-07-08T11:34:24Z

hansfriese marked the issue as primary issue

#1 - c4-sponsor

2023-07-10T09:43:04Z

Picodes marked the issue as sponsor confirmed

#2 - Picodes

2023-07-10T09:46:03Z

Indeed we are assuming throughout the test base that the first value is 0 but the check is missing in the code so there this could happen. It's kind of conditional to a misconfiguration though. Leaving it up to the judge.

#3 - hansfriese

2023-07-10T10:13:21Z

Medium is appropriate because it's likely to happen with the current configuration.

#4 - c4-judge

2023-07-10T10:13:28Z

hansfriese marked the issue as satisfactory

#5 - c4-judge

2023-07-10T15:39:40Z

hansfriese marked the issue as selected for report

Findings Information

🌟 Selected for report: auditor0517

Also found by: Lambda

Labels

bug
2 (Med Risk)
disagree with severity
primary issue
satisfactory
selected for report
M-02

Awards

Data not available

External Links

Lines of code

https://github.com/AngleProtocol/angle-transmuter/blob/9707ee4ed3d221e02dcfcd2ebaa4b4d38d280936/contracts/transmuter/libraries/LibGetters.sol#L87

Vulnerability details

Impact

LibGetters.getCollateralRatio() might return the incorrect ratio due to the unsafe cast.

Proof of Concept

getCollateralRatio() outputs the collateral ratio using the total collaterals and issued agTokens.

    // The `stablecoinsIssued` value need to be rounded up because it is then used as a divizer when computing
    // the amount of stablecoins issued
    stablecoinsIssued = uint256(ts.normalizedStables).mulDiv(ts.normalizer, BASE_27, Math.Rounding.Up);
    if (stablecoinsIssued > 0)
        collatRatio = uint64(totalCollateralization.mulDiv(BASE_9, stablecoinsIssued, Math.Rounding.Up)); //@audit unsafe cast
    else collatRatio = type(uint64).max;

Typically, the collatRatio should be around BASE_9 but the ratio might be larger than type(uint64).max during the initial stage.

Furthermore, totalCollateralization is calculated using the raw balance of collaterals and it might be manipulated when stablecoinsIssued is not large.

Then collatRatio might be cast to the wrong value.

After all, getCollateralRatio() will return the wrong ratio and it will affect the protocol seriously.

Tools Used

Manual Review

I think we should use the SafeCast library in getCollateralRatio().

Assessed type

Other

#0 - c4-judge

2023-07-08T11:45:34Z

hansfriese marked the issue as primary issue

#1 - Picodes

2023-07-10T08:55:59Z

This seems hardly doable. Assuming there is 1 stablecoin issued (1e18), you'd need 1e28 collateral. Furthermore no impact is described: this would just break this view function. Overall I think Low severity is more appropriate

#2 - c4-sponsor

2023-07-10T08:56:10Z

Picodes marked the issue as disagree with severity

#3 - hansfriese

2023-07-10T11:41:03Z

Although it's an edge case, it's likely to happen and getCollateralRatio() plays an important role in the protocol. Will keep as Medium.

#4 - c4-judge

2023-07-10T11:41:14Z

hansfriese marked the issue as satisfactory

#5 - GuillaumeNervoXS

2023-07-10T15:08:04Z

To overflow the protocol would need to be 1 billion % over collateralise so it is not likely to happen.

#6 - hansfriese

2023-07-10T15:23:01Z

As #9 shows, 1 USD is enough when stablecoinsIssued = 1 wei. I think it should be mitigated for safety.

#7 - c4-judge

2023-07-10T15:39:46Z

hansfriese marked the issue as selected for report

Findings Information

🌟 Selected for report: auditor0517

Labels

bug
2 (Med Risk)
primary issue
satisfactory
selected for report
sponsor confirmed
M-03

Awards

Data not available

External Links

Lines of code

https://github.com/AngleProtocol/angle-transmuter/blob/9707ee4ed3d221e02dcfcd2ebaa4b4d38d280936/contracts/transmuter/facets/Swapper.sol#L206 https://github.com/AngleProtocol/angle-transmuter/blob/9707ee4ed3d221e02dcfcd2ebaa4b4d38d280936/contracts/transmuter/facets/Redeemer.sol#L131 https://github.com/AngleProtocol/angle-transmuter/blob/9707ee4ed3d221e02dcfcd2ebaa4b4d38d280936/contracts/savings/SavingsVest.sol#L110

Vulnerability details

Impact

The agToken might be minted wrongly as rewards due to the reentrancy attack.

Proof of Concept

There are redeem/swap logics in the transmuter contract and all functions don't have a nonReentrant modifier.

So the typical reentrancy attack is possible during redeem/swap as I mentioned in my other report.

But besides that, the read-only reentrancy attack is possible from the SavingsVest contract, and the agToken might be minted/burnt incorrectly like this.

  1. The collatRatio is BASE_9(100%) now and Alice starts a swap from collateral to agToken in Swapper contract.
  2. In _swap(), it mints the agToken after depositing the collaterals.
    if (mint) {
        uint128 changeAmount = (amountOut.mulDiv(BASE_27, ts.normalizer, Math.Rounding.Up)).toUint128();
        // The amount of stablecoins issued from a collateral are not stored as absolute variables, but
        // as variables normalized by a `normalizer`
        collatInfo.normalizedStables += uint216(changeAmount);
        ts.normalizedStables += changeAmount;
        if (permitData.length > 0) {
            PERMIT_2.functionCall(permitData);
        } else if (collatInfo.isManaged > 0)
            IERC20(tokenIn).safeTransferFrom(
                msg.sender,
                LibManager.transferRecipient(collatInfo.managerData.config),
                amountIn
            );
        else IERC20(tokenIn).safeTransferFrom(msg.sender, address(this), amountIn); //@audit reentrancy
        if (collatInfo.isManaged > 0) {
            LibManager.invest(amountIn, collatInfo.managerData.config);
        }
        IAgToken(tokenOut).mint(to, amountOut);
    }

After depositing the collaterals, Alice might have a hook in the case of ERC777 tokens before the agToken is minted.

  1. Then Alice calls SavingsVest.accrue() inside the hook and getCollateralRatio() will return the incorrect ratio as the agToken isn't minted yet.
  2. So collatRatio will be larger than the real value and additional rewards would be minted if collatRatio > BASE_9 + BASE_6.

Then Alice would get more rewards from the SavingsVest.

Tools Used

Manual Review

Recommend adding the nonReentrant modifer to getCollateralRatio() as well as redeem()/swap() functions.

Assessed type

Reentrancy

#0 - c4-judge

2023-07-08T11:46:51Z

hansfriese marked the issue as primary issue

#1 - Picodes

2023-07-10T09:57:00Z

So the assumption is that there is an accepted collateral ERC777, which is really unlikely as there is no credible candidate and it would bring additional risk. But the scenario is valid.

#2 - c4-sponsor

2023-07-10T09:58:33Z

Picodes marked the issue as sponsor confirmed

#3 - c4-judge

2023-07-10T11:41:54Z

hansfriese marked the issue as satisfactory

#4 - c4-judge

2023-07-10T15:39:51Z

hansfriese marked the issue as selected for report

Findings Information

🌟 Selected for report: auditor0517

Labels

bug
2 (Med Risk)
primary issue
satisfactory
selected for report
sponsor confirmed
M-04

Awards

Data not available

External Links

Lines of code

https://github.com/AngleProtocol/angle-transmuter/blob/9707ee4ed3d221e02dcfcd2ebaa4b4d38d280936/contracts/savings/SavingsVest.sol#L183

Vulnerability details

Impact

estimatedAPR() might return the wrong APR and it will make users confused.

Proof of Concept

SavingsVest.estimatedAPR() returns the APR using the current vestingProfit and vestingPeriod.

    function estimatedAPR() external view returns (uint256 apr) {
        uint256 currentlyVestingProfit = vestingProfit;
        uint256 weightedAssets = vestingPeriod * totalAssets();
        if (currentlyVestingProfit != 0 && weightedAssets != 0)
            apr = (currentlyVestingProfit * 3600 * 24 * 365 * BASE_18) / weightedAssets;
    }

First of all, it uses the current vestingRatio = vestingProfit / vestingPeriod for 1 year even if vestingPeriod < 1 year. I think it might be an intended behavior to estimate the APR with the current vesting ratio.

But it's wrong to use the same vesting ratio after the vesting period is finished already.

In accrue(), it updates the vestingProfit and lastUpdate only when it's overcollateralized/undercollateralized more than 0.1%.

So lastUpdate wouldn't be changed for a certain time while collatRatio is in range (99.9%, 100.1%).

  1. At the first time, vestingProfit = 100, vestingPeriod = 10 days and estimatedAPR() returns the correct value.
  2. After 10 days, all vestings are unlocked and there is no locked profit. But accrue() has never been called due to the stable collateral ratio.
  3. In estimatedAPR(), vestingProfit will be 100 and it will return the same APR as 10 days before.
  4. But the APR should be 0 as there is no locked profit now.

Tools Used

Manual Review

estimatedAPR() should return 0 when lockedProfit() == 0.

    function estimatedAPR() external view returns (uint256 apr) {
        if (lockedProfit() == 0) return 0; //check locked profit first

        uint256 currentlyVestingProfit = vestingProfit;
        uint256 weightedAssets = vestingPeriod * totalAssets();
        if (currentlyVestingProfit != 0 && weightedAssets != 0)
            apr = (currentlyVestingProfit * 3600 * 24 * 365 * BASE_18) / weightedAssets;
    }

Assessed type

Other

#0 - c4-judge

2023-07-08T11:47:55Z

hansfriese marked the issue as primary issue

#1 - Picodes

2023-07-10T10:09:51Z

Valid, although this function isn't really useful as what matters in the end is how much a call to accrue would give. estimatedAPR should return an approximation of the current APR but is an approximation on the long run

#2 - c4-sponsor

2023-07-10T10:09:56Z

Picodes marked the issue as sponsor confirmed

#3 - hansfriese

2023-07-10T11:46:39Z

It looks like a middle of Medium and Low as it's a view function. Will keep as Medium because it will be used to estimate the profit rate for users.

#4 - c4-judge

2023-07-10T11:46:46Z

hansfriese marked the issue as satisfactory

#5 - c4-judge

2023-07-10T15:39:57Z

hansfriese marked the issue as selected for report

Findings Information

🌟 Selected for report: Lambda

Also found by: Jeiwan, Udsen, auditor0517

Labels

bug
grade-a
QA (Quality Assurance)
Q-02

Awards

Data not available

External Links

[L-01] setDisputePeriod() should validate disputePeriod > 0

    function setDisputePeriod(uint48 _disputePeriod) external onlyGovernorOrGuardian {
        disputePeriod = uint48(_disputePeriod); //@audit should check > 0
        emit DisputePeriodUpdated(_disputePeriod);
    }

If disputePeriod == 0, _endOfDisputePeriod() will return the same time as treeUpdate when it's divisible by _EPOCH_DURATION. Then the merkle tree would be updated without any dispute period.

    function _endOfDisputePeriod(uint48 treeUpdate) internal view returns (uint48) {
        return ((treeUpdate - 1) / _EPOCH_DURATION + 1 + disputePeriod) * (_EPOCH_DURATION);
    }

[L-02] The merkle tree might be updated too soon by the governor.

    function updateTree(MerkleTree calldata _tree) external {
        if (
            disputer != address(0) ||
            // A trusted address cannot update a tree right after a precedent tree update otherwise it can de facto
            // validate a tree which has not passed the dispute period
            ((canUpdateMerkleRoot[msg.sender] != 1 || block.timestamp < endOfDisputePeriod) &&
                !core.isGovernorOrGuardian(msg.sender))
        ) revert NotTrusted();

Users can dispute the merkle tree within endOfDisputePeriod but the tree might be confirmed anytime by the governor and it might break the fairness of the reward system.

[L-03] Centralization risk

    function recoverERC20(address tokenAddress, address to, uint256 amountToRecover) external onlyGovernorOrGuardian {
        IERC20(tokenAddress).safeTransfer(to, amountToRecover);
        emit Recovered(tokenAddress, to, amountToRecover);
    }

The rewards might be withdrawn by the governor.

[L-04] Wrong comment

//                      = (g(0)-1+sqrt[(1+g(0))**2+2M(f_{i+1}-g(0))/b_{i+1})]) @audit divide by 2
//                      = (g(0)+1-sqrt[(1-g(0))**2-2M(f_{i+1}-g(0))/b_{i+1})]) @audit divide by 2

[N-01] Variables need not be initialized to zero

#0 - c4-judge

2023-07-10T15:57:10Z

hansfriese marked the issue as grade-a

Findings Information

🌟 Selected for report: __141345__

Also found by: Lambda, auditor0517

Labels

analysis-advanced
grade-b
A-01

Awards

Data not available

External Links

I found H3, M12 and QGA for 40 hours.

This audit for Angle protocol consists of two main parts, transmitter, and merkl. The test and the documentation were good and especially the whitepaper was great. I started with the transmuter because it contains vast mathematical logic for the stablecoin trading actions. The transmuter uses the Diamond Proxy pattern and I was familiar with the pattern from the ZkSynk contest.

The main functionalities of the transmuter are redemption and mint/burn. The Redeemer contract implements redemption and it returns all of the backing collaterals while redeeming. During redemption, Redeemer gets the values of collaterals from the oracle and uses those values so this protocol will be robust during black swan events. Mint/burn are handled in the Swapper contract. The main contribution of Swapper is setting fees so they can stabilize the total collateral ratio by manipulating of fee price of mint and burn. We can also specify exact input and output amounts and huge mathematical perspectives are used in whitepaper and implementation. I found a mistake on the whitepaper, but the implementation was good, fortunately.

I moved to the merkl module and it handles the distribution of rewards. Those distribution logics are interesting and I enjoyed finding several vulnerabilities there. I feel Distribution contracts are more centralized because the governor and guardians can affect the reward distribution a lot. There were some instances of array copying and if we resize instead of copy, some gas will be saved.

Time spent:

40 hours

#0 - c4-judge

2023-07-08T12:36:22Z

hansfriese 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