Platform: Code4rena
Start Date: 26/07/2022
Pot Size: $75,000 USDC
Total HM: 29
Participants: 179
Period: 6 days
Judge: LSDan
Total Solo HM: 6
Id: 148
League: ETH
Rank: 65/179
Findings: 4
Award: $174.91
🌟 Selected for report: 1
🚀 Solo Findings: 0
🌟 Selected for report: cloudjunky
Also found by: 0x1f8b, 0x4non, 0x52, 0xDjango, 0xHarry, 0xNazgul, 0xNineDec, 0xf15ers, 0xsanson, 0xsolstars, 8olidity, Bnke0x0, CertoraInc, Chom, Deivitto, Dravee, GalloDaSballo, GimelSec, IllIllI, Jmaxmanblue, JohnSmith, Jujic, Kenshin, Krow10, Lambda, MEP, Noah3o6, RedOneN, Ruhum, StErMi, StyxRave, TomJ, Treasure-Seeker, TrungOre, _Adam, __141345__, arcoun, asutorufos, bardamu, bearonbike, bin2chen, brgltd, bulej93, c3phas, cRat1st0s, carlitox477, cccz, codexploder, cryptonue, cryptphi, cthulhu_cult, dharma09, dipp, djxploit, durianSausage, ellahi, giovannidisiena, hansfriese, horsefacts, hyh, immeas, indijanc, jayjonah8, jayphbee, joestakey, kenzo, kyteg, ladboy233, minhquanym, navinavu, obront, oyc_109, peritoflores, rbserver, reassor, rokinot, rotcivegaf, saian, scaraven, shenwilly, simon135, sseefried, teddav, zzzitron
0.0037 USDC - $0.00
Judge has assessed an item in Issue #873 as Medium risk. The relevant finding follows:
GolomTrader#payEther uses payable(address).transfer to send native ETH. It's considered a best practice to avoid this pattern for ETH transfers, since it forwards a fixed gas stipend that may be insufficient for smart contract recipients, and could potentially revert in the future if gas costs change. (See the Consensys Diligence article here).
function payEther(uint256 payAmt, address payAddress) internal { if (payAmt > 0) { // if royalty has to be paid payable(payAddress).transfer(payAmt); // royalty transfer to royaltyaddress } }
Impact: Some orders may be unfillable if payment destination addresses are smart contract wallets.
Suggestion: Consider using payable(address).call, or OpenZeppelin Address.sendValue. However, note that any use of call() introduces an opportunity for re-entrancy.
#0 - dmvt
2022-10-21T14:25:08Z
Duplicate #343
🌟 Selected for report: horsefacts
Also found by: GalloDaSballo, IllIllI, berndartmueller, csanuragjain, hansfriese, kenzo, minhquanym, rotcivegaf
135.2182 USDC - $135.22
https://github.com/code-423n4/2022-07-golom/blob/e5efa8f9d6dda92a90b8b2c4902320acf0c26816/contracts/vote-escrow/VoteEscrowCore.sol#L893-L908 https://github.com/code-423n4/2022-07-golom/blob/e5efa8f9d6dda92a90b8b2c4902320acf0c26816/contracts/vote-escrow/VoteEscrowCore.sol#L1004-L1030 https://github.com/code-423n4/2022-07-golom/blob/e5efa8f9d6dda92a90b8b2c4902320acf0c26816/contracts/vote-escrow/VoteEscrowCore.sol#L1226-L1236
Golom is impacted by a known issue with the veNFT contract that causes the merge
and withdraw
functions to revert when called by an approved spender rather than the token owner.
merge
and withdraw
may both be called by either the token owner or an approved spender. Note that both of these functions check _isApprovedOrOwner
:
function merge(uint256 _from, uint256 _to) external { require(attachments[_from] == 0 && !voted[_from], 'attached'); require(_from != _to); require(_isApprovedOrOwner(msg.sender, _from)); require(_isApprovedOrOwner(msg.sender, _to)); LockedBalance memory _locked0 = locked[_from]; LockedBalance memory _locked1 = locked[_to]; uint256 value0 = uint256(int256(_locked0.amount)); uint256 end = _locked0.end >= _locked1.end ? _locked0.end : _locked1.end; locked[_from] = LockedBalance(0, 0); _checkpoint(_from, _locked0, LockedBalance(0, 0)); _burn(_from); _deposit_for(_to, value0, end, _locked1, DepositType.MERGE_TYPE); }
/// @notice Withdraw all tokens for `_tokenId` /// @dev Only possible if the lock has expired function withdraw(uint256 _tokenId) external nonreentrant { assert(_isApprovedOrOwner(msg.sender, _tokenId)); require(attachments[_tokenId] == 0 && !voted[_tokenId], 'attached'); LockedBalance memory _locked = locked[_tokenId]; require(block.timestamp >= _locked.end, "The lock didn't expire"); uint256 value = uint256(int256(_locked.amount)); locked[_tokenId] = LockedBalance(0, 0); uint256 supply_before = supply; supply = supply_before - value; // old_locked can have either expired <= timestamp or zero end // _locked has only 0 end // Both can have >= 0 amount _checkpoint(_tokenId, _locked, LockedBalance(0, 0)); assert(IERC20(token).transfer(msg.sender, value)); // Burn the NFT _burn(_tokenId); emit Withdraw(msg.sender, _tokenId, value, block.timestamp); emit Supply(supply_before, supply_before - value); }
However, both functions make internal calls to _burn
, which does not handle the case of an approved caller correctly. The call to _removeTokenFrom
on L1234 passes msg.sender
rather than the token owner
, which will revert:
function _burn(uint256 _tokenId) internal { require(_isApprovedOrOwner(msg.sender, _tokenId), 'caller is not owner nor approved'); address owner = ownerOf(_tokenId); // Clear approval approve(address(0), _tokenId); // Remove token _removeTokenFrom(msg.sender, _tokenId); emit Transfer(owner, address(0), _tokenId); }
Impact: Approved callers cannot merge
or withdraw
veNFTs. merge
and withdraw
may only be called by the token owner.
Suggestion:
Update _burn
to pass token owner address rather than msg.sender
:
function _burn(uint256 _tokenId) internal { require(_isApprovedOrOwner(msg.sender, _tokenId), 'caller is not owner nor approved'); address owner = ownerOf(_tokenId); // Clear approval approve(address(0), _tokenId); // Remove token _removeTokenFrom(owner, _tokenId); emit Transfer(owner, address(0), _tokenId); }
#0 - zeroexdead
2022-09-03T18:54:52Z
Removed merge
and fixed withdraw here: https://github.com/golom-protocol/contracts/commit/c79913ec08ba2dca87a22f1bc6fe47f65f7b4202
🌟 Selected for report: AuditsAreUS
Also found by: 0xSky, CertoraInc, GimelSec, GiveMeTestEther, Green, Lambda, Ruhum, RustyRabbit, Treasure-Seeker, Twpony, arcoun, bin2chen, cccz, codexploder, cryptonue, dipp, horsefacts, jayphbee, joestakey, minhquanym, obront, peritoflores, rbserver, reassor, rotcivegaf, scaraven, ych18
4.5163 USDC - $4.52
Callers of GolomTrader#fillAsk
may provide more ETH than required to fulfill an order:
// attached ETH value should be greater than total value of one NFT * total number of NFTs + any extra payment to be given require(msg.value >= o.totalAmt * amount + p.paymentAmt, 'mgmtm');
However, any ETH in excess of the amount required to fulfill the order is not refunded to the caller, and there is no mechanism for retrieving this excess amount from the contract.
Impact: Excess ETH payments intentionally or accidentally provided to fillAsk
may be permanently locked in the contract.
Suggestion: Since the required payment amount required to fill an order is known, require an exact payment amount rather than accepting excess ETH:
// attached ETH value should be greater than total value of one NFT * total number of NFTs + any extra payment to be given require(msg.value == o.totalAmt * amount + p.paymentAmt, 'mgmtm');
#0 - KenzoAgada
2022-08-04T02:53:06Z
Duplicate of #75
🌟 Selected for report: IllIllI
Also found by: 0x1f8b, 0x4non, 0x52, 0xA5DF, 0xDjango, 0xLovesleep, 0xNazgul, 0xNineDec, 0xSmartContract, 0xackermann, 0xc0ffEE, 0xf15ers, 0xmatt, 0xsanson, 0xsolstars, 8olidity, AuditsAreUS, Bahurum, Bnke0x0, CRYP70, CertoraInc, Ch_301, Chom, CryptoMartian, Deivitto, DevABDee, Dravee, ElKu, Franfran, Funen, GalloDaSballo, GimelSec, GiveMeTestEther, Green, JC, Jmaxmanblue, JohnSmith, Jujic, Junnon, Kenshin, Krow10, Kumpa, Lambda, MEP, Maxime, MiloTruck, Mohandes, NoamYakov, Picodes, RedOneN, Rohan16, Rolezn, Ruhum, RustyRabbit, Sm4rty, Soosh, StErMi, StyxRave, Tadashi, TomJ, Treasure-Seeker, TrungOre, Waze, _Adam, __141345__, ajtra, ak1, apostle0x01, arcoun, asutorufos, async, benbaessler, berndartmueller, bin2chen, brgltd, c3phas, cRat1st0s, carlitox477, chatch, codetilda, codexploder, cryptonue, cryptphi, csanuragjain, cthulhu_cult, delfin454000, dipp, dirk_y, djxploit, ellahi, exd0tpy, fatherOfBlocks, giovannidisiena, hansfriese, horsefacts, hyh, idkwhatimdoing, indijanc, jayfromthe13th, jayphbee, joestakey, kenzo, kyteg, lucacez, luckypanda, mics, minhquanym, obront, oyc_109, pedr02b2, rajatbeladiya, rbserver, reassor, robee, rokinot, rotcivegaf, sach1r0, saian, saneryee, sashik_eth, scaraven, shenwilly, simon135, sseefried, supernova, teddav, ych18, zuhaibmohd, zzzitron
35.1687 USDC - $35.17
payable(address).transfer
GolomTrader#payEther
uses payable(address).transfer
to send native ETH. It's considered a best practice to avoid this pattern for ETH transfers, since it forwards a fixed gas stipend that may be insufficient for smart contract recipients, and could potentially revert in the future if gas costs change. (See the Consensys Diligence article here).
function payEther(uint256 payAmt, address payAddress) internal { if (payAmt > 0) { // if royalty has to be paid payable(payAddress).transfer(payAmt); // royalty transfer to royaltyaddress } }
Impact: Some orders may be unfillable if payment destination addresses are smart contract wallets.
Suggestion:
Consider using payable(address).call
, or OpenZeppelin Address.sendValue
. However, note that any use of call()
introduces an opportunity for re-entrancy.
Since the ERC721.transferFrom
interface is the same as ERC20.transferFrom
, a malicious user may create what appears to be an ERC721 order, but pass an ERC20
contract as the collection
:
interface ERC721 { function transferFrom( address from, address to, uint256 tokenId ) external; }
struct Order { address collection; // NFT contract address uint256 tokenId; // order for which tokenId of the collection address signer; // maker of order address uint256 orderType; // 0 if selling nft for eth , 1 if offering weth for nft,2 if offering weth for collection with special criteria root uint256 totalAmt; // price value of the trade // total amt maker is willing to give up per unit of amount Payment exchange; // payment agreed by maker of the order to pay on succesful filling of trade this amt is subtracted from totalamt Payment prePayment; // another payment , can be used for royalty, facilating trades bool isERC721; // standard of the collection , if 721 then true , if 1155 then false uint256 tokenAmt; // token amt useful if standard is 1155 if >1 means whole order can be filled tokenAmt times uint256 refererrAmt; // amt to pay to the address that helps in filling your order bytes32 root; // A merkle root derived from each valid tokenId — set to 0 to indicate a collection-level or tokenId-specific order. address reservedAddress; // if not address(0) , only this address can fill the order uint256 nonce; // nonce of order usefull for cancelling in bulk uint256 deadline; // timestamp till order is valid epoch timestamp in secs uint8 v; bytes32 r; bytes32 s; }
Calls to transferFrom
will succeed, since the interface is the same as ERC20:
ERC721(o.collection).transferFrom(o.signer, receiver, o.tokenId);
nftcontract.transferFrom(msg.sender, o.signer, o.tokenId);
nftcontract.transferFrom(msg.sender, o.signer, tokenId);
Unwittingly filling such an order would transfer a small amount of the ERC20 equal to the order's tokenId
. These orders will likely look suspicious and do require the taker to opt-in, but it's worth documenting this possibility for your users. Additionally, consider using safeTransfer
for ERC721 transfers (see findings below), which will prevent this altogether.
GolomTrader
transfers ERC721 tokens using transferFrom
, which does not check that the receiver address supports ERC721 token transfers:
ERC721(o.collection).transferFrom(o.signer, receiver, o.tokenId);
nftcontract.transferFrom(msg.sender, o.signer, o.tokenId);
nftcontract.transferFrom(msg.sender, o.signer, tokenId);
Impact: if the receiver
of a filled ask or signer
of a filled bid are a contract that does not support ERC721 token transfers, the token may be locked.
Suggestion: Use safeTransferFrom
to transfer ERC721 tokens. The safeTransferFrom
callback introduces an opportunity for re-entrancy, but functions in GolomTrader
that perform transfers already use the nonReentrant
modifier. Note that this change will also prevent users from creating ERC721 orders with ERC20 tokens, since safeTransferFrom
is not part of the ERC20 interface.
The native ecrecover
precompile is subject to signature malleability (non-unique signatures) and returns address(0)
for invalid signatures. Since validateOrder
does not check that the recovered address is nonzero, orders created with address(0)
as the signer will be considered valid for any signature:
address signaturesigner = ecrecover(hash, o.v, o.r, o.s); require(signaturesigner == o.signer, 'invalid signature');
The impact of this appears to be limited, since address(0)
cannot approve token transfers to GolomTrader
, but consider using a helper like OpenZeppelin ECDSA
that handles both these edge cases.
GolomTrader
generates an EIP712 domain typehash at construction time and stores it as an immutable value:
bytes32 public immutable EIP712_DOMAIN_TYPEHASH;
uint256 chainId; assembly { chainId := chainid() } EIP712_DOMAIN_TYPEHASH = keccak256( abi.encode( keccak256('EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)'), keccak256(bytes('GOLOM.IO')), keccak256(bytes('1')), chainId, address(this) ) );
This implementation was modified from an earlier version that used a hardcoded chain ID to the current version that reads and stores the chain ID at construction time according to a previous audit. However, it's still possible for the chain ID to change in the event of a hard fork on an existing chain. Since the chain ID is read only once and stored here forever, signatures on one fork would be replayable on another. This is a real possibility with discussion of a PoW hard fork as Ethereum approaches the Merge.
Suggestion: Derive and store an immutable domain separator and the chain ID at construction time. When referring to the domain separator, check if the current chain ID matches the original chain ID. If so, use the cached value. If not, re-derive the domain separator using the new chain ID. The OpenSea Seaport codebase has a good example of this pattern.
fillBid
with zero amountsUsers may call GolomTrader#fillBid
with a zero amount. This will emit an OrderFilled
event but perform no transfers. Consider requiring that the amount
argument is a nonzero value.
Consider emitting events from permissioned functions that change important parameters. This is useful as a hook for your own security monitoring tools, and for users who wish to observe admin changes.
GolomTrader#setDistributor
GolomTrader#executeSetDistributor
RewardDistributor#changeTrader
RewardDistributor#executeChangeTrader
RewardDistributor#addVoteEscrow
RewardDistributor#exectueAddVoteEscrow
GolomToken#setMinter
GolomToken#executeSetMinter
VoteEscrowDelegation#changeMinVotingPower
block.chainid
rather than inline assemblyThe chain ID is available as an attribute of block
in Solidity 0.8.x and may be accessed directly rather than via inline assembly.
uint256 chainId; assembly { chainId := chainid() }
Suggestion:
uint256 chainId = block.chainid;
The Payment
and Order
EIP712 type hashes use lowercased payment
and order
as the struct name when generating
typehash values:
keccak256('payment(uint256 paymentAmt,address paymentAddress)'),
keccak256( 'order(address collection,uint256 tokenId,address signer,uint256 orderType,uint256 totalAmt,payment exchange,payment prePayment,bool isERC721,uint256 tokenAmt,uint256 refererrAmt,bytes32 root,address reservedAddress,uint256 nonce,uint256 deadline)payment(uint256 paymentAmt,address paymentAddress)' ),
It would be more idiomatic and consistent with the EIP712 spec to use capitalized struct names (i.e. Payment
and Order
) in the typehash definition.
string.concat
Solidity 0.8.12 introduced a string.concat
function, that may be used to simplify the SVG generation in TokenUriHelper
. For example:
output = string( abi.encodePacked( output, '<text y="128px" x="60px" fill="white" font-family="Lexend Deca, sans-serif" font-weight="400" font-size="64px">#', toString(_tokenId), '</text><text y="318px" x="54px" fill="white" font-family="Lexend Deca, sans-serif" font-weight="400" font-size="24px" fill-opacity="0.64">Locked Till</text>' ) );
May be rewritten as:
output = string.concat( output, '<text y="128px" x="60px" fill="white" font-family="Lexend Deca, sans-serif" font-weight="400" font-size="64px">#', toString(_tokenId), '</text><text y="318px" x="54px" fill="white" font-family="Lexend Deca, sans-serif" font-weight="400" font-size="24px" fill-opacity="0.64">Locked Till</text>' );
Consider optimizing your onchain SVG by 1) declaring repeated style attributes like fonts as global styles and 2) running the generated image through an SVG optimization tool like SVGOMG to remove unused or unnecessary attributes.
address.code.size
Solidity version 0.8.13 introduced an address.code.size
attribute, which is equivalent to calling the extcodesize
opcode in inline assembly. It can be used to simplify the _isContract
function:
function _isContract(address account) internal view returns (bool) { // This method relies on extcodesize, which returns 0 for contracts in // construction, since the code is only stored at the end of the // constructor execution. uint256 size; assembly { size := extcodesize(account) } return size > 0; }
Suggestion:
function _isContract(address account) internal view returns (bool) { return address.code.size > 0; }
This if statement in GolomTrader#validateOrder
will never evaluate, due to the require
statement on L177. If signaturesigner != o.signer
, the function will revert and the if
statement will never evaluate:
require(signaturesigner == o.signer, 'invalid signature'); if (signaturesigner != o.signer) { return (0, hashStruct, 0); }
The require check on L220 inside GolomTrader#fillAsk
is missing an error string:
require(msg.sender == o.reservedAddress);
Consider adding an error message to this check.
Since GolomTrader
and RewardDistributor
define payable
receive
functions, they do not need to define separate fallback
functions in order to receive ETH payments.
fallback() external payable {}
fallback() external payable {}
Suggestion: remove the fallback
functions.
The ERC20
interface declared in GolomTrader.sol
is actually the WETH
interface. The withdraw
function is not part of the ERC20
interface:
interface ERC20 { function transferFrom( address src, address dst, uint256 wad ) external returns (bool); function withdraw(uint256 wad) external; }
Suggestion: Rename ERC20
WETH
for clarity.
The ERC20
interface declared in RewardDistributor.sol
includes functions from several unrelated interfaces:
interface ERC20 { function totalSupply() external returns (uint256); function balanceOf(address account) external returns (uint256); function transferFrom( address src, address dst, uint256 wad ) external returns (bool); function transfer(address to, uint256 amount) external returns (bool); function mint(address account, uint256 amount) external; function balanceOfNFTAt(uint256 _tokenId, uint256 _t) external view returns (uint256); function deposit() external payable; }
Here, mint
is from GolomToken
, balanceOfNFTAt
is from VoteEscrow
, and deposit
is from WETH
.
Suggestion: Extract separate WETH
and IGolomToken
interfaces. Remove balanceOfNFTAt
, which is unused and not included in either interface.
The tokenId
and checkpoint
variables, defined in VoteEscrowCore.sol
, are shadowed as local variables in:
VoteEscrowDelegation#delegate
VoteEscrowDelegation#_getCurrentDelegated
VoteEscrowDelegation#getVotes
VoteEscrowDelegation#getPriorVotes
VoteEscrowDelegation#removeDelegation
Consider giving these local variables unique names to distinguish them from the values they shadow.
Hardhat console in RewardDistributor
:
import 'hardhat/console.sol';
(You may also want to remove the comment on L99):
//console.log(block.timestamp,epoch,fee);
Math
in VoteEscrowDelegation
:
// import {Math} from '@openzeppelin-contracts/utils/math/SafeCast.sol';
A few natspec @notice
comments have placeholder descriptions:
/// @notice Explain to an end user what this does
/// @notice Explain to an end user what this does
Remove the commented-out removeDelegationByOwner
function in VoteEscrowDelegation
:
// /// @notice Remove delegation by user // function removeDelegationByOwner(uint256 delegatedTokenId, uint256 ownerTokenId) external { // require(ownerOf(ownerTokenId) == msg.sender, 'VEDelegation: Not allowed'); // uint256 nCheckpoints = numCheckpoints[delegatedTokenId]; // Checkpoint storage checkpoint = checkpoints[delegatedTokenId][nCheckpoints - 1]; // removeElement(checkpoint.delegatedTokenIds, delegatedTokenId); // _writeCheckpoint(ownerTokenId, nCheckpoints, checkpoint.delegatedTokenIds); // }