Weather Witness

First Flight #40
Beginner FriendlyFoundrySolidityNFT
100 EXP
View results
Submission Details
Severity: high
Valid

Inadequate Error Handling of Chainlink Functions Request Failures

Inadequate handling of Chainlink Functions request errors can lead to user's native tokens being stuck.

Description

  • The minting process for a Weather NFT begins when a user calls requestMintWeatherNFT(), initiating an off-chain Chainlink Functions request for external weather data and simultaneously submitting the minting fee in native tokens (e.g., ETH, AVAX). The Chainlink Functions oracle is designed to return both a response (for successful data) and an err (error message) if the off-chain computation or API call encounters an issue.

    The critical vulnerability lies in the contract's fulfillRequest() and subsequent fulfillMintRequest() logic. While fulfillRequest() correctly receives and stores the err bytes if the Chainlink Functions execution fails, the fulfillMintRequest() function, which is responsible for finalizing the mint, does not adequately process this error state. It proceeds with checks that assume a successful fulfillment or, more critically, it lacks a mechanism to allow the user to reclaim their initial native token payment when an explicit error is returned by Chainlink Functions, or when the request simply fails to fulfill at all. This results in the user's funds becoming permanently locked in the contract without receiving the NFT.

function fulfillMintRequest(bytes32 requestId) external {
bytes memory response = s_funcReqIdToMintFunctionReqResponse[requestId].response;
bytes memory err = s_funcReqIdToMintFunctionReqResponse[requestId].err;
// @> This require only checks if *either* response or error is present, not distinguishing success from failure
require(response.length > 0 || err.length > 0, WeatherNft__Unauthorized());
// @> This 'if' block incorrectly returns if response is zero OR error is present.
// If err.length > 0, it returns, but the user's funds are still held.
// It should explicitly handle the error condition to allow refund/revert.
if (response.length == 0 || err.length > 0) {
// If err.length > 0, the request failed. Funds are now stuck.
// @> Funds are not refunded here.
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
}
function fulfillRequest(bytes32 requestId, bytes memory response, bytes memory err) internal override {
if (s_funcReqIdToUserMintReq[requestId].user != address(0)) {
// @> Error bytes are stored here, but not immediately acted upon for mint requests
s_funcReqIdToMintFunctionReqResponse[requestId] = MintFunctionReqResponse({response: response, err: err});
} else if (s_funcReqIdToTokenIdUpdate[requestId] > 0) {
_fulfillWeatherUpdate(requestId, response, err); // _fulfillWeatherUpdate also returns on error
}
}

Risk

Likelihood: medium

  • Chainlink Functions requests are susceptible to various external failures (e.g., API downtime, incorrect API keys, rate limits, network congestion, or Chainlink subscription running out of LINK). These failures will result in the err parameter being populated or no fulfillment at all.

Impact: high

  • Financial loss for the user. The user pays the minting fee in native tokens, but if the Chainlink Functions request fails (indicated by err or no fulfillment), they do not receive the NFT, and their funds remain inaccessible within the contract.

  • Deterioration of user trust. The inability to recover funds after a paid service fails creates a negative user experience and undermines confidence in the reliability and fairness of the protocol.

Proof of Concept

This Foundry test demonstrates a scenario where a user's native tokens become stuck because the Chainlink Functions request is fulfilled with an explicit error message, and the fulfillMintRequest function, upon being called by the user, fails to handle this error appropriately, leading to locked funds.

contract WeatherNftForkTest is Test {
// Demonstrate stuck funds if Chainlink request returns an error
function test_failedChainlinkRequestReturnsErrorLocksFunds() public {
console.log("\n--- Starting test_failedChainlinkRequestReturnsErrorLocksFunds ---");
// --- 1. Get initial contract balance ---
uint256 initialContractBalance = address(weatherNft).balance;
uint256 mintPrice = weatherNft.s_currentMintPrice();
console.log("Initial WeatherNft contract balance:", initialContractBalance);
console.log("Current Mint Price:", mintPrice);
// --- 2. User requests mint ---
// The user calls requestMintWeatherNFT, sending the mint price.
vm.startPrank(user);
vm.recordLogs(); // Record logs to confirm the event if needed for debugging
weatherNft.requestMintWeatherNFT{value: mintPrice}(
"99999", "ZZ", false, 1 days, 0 // Use dummy data, no keeper registration to simplify
);
vm.stopPrank();
console.log("User requested NFT mint, sent", mintPrice, "native tokens.");
// Extract the request ID
Vm.Log[] memory logs = vm.getRecordedLogs();
bytes32 requestIdForFailedMint;
for (uint256 i; i < logs.length; i++) {
if (logs[i].topics[0] == keccak256("WeatherNFTMintRequestSent(address,string,string,bytes32)")) {
(, , , requestIdForFailedMint) = abi.decode(logs[i].data, (address, string, string, bytes32));
break;
}
}
assert(requestIdForFailedMint != bytes32(0));
// --- 3. Simulate Chainlink Functions fulfillment with an ERROR ---
// This simulates the Chainlink Functions execution failing off-chain and returning an error.
string memory mockErrorMessage = "API_ERROR: Could not fetch weather data.";
vm.prank(functionsRouter); // Prank with the Chainlink Functions Router address
weatherNft.handleOracleFulfillment(requestIdForFailedMint, "", abi.encodePacked(mockErrorMessage));
console.log("Simulated Chainlink Functions fulfillment returning an ERROR:", mockErrorMessage);
// --- 4. User attempts to call fulfillMintRequest ---
// Now, the user calls fulfillMintRequest, expecting their NFT.
// The contract's current logic will see `err.length > 0` and simply `return`.
vm.startPrank(user);
weatherNft.fulfillMintRequest(requestIdForFailedMint);
vm.stopPrank();
console.log("User called fulfillMintRequest after Chainlink error.");
// --- 5. Get final contract balance ---
uint256 finalContractBalance = address(weatherNft).balance;
console.log("Final WeatherNft contract balance:", finalContractBalance);
// --- 6. Assert balances and NFT state ---
// The contract's balance should still reflect the mint price paid by the user.
assertEq(finalContractBalance, initialContractBalance + mintPrice, "Mint price native tokens should be stuck in the contract.");
// The NFT should *not* have been minted.
uint256 expectedUnmintedTokenId = weatherNft.s_tokenCounter(); // This ID was reserved but not minted
console.log("Attempting to verify NFT (ID:", expectedUnmintedTokenId, ") was NOT minted.");
// Assert that calling ownerOf for this ID reverts because the token does not exist.
// This is the correct way to prove it was not minted.
vm.expectRevert();
weatherNft.ownerOf(expectedUnmintedTokenId); // This call is expected to revert
console.log("NFT (ID:", expectedUnmintedTokenId, ") was NOT minted.");
// --- Conclusion ---
console.log("\nResult: User paid", mintPrice, "native tokens for an NFT.");
console.log("The Chainlink Functions request returned an error, but the contract's fulfillMintRequest");
console.log("did not handle this error by refunding funds or reverting explicitly with an error for the user.");
console.log("The native tokens remain stuck in the WeatherNft contract's balance, and the user did not receive their NFT.");
console.log("This demonstrates inadequate error handling for Chainlink Functions fulfillment errors.");
}
}

Recommended Mitigation

To properly handle Chainlink Functions request failures and prevent user funds from being stuck, implement robust error handling within fulfillMintRequest() and provide a clear refund mechanism.

  1. Explicit Error Check and Revert in fulfillMintRequest: Modify fulfillMintRequest to explicitly check for err.length > 0. If an error is present, the function should revert with a descriptive error message, and importantly, clear the request state to prevent future attempts to fulfill the same errored request. This ensures that no NFT is minted for a failed request.

  2. Implement a User-Initiated Refund Function: Add a new function (e.g., claimFailedMintRefund(bytes32 requestId)) that allows the original user who initiated the request to claim back their native token payment if the Chainlink Functions request failed to fulfill successfully (i.e., s_funcReqIdToMintFunctionReqResponse[requestId].err is populated, or a defined timeout period has passed without any fulfillment). This function must also clear the request's state to prevent double-claiming.

function fulfillMintRequest(bytes32 requestId) external nonReentrant {
bytes memory response = s_funcReqIdToMintFunctionReqResponse[requestId].response;
bytes memory err = s_funcReqIdToMintFunctionReqResponse[requestId].err;
// Ensure fulfillment data exists for this requestId
require(response.length > 0 || err.length > 0, "WeatherNft: No fulfillment data for this request ID.");
UserMintRequest memory _userMintRequest = s_funcReqIdToUserMintReq[requestId];
require(_userMintRequest.user == msg.sender, WeatherNft__Unauthorized());
+ // --- MITIGATION: Explicitly handle fulfillment errors for mint requests ---
+ // If there's an error in the fulfillment, revert the mint and clear state.
+ if (err.length > 0) {
+ // Clear the state for this failed request.
+ delete s_funcReqIdToMintFunctionReqResponse[requestId];
+ delete s_funcReqIdToUserMintReq[requestId];
+ revert WeatherNft__MintFulfillmentFailed(err); // Revert with the error data
+ }
+
+ // If response.length is 0 here, it implies an issue where no error was returned but no data either.
+ // This should theoretically be caught by the initial `require` or an earlier Chainlink error.
+ require(response.length > 0, "WeatherNft: No valid response for mint fulfillment.");
// .. rest of logic
}
Updates

Appeal created

bube Lead Judge 6 days ago
Submission Judgement Published
Validated
Assigned finding tags:

Lost fee in case of Oracle failure

If Oracle fails, the `fulfillMintRequest` function will not return the payed fee for the token to the user.

Support

FAQs

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