Weather Witness

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

Lack of Validation for LINK Deposit Allows Silent Automation Failure in `WeatherNft::fulfillMintRequest`

Description

The requestMintWeatherNFT and fulfillMintRequest functions allow users to register their NFT for automated weather updates via Chainlink Automation without verifying that the initLinkDeposit covers at least one upkeep execution. As a result, a user may mint an NFT believing updates are automated, yet the first upkeep silently fails due to insufficient LINK.

Vulnerability Details

Function: WeatherNft::fulfillMintRequest(bytes32 requestId)

Conditions:
A user calls requestMintWeatherNFT with _registerKeeper = true and a very low _initLinkDeposit.

The mint process completes successfully:

Oracle fulfillment is simulated.

fulfillMintRequest mints the NFT.

A keeper is registered via a call to registerUpkeep.

When checkUpkeep returns true and performUpkeep is triggered, it fails due to lack of LINK.

Root cause:
The contract does not validate whether the provided initLinkDeposit covers at least one performUpkeep execution. This causes the automation to fail immediately, breaking user expectations and rendering the core automation feature non-functional.

Impact

NFT is minted successfully, but automated weather updates never occur.

The user believes automation is active, leading to a false sense of reliability.

System integrity is degraded as the feature promise (dynamic NFT) is broken.


Proof of Concept

The user can mint and register automation with less LINK than required for one upkeep.

The contract does not validate if that LINK is enough for only one upkeep.

The first upkeep already fails, leaving the NFT “frozen” without automatic update.

This is especially problematic because the user does not receive any alert: he thinks his NFT is automated, but it is not.

Proof of Code

For testing purposes, make _sendFunctionsWeatherFetchRequest and performUpkeep 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 (...)
.
.
.
- function performUpkeep(bytes calldata performData) external override {
+ function performUpkeep(bytes calldata performData) external override virtual {

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 MockAutomationRegistrar fakes the upkeep registration process, capturing and returning predictable upkeep IDs. 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:
Note: uint256 upkeepCost = 0.1 ether; This is arbitrarily chosen to prove the exploit. This is not the real price for upkeep execution.

// 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);
}
function performUpkeep(bytes calldata performData) external override {
uint256 tokenId = abi.decode(performData, (uint256));
uint256 upkeepCost = 0.1 ether;
bool success = LinkTokenInterface(s_link).transferFrom(address(this), address(0xdead), upkeepCost);
require(success, "MockLink: insufficient");
bytes32 reqId = keccak256(abi.encodePacked(block.timestamp, tokenId));
s_funcReqIdToTokenIdUpdate[reqId] = tokenId;
emit NftWeatherUpdateRequestSend(tokenId, reqId, s_weatherNftInfo[tokenId].upkeepId);
}
}
// SPDX-License-Identifier: MIT
pragma solidity 0.8.29;
import {IAutomationRegistrarInterface} from "src/interfaces/IAutomationRegistrarInterface.sol";
contract MockAutomationRegistrar is IAutomationRegistrarInterface {
uint256 public nextUpkeepId = 1;
RegistrationParams public lastParams;
event RegisterCalled(address caller, uint256 upkeepId);
function registerUpkeep(
RegistrationParams calldata params
) external override returns (uint256) {
lastParams = params;
uint256 registeredId = nextUpkeepId;
nextUpkeepId++;
emit RegisterCalled(msg.sender, registeredId);
return registeredId;
}
}

Then, add this code to a new test enviroment, importing the Mock contracts files:

// SPDX-License-Identifier: MIT
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";
import {MockAutomationRegistrar} from "test/mocks/MockAutomationRegistrar.sol";
contract WeatherNftUnitTest is Test {
MockLinkToken public linkToken;
MockWeatherNft public weatherNft;
MockAutomationRegistrar public automationRegistrar;
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
automationRegistrar = new MockAutomationRegistrar();
weatherNft = new MockWeatherNft(
weathers,
uris,
functionsRouter,
cfg,
/* mintPrice= */ 1 ether,
/* step= */ 0.1 ether,
address(linkToken),
/* keeperRegistry= */ address(0),
/* keeperRegistrar=*/ address(automationRegistrar),
/* upkeepGaslimit= */ 200_000
);
// 5) Fund user
user = makeAddr("user");
vm.deal(user, 10 ether);
linkToken.mint(user, 1000 ether);
}
function test_LinkSuscriptionWithLowDeposit() public {
uint256 actualPrice = weatherNft.s_currentMintPrice();
uint256 tinyDeposit = LINK_PER_KEEP - 0.001 ether;
linkToken.mint(user, tinyDeposit);
vm.startPrank(user);
linkToken.approve(address(weatherNft), tinyDeposit);
bytes32 reqId = weatherNft.requestMintWeatherNFT{ value: actualPrice }(
"28001", "ES", true, HEARTBEAT, tinyDeposit
);
weatherNft.simulateOracleResponse(
reqId,
abi.encode(uint8(WeatherNftStore.Weather.SUNNY)),
""
);
weatherNft.fulfillMintRequest(reqId);
vm.stopPrank();
uint256 tokenId = weatherNft.s_tokenCounter() - 1;
bytes memory checkData = abi.encode(tokenId);
console2.log("Token Id", tokenId);
uint firstCall = HEARTBEAT + 1;
vm.warp (HEARTBEAT + 1 );
(bool upkeepNeeded, bytes memory data) = weatherNft.checkUpkeep(checkData);
assertTrue(upkeepNeeded, "Should need an upkeep");
vm.expectRevert();
weatherNft.performUpkeep(data);
}
}

This test verifies that if a user deposits slightly less LINK than required for the next upkeep, the contract still signals that upkeep is needed (after the heartbeat interval) but correctly reverts when performUpkeep is called without sufficient funds. It mints an NFT by supplying the exact ETH mint price and an, intentionally, under-funded LINK deposit. Then, simulates the oracle response, advances time past the heartbeat, checks that checkUpkeep returns true, and finally ensures performUpkeep reverts due to the inadequate LINK balance.

Recomended Mitigation

  1. Introduce a require statement to check that the LINK deposit is enough to cover the upkeep cost.

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()
);
if (_registerKeeper) {
+ require(
+ _initLinkDeposit >= MIN_LINK_FOR_ONE_UPKEEP,
+ "Insufficient LINK for upkeep"
);
IERC20(s_link).safeTransferFrom(
msg.sender,
address(this),
_initLinkDeposit
);
}

Note: In this example, The minimum price will cover at least one upkeep.

However, this only checks that the LINK deposit is enough to cover the upkeep cost. It does not check that the LINK deposit is enough to cover multiple upkeeps. Consider, adding some logic to track the LINK deposit and ensure it is enough to cover for a certain amount of upkeeps.

Updates

Appeal created

bube Lead Judge 23 days ago
Submission Judgement Published
Invalidated
Reason: Non-acceptable severity
Assigned finding tags:

[Invalid] The LINK deposit is not checked

This is informational/invalid. If the LINK deposit is not enough, the function `registerUpkeep` will revert and it is responsibility of the user to provide the correct amount of `_initLinkDeposit`, if the user wants automated weather updates.

Support

FAQs

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