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.
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);
}
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);
}
}
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:
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;
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)
});
automationRegistrar = new MockAutomationRegistrar();
weatherNft = new MockWeatherNft(
weathers,
uris,
functionsRouter,
cfg,
1 ether,
0.1 ether,
address(linkToken),
address(0),
address(automationRegistrar),
200_000
);
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
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.