Weather Witness

First Flight #40
Beginner FriendlyFoundrySolidityNFT
100 EXP
View results
Submission Details
Impact: high
Likelihood: medium
Invalid

NFT Loss When Minting to Non-Compliant Contracts

_mint() to smart contract might cause loss of nft

Description

  • When a user calls fulfillMintRequest(), the _mint() function is used to transfer the newly created NFT to msg.sender. While External Owned Accounts (EOAs) can inherently receive and manage ERC721 tokens, smart contract addresses require specific logic to safely handle incoming tokens. If msg.sender is a smart contract that does not implement the IERC721Receiver interface (specifically the onERC721Received() callback function) or does not handle it correctly, the minted token will be transferred to that contract's address but become permanently inaccessible, as the contract has no mechanism to acknowledge or manage it.

function fulfillMintRequest(bytes32 requestId) external {
bytes memory response = s_funcReqIdToMintFunctionReqResponse[requestId].response;
bytes memory err = s_funcReqIdToMintFunctionReqResponse[requestId].err;
require(response.length > 0 || err.length > 0, WeatherNft__Unauthorized());
if (response.length == 0 || err.length > 0) {
return;
}
UserMintRequest memory _userMintRequest = s_funcReqIdToUserMintReq[requestId];
require(_userMintRequest.user == msg.sender, WeatherNft__Unauthorized());
uint8 weather = abi.decode(response, (uint8));
uint256 tokenId = s_tokenCounter;
s_tokenCounter++;
emit WeatherNFTMinted(requestId, msg.sender, Weather(weather));
//@> _mint(msg.sender, tokenId);
s_tokenIdToWeather[tokenId] = Weather(weather);
// .. rest of logic
}

Risk

Likelihood: medium

  • Users might interact with the contract through non-compliant smart contract wallets, multi-signature wallets without onERC721Received implemented, or custom proxy contracts that are not designed to receive ERC721 tokens safely.

  • Reason 2

Impact: high

  • Permanent loss of the minted NFT. The token will exist on-chain and be owned by the recipient contract address, but the contract will be unable to transfer, sell, or otherwise manage the token, effectively locking it forever.

  • Financial loss and poor user experience for the legitimate user. The user would have paid the required mint price (and potentially other fees) but would not receive a usable or accessible NFT, leading to frustration and distrust in the protocol.

Proof of Concept

// A smart contract that intentionally does NOT implement onERC721Received() ---
contract NonCompliantNFTReceiver {
address public weatherNftAddress;
constructor(address _weatherNftAddress) {
weatherNftAddress = _weatherNftAddress;
}
// Function for this contract to call requestMintWeatherNFT on the main NFT contract
function callRequestMint(
string memory _pincode,
string memory _isoCode,
bool _registerKeeper,
uint256 _heartbeat,
uint256 _initLinkDeposit,
uint256 _mintPrice
) external payable {
WeatherNft(weatherNftAddress).requestMintWeatherNFT{value: _mintPrice}(
_pincode, _isoCode, _registerKeeper, _heartbeat, _initLinkDeposit
);
}
// Function for this contract to call fulfillMintRequest on the main NFT contract
function callFulfillMint(bytes32 requestId) external {
WeatherNft(weatherNftAddress).fulfillMintRequest(requestId);
}
// IMPORTANT: This contract deliberately omits the `onERC721Received` function.
// Because of this, when _mint() sends an NFT here, the NFT becomes locked.
}
// Test to prove that a non-compliant smart contract cannot handle NFTs ---
function test_nonCompliantContractCannotHandleNFT() public {
// --- 1. Deploy the NonCompliantNFTReceiver contract ---
// This contract will be the recipient of the NFT.
// It's deployed by the 'user' address to manage its funds and calls.
vm.startPrank(user);
NonCompliantNFTReceiver nonCompliantReceiver = new NonCompliantNFTReceiver(address(weatherNft));
vm.stopPrank();
// Fund the `nonCompliantReceiver` contract with native tokens to pay for minting
vm.deal(address(nonCompliantReceiver), 100e18);
console.log("NonCompliantReceiver deployed at:", address(nonCompliantReceiver));
console.log("NonCompliantReceiver balance:", address(nonCompliantReceiver).balance);
// --- 2. Prepare for minting parameters ---
string memory pincode = "90210";
string memory isoCode = "US";
bool registerKeeper = false; // Simplified for this test, no keeper LINK deposit
uint256 heartbeat = 1 hours;
uint256 mintPrice = weatherNft.s_currentMintPrice();
// Get the expected token ID *before* the minting transaction
uint256 expectedTokenId = weatherNft.s_tokenCounter();
console.log("Expected Token ID to be minted:", expectedTokenId);
// --- 3. NonCompliantNFTReceiver initiates the mint request ---
// The `nonCompliantReceiver` calls `requestMintWeatherNFT` via its wrapper function.
vm.startPrank(address(nonCompliantReceiver));
vm.recordLogs(); // Start recording logs to capture the mint request ID
nonCompliantReceiver.callRequestMint{value: mintPrice}(
pincode, isoCode, registerKeeper, heartbeat, 0, mintPrice
);
vm.stopPrank();
// Extract the `mintReqId` from the emitted `WeatherNFTMintRequestSent` event
Vm.Log[] memory logs = vm.getRecordedLogs();
bytes32 mintReqId;
for (uint256 i; i < logs.length; i++) {
if (logs[i].topics[0] == keccak256("WeatherNFTMintRequestSent(address,string,string,bytes32)")) {
(,,, mintReqId) = abi.decode(logs[i].data, (address, string, string, bytes32));
break;
}
}
assert(mintReqId != bytes32(0));
// console.log("Mint Request ID:", mintReqId);
// --- 4. Simulate Chainlink Functions fulfillment ---
// This step is crucial because `fulfillMintRequest` can only be called after
// the Chainlink Functions response is available on-chain.
vm.prank(functionsRouter); // Prank with the Chainlink Functions Router address
bytes memory weatherResponse = abi.encode(WeatherNftStore.Weather.RAINY);
weatherNft.handleOracleFulfillment(mintReqId, weatherResponse, "");
console.log("Chainlink Functions fulfillment simulated.");
// --- 5. NonCompliantNFTReceiver completes the minting process ---
// The `nonCompliantReceiver` calls `fulfillMintRequest` via its wrapper function.
vm.startPrank(address(nonCompliantReceiver));
nonCompliantReceiver.callFulfillMint(mintReqId);
vm.stopPrank();
console.log("NonCompliantReceiver called fulfillMintRequest.");
// --- 6. Assert NFT Ownership (The Proof) ---
// The `_mint` function successfully transfers the NFT to the `nonCompliantReceiver` contract.
assertEq(
weatherNft.ownerOf(expectedTokenId),
address(nonCompliantReceiver),
"NFT should be minted to the non-compliant contract address."
);
console.log("NFT (ID:", expectedTokenId, ") owned by:", weatherNft.ownerOf(expectedTokenId));
// --- The Implied Proof of NFT Lock ---
// At this point, `weatherNft.ownerOf(expectedTokenId)` correctly reports
// `nonCompliantReceiver` as the owner. However, because `NonCompliantNFTReceiver`
// does not implement `onERC721Received()` (which `_safeMint` would check)
// and has no other logic to interact with ERC721 tokens (like `transferFrom`),
// the NFT is effectively locked within this contract. It cannot be moved out
// by the `nonCompliantReceiver` itself. This demonstrates the vulnerability
// of using `_mint()` without a safe transfer check.
}

Recommended Mitigation

Replace the _mint() function call with _safeMint() in fulfillMintRequest(). The _safeMint() function, provided by OpenZeppelin's ERC721 implementation, includes a check that ensures the recipient address can safely receive ERC721 tokens (by calling onERC721Received() if the recipient is a contract). If the recipient contract does not implement this function or returns an invalid value, the transaction will revert, preventing the token from being irretrievably locked.

function fulfillMintRequest(bytes32 requestId) external {
bytes memory response = s_funcReqIdToMintFunctionReqResponse[requestId].response;
bytes memory err = s_funcReqIdToMintFunctionReqResponse[requestId].err;
require(response.length > 0 || err.length > 0, WeatherNft__Unauthorized());
if (response.length == 0 || err.length > 0) {
return;
}
UserMintRequest memory _userMintRequest = s_funcReqIdToUserMintReq[requestId];
require(_userMintRequest.user == msg.sender, WeatherNft__Unauthorized());
uint8 weather = abi.decode(response, (uint8));
uint256 tokenId = s_tokenCounter;
s_tokenCounter++;
emit WeatherNFTMinted(requestId, msg.sender, Weather(weather));
- _mint(msg.sender, tokenId);
+ _safeMint(msg.sender, tokenId);
s_tokenIdToWeather[tokenId] = Weather(weather);
// .. rest of logic
}
Updates

Appeal created

bube Lead Judge 4 months ago
Submission Judgement Published
Invalidated
Reason: Non-acceptable severity
Assigned finding tags:

[Invalid] Use of `_mint` istead of `_safeMint`

The `fulfillMintRequest` function is external and anyone can call it. If the protocol uses `_safeMint` instead of `_mint`, this introduces a reentrancy risk. It is better to use `_mint` and the caller is responsible for being able to obtain the token.

Support

FAQs

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