Project

One World
NFTDeFi
15,000 USDC
View results
Submission Details
Severity: medium
Invalid

There is no validation in `updateDAOMembership` to make sure that higher tier have a higher price, there is also no compensation for existing members when a tier's price goes down

Summary

When updating a DAO membership with the updateDAOMembership function there is no validations made for the new tier prices. This can result in lower tiers having a higher price than higher tiers. Setting the lower tiers price higher than a higher tier breaks the logic of the contract that can be observed in updateTier and shareOf functions. There are also no funds paid back to existing tier members when that tier's price goes down with the update.

Vulnerability Details

The EXTERNAL_CALLER role can update a dao with the updateDAOMembership function.

function updateDAOMembership(string calldata ensName, TierConfig[] memory tierConfigs)
external onlyRole(EXTERNAL_CALLER) returns (address) {
address daoAddress = getENSAddress[ensName];
require(tierConfigs.length <= TIER_MAX, "Invalid tier count.");
require(tierConfigs.length > 0, "Invalid tier count.");
require(daoAddress != address(0), "DAO does not exist.");
DAOConfig storage dao = daos[daoAddress];
if(dao.daoType == DAOType.SPONSORED){
require(tierConfigs.length == TIER_MAX, "Invalid tier count.");
}
uint256 maxMembers = 0;
// Preserve minted values and adjust the length of dao.tiers
for (uint256 i = 0; i < tierConfigs.length; i++) {
if (i < dao.tiers.length) {
tierConfigs[i].minted = dao.tiers[i].minted;
}
}
// Reset and update the tiers array
delete dao.tiers;
for (uint256 i = 0; i < tierConfigs.length; i++) {
dao.tiers.push(tierConfigs[i]);
maxMembers += tierConfigs[i].amount;
}
// updating the ceiling limit acc to new data
if(maxMembers > dao.maxMembers){
dao.maxMembers = maxMembers;
}
dao.noOfTiers = tierConfigs.length;
return daoAddress;
}

As observed in this function, there are no validations to make sure that the higher tier price is higher than the tiers below it.

Taking a look at the upgradeTier function:

function upgradeTier(address daoMembershipAddress, uint256 fromTierIndex) external {
require(daos[daoMembershipAddress].daoType == DAOType.SPONSORED, "Upgrade not allowed.");
require(daos[daoMembershipAddress].noOfTiers >= fromTierIndex + 1, "No higher tier available.");
IMembershipERC1155(daoMembershipAddress).burn(_msgSender(), fromTierIndex, 2);
IMembershipERC1155(daoMembershipAddress).mint(_msgSender(), fromTierIndex - 1, 1);
emit UserJoinedDAO(_msgSender(), daoMembershipAddress, fromTierIndex - 1);
}

It is observed that this function burns 2 tokens of a lower tier to mint 1 token from a higher tier while doing no price adjustments for the users according to these tiers such as refunding or requesting more funds. We can assume that a higher tier is supposed to be worth twice the amount of a lower tier (e.g. if Tier 1 price is 100 USDC, Tier 2 price would be 50 USDC).

Taking a look at the shareOf function, it supports this assumption that is made by observing the upgradeTier function.

function shareOf(address account) public view returns (uint256) {
return (balanceOf(account, 0) * 64) +
(balanceOf(account, 1) * 32) +
(balanceOf(account, 2) * 16) +
(balanceOf(account, 3) * 8) +
(balanceOf(account, 4) * 4) +
(balanceOf(account, 5) * 2) +
balanceOf(account, 6);
}

This shareOf function is used when calculating the users' profit as a DAO member.

function claimProfit() external returns (uint256 profit) {
profit = saveProfit(msg.sender);
require(profit > 0, "No profit available");
savedProfit[msg.sender] = 0;
IERC20(currency).safeTransfer(msg.sender, profit);
emit Claim(msg.sender, profit);
}
function profitOf(address account) external view returns (uint256) {
return savedProfit[account] + getUnsaved(account);
}
function getUnsaved(address account) internal view returns (uint256 profit) {
return ((totalProfit - lastProfit[account]) * shareOf(account)) / ACCURACY;
}

It is observed that, Tier 1 members earn twice the profit of Tier 2 members who earn twice the profit of Tier 3 members etc.

Considering all this, we can assume that Tier 1 membership that gives twice the profit and has twice the voting power should be costing twice the amount of a Tier 2 membership, if not twice, it should cost at least more as upgradeTier function makes it evident that upgrading to Tier 1 from Tier 2 would burn 2 Tier 2 tokens to mint 1 Tier 1 token.

Consider the scenario where the DAO prices are as follows:

  • Tier 1 = 100 USDC

  • Tier 2 = 50 USDC

  • Tier 3 = 25 USDC

These prices would make sense considering the calculations in the upgradeTier and shareOf functions. However, since there are no price validations done in the updateDAOMembership function, a DAO can be upgraded with the prices below:

  • Tier 1 = 25 USDC

  • Tier 2 = 100 USDC

  • Tier 3 = 50 USDC

This scenario would break the logic of the contract as observed with the calculations in the upgradeTier and shareOf functions. Tier 1, having twice the profits and voting power of Tier 2 can cost less than a Tier 2 membership.
This also creates an issue where updated prices will be unfair for users that have joined the DAO before the update as people can now join the same Tier paying less.

Proof of Concept

Following test proves that after the update, second member paid less for the same tier, first member did not get compensated and tier prices does not make sense.

1) Implement a mock WETH contract for testing inside the test folder.

// SPDX-License-Identifier: MIT
pragma solidity 0.8.22;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
contract MockWETH is ERC20 {
constructor(string memory name, string memory symbol) ERC20(name, symbol) {}
function mint(address to, uint256 amount) external {
_mint(to, amount);
}
}

2) Implement the following foundry test and run it to observe the vulnerability

// SPDX-License-Identifier: MIT
pragma solidity 0.8.22;
import { Test, console } from "forge-std/Test.sol";
import { MembershipFactory } from "../contracts/dao/MembershipFactory.sol";
import { CurrencyManager } from "../contracts/dao/CurrencyManager.sol";
import { MembershipERC1155 } from "../contracts/dao/tokens/MembershipERC1155.sol";
import { OWPIdentity } from "../contracts/OWPIdentity.sol";
import "../contracts/dao/libraries/MembershipDAOStructs.sol";
import { MockWETH } from "./MockWETH.sol";
import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
contract TestOneWorld is Test {
// actors
address OWPTeam = makeAddr("1WPTeam");
address OWPWallet = makeAddr("OWPWallet");
address DAOCreator = makeAddr("DAOCreator");
address DAOMember = makeAddr("DAOMember");
address DAOMember2 = makeAddr("DAOMember2");
address DAOMember3 = makeAddr("DAOMember3");
address WETHDeployer = makeAddr("WETHDeployer");
//contracts
CurrencyManager currencyManager;
MembershipERC1155 membershipERC1155;
MembershipFactory membershipFactory;
MockWETH weth;
// addresses
address daoSponsoredProxy;
address daoPublicProxy;
function setUp() public {
// Deploy necessary contract
vm.prank(WETHDeployer);
weth = new MockWETH("Wrapped ETHER", "WETH");
vm.startPrank(OWPTeam);
currencyManager = new CurrencyManager();
membershipERC1155 = new MembershipERC1155();
membershipFactory = new MembershipFactory(
address(currencyManager),
OWPWallet,
"https://images.com/{id}",
address(membershipERC1155)
);
//Whitelist weth
currencyManager.addCurrency(address(weth));
vm.stopPrank();
weth.mint(DAOMember, 1000e18);
weth.mint(DAOMember2, 1000e18);
weth.mint(DAOMember3, 1000e18);
}
function testDAOPricesUpdated() public {
//DAO config with 7 tiers
TierConfig[] memory tiersInput = new TierConfig[]();
tiersInput[0] = TierConfig({ amount: 1, price: 64, power: 64, minted: 0 });
tiersInput[1] = TierConfig({ amount: 2, price: 32, power: 32, minted: 0 });
tiersInput[2] = TierConfig({ amount: 4, price: 16, power: 16, minted: 0 });
tiersInput[3] = TierConfig({ amount: 8, price: 8, power: 8, minted: 0 });
tiersInput[4] = TierConfig({ amount: 16, price: 4, power: 4, minted: 0 });
tiersInput[5] = TierConfig({ amount: 32, price: 2, power: 2, minted: 0 });
tiersInput[6] = TierConfig({ amount: 64, price: 100, power: 1, minted: 0 });
// DAO config to be updated to
TierConfig[] memory tiersInput2 = new TierConfig[]();
tiersInput2[0] = TierConfig({ amount: 1, price: 1, power: 64, minted: 0 });
tiersInput2[1] = TierConfig({ amount: 2, price: 20, power: 32, minted: 0 });
tiersInput2[2] = TierConfig({ amount: 4, price: 45, power: 16, minted: 0 });
tiersInput2[3] = TierConfig({ amount: 8, price: 15, power: 8, minted: 0 });
tiersInput2[4] = TierConfig({ amount: 16, price: 34, power: 4, minted: 0 });
tiersInput2[5] = TierConfig({ amount: 32, price: 2, power: 2, minted: 0 });
tiersInput2[6] = TierConfig({ amount: 64, price: 10, power: 1, minted: 1 });
DAOInputConfig memory daoPublicInputConfig = DAOInputConfig({
ensname: "daoPublic",
daoType: DAOType.PUBLIC,
currency: address(weth),
maxMembers: 300,
noOfTiers: 7
});
// create DAO
vm.prank(DAOCreator);
daoPublicProxy = membershipFactory.createNewDAOMembership(
daoPublicInputConfig,
tiersInput
);
// DAOMember join DAO tier 7
vm.startPrank(DAOMember);
uint256 DAOMemberWETBalanceBefore = IERC20(weth).balanceOf(DAOMember); // cache balance before payment
IERC20(weth).approve(address(membershipFactory), 1000e18);
membershipFactory.joinDAO(daoPublicProxy, 6);
uint256 DAOMemberWETBalanceAfter = IERC20(weth).balanceOf(DAOMember); // cache balance after payment
uint256 DAOMemberPaidForTier7 = DAOMemberWETBalanceBefore - DAOMemberWETBalanceAfter; // cache balance difference
vm.stopPrank();
// update DAO
vm.prank(OWPTeam);
membershipFactory.updateDAOMembership("daoPublic", tiersInput2);
// DAOMember2 join updated DAO tier 7
vm.startPrank(DAOMember2);
uint256 DAOMember2WETBalanceBefore = IERC20(weth).balanceOf(DAOMember2); //cache balance before payment
IERC20(weth).approve(address(membershipFactory), 1000e18);
membershipFactory.joinDAO(daoPublicProxy, 6);
uint256 DAOMember2WETBalanceAfter = IERC20(weth).balanceOf(DAOMember2); // cache balance after payment
uint256 DAOMember2PaidForTier7 = DAOMember2WETBalanceBefore - DAOMember2WETBalanceAfter; // cache balance difference
vm.stopPrank();
uint256 DAOMemberLastBalance = IERC20(weth).balanceOf(DAOMember); // cache last balance of DAOMember
// assertion to prove the issue
assertGt(DAOMemberPaidForTier7, DAOMember2PaidForTier7);
// assertion to prove that no compensation is paid
assertEq(DAOMemberWETBalanceAfter, DAOMemberLastBalance);
}
}

Impact

Updating tier prices to amounts that do not match the calculations in the upgradeTier and shareOf functions will cause users to have a gas race where they front-run each other to join the most profitable tiers for its price.
Additionally users that have joined a tier before the update will not get compensated if the tier price goes down.

Impact: High
Likelihood: Low

Tools Used

Manual review

Recommendations

Implement a for loop in the updateDAOMembership function to validate tier prices. An example of this is shown below.

function updateDAOMembership(string calldata ensName, TierConfig[] memory tierConfigs)
external onlyRole(EXTERNAL_CALLER) returns (address) {
address daoAddress = getENSAddress[ensName];
require(tierConfigs.length <= TIER_MAX, "Invalid tier count.");
require(tierConfigs.length > 0, "Invalid tier count.");
require(daoAddress != address(0), "DAO does not exist.");
DAOConfig storage dao = daos[daoAddress];
if(dao.daoType == DAOType.SPONSORED){
require(tierConfigs.length == TIER_MAX, "Invalid tier count.");
}
uint256 maxMembers = 0;
// Preserve minted values and adjust the length of dao.tiers
for (uint256 i = 0; i < tierConfigs.length; i++) {
if (i < dao.tiers.length) {
tierConfigs[i].minted = dao.tiers[i].minted;
}
}
// Reset and update the tiers array
delete dao.tiers;
for (uint256 i = 0; i < tierConfigs.length; i++) {
dao.tiers.push(tierConfigs[i]);
maxMembers += tierConfigs[i].amount;
}
// Check price hierarchy
for (uint256 i = 1; i < tierConfigs.length; i++) {
require(
tierConfigs[i - 1].price >= 2 * tierConfigs[i].price,
"Each tier must be at least double the price of the next tier."
);
//rest of the function
}

Additionally, when a tier price goes down, compensate members that have joined this tier before the update.

Updates

Lead Judging Commences

0xbrivan2 Lead Judge 10 months ago
Submission Judgement Published
Invalidated
Reason: Known issue

Appeal created

ljj Submitter
10 months ago
0xbrivan2 Lead Judge
10 months ago
ljj Submitter
10 months ago
0xbrivan2 Lead Judge
10 months ago
0xbrivan2 Lead Judge 10 months ago
Submission Judgement Published
Invalidated
Reason: Known issue

Support

FAQs

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