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;
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);
}
function fulfillRequest(bytes32 requestId, bytes memory response, bytes memory err) internal override {
if (s_funcReqIdToUserMintReq[requestId].user != address(0)) {
s_funcReqIdToMintFunctionReqResponse[requestId] = MintFunctionReqResponse({response: response, err: err});
} else if (s_funcReqIdToTokenIdUpdate[requestId] > 0) {
_fulfillWeatherUpdate(requestId, response, err);
}
}
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 {
function test_failedChainlinkRequestReturnsErrorLocksFunds() public {
console.log("\n--- Starting test_failedChainlinkRequestReturnsErrorLocksFunds ---");
uint256 initialContractBalance = address(weatherNft).balance;
uint256 mintPrice = weatherNft.s_currentMintPrice();
console.log("Initial WeatherNft contract balance:", initialContractBalance);
console.log("Current Mint Price:", mintPrice);
vm.startPrank(user);
vm.recordLogs();
weatherNft.requestMintWeatherNFT{value: mintPrice}(
"99999", "ZZ", false, 1 days, 0
);
vm.stopPrank();
console.log("User requested NFT mint, sent", mintPrice, "native tokens.");
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));
string memory mockErrorMessage = "API_ERROR: Could not fetch weather data.";
vm.prank(functionsRouter);
weatherNft.handleOracleFulfillment(requestIdForFailedMint, "", abi.encodePacked(mockErrorMessage));
console.log("Simulated Chainlink Functions fulfillment returning an ERROR:", mockErrorMessage);
vm.startPrank(user);
weatherNft.fulfillMintRequest(requestIdForFailedMint);
vm.stopPrank();
console.log("User called fulfillMintRequest after Chainlink error.");
uint256 finalContractBalance = address(weatherNft).balance;
console.log("Final WeatherNft contract balance:", finalContractBalance);
assertEq(finalContractBalance, initialContractBalance + mintPrice, "Mint price native tokens should be stuck in the contract.");
uint256 expectedUnmintedTokenId = weatherNft.s_tokenCounter();
console.log("Attempting to verify NFT (ID:", expectedUnmintedTokenId, ") was NOT minted.");
vm.expectRevert();
weatherNft.ownerOf(expectedUnmintedTokenId);
console.log("NFT (ID:", expectedUnmintedTokenId, ") was NOT minted.");
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.
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.
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
}