Beginner FriendlyFoundry
100 EXP
View results
Submission Details
Severity: high
Valid

`MondrianWallet::tokenURI` has incorrect logic for assigning `tokenURI`s to `tokenId`s

Summary

MondrianWallet::tokenURI has incorrect logic for assigning tokenURIs to tokenIds:

  • the 4 tokenURIs are not randomly assigned to tokenIds, and

  • the assignment does not result in uniform distribution of the 4 tokenURIs.

Vulnerability Details

MondrianWallet::tokenURI is supposed to randomly assign 1 of the 4 tokenURIs to each tokenId.
However, the current logic results in

  • a deterministic assignment of tokenURIs to tokenIds, and

  • a non-uniform distribution of the 4 tokenURIs.

function tokenURI(uint256 tokenId) public view override returns (string memory) {
if (ownerOf(tokenId) == address(0)) {
revert MondrainWallet__InvalidTokenId();
}
uint256 modNumber = tokenId % 10;
if (modNumber == 0) {
return ART_ONE;
} else if (modNumber == 1) {
return ART_TWO;
} else if (modNumber == 2) {
return ART_THREE;
} else {
return ART_FOUR;
}
}

Importantly, not only the tokenURI distribution/assignment is incorrect, but the tokenId distribution is also completely missing (i.e. the mint and distribution of the NFTs is not implemented, as described in a separate findings report). These 2 bugs are intertwined (and also are kind of worsened by the design).

Impact

tokenURI distribution will not be as desired.

Since the tokenId distribution is also missing from the codebase (NFTs are not minted at all), the specific details of the impact cannot be accessed. There are three possible scenarios based on how NFT minting is supposed to be handled:

  1. SCENARIO 1:
    MondrianWallet handles the NFT functionality (as currently is the case, as it inherits ERC721), and minting is starting with a predefined tokenID (it is the common practice with NFT contracts), e.g. tokenID=0. In this case, since every instance of MondrianWallet is a separate NFT contract, the tokenIDs of the NFTs minted by the different instance will be the same, i.e. tokenID=0. Consequently, each Mondrian Wallet will receive an NFT with the same tokenURI, ART_ONE.

  2. SCENARIO 2: MondrianWallet handles the NFT functionality (as currently is the case, as it inherits ERC721), but minting is starting with a random tokenID. In this case, the distribution share of the 4 different tokenURIs will be the same as in the 3rd scenario: check the point below for details.

  3. SCENARIO 3: NFT functionality is separated from MondrianWallet (separation of concerns is among the most important best practices in smart contract development) as follows:

    1. MondrianWallet does not inherit ERC721,

    1. NFT functionality is handled in a separate contract, MondrianNFT,

    1. MondrianNFT has an external mint function to mint NFTs with incremental tokenIDs, and this mint function is gated to MondrianWallet instances (via a check of the runtime bytecode),

    1. Each MondrianWallet instance mints one NFT by calling MondrianNFT::mint from its constructor.

In this scenario, the distribution share of the 4 different tokenURIs will be as follows.
Consider the first 12 tokenIDs and the modNumbers and tokenURIs corresponding to each:

tokenId modNumber tokenURI
0 0 ART_ONE
1 1 ART_TWO
2 2 ART_THREE
3 3 ART_FOUR
4 4 ART_FOUR
5 5 ART_FOUR
6 6 ART_FOUR
7 7 ART_FOUR
8 8 ART_FOUR
9 9 ART_FOUR
10 0 ART_ONE
11 1 ART_TWO
12 2 ART_THREE
13 3 ART_FOUR
14 4 ART_FOUR
... ... ...

For a larger number of NFTs, the distribution of the four different tokenURIs in percentages will be as follows:

tokenURI distribution share (%)
ART_ONE 10%
ART_TWO 10%
ART_THREE 10%
ART_FOUR 70%

Tools Used

Manual review, Foundry.

Recommendations

Before fixing this issue, first you need to make a design choice on how NFT minting (i.e. tokenId distribution) is supposed to be handled. Any of the 3 scenarios is a viable option if then the logic for assiging tokenURIs to tokenIds is appropriately modified. However, only SCENARIO 3 applies separation of concerns, so consider implementing that one as follows:

  1. MondrianWallet:

// SPDX-License-Identifier: GPL-3.0
pragma solidity 0.8.24;
// AccountAbstraction Imports
import {IAccount} from "accountabstraction/contracts/interfaces/IAccount.sol";
import {IEntryPoint} from "accountabstraction/contracts/interfaces/IEntryPoint.sol";
import {UserOperationLib} from "accountabstraction/contracts/core/UserOperationLib.sol";
import {SIG_VALIDATION_FAILED, SIG_VALIDATION_SUCCESS} from "accountabstraction/contracts/core/Helpers.sol";
import {PackedUserOperation} from "accountabstraction/contracts/interfaces/PackedUserOperation.sol";
// OZ Imports
import {MessageHashUtils} from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol";
import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
- import {ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol";
+ import {MondrianNFT} from "./MondrianNFT.sol";
/**
* Our abstract art account abstraction... hehe
*/
- contract MondrianWallet is Ownable, ERC721, IAccount {
+ contract MondrianWallet is Ownable, IAccount {
using UserOperationLib for PackedUserOperation;
error MondrianWallet__NotFromEntryPoint();
error MondrianWallet__NotFromEntryPointOrOwner();
- error MondrainWallet__InvalidTokenId();
- /*//////////////////////////////////////////////////////////////
- NFT URIS
- //////////////////////////////////////////////////////////////*/
- string constant ART_ONE = "ar://jMRC4pksxwYIgi6vIBsMKXh3Sq0dfFFghSEqrchd_nQ";
- string constant ART_TWO = "ar://8NI8_fZSi2JyiqSTkIBDVWRGmHCwqHT0qn4QwF9hnPU";
- string constant ART_THREE = "ar://AVwp_mWsxZO7yZ6Sf3nrsoJhVnJppN02-cbXbFpdOME";
- string constant ART_FOUR = "ar://n17SzjtRkcbHWzcPnm0UU6w1Af5N1p0LAcRUMNP-LiM";
/*//////////////////////////////////////////////////////////////
STATE VARIABLES
//////////////////////////////////////////////////////////////*/
IEntryPoint private immutable i_entryPoint;
+ MondiranNFT private immutable i_mondrianNFT;
/*//////////////////////////////////////////////////////////////
MODIFIERS
//////////////////////////////////////////////////////////////*/
modifier requireFromEntryPoint() {
if (msg.sender != address(i_entryPoint)) {
revert MondrianWallet__NotFromEntryPoint();
}
_;
}
modifier requireFromEntryPointOrOwner() {
if (msg.sender != address(i_entryPoint) && msg.sender != owner()) {
revert MondrianWallet__NotFromEntryPointOrOwner();
}
_;
}
/*//////////////////////////////////////////////////////////////
FUNCTIONS
//////////////////////////////////////////////////////////////*/
- constructor(address entryPoint) Ownable(msg.sender) ERC721("MondrianWallet", "MW") {
+ constructor(address entryPoint, address _mondrianNftAddress) Ownable(msg.sender) {
i_entryPoint = IEntryPoint(entryPoint);
+ i_mondrianNFT = MondrianNFT(_mondrianNftAddress);
+ i_mondrianNFT.mint(address(this));
}
receive() external payable {}
/*//////////////////////////////////////////////////////////////
FUNCTIONS - EXTERNAL
//////////////////////////////////////////////////////////////*/
/// @inheritdoc IAccount
function validateUserOp(PackedUserOperation calldata userOp, bytes32 userOpHash, uint256 missingAccountFunds)
external
virtual
override
requireFromEntryPoint
returns (uint256 validationData)
{
validationData = _validateSignature(userOp, userOpHash);
_validateNonce(userOp.nonce);
_payPrefund(missingAccountFunds);
}
/**
* execute a transaction (called directly from owner, or by entryPoint)
* @param dest destination address to call
* @param value the value to pass in this call
* @param func the calldata to pass in this call
*/
function execute(address dest, uint256 value, bytes calldata func) external requireFromEntryPointOrOwner {
(bool success, bytes memory result) = dest.call{value: value}(func);
if (!success) {
assembly {
revert(add(result, 32), mload(result))
}
}
}
/*//////////////////////////////////////////////////////////////
FUNCTIONS - INTERNAL
//////////////////////////////////////////////////////////////*/
/**
* Validate the signature is valid for this message.
* @param userOp - Validate the userOp.signature field.
* @param userOpHash - Convenient field: the hash of the request, to check the signature against.
* (also hashes the entrypoint and chain id)
* @return validationData - Signature and time-range of this operation.
* <20-byte> aggregatorOrSigFail - 0 for valid signature, 1 to mark signature failure,
* otherwise, an address of an aggregator contract.
* <6-byte> validUntil - last timestamp this operation is valid. 0 for "indefinite"
* <6-byte> validAfter - first timestamp this operation is valid
* If the account doesn't use time-range, it is enough to return
* SIG_VALIDATION_FAILED value (1) for signature failure.
* Note that the validation code cannot use block.timestamp (or block.number) directly.
*/
function _validateSignature(PackedUserOperation calldata userOp, bytes32 userOpHash)
internal
pure
returns (uint256 validationData)
{
bytes32 hash = MessageHashUtils.toEthSignedMessageHash(userOpHash);
ECDSA.recover(hash, userOp.signature);
return SIG_VALIDATION_SUCCESS;
}
/**
* Validate the nonce of the UserOperation.
* This method may validate the nonce requirement of this account.
* e.g.
* To limit the nonce to use sequenced UserOps only (no "out of order" UserOps):
* `require(nonce < type(uint64).max)`
* For a hypothetical account that *requires* the nonce to be out-of-order:
* `require(nonce & type(uint64).max == 0)`
*
* The actual nonce uniqueness is managed by the EntryPoint, and thus no other
* action is needed by the account itself.
*
* @param nonce to validate
*
* solhint-disable-next-line no-empty-blocks
*/
function _validateNonce(uint256 nonce) internal view virtual {}
/**
* Sends to the entrypoint (msg.sender) the missing funds for this transaction.
* SubClass MAY override this method for better funds management
* (e.g. send to the entryPoint more than the minimum required, so that in future transactions
* it will not be required to send again).
* @param missingAccountFunds - The minimum value this method should send the entrypoint.
* This value MAY be zero, in case there is enough deposit,
* or the userOp has a paymaster.
*/
function _payPrefund(uint256 missingAccountFunds) internal virtual {
if (missingAccountFunds != 0) {
(bool success,) = payable(msg.sender).call{value: missingAccountFunds, gas: type(uint256).max}("");
(success);
//ignore failure (its EntryPoint's job to verify, not account.)
}
}
/*//////////////////////////////////////////////////////////////
VIEW AND PURE
//////////////////////////////////////////////////////////////*/
- function tokenURI(uint256 tokenId) public view override returns (string memory) {
- if (ownerOf(tokenId) == address(0)) {
- revert MondrainWallet__InvalidTokenId();
- }
- uint256 modNumber = tokenId % 10;
- if (modNumber == 0) {
- return ART_ONE;
- } else if (modNumber == 1) {
- return ART_TWO;
- } else if (modNumber == 2) {
- return ART_THREE;
- } else {
- return ART_FOUR;
- }
- }
function getEntryPoint() public view returns (IEntryPoint) {
return i_entryPoint;
}
/**
* Return the account nonce.
* This method returns the next sequential nonce.
* For a nonce of a specific key, use `entrypoint.getNonce(account, key)`
*/
function getNonce() public view virtual returns (uint256) {
return i_entryPoint.getNonce(address(this), 0);
}
/**
* check current account deposit in the entryPoint
*/
function getDeposit() public view returns (uint256) {
return i_entryPoint.balanceOf(address(this));
}
/**
* deposit more funds for this account in the entryPoint
*/
function addDeposit() public payable {
i_entryPoint.depositTo{value: msg.value}(address(this));
}
}
  1. MondrianNFT:

Note:

    1. the external mint function is gated to MondrianWallet instances (based on a check on runtime bytecode),

    1. minting is starting with tokenId=0, and tokenId is incremented,

    1. tokenURIs are assigned to tokenIds randomly. Randomness is generated via Chainlink VRF.

Note: this would not work on ZkSync. Chainlink's VRF v2 service is currently not available on the zkSync. An alternative provider for random numbers on ZkSync could be Randomizer.AI ( https://randomizer.substack.com/p/introducing-randomizerai-random-numbers-22-06-25 ).

+ // SPDX-License-Identifier: MIT
+ pragma solidity 0.8.24;
+ import "@chainlink/contracts/src/v0.8/interfaces/VRFCoordinatorV2Interface.sol";
+ import "@chainlink/contracts/src/v0.8/VRFConsumerBaseV2.sol";
+ import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
+ import "@openzeppelin/contracts/access/Ownable.sol";
+ import "./MondrianWallet.sol";
+ contract MondrianNFT is ERC721, Ownable, VRFConsumerBaseV2 {
+ uint256 public tokenCounter;
+ mapping(uint256 => string) private _tokenURIs;
+ VRFCoordinatorV2Interface private vrfCoordinator;
+ bytes32 private keyHash;
+ uint64 private subscriptionId;
+ uint32 private callbackGasLimit;
+ uint16 private requestConfirmations;
+ uint32 private numWords;
+ // Store the hash of the MondrianWallet runtime bytecode
+ bytes32 public constant MONDRIAN_WALLET_CODEHASH = keccak256(type(MondrianWallet).runtimeCode);
+ string constant ART_ONE = "ar://jMRC4pksxwYIgi6vIBsMKXh3Sq0dfFFghSEqrchd_nQ";
+ string constant ART_TWO = "ar://8NI8_fZSi2JyiqSTkIBDVWRGmHCwqHT0qn4QwF9hnPU";
+ string constant ART_THREE = "ar://AVwp_mWsxZO7yZ6Sf3nrsoJhVnJppN02-cbXbFpdOME";
+ string constant ART_FOUR = "ar://n17SzjtRkcbHWzcPnm0UU6w1Af5N1p0LAcRUMNP-LiM";
+ string[] private artURIs = [ART_ONE, ART_TWO, ART_THREE, ART_FOUR];
+ uint256 private randomResult;
+ event RandomnessRequested(uint256 requestId);
+ constructor(
+ address _vrfCoordinator,
+ bytes32 _keyHash,
+ uint64 _subscriptionId,
+ uint32 _callbackGasLimit,
+ uint16 _requestConfirmations,
+ uint32 _numWords
+ )
+ ERC721("MondrianNFT", "MNFT")
+ VRFConsumerBaseV2(_vrfCoordinator)
+ {
+ vrfCoordinator = VRFCoordinatorV2Interface(_vrfCoordinator);
+ keyHash = _keyHash;
+ subscriptionId = _subscriptionId;
+ callbackGasLimit = _callbackGasLimit;
+ requestConfirmations = _requestConfirmations;
+ numWords = _numWords;
+ }
+ function mint(address to) external {
+ require(isMondrianWallet(msg.sender), "Not authorized to mint");
+
+ uint256 tokenId = tokenCounter;
+ tokenCounter++;
+ // Request randomness for tokenURI assignment
+ uint256 requestId = vrfCoordinator.requestRandomWords(
+ keyHash,
+ subscriptionId,
+ requestConfirmations,
+ callbackGasLimit,
+ numWords
+ );
+ emit RandomnessRequested(requestId);
+ // Mint the NFT
+ _safeMint(to, tokenId);
+ }
+ function fulfillRandomWords(uint256 requestId, uint256[] memory randomWords) internal override {
+ uint256 randomIndex = randomWords[0] % artURIs.length;
+ _tokenURIs[tokenCounter - 1] = artURIs[randomIndex];
+ }
+ function tokenURI(uint256 tokenId) public view override returns (string memory) {
+ require(_exists(tokenId), "ERC721Metadata: URI query for nonexistent token");
+ return _tokenURIs[tokenId];
+ }
+ function isMondrianWallet(address account) internal view returns (bool) {
+ return keccak256(account.code) == MONDRIAN_WALLET_CODEHASH;
+ }
+}
Updates

Lead Judging Commences

inallhonesty Lead Judge about 1 year ago
Submission Judgement Published
Validated
Assigned finding tags:

NFT's should have equal distribution

NFTs are not random

Extremely Wrong Implementation of ERC721

The Wallet doesn't end up owning any nft

Support

FAQs

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