Description
The WeatherNft::requestMintWeatherNFT
function increases the global s_currentMintPrice
immediately upon receiving a mint request, before confirming that the minting process has succeeded. If the Chainlink oracle call fails (due to an error or a timeout), the minting is aborted, but the price is still incremented and the user has paid for nothing in return. This results in users losing ETH without receiving an NFT, and future users being unfairly charged a higher mint price, despite no NFT being minted.
Vulnerability Details
The vulnerability originates in the logic of WeatherNft::requestMintWeatherNFT
, which:
Requires the user to send exactly s_currentMintPrice
in ETH.
Immediately increases the global s_currentMintPrice
.
Issues a Chainlink Functions request.
Defers NFT minting until WeatherNft::fulfillMintRequest
receives a valid oracle response.
If the oracle call fails:
No NFT is minted.
The user's ETH is retained by the contract with no refund.
The s_currentMintPrice
increases for all future users.
This breaks user expectations by charging them for a mint operation that never completes, while also causing unjustified mint price inflation.
Likelihood
The issue will happen every time requestMintWeatherNFT
is called and no mint happens, for example with an oracle fail.
Impact
Permanent Loss of Funds: Users cannot recover ETH or obtain the intended NFT if the oracle request fails.
Broken Pricing Model: The mint price increases despite no NFT being issued, which degrades the logic and fairness of the protocol’s supply/demand mechanism.
Proof of Concept
A user sends the exact required payment to requestMintWeatherNFT
..
The s_currentMintPrice
increases immediately.
The Chainlink oracle returns an error.
The contract exits without minting and retains the user's ETH.
Future users face a higher s_currentMintPrice
.
Proof of Code
To reproduce the vulnerability, modify the WeatherNft::_sendFunctionsWeatherFetchRequest
function, to allow a mock test that uses this function:
Important Note: This is made only for test purposes and should not be used in production.
Remember to revert the changes after testing.
- function _sendFunctionsWeatherFetchRequest(...) internal returns (...)
+ function _sendFunctionsWeatherFetchRequest(...) internal virtual returns (...)
Use mocks to create a fully self-contained testing environment that avoids any live Chainlink deployments. The MockLinkToken stands in for the real LINK token so your contract can receive, approve and spend LINK without a live ERC-20. The MockWeatherNft extends your real NFT contract to override external oracle calls and keeper logic, giving you a fixed request ID, a way to inject simulated oracle responses, and a controlled upkeep flow.
Testing Note: The MockLinkToken
contract simulates an ERC-20 LINK token by using native ETH balances. Instead of minting LINK tokens, ETH is assigned to the mock and treated as LINK to satisfy subscription-payment logic without requiring a real LINK deployment. This simplification enables focus on NFT minting, upkeeps, and oracle behaviors without managing a separate ERC-20 token.
Create a MockContracts
test file containing mock implementations of both the LinkToken
contract and the WeatherNft
contract.
pragma solidity 0.8.29;
import {LinkTokenInterface} from "@chainlink/contracts/src/v0.8/shared/interfaces/LinkTokenInterface.sol";
import {WeatherNft, WeatherNftStore} from "src/WeatherNft.sol";
contract MockLinkToken is LinkTokenInterface {
mapping(address => uint256) public override balanceOf;
mapping(address => mapping(address => uint256)) public override allowance;
uint256 public totalSupply;
function mint(address to, uint256 amount) external {
balanceOf[to] += amount;
totalSupply += amount;
}
function transfer(address to, uint256 amt) external override returns (bool) {
require(balanceOf[msg.sender] >= amt, "MockLink: insufficient");
balanceOf[msg.sender] -= amt;
balanceOf[to] += amt;
return true;
}
function approve(address spender, uint256 amt) external override returns (bool) {
allowance[msg.sender][spender] = amt;
return true;
}
function transferFrom(address from, address to, uint256 amt) external override returns (bool) {
require(allowance[from][msg.sender] >= amt, "MockLink: not allowed");
allowance[from][msg.sender] -= amt;
balanceOf[from] -= amt;
balanceOf[to] += amt;
return true;
}
function transferAndCall(address to, uint256 amt, bytes calldata) external override returns (bool) {
require(balanceOf[msg.sender] >= amt, "MockLink: insufficient");
balanceOf[msg.sender] -= amt;
balanceOf[to] += amt;
return true;
}
function decimals() external pure override returns (uint8) {
return 18;
}
function name() external pure override returns (string memory) {
return "MockLINK";
}
function symbol() external pure override returns (string memory) {
return "LINK";
}
function decreaseApproval(address spender, uint256 addedValue) external override returns (bool) {
uint256 old = allowance[msg.sender][spender];
if (addedValue >= old) {
allowance[msg.sender][spender] = 0;
} else {
allowance[msg.sender][spender] = old - addedValue;
}
return true;
}
function increaseApproval(address spender, uint256 subtractedValue) external override {
allowance[msg.sender][spender] += subtractedValue;
}
}
contract MockWeatherNft is WeatherNft {
bytes32 public constant FIXED_REQ = keccak256("MOCK_REQ");
constructor(
WeatherNftStore.Weather[] memory weathers,
string[] memory uris,
address functionsRouter,
WeatherNftStore.FunctionsConfig memory cfg,
uint256 mintPrice,
uint256 step,
address link,
address keeperRegistry,
address keeperRegistrar,
uint32 upkeepGaslimit
) WeatherNft(
weathers,
uris,
functionsRouter,
cfg,
mintPrice,
step,
link,
keeperRegistry,
keeperRegistrar,
upkeepGaslimit
) {}
function _sendFunctionsWeatherFetchRequest(
string memory,
string memory
) internal pure override returns (bytes32) {
return FIXED_REQ;
}
function simulateOracleResponse(
bytes32 reqId,
bytes memory resp,
bytes memory err
) external {
fulfillRequest(reqId, resp, err);
}
}
Then, create a test file that uses these mocks to demonstrate how the system behaves when the oracle does not respond and no NFT is minted.:
pragma solidity 0.8.29;
import {Test, console2} from "forge-std/Test.sol";
import {Vm} from "forge-std/Vm.sol";
import {WeatherNftStore} from "src/WeatherNftStore.sol";
import {MockLinkToken, MockWeatherNft} from "test/mocks/MockContracts.t.sol";
contract WeatherNftUnitTest is Test {
MockLinkToken public linkToken;
MockWeatherNft public weatherNft;
address public user;
address public functionsRouter = address(0x1234);
function setUp() public {
linkToken = new MockLinkToken();
WeatherNftStore.Weather[] memory weathers = new WeatherNftStore.Weather[](1);
weathers[0] = WeatherNftStore.Weather.SUNNY;
string[] memory uris = new string[](1);
uris[0] = "ipfs://dummy";
WeatherNftStore.FunctionsConfig memory cfg = WeatherNftStore.FunctionsConfig({
source: "",
encryptedSecretsURL: "",
subId: 0,
gasLimit: 200_000,
donId: bytes32(0)
});
weatherNft = new MockWeatherNft(
weathers,
uris,
functionsRouter,
cfg,
1 ether,
0.1 ether,
address(linkToken),
address(0),
address(0),
200_000
);
user = makeAddr("user");
vm.deal(user, 10 ether);
linkToken.mint(user, 1000 ether);
}
function test_priceIncreasesWhenOracleError() public {
uint256 beforePrice = weatherNft.s_currentMintPrice();
console2.log("Price before request", beforePrice);
uint256 beforeCounter = weatherNft.s_tokenCounter();
console2.log("NFTs minted before request", beforeCounter);
uint256 beforeUserBalance = user.balance;
console2.log("User balance before request", beforeUserBalance);
vm.startPrank(user);
bytes32 reqId = weatherNft.requestMintWeatherNFT{ value: beforePrice }(
"28001", "ES", false, 0, 0
);
vm.stopPrank();
weatherNft.simulateOracleResponse(
weatherNft.FIXED_REQ(),
"",
hex"01"
);
uint256 afterPrice = weatherNft.s_currentMintPrice();
console2.log("Price after request", afterPrice);
uint256 afterCounter = weatherNft.s_tokenCounter();
console2.log("NFTs minted after request", afterCounter);
uint256 afterUserBalance = user.balance;
console2.log("User balance after request", afterUserBalance);
console2.log("NFT's balance of user", weatherNft.balanceOf(user));
The console logs should clearly demonstrate the following:
-
The mint price increases even though no NFT is minted.
-
No NFT is actually minted as a result of the simulated oracle failure.
-
The user's ETH balance decreases, indicating that funds were accepted despite the failure to deliver the NFT.
Price before request 1000000000000000000
NFTs minted before request 1
User balance before request 10000000000000000000
Price after request 1100000000000000000
NFTs minted after request 1
User balance after request 9000000000000000000
NFT's balance of user 0
Tools Used
This issue was found by manual review of the code.
Recommendations
Refactor requestMintWeatherNFT
so that the price increment occurs after a successful oracle response.
Store the user’s ETH in a temporary buffer to allow for refunds if the mint fails.
Add the following changes to WeatherNftStore.sol
file:
This will temporarily hold each user’s ETH in a “pending” buffer keyed by their oracle request ID. If the mint fails, we can refund exactly what they paid.
// variables
uint256 public s_tokenCounter;
mapping(Weather => string) public s_weatherToTokenURI;
FunctionsConfig public s_functionsConfig;
mapping(bytes32 => UserMintRequest) public s_funcReqIdToUserMintReq;
mapping(bytes32 => MintFunctionReqResponse) public s_funcReqIdToMintFunctionReqResponse;
mapping(bytes32 => uint256) public s_funcReqIdToTokenIdUpdate;
uint256 public s_currentMintPrice;
uint256 public s_stepIncreasePerMint;
mapping(uint256 => Weather) public s_tokenIdToWeather;
mapping(uint256 => WeatherNftInfo) public s_weatherNftInfo;
address public s_link;
address public s_keeperRegistry;
address public s_keeperRegistrar;
uint32 public s_upkeepGaslimit;
+ mapping(bytes32 => uint256) private s_pendingMintValue;
// events
event WeatherNFTMintRequestSent(address user, string pincode, string isoCode, bytes32 reqId);
event WeatherNFTMinted(bytes32 reqId, address user, Weather weather);
event NftWeatherUpdateRequestSend(uint256 tokenId, bytes32 reqId, uint256 upkeepId);
event NftWeatherUpdated(uint256 tokenId, Weather weather);
+ event MintRefunded(bytes32 indexed requestId, address indexed user, uint256 amount);
And this modifications to the WeatherNft.sol
file:
Remove the immediate price bump from requestMintWeatherNFT
and add the new logic, that right after you emit the mint‐request event, saves the exact msg.value
under the returned _reqId
. Then, add to fulfillMintRequest
the code shown, which drives both refund and successful mint logic.
function requestMintWeatherNFT(
string memory _pincode,
string memory _isoCode,
bool _registerKeeper,
uint256 _heartbeat,
uint256 _initLinkDeposit
) external payable returns (bytes32 _reqId) {
require(
msg.value == s_currentMintPrice,
WeatherNft__InvalidAmountSent()
);
- s_currentMintPrice += s_stepIncreasePerMint;
if (_registerKeeper) {
IERC20(s_link).safeTransferFrom(
msg.sender,
address(this),
_initLinkDeposit
);
}
_reqId = _sendFunctionsWeatherFetchRequest(_pincode, _isoCode);
emit WeatherNFTMintRequestSent(msg.sender, _pincode, _isoCode, _reqId);
+ s_pendingMintValue[_reqId] = msg.value;
.
.
.
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) {
uint256 paid = s_pendingMintValue[requestId];
+ delete s_pendingMintValue[requestId];
+ address user = s_funcReqIdToUserMintReq[requestId].user;
+ payable(user).transfer(paid);
+ emit MintRefunded(requestId, user, paid);
+ return;
}
+ s_currentMintPrice += s_stepIncreasePerMint;
UserMintRequest memory _userMintRequest = s_funcReqIdToUserMintReq[
requestId
];
Finally, create a function that allows users to get a refund if its NFT is not minted. Add it to WeatherNftStore.sol
function refundFailedMint(bytes32 requestId) external {
require(
msg.sender == s_funcReqIdToUserMintReq[requestId].user,
"Only requester can refund"
);
uint256 paid = s_pendingMintValue[requestId];
require(paid > 0, "No refund available");
delete s_pendingMintValue[requestId];
payable(msg.sender).transfer(paid);
}