Weather Witness

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

Unbounded Mapping Storage Growth

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

  • Missed Refunds: By not deleting entries, the contract forfeits the per-slot gas refund.

  • Uncontrolled State Growth: Thousands of entries bloat state, making storage writes costlier and straining network nodes.

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:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.29;
import {LinkTokenInterface} from "@chainlink/contracts/src/v0.8/shared/interfaces/LinkTokenInterface.sol";
import {WeatherNft, WeatherNftStore} from "src/WeatherNft.sol";
/// @notice Mock LINK token implementing minimal ERC20 + transferAndCall
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; // 60 s entre upkeeps
uint256 constant LINK_PER_KEEP = 0.1 ether; // coste LINK por upke
function setUp() public {
// 1) Deploy mock LINK
linkToken = new MockLinkToken();
// 2) Prepare Weather enum array and URIs
WeatherNftStore.Weather[] memory weathers = new WeatherNftStore.Weather[](1);
weathers[0] = WeatherNftStore.Weather.SUNNY;
string[] memory uris = new string[](1);
uris[0] = "ipfs://dummy";
// 3) Configure Chainlink Functions
WeatherNftStore.FunctionsConfig memory cfg = WeatherNftStore.FunctionsConfig({
source: "",
encryptedSecretsURL: "",
subId: 0,
gasLimit: 200_000,
donId: bytes32(0)
});
// 4) Deploy MockWeatherNft pointing to our mocks
weatherNft = new MockWeatherNft(
weathers,
uris,
functionsRouter,
cfg,
/* mintPrice= */ 1 ether,
/* step= */ 0.1 ether,
address(linkToken),
/* keeperRegistry= */ address(0),
/* keeperRegistrar=*/ address(0),
/* upkeepGaslimit= */ 200_000
);
// 5) Fund user
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.

Updates

Appeal created

bube Lead Judge 5 months ago
Submission Judgement Published
Invalidated
Reason: Non-acceptable severity

Support

FAQs

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