Weather Witness

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

Excess LINK Locked due to uint96 Truncation

Summary

The WeatherNft::requestMintWeatherNFT function processes LINK deposits for Chainlink Automation by converting the initLinkDeposit parameter (provided as uint256) to uint96 before registering the upkeep. If a user supplies a value greater than type(uint96).max, the entire amount is transferred to the contract, but only the lower 96 bits are actually used for the upkeep registration. The excess LINK above the uint96 limit is permanently locked in the contract, becoming inaccessible to both the user and the protocol.


Vulnerability Details

// @audit-issue Truncation of LINK deposit due to uint96 cast
@> IAutomationRegistrarInterface.RegistrationParams({
...
amount: uint96(_userMintRequest.initLinkDeposit)
});

Issue Identified

  • The contract does not enforce an upper bound on the initLinkDeposit parameter.

  • If a user supplies an initLinkDeposit greater than 2^96-1, the entire amount is transferred from the user to the contract.

  • Only the least significant 96 bits of the deposit are used in the keeper registration (uint96 cast); any excess LINK above this limit is not refunded or accessible.

  • As a result, users can permanently lose any excess LINK they deposit over the uint96 maximum.


Risk

Likelihood:

  • Any user can accidentally or intentionally supply an initLinkDeposit that exceeds the uint96 limit, especially when handling large values or using automated tooling.

  • In practice, most users are unlikely to hold such a large amount of LINK tokens, so this scenario is uncommon in typical user behavior.

Impact:

  • The excess LINK is permanently locked in the contract, leading to loss of funds.

  • There is no way for users or protocol admins to recover or refund the locked LINK.


Proof of Concept (PoC)

This PoC demonstrates that if a user submits an initLinkDeposit exceeding the uint96 maximum, the full amount is transferred, but only 2^96-1 LINK (plus 1 for rounding) is actually credited to the upkeep, and the excess LINK remains stuck in the contract.

PoC Explanation

  1. The attacker funds their account with LINK exceeding the uint96 maximum.

  2. The attacker calls requestMintWeatherNFT with a large initLinkDeposit value.

  3. The full amount is transferred to the contract.

  4. Only the lower 96 bits of initLinkDeposit are used in the keeper registration.

  5. The excess LINK above 2^96-1 cannot be withdrawn or used.

// SPDX-License-Identifier: MIT
pragma solidity 0.8.29;
import {Test, console} from "forge-std/Test.sol";
import {WeatherNft, WeatherNftStore} from "src/WeatherNft.sol";
import {LinkTokenInterface} from "@chainlink/contracts/src/v0.8/shared/interfaces/LinkTokenInterface.sol";
import {Vm} from "forge-std/Vm.sol";
contract WeatherNftForkTest is Test {
WeatherNft weatherNft;
LinkTokenInterface linkToken;
address functionsRouter;
address user = makeAddr("user");
function setUp() external {
weatherNft = WeatherNft(0x4fF356bB2125886d048038386845eCbde022E15e);
linkToken = LinkTokenInterface(0x0b9d5D9136855f6FEc3c0993feE6E9CE8a297846);
functionsRouter = 0xA9d587a00A31A52Ed70D6026794a8FC5E2F5dCb0;
vm.deal(user, 1000e18);
uint256 huge = uint256(type(uint96).max) + 100e18 + 5e18;
deal(address(linkToken), user, huge);
// Fund the subscription required by Chainlink Functions
vm.prank(user);
linkToken.transferAndCall(functionsRouter, 100e18, abi.encode(15459));
}
function test_InitLinkDeposit_Truncation_Locks_Excess_LINK() public {
string memory pincode = "125001";
string memory isoCode = "IN";
bool registerKeeper = true;
uint256 heartbeat = 12 hours;
uint256 initLinkDeposit = uint256(type(uint96).max) + 5e18;
uint256 tokenId = weatherNft.s_tokenCounter();
// Step 1: Attacker initiates a Weather NFT mint request
vm.startPrank(user);
linkToken.approve(address(weatherNft), initLinkDeposit);
vm.recordLogs();
weatherNft.requestMintWeatherNFT{value: weatherNft.s_currentMintPrice()}(
pincode, isoCode, registerKeeper, heartbeat, initLinkDeposit
);
vm.stopPrank();
// Step 2: Extract requestId from the WeatherNFTMintRequestSent event
Vm.Log[] memory logs = vm.getRecordedLogs();
bytes32 reqId;
for (uint256 i; i < logs.length; i++) {
if (logs[i].topics[0] == keccak256("WeatherNFTMintRequestSent(address,string,string,bytes32)")) {
(,,, reqId) = abi.decode(logs[i].data, (address, string, string, bytes32));
break;
}
}
// Step 3: Simulate successful Chainlink oracle fulfillment
vm.prank(functionsRouter);
bytes memory weatherResponse = abi.encode(WeatherNftStore.Weather.RAINY);
weatherNft.handleOracleFulfillment(reqId, weatherResponse, "");
// Step 4: Attacker calls fulfillMintRequest once —
// due to uint96 truncation, only the lower 96 bits of initLinkDeposit are used,
// so any excess LINK above 2^96-1 remains locked in the contract.
vm.startPrank(user);
weatherNft.fulfillMintRequest(reqId); // First call, mints NFT
// Verify that only 2^96-1 + 1 LINK was transferred in, not the full initLinkDeposit.
uint256 expectedLocked = uint256(type(uint96).max) + 1;
vm.assertEq(linkToken.balanceOf(address(weatherNft)), expectedLocked);
}
}

Tools Used

  • Manual Review

  • Foundry Unit Testing


Recommendations

Enforce Upper Bound in requestMintWeatherNFT

Add a require at the top of requestMintWeatherNFT to reject any initLinkDeposit exceeding the uint96 limit:

function requestMintWeatherNFT(
string memory _pincode,
string memory _isoCode,
bool _registerKeeper,
uint256 _heartbeat,
uint256 _initLinkDeposit
) external payable returns (bytes32 _reqId) {
+ require(_initLinkDeposit <= type(uint96).max, "initLinkDeposit exceeds uint96 max");
// ... existing logic ...
}

This ensures the full LINK deposit will always fit into a uint96 without truncation, preventing any excess LINK from becoming permanently locked in the contract.


Updates

Appeal created

bube Lead Judge 4 months ago
Submission Judgement Published
Validated
Assigned finding tags:

[Invalid] Loss of LINK tokens

After discussion with the sponsor, this turns out to be invalid. This is because the Chainlink has a capped maximum supply of 1 billion LINK tokens. This means that the total number of LINK tokens will never exceed 1 billion. The token has 18 decimals, so the max scaled value that is required to represent all LINK tokens is 1e27. The max value of `uint96` is 2**96 - 1, that is around 79e27 and it is sufficient to store all LINK tokens. Therefore, the cast from uint256 to uint96 is safe and there is no possibility of token truncation/loss of tokens.

bube Lead Judge 4 months ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement
Assigned finding tags:

[Invalid] Loss of LINK tokens

After discussion with the sponsor, this turns out to be invalid. This is because the Chainlink has a capped maximum supply of 1 billion LINK tokens. This means that the total number of LINK tokens will never exceed 1 billion. The token has 18 decimals, so the max scaled value that is required to represent all LINK tokens is 1e27. The max value of `uint96` is 2**96 - 1, that is around 79e27 and it is sufficient to store all LINK tokens. Therefore, the cast from uint256 to uint96 is safe and there is no possibility of token truncation/loss of tokens.

Support

FAQs

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