Platform: Code4rena
Start Date: 21/07/2023
Pot Size: $90,500 USDC
Total HM: 8
Participants: 60
Period: 7 days
Judge: 0xean
Total Solo HM: 2
Id: 264
League: ETH
Rank: 13/60
Findings: 2
Award: $725.77
🌟 Selected for report: 1
🚀 Solo Findings: 0
406.5609 USDC - $406.56
https://github.com/code-423n4/2023-07-arcade/blob/main/contracts/NFTBoostVault.sol#L363-#L371
Currently the function setMultiplier
of contract NFTBoostVault
is implemented as follows:
function setMultiplier(address tokenAddress, uint128 tokenId, uint128 multiplierValue) public override onlyManager { if (multiplierValue > MAX_MULTIPLIER) revert NBV_MultiplierLimit(); NFTBoostVaultStorage.AddressUintUint storage multiplierData = _getMultipliers()[tokenAddress][tokenId]; // set multiplier value multiplierData.multiplier = multiplierValue; emit MultiplierSet(tokenAddress, tokenId, multiplierValue); }
The problem with this code is that it does not update the users voting power, even though their multiplier has changed. The contract did provide a function called updateVotingPower
to update users voting power as follows:
function updateVotingPower(address[] calldata userAddresses) public override { if (userAddresses.length > 50) revert NBV_ArrayTooManyElements(); for (uint256 i = 0; i < userAddresses.length; ++i) { NFTBoostVaultStorage.Registration storage registration = _getRegistrations()[userAddresses[i]]; _syncVotingPower(userAddresses[i], registration); } }
We can argue that the manager can call this function to update users voting power after they change multiplier; however, there are multiple problems with this solution:
updateVotingPower
is a list of user adresses, so the manager must have map of token id to a list of users using that token id as their multiplier. However, this data is not saved in the contract. When users register, their registrations are tracked using a variable of type map(address => NFTBoostVaultStorage.Registration)
. I've looked into the contracts and found no way to retrieve a list of users associated with a token id, even function _registerAndDelegate
does not emit an event showing which ERC1155 tokenId the users use at registration time.updateVotingPower
only allow updating 50 addresses at a time, so if there are many users associated with a particular token ids, they have more time to try front running the manager.Below is a POC for this issue, place this file under test
folder with name VotingPowerNotUpdatedAfterMultiplierChange.ts
and run it using command:
npx hardhat test test/VotingPowerNotUpdatedAfterMultiplierChange.ts
The flow of this POC is as follows:
import { expect } from "chai"; import { constants } from "ethers"; import { ethers, waffle } from "hardhat"; import { deploy } from "./utils/deploy"; import { TestContextGovernance, governanceFixture } from "./utils/governanceFixture"; import { TestContextToken, tokenFixture } from "./utils/tokenFixture"; const { provider, loadFixture } = waffle; import { ArcadeToken, ArcadeTreasury, CoreVoting, LockingVault, NFTBoostVault, VestingVault, ArcadeTokenDistributor, ARCDVestingVault, FeeController, MockERC1155, PromissoryNote, ReputationBadge, IReputationBadge, IBadgeDescriptor } from "../../src/types"; import { BlockchainTime } from "./utils/time"; import { MerkleTree } from "merkletreejs"; import { BADGE_MANAGER_ROLE } from "./utils/constants"; /** * nft boost vault test */ describe("Governance operations with nft boost voting vault", async () => { const ONE = ethers.utils.parseEther("1"); const MULTIPLIER_DENOMINATOR = 1e3; const MAX = ethers.constants.MaxUint256; let signers: SignerWithAddress[]; let alice: Signer; let arcdToken: ArcadeToken; let arcdVestingVault: ARCDVestingVault; let deployer: Signer; let arcdDst: ArcadeTokenDistributor; let reputationBadge: ReputationBadge; let nftBoostVault: NFTBoostVault; let feeController: FeeController; let coreVoting: CoreVoting; let blockchainTime: BlockchainTime; let descriptor: IBadgeDescriptor; beforeEach( async function () { // get signers signers = await ethers.getSigners(); alice = signers[6]; deployer = signers[0]; // tree data for two tokens let recipientsTokenId = [{ address: alice.address, tokenId: 1, amount: 1, }]; let merkleTrieTokenId = await getMerkleTree(recipientsTokenId); let rootTokenId = merkleTrieTokenId.getHexRoot(); let proofAlice = merkleTrieTokenId.getHexProof( ethers.utils.solidityKeccak256( ["address", "uint256", "uint256"], [alice.address, 1, 1], ), ); // deploy descriptor contract descriptor = <IBadgeDescriptor>await deploy("BadgeDescriptor", deployer, ["https://www.domain.com/"]); await descriptor.deployed(); // Deploy reputation badge reputationBadge = <IReputationBadge>await deploy("ReputationBadge", deployer, [deployer.address, descriptor.address]); await reputationBadge.deployed(); await reputationBadge.connect(deployer).grantRole(BADGE_MANAGER_ROLE, deployer.address); // Deployer set expiration time let blockchainTime = new BlockchainTime(); let expiration = await blockchainTime.secondsFromNow(3600); // 1 hour await reputationBadge.connect(deployer).publishRoots([{ tokenId:1, claimRoot: rootTokenId, claimExpiration: expiration, mintPrice: 0 }]); //Alice mint tokenId await reputationBadge.connect(alice).mint(alice.address, 1, 1, 1, proofAlice); // deploy arcade token distributor arcdDst = <ArcadeTokenDistributor> await deploy( "ArcadeTokenDistributor", signers[0], [] ); await arcdDst.deployed(); // Deploy arcade token arcdToken = <ArcadeToken> await deploy( "ArcadeToken", signers[0], [ deployer.address, arcdDst.address ] ); await arcdToken.deployed(); // set arcade token as token for arcade distribution await arcdDst.connect(deployer).setToken(arcdToken.address); // mint tokens take tokens from the distributor for use in tests await arcdDst.connect(deployer).toPartnerVesting(signers[0].address); // transfer tokens to signers and approve locking vault to spend for (let i = 0; i< signers.length; i++){ await arcdToken.connect(deployer).transfer(signers[i].address, ONE.mul(100)); } // Deploy nft boost vault let staleBlockNum = 0; nftBoostVault = <NFTBoostVault>await deploy("NFTBoostVault", signers[0], [ arcdToken.address, staleBlockNum, deployer.address, // timelock address who can update the manager deployer.address, // manager address who can update multiplier values ]); await nftBoostVault.deployed(); }); it("Voting power is not updated after multiplier change", async () => { // Add multiplier for reputation badge token id = 1 // Multiplier is 1400 await nftBoostVault.connect(deployer).setMultiplier(reputationBadge.address,1, 1400); // Confirm that alice is holding the token expect(await reputationBadge.balanceOf(alice.address, 1)).to.equal(1); // Alice add nft id =1 to nft boost vault await arcdToken.connect(alice).approve(nftBoostVault.address, ONE); await reputationBadge.connect(alice).setApprovalForAll(nftBoostVault.address, true); const tx = await nftBoostVault.connect(alice).addNftAndDelegate(ONE, 1, reputationBadge.address, alice.address); const votingPowerBefore = await nftBoostVault.queryVotePowerView(alice.address, tx.blockNumber); // Alice voting power is the amount * multiplier expect(votingPowerBefore).to.be.eq(ONE.mul(1400).div(MULTIPLIER_DENOMINATOR)); // Now the manager reduce the token's multiplier to 1200 await nftBoostVault.connect(deployer).setMultiplier(reputationBadge.address,1, 1200); expect(await nftBoostVault.getMultiplier(reputationBadge.address, 1), 1200); // Get current block let blockchainTime = new BlockchainTime(); let nowBlock = await blockchainTime.secondsFromNow(0); // 1 hour // Alice voting power is unchanged const votingPowerAfter = await nftBoostVault.queryVotePowerView(alice.address, nowBlock); expect(votingPowerBefore).to.be.eq(votingPowerAfter); }); async function getMerkleTree(accounts: Account[]) { const leaves = await Promise.all(accounts.map(account => hashAccount(account))); return new MerkleTree(leaves, keccak256Custom, { hashLeaves: false, sortPairs: true, }); } async function hashAccount(account: Account) { return ethers.utils.solidityKeccak256( ["address", "uint256", "uint256"], [account.address, account.tokenId, account.amount], ); } function keccak256Custom(bytes: Buffer) { const buffHash = ethers.utils.solidityKeccak256(["bytes"], ["0x" + bytes.toString("hex")]); return Buffer.from(buffHash.slice(2), "hex"); } });
Manual review
I recommend creating a map of token id to a list of users using that token as multiplier and synchronize their voting power in function setMultiplier
Other
#0 - c4-pre-sort
2023-07-30T08:56:18Z
141345 marked the issue as duplicate of #160
#1 - c4-pre-sort
2023-08-01T09:15:14Z
141345 marked the issue as duplicate of #431
#2 - c4-judge
2023-08-11T16:05:21Z
0xean marked the issue as satisfactory
#3 - c4-judge
2023-08-12T14:00:14Z
0xean marked the issue as selected for report
#4 - Nabeel-javaid
2023-08-15T05:34:41Z
This is intentional and by design an noticed same issue and than read the following in the details page of code4rena:
Due to gas constraints, this will not immediately update the voting power of users who are using this NFT for a boost. However, any user's voting power can be updated by any other user via the updateVotingPower function - this value will look up the current multiplier of the user's registered NFT and recalculate the boosted voting power. This can be used in cases where obselete boosts may be influencing the outcome of a vote.
Given that its unfair to other wardens. Better be a QA IMO,
#5 - viktorcortess
2023-08-15T15:16:23Z
This is intentional and by design an noticed same issue and than read the following in the details page of code4rena:
Due to gas constraints, this will not immediately update the voting power of users who are using this NFT for a boost. However, any user's voting power can be updated by any other user via the updateVotingPower function - this value will look up the current multiplier of the user's registered NFT and recalculate the boosted voting power. This can be used in cases where obselete boosts may be influencing the outcome of a vote.
Given that its unfair to other wardens. Better be a QA IMO,
https://github.com/code-423n4/2023-07-arcade-findings/issues/431
There is a discussion of this issue. One of the problems with the update mechanism is that nobody knows the addresses of the users whose votes should be updated. So if the user's multiplier decreases he can avoid updating his voting power.
#6 - 0xean
2023-08-15T17:53:02Z
Given that its unfair to other wardens. Better be a QA IMO,
This isn't a consideration in a severity of an issue and i think its worth highlighting the current design flaw that I think qualifies as M. This could be thought of as a "leak of value" since votes have some value, and this allows for a user to have more votes than expected in some scenarios.
🌟 Selected for report: Sathish9098
Also found by: 0x3b, 0xnev, 3agle, BugBusters, DadeKuma, K42, Udsen, foxb868, ktg, kutugu, oakcobalt, peanuts, squeaky_cactus
The followings are my understanding of Arcade audit main contracts
Vault contracts:
BaseVotingVault
: The contract provides basic voting vault functions,
it tracks and allows querying users voting power. All 3 vaults ARCDVestingVault
, NFTBoostVault
and ImmutableVestingVault
inherits this contract
ARCDVestingVault
: This is a vesting vault for Arcade token with the following main
features
ImmutableVestingVault
: This contract is exactly the same with ARCDVestingVault
,
except that revoking grant is disabled.
NFTBoostVault
: This contract contains the most logic in Arcade audit, its main
features are as follows:
Token contracts:
ArcadeToken
: an ERC20 token with main features:
ArcadeTokenDistributor
: This is the contract for distributing arcade tokens,
it contains predefined amounts of tokens that needed to be transferred to specific
organization/user and also flags indicating if those amounts are sent. After a
specific amount is already sent, it cannot be sent again.ArcadeAirdrop
: This contract receives arcade tokens from ArcadeTokenDistributor
and
allows users to claim it using merkle proofs.Badge related contracts:
BadgeDescriptor
: This is to be used with ReputationBadge
to allow
querying token URIReputationBadge
: This is an ERC1155 contract, its tokens are used
as multiplier
in NFTBoostVault
. User must pay ETH to mint new
tokens, and then they can use these tokens as multipliers.Following is my risk analysis of Arcade main contracts, namely ARCDVestingVault
and NFTBoostVault
, which contains the most logic.
ARCDVestingVault
:
NFTBoostVault
:
ARCDVestingVault
. User can deposit tokens and nft to vaults,
add tokens to their registrations, update their nfts or event withdraw the nft.ReputationBadge
contract:
setMultiplier
but it will not update voting power of users using those tokensReputationBadge
supports this caseReputationBadge
contract
use uint25620 hours
#0 - c4-pre-sort
2023-07-31T16:41:08Z
141345 marked the issue as high quality report
#1 - liveactionllama
2023-08-02T17:37:10Z
After discussion with the lookout, removing the high quality
label here, simply to focus usage of that label on the top 2 QA reports.
#2 - c4-judge
2023-08-10T22:59:57Z
0xean marked the issue as grade-a