Description
The contract stores data for each mint request and each weather update in three separate mappings—and never deletes those entries. Over time, this causes on-chain state to grow unboundedly, increasing gas costs for future writes and threatening long-term protocol viability.
Vulnerability Details
Mappings Involved:
s_funcReqIdToUserMintReq – holds parameters of each mint request
s_funcReqIdToMintFunctionReqResponse – holds the oracle response for each mint
s_funcReqIdToTokenIdUpdate – holds the tokenId for each upkeep-triggered update
Lack of Cleanup:
None of these mappings ever have their entries removed via delete, so each new request or update permanently consumes storage.
Impact
Likelihood
High.
No on-chain mechanism prevents or cleans up stale entries.
Any user can mint indefinitely, adding ever more storage entries.
Proof of Concept
Write a Foundry test that calls the mint simulation to fill a mapping, then compare gas usage before and after adding delete statements to demonstrate gas consumption differences.
Proof of Code
For testing purposes, make _sendFunctionsWeatherFetchRequest virtual.
Important Note: This is made only for testing 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 and 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.
Add this mock contracts to two new test files:
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(balanceOf[from] >= amt, "MockLink: insufficient");
if (from != msg.sender) {
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
) public {
fulfillRequest(reqId, resp, err);
}
}
Then, add this code to a new test environment, importing the Mock contracts files:
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);
uint256 constant HEARTBEAT = 60;
uint256 constant LINK_PER_KEEP = 0.1 ether;
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_GasConsumptionWithoutDeletingMappings() public {
uint256 actualPrice = weatherNft.s_currentMintPrice();
vm.startPrank(user);
bytes32 reqId = weatherNft.requestMintWeatherNFT{ value: actualPrice }(
"28001", "ES", false, 0, 0
);
weatherNft.simulateOracleResponse(
reqId,
abi.encode(uint8(WeatherNftStore.Weather.SUNNY)),
""
);
weatherNft.fulfillMintRequest(reqId);
vm.stopPrank();
}
In your terminal, run: forge test --match-test test_GasConsumptionWithoutDeletingMappings --via-ir --gas-report. You should see a gas usage report for fulfillMinRequest similar to:
fulfillMintRequest | 179570 | 179570 | 179570 | 179570 | 1
Next, apply the code changes listed under “Recommended Mitigations,” then rerun the exact same command. The gas usage for fulfillMinRequest should now be lower, for example:
fulfillMintRequest | 176478 | 176478 | 176478 | 176478 | 1
Recommended Mitigations
Delete entries after use.
In the mint-fulfillment branch of fulfillRequest, after minting the NFT:
function fulfillMintRequest(bytes32 requestId) external {
bytes memory response = s_funcReqIdToMintFunctionReqResponse[requestId].response;
bytes memory err = s_funcReqIdToMintFunctionReqResponse[requestId].err;
.
.
.
s_weatherNftInfo[tokenId] = WeatherNftInfo({
heartbeat: _userMintRequest.heartbeat,
lastFulfilledAt: block.timestamp,
upkeepId: upkeepId,
pincode: _userMintRequest.pincode,
isoCode: _userMintRequest.isoCode
});
+ delete s_funcReqIdToUserMintReq[requestId];
+ delete s_funcReqIdToMintFunctionReqResponse[requestId];
}
In _fulfillWeatherUpdate, after updating the NFT’s weather:
function _fulfillWeatherUpdate(bytes32 requestId, bytes memory response, bytes memory err) internal {
if (response.length == 0 || err.length > 0) {
return;
}
uint256 tokenId = s_funcReqIdToTokenIdUpdate[requestId];
uint8 weather = abi.decode(response, (uint8));
s_weatherNftInfo[tokenId].lastFulfilledAt = block.timestamp;
s_tokenIdToWeather[tokenId] = Weather(weather);
emit NftWeatherUpdated(tokenId, Weather(weather));
+ delete s_funcReqIdToTokenIdUpdate[requestId];
}
This achieves:
Monitor Gas Refunds.
Benchmark gas savings after applying deletes to verify their effectiveness.
Ensure no mapping entry persists longer than strictly necessary.