SBET Protocol Deep Audit Report
Date: 2026-02-01 – 2026-02-26 · Auditor: Versus Security
Methodology: Expert Manual Review · Automated Tools · Slither · Echidna · AI-Assisted Analysis
Scope: SBET.sol, SBETClaim.sol, SBETNFT.sol, SBETTrading.sol, SBETPool.sol, SBETQuery.sol, SBETMath.sol, SBETTreasury.sol, TreasuryFeeManager.sol, TreasuryVesting.sol, TreasuryYield.sol, TreasuryBudgets.sol, TreasuryMultiSig.sol, TreasuryNFTFees.sol, TreasuryNFTManager.sol, IntegratorHub.sol, NFTVault.sol, Donation.sol, PredictionAMM.sol, PredictionMarket.sol
Solidity: 0.8.34, EVM target: Prague, via_ir + optimizer (200 runs)
📄 Download Full Audit Report (PDF)
Executive Summary
This deep audit covered the full SBET Protocol in seven phases. Phase 1 targeted the core betting contracts — EIP-712 signing, oracle finalization, NFT staking/finalization, P2P order matching, pooled betting, claim settlement, and math libraries (21 findings). Phase 2 targeted SBETTreasury.sol — fund custody, withdrawal limits, module funding, integrator payouts, emergency pause, migration, and access control (15 findings). Phase 3 targeted TreasuryFeeManager.sol — fee recipient management, timelocked operations, fee accumulation, and distribution (10 findings). Phase 4 targeted TreasuryVesting.sol — vesting schedule creation, release mechanics, revocation, balance invariants, and emergency withdrawal accounting (7 findings). Phase 5 targeted TreasuryYield.sol — yield strategy deployment, withdrawal, harvesting, rebalancing, slippage protection, and accounting integrity (6 findings). Phase 6 targeted TreasuryBudgets.sol — budget creation, department allocations, spending approvals, deposit tracking, and emergency withdrawal accounting (8 findings). Phase 7 targeted TreasuryMultiSig.sol — proposal lifecycle, signer management, threshold governance, execution-time re-validation, and withdrawal metrics (7 findings). Phase 8 targeted TreasuryNFTFees.sol — tiered fee schedules, category-based fees, exemptions, fee distribution with rounding, accumulation accounting, and emergency withdrawal consistency (8 findings). Phase 9 targeted TreasuryNFTManager.sol — NFT vault deposit/withdrawal, batch operations, input validation, and constructor guards (3 findings). Phase 10 targeted IntegratorHub.sol — app registration, fee accrual/consumption, batch operations, sweep lifecycle, and error handling (7 findings). Phase 11 targeted NFTVault.sol — NFT escrow, emergency recovery, holdings tracking, batch operations, and interface compliance (6 findings). Phase 12 targeted Donation.sol — organization management, cause-based donation distribution, token validation, and gas-bounded iteration (6 findings). Phase 13 targeted PredictionAMM.sol — LMSR market maker, liquidity seeding, share buy/sell, pro-rata redemption, liquidity removal, and constructor validation (7 findings). Phase 14 targeted PredictionMarket.sol — market lifecycle, ecrecover signature validation, M-of-N dispute council verification, bond distribution, view pagination, and pause enforcement (14 findings). Phase 1–14 identified a combined total of 125 findings (10 Critical, 30 High, 58 Medium, 27 Low). A Follow-Up Audit (Round 2) performed a deep review of SBET.sol and all inherited modules, producing an additional 21 findings (6 Critical, 4 High, 8 Medium, 3 Low). The grand total across both rounds is 146 findings: 16 Critical, 34 High, 66 Medium, and 30 Low.
Overall Assessment: The most critical SBET.sol issues are C2 (oracle price truncation), C4/C5 (NFT finalization path conflicts), and C6 (matchOrders nonce double-marking). The Treasury critical findings (TC1/TC2) allow ETH deposits to bypass pause and lock guards. The FeeManager's most impactful finding (FMH1) allows timelocked operations to bypass constraints when state changes during the delay. The Vesting high finding (VH1) allows over-promising tokens beyond contract balance. The Yield high finding (YH1) inflates totalDeposited on rebalance, corrupting accounting. The Budgets high findings (BH1/BH2) allow budget creation beyond contract balance and stale accounting after emergency withdrawal. The MultiSig high findings (MSH1/MSH2) prevent unanimity thresholds and allow stale-state proposal execution. The NFTFees high findings (NFH1/NFH2) allow rounding dust loss in fee distribution and zero-amount fee accumulation. The IntegratorHub high findings (IHH1/IHH2) use string reverts and unbounded batch arrays. The NFTVault high findings (NVH1/NVH2) leave stale holdings after emergency transfer and allow zero-address recipients. The Donation high finding (DH1) allows unbounded iteration in cause-based donations. The PredictionAMM high findings (PAH1/PAH2/PAH3) allow rounding dust loss in redemption, liquidity seeding on non-active markets, and deployment with zero-address prediction market. The PredictionMarket critical findings (PMC1/PMC2) allow ecrecover address(0) bypass and duplicate signature counting in M-of-N dispute council verification. The PredictionMarket high findings (PMH1–PMH5) include require-string gas waste, missing zero-address checks on AMM/token/council members, and duplicate council member acceptance. All critical and high findings have been fixed across all contracts. The Round 2 follow-up audit confirmed that previous fixes hold and identified 21 additional findings in SBET.sol and its modules — all remediated or acknowledged.
Findings Summary
| Severity | Count | Fixed | Acknowledged |
|---|---|---|---|
| CRITICAL | 16 | 16 | 0 |
| HIGH | 34 | 34 | 0 |
| MEDIUM | 66 | 52 | 14 |
| LOW | 30 | 21 | 9 |
| Total | 146 | 123 | 23 |
Remediation Summary
| # | Severity | Status | Fix |
|---|---|---|---|
| SBET.sol Findings | |||
| C1 | CRITICAL | Fixed | Removed spurious spaces in EIP-712 domain type string |
| C2 | CRITICAL | Fixed | Added price ≤ 0 and ≥ MAX_PRICE checks before uint32 cast |
| C3 | CRITICAL | Fixed | Rerouted claim() through _settlePosition for grader fee deduction |
| C4 | CRITICAL | Fixed | Set matchFinalPrice in NFT finalization for P2P settlement |
| C5 | CRITICAL | Fixed | Batch NFT finalize uses if (!matchFinalized) guard + outcome tamper check |
| C6 | CRITICAL | Fixed | Moved _markNonceUsed(left) out of per-right-order loop |
| H1 | HIGH | Fixed | Equal shares for all graders, no claim-order privilege |
| H2 | HIGH | Fixed | poolId bounds check before storage dereference |
| H3 | HIGH | Fixed | _finalizeMatch rejects finalPrice == 0 |
| H4 | HIGH | Fixed | Removed dead safeTransferFrom negative payoff branch |
| M1 | MEDIUM | Acknowledged | Cosmetic; salt is redundant but harmless |
| M2 | MEDIUM | Fixed | Explicit clearEmergency() replaces modifier side-effect |
| M3 | MEDIUM | Fixed | Custom errors replace assert() in SBETMath |
| M4 | MEDIUM | Acknowledged | Dead virtual functions are the override pattern; removing them breaks the interface |
| M5 | MEDIUM | Fixed | Zero userStake on normal pool claim |
| M6 | MEDIUM | Fixed | Checked arithmetic replaces unchecked + manual check |
| M7 | MEDIUM | Fixed | Removed dead if-block |
| M8 | MEDIUM | Acknowledged | ETH path is dead code guarded by upstream token != address(0) checks |
| L1 | LOW | Acknowledged | Correct behavior — empty pools are a no-op |
| L2 | LOW | Fixed | Math.mulDiv in SBETQuery pool simulation |
| L3 | LOW | Fixed | currentPrice bounds check + zero-position early return |
| SBETTreasury.sol Findings | |||
| TC1 | CRITICAL | Fixed | Added notPaused modifier and lock check to receive() |
| TC2 | CRITICAL | Fixed | Added lock check to batchDeposit() |
| TH1 | HIGH | Fixed | Safe subtraction prevents daily withdrawal limit underflow |
| TH2 | HIGH | Fixed | Zero-amount check in batchDeposit loop |
| TH3 | HIGH | Fixed | Universal payout address verification (removed EOA-only bypass) |
| TM1 | MEDIUM | Fixed | Added nonReentrant to accrueFeeFor() |
| TM2 | MEDIUM | Fixed | emergencyWithdraw metrics guarded by allowlist check |
| TM3 | MEDIUM | Fixed | Zero-address checks on all 6 module setters |
| TM4 | MEDIUM | Fixed | notPaused auto-clears expired pause; emergencyPaused() view fixed |
| TM5 | MEDIUM | Fixed | fundModule now updates token metrics (totalWithdrawn + lastUpdated) |
| TM6 | MEDIUM | Fixed | payoutIntegrator event uses actual treasury fee amount |
| TM7 | MEDIUM | Fixed | Added WithdrawalLimitUpdated event to setGlobalDailyWithdrawalLimit |
| TL1 | LOW | Fixed | Per-token deposit events inside batchDeposit loop |
| TL2 | LOW | Fixed | Consolidated scattered storage declarations to storage section |
| TL3 | LOW | Acknowledged | Manual hasRole pattern kept for TreasuryFacade compatibility |
| TreasuryFeeManager.sol Findings | |||
| FMH1 | HIGH | Fixed | Re-validate constraints (caps, bounds, duplicates) at execution time |
| FMH2 | HIGH | Fixed | Reject zero-amount fee accumulation |
| FMM1 | MEDIUM | Fixed | Last active recipient gets remainder to eliminate dust loss |
| FMM2 | MEDIUM | Fixed | Stale index access covered by H1 execution-time re-validation |
| FMM3 | MEDIUM | Fixed | setRecipientActive elevated to FEE_ADMIN_ROLE |
| FMM4 | MEDIUM | Fixed | emergencyWithdraw reduces accumulatedFees |
| FMM5 | MEDIUM | Fixed | receive() tracks ETH in accumulatedFees with event |
| FML1 | LOW | Acknowledged | Duplicate check includes inactive recipients — design choice |
| FML2 | LOW | Fixed | 14-day expiry window on queued operations |
| FML3 | LOW | Acknowledged | Immutable treasury dependency — design constraint |
| TreasuryVesting.sol Findings | |||
| VH1 | HIGH | Fixed | createVesting verifies contract balance covers outstanding vesting obligations |
| VM1 | MEDIUM | Fixed | Added nonReentrant to revokeVesting |
| VM2 | MEDIUM | Fixed | Added notVestingPaused modifier to releaseVested and batchRelease |
| VM3 | MEDIUM | Fixed | depositForVesting validates amount > 0 and emits VestingDeposit event |
| VM4 | MEDIUM | Fixed | emergencyWithdraw reduces totalVestedByToken to keep accounting consistent |
| VL1 | LOW | Fixed | receive() emits VestingDeposit event for ETH tracking |
| VL2 | LOW | Acknowledged | Unbounded beneficiarySchedules array — acceptable gas trade-off |
| TreasuryYield.sol Findings | |||
| YH1 | HIGH | Fixed | Rebalance no longer inflates totalDeposited — moves capital without double-counting |
| YM1 | MEDIUM | Fixed | withdrawFromStrategy returns tokens to treasury instead of holding locally |
| YM2 | MEDIUM | Fixed | Added slippage check on rebalance withdraw side |
| YM3 | MEDIUM | Fixed | emergencyWithdrawFromStrategy accounts based on actual received tokens |
| YM4 | MEDIUM | Acknowledged | Swap-and-pop strategy ordering — acceptable design trade-off |
| YL1 | LOW | Fixed | setTreasury emits TreasuryUpdated event |
| TreasuryBudgets.sol Findings | |||
| BH1 | HIGH | Fixed | createBudget verifies contract balance covers outstanding budget obligations |
| BH2 | HIGH | Fixed | emergencyWithdraw reduces totalBudgetedByToken to keep accounting consistent |
| BM1 | MEDIUM | Fixed | depositForBudget validates amount > 0 and emits BudgetDeposit event |
| BM2 | MEDIUM | Fixed | receive() emits BudgetDeposit event for ETH tracking |
| BM3 | MEDIUM | Fixed | extendBudgetPeriod bounded by MAX_BUDGET_DURATION with zero-check |
| BM4 | MEDIUM | Fixed | Utilization alert moved from allocateToDepartment to spend() |
| BL1 | LOW | Acknowledged | Unbounded allBudgetIds array — acceptable for admin-managed budgets |
| BL2 | LOW | Fixed | Removed duplicate getAllBudgets() — kept getAllBudgetIds() |
| TreasuryMultiSig.sol Findings | |||
| MSH1 | HIGH | Fixed | proposeChangeThreshold now allows threshold == signerCount (unanimity) |
| MSH2 | HIGH | Fixed | executeProposal re-validates AddSigner/RemoveSigner/ChangeThreshold state at execution |
| MSM1 | MEDIUM | Fixed | revokeRole checks hasRole before decrementing signerCount |
| MSM2 | MEDIUM | Fixed | deposit and receive() emit Deposit event with zero-amount check |
| MSM3 | MEDIUM | Fixed | emergencyWithdraw updates totalWithdrawn metrics |
| MSM4 | MEDIUM | Fixed | Signer removal re-validates signerCount ≥ requiredSignatures at execution |
| MSL1 | LOW | Acknowledged | Linear scan in _removePendingProposal — bounded by MAX_PENDING_PROPOSALS (50) |
| TreasuryNFTFees.sol Findings | |||
| NFH1 | HIGH | Fixed | distributeFees last recipient gets remainder to eliminate rounding dust |
| NFH2 | HIGH | Fixed | accumulateFees rejects zero-amount accumulation |
| NFM1 | MEDIUM | Fixed | emergencyWithdraw reduces accumulatedFeesByToken and _accrued |
| NFM2 | MEDIUM | Acknowledged | Dual fee accounting — separate paths by design (collectNFTFee vs accumulateFees) |
| NFM3 | MEDIUM | Fixed | receive() emits EthReceived event for off-chain tracking |
| NFM4 | MEDIUM | Fixed | setTreasury emits TreasuryUpdated event |
| NFL1 | LOW | Fixed | Removed misleading ERC-20 balance pre-check in distributeFees |
| NFL2 | LOW | Acknowledged | accumulatedFees variable required by INFTFeeManager interface |
| TreasuryNFTManager.sol Findings | |||
| NMM1 | MEDIUM | Fixed | setNftVault validates vault != address(0) |
| NMM2 | MEDIUM | Fixed | Constructor validates both treasury and nftVault are non-zero |
| NML1 | LOW | Fixed | Batch operations bounded by MAX_BATCH_SIZE (50) |
| IntegratorHub.sol Findings | |||
| IHH1 | HIGH | Fixed | receive() uses custom error UnexpectedETH instead of string revert |
| IHH2 | HIGH | Fixed | batchConsume bounded by MAX_BATCH_SIZE (50) |
| IHM1 | MEDIUM | Acknowledged | routerTokens array append-only — totalAccrued() filters zero balances |
| IHM2 | MEDIUM | Fixed | _consumeAccrued no longer updates lastSweep for inactive apps |
| IHM3 | MEDIUM | Fixed | Removed contradictory minAccrualAmount storage-level initialization |
| IHM4 | MEDIUM | Fixed | reactivateMyApp resets lastSweep to prevent stale sweep triggers |
| IHL1 | LOW | Fixed | getBatchAccrued bounded by MAX_BATCH_SIZE (50) |
| NFTVault.sol Findings | |||
| NVH1 | HIGH | Fixed | emergencyNFTTransfer now updates _holdings after transfer |
| NVH2 | HIGH | Fixed | emergencyNFTTransfer validates to != address(0) |
| NVM1 | MEDIUM | Acknowledged | setTokenAllowed can strand NFTs — emergency recovery path available |
| NVM2 | MEDIUM | Fixed | Batch operations bounded by MAX_BATCH_SIZE (50) |
| NVL1 | LOW | Acknowledged | Repeated supportsInterface calls in batches — caching adds complexity for minor savings |
| NVL2 | LOW | Fixed | getNFTBalance ERC-721 stale data — resolved by NVH1 holdings sync |
| Donation.sol Findings | |||
| DH1 | HIGH | Fixed | addOrganization enforces MAX_ORGANIZATIONS (500) to bound donateByCause loops |
| DM1 | MEDIUM | Acknowledged | fundsRaised mixes token denominations — informational metric only |
| DM2 | MEDIUM | Acknowledged | ETH forwarding to arbitrary wallets — admin-controlled via DONATION_MANAGER_ROLE |
| DM3 | MEDIUM | Fixed | donate/donateByCause/donateFromWinnings validate tokenAddress != address(0) |
| DL1 | LOW | Fixed | Removed unchecked wrapper on organizationCount increment |
| DL2 | LOW | Fixed | verifyOrganization rejects redundant unverify calls |
| PredictionAMM.sol Findings | |||
| PAH1 | HIGH | Fixed | Last redeemer gets all remaining collateral (eliminates rounding dust) |
| PAH2 | HIGH | Fixed | seedLiquidity checks market status is Active |
| PAH3 | HIGH | Fixed | Constructor validates _predictionMarket != address(0) |
| PAM1 | MEDIUM | Fixed | sell checks payout ≤ pool.collateral before subtraction |
| PAM2 | MEDIUM | Fixed | removeLiquidity blocks if winning shares not fully redeemed |
| PAM3 | MEDIUM | Fixed | redeem only accepts Resolved status (not ResolutionPending) |
| PAL1 | LOW | Fixed | Uniform price rounding remainder assigned to last outcome |
| PredictionMarket.sol Findings | |||
| PMC1 | CRITICAL | Fixed | ecrecover address(0) check added to prevent signature bypass |
| PMC2 | CRITICAL | Fixed | Bitmap prevents duplicate signature counting in M-of-N dispute council |
| PMH1 | HIGH | Fixed | withdrawFees uses custom error instead of require string |
| PMH2 | HIGH | Fixed | resolveDispute ETH transfers use custom errors |
| PMH3 | HIGH | Fixed | setAMM validates address != address(0) |
| PMH4 | HIGH | Fixed | setDisputeCouncilMultisig rejects zero-address and duplicate members |
| PMH5 | HIGH | Fixed | createMarket validates params.token != address(0) |
| PMM1 | MEDIUM | Ack | disputeWindow can be set to 0 — admin design decision |
| PMM2 | MEDIUM | Ack | creationFee and disputeBond can be set to 0 — admin design decision |
| PMM3 | MEDIUM | Fixed | Dispute bond refund has fallback to feeRecipient if disputer rejects ETH |
| PMM4 | MEDIUM | Fixed | Removed bare receive() — ETH only accepted through payable functions |
| PML1 | LOW | Fixed | getMarketsByCategory caps limit to MAX_BATCH_SIZE (100) |
| PML2 | LOW | Fixed | getCreatorMarkets paginated with offset/limit and MAX_BATCH_SIZE cap |
| PML3 | LOW | Fixed | Added whenNotPaused to resolveMarket, disputeResolution, resolveDispute |
Findings
C1: EIP-712 Domain Separator Spurious Space
Fixed
Description:
The EIP-712 domain separator has a space before address —
"uint256 chainId, address verifyingContract" has a spurious space after the comma
that deviates from the canonical EIP-712 spec. Most wallets compute domains with
"uint256 chainId,address verifyingContract" (no space). Frontends that follow the
standard format will produce a different domain separator hash, causing all signature
verifications to fail or require the frontend to replicate this exact typo.
Impact: Signatures from standard EIP-712 libraries will not match; interop risk if any signer uses canonical spacing.
Fix: Removed spurious spaces in EIP-712 domain type string.
C2: Oracle Price Truncated Unsafely
Fixed
Description:
uint32(uint256(price)) silently truncates a Chainlink int256 answer
(typically 8 or 18 decimals) to 32 bits (max ~4.29B). A Chainlink price feed with 8 decimals
returning e.g. $50,000 = 5,000,000,000,000 would be truncated to garbage. There's also no
price > 0 check (negative oracle answers are cast to uint).
Impact: Match finalized at an incorrect/garbage price; irreversible fund loss for all participants.
Fix: Added price ≤ 0 and ≥ MAX_PRICE checks before uint32 cast.
C3: Standalone claim() Ignores Grader Fees
Fixed
Description:
The base virtual claim() at line 39 always emits graderFee: 0 and
transfers the full payoff. Only _settlePosition() (used by _executeClaim)
deducts grader fees. If the unoverridden claim() path is ever callable (dispatch
ambiguity or future refactor), grader fees are bypassed entirely. Currently the override in
SBET.sol routes to _executeClaim, but the dead code is a maintenance trap.
Impact: Grader fees could be bypassed; funds drained from the grader fee pool.
Fix: Rerouted claim() through _settlePosition for grader fee deduction.
C4: NFT Finalization Doesn't Set matchFinalPrice
Fixed
Description:
_executeFinalizeMatchNFTs sets matchFinalized[matchId] = true but
never sets matchFinalPrice — P2P positions on the same matchId become
unclaimable because _computePayoff uses matchFinalPrice[matchId]
which is 0, paying out mulDiv(position, 0, MAX_PRICE) = 0 for longs. NFT bets
and P2P bets share the same matchId namespace.
Impact: If a match has both NFT stakes and P2P positions, the NFT finalization path locks up all P2P funds permanently.
Fix: Set matchFinalPrice in NFT finalization for P2P settlement.
C5: Batch NFT Finalization Broken After First Call
Fixed
Description:
_executeFinalizeMatchNFTsBatch always sets matchFinalized = true on
every call — After the first batch call, subsequent calls will revert at
if (matchFinalized[matchId]) revert MatchIsFinalized() on line 309. The batch
design (process slices over multiple txs) is broken — only the first batch runs.
Impact: NFTs beyond the first batch are permanently locked in the contract.
Fix: Batch NFT finalize uses if (!matchFinalized) guard + outcome tamper check.
C6: matchOrders Marks Left Nonce Used on Every Right-Order Match
Fixed
Description:
_markNonceUsed(left.maker, left.nonce) is called inside the loop for each right
order. After the first right order fills, the left order's nonce is used. On the second
iteration, _unpackOrderSoft doesn't re-check (left was already unpacked), but
position state has changed from the first fill, making subsequent tradeCore calls
operate on stale position snapshots.
Impact: Incorrect position calculations; potential double-counting of the left order's fill.
Fix: Moved _markNonceUsed(left) out of per-right-order loop.
H1: Grader Fee Last-Grader Bonus Is Order-Dependent
Fixed
Description:
The "last grader gets remainder" logic depends on claim order, not grader identity. If
claimedSoFar == numReporters - 1, the current claimer gets the remainder regardless
of whether they're the "last" reporter. Race conditions between graders determine who gets the
rounding dust bonus (or a larger-than-expected share if the pool was partially drained by a
reentrancy or accounting bug).
Impact: Unfair fee distribution; minor value leakage on rounding.
Fix: Equal shares for all graders, no claim-order privilege.
H2: joinPoolBet Missing poolId Bounds Check
Fixed
Description:
joinPoolBet checks poolId == 0 but does not validate that the poolId
references a valid existing pool before dereferencing storage.
Impact: Out-of-bounds storage access for non-existent pool IDs.
Fix: poolId bounds check before storage dereference.
H3: _finalizeMatch Allows finalPrice == 0
Fixed
Description:
The check is if (finalPrice > MAX_PRICE), not >= MAX_PRICE. A
finalPrice of 0 passes validation. A match finalized at price 0 gives longs a payoff of 0 and
shorts their full position. This is exploitable if recoverFunds is called after
matchCancelPrice is somehow set to 0. More importantly, the
executeFinalization oracle path could yield 0 after truncation.
Impact: Match finalized at price 0 = total loss for all long positions. Combined with C2, this is especially dangerous.
Fix: _finalizeMatch rejects finalPrice == 0.
H4: claim() Pulls Tokens From Losers via safeTransferFrom
Fixed
Description:
When payoff < 0, the losing side must send tokens to the contract. This requires
the user to have pre-approved the contract for that amount. If they haven't, the claim reverts.
This means losers can grief winners by never calling claim — the winning side's tokens are
locked because the contract needs the losing side's tokens to fund the payout. The system relies
on losers cooperating.
Impact: Winners cannot claim if losers don't have sufficient balance/approval. Funds can be griefed.
Fix: Removed dead safeTransferFrom negative payoff branch.
M1: Hardcoded SALT in EIP-712 Domain
AcknowledgedDescription: The EIP-712 domain separator uses a fixed salt. If the contract is deployed to multiple chains or as a proxy, the salt is identical. Combined with chain-specific chainId in the domain, this is likely fine, but the salt adds no entropy over what chainId + verifyingContract already provide.
Impact: Cosmetic; no direct exploit but misleading security assumption.
Status: Acknowledged — salt is redundant but harmless.
M2: notEmergency Modifier Has a Side Effect
Fixed
Description:
if (block.timestamp >= emergencyEndTime) emergencyPaused = false; writes storage on
every call after emergency expires. This costs extra gas on the first post-emergency call to any
function using this modifier.
Impact: Unexpected gas cost; benign but surprising.
Fix: Explicit clearEmergency() replaces modifier side-effect.
M3: priceDivide Uses assert
Fixed
Description:
assert(amount >= 0 && price > 0) consumes all remaining gas on failure
(unlike require/custom errors which refund). In Solidity 0.8.x, assert is meant
for invariant violations only.
Impact: Wasted gas on edge-case failures that could be handled gracefully.
Fix: Custom errors replace assert() in SBETMath.
M4: Dead Code — Base trade() in SBETTrading
Acknowledged
Description:
The virtual trade() at line 86 contains the same logic as
_executeTrade() at line 413. Both are complete implementations. The override in
SBET.sol calls _executeTrade, making the base trade() unreachable dead
code. Same for matchOrders, cancelAll,
getAndIncrementNonce, cancelOrderNonce, cancelUpTo.
Impact: Code bloat; maintenance risk if one copy is updated but not the other.
Status: Acknowledged — dead virtual functions are the override pattern; removing them breaks the interface.
M5: Pool Claim Doesn't Reset userStake
Fixed
Description:
Normal settlement only clears userOutcomeStake[msg.sender][p.outcome] but not
userStake[msg.sender]. If a user bet on multiple outcomes, userStake
retains the total. This allows getUserTotalStake to return stale data post-claim,
and getUserActivePools to list pools where the user has already claimed.
Impact: Misleading view function results; no fund loss but confusing UX.
Fix: Zero userStake on normal pool claim.
M6: Unchecked tradeSize Addition
Fixed
Description:
tradeSize = t.longAmount + t.shortAmount is computed in an unchecked block, then
overflow is checked on the next line. This is intentional (check-after pattern), but the
unchecked block unnecessarily exposes intermediate overflow. A single checked addition would be
clearer and equally gas-efficient in 0.8.x.
Impact: Style issue; the subsequent check catches it, but the pattern is fragile.
Fix: Checked arithmetic replaces unchecked + manual check.
M7: Dead If-Block in finalizeWithGraders
Fixed
Description:
The block if (matchRecoveryDeadline[matchId] == 0) { // No explicit recovery needed }
does nothing.
Impact: Dead code.
Fix: Removed dead if-block.
M8: _applyTradeAll Allows token == address(0) ETH Path
Acknowledged
Description:
_applyTradeAll allows o.token == address(0) to route to ETH path
— But _adjustETH reverts on negative deltas
(NegativeETHDeltaNotSupported), making the ETH path broken for any trade where
the short side owes. The trade() function validates
token != address(0) but matchOrders also validates this. However, the
ETH path logic is dead/broken code.
Impact: Broken ETH-native trading path; no exploit if token == address(0) is always rejected upstream.
Status: Acknowledged — ETH path is dead code guarded by upstream token != address(0) checks.
L1: Pool Void Condition Edge Case
Acknowledged
Description:
Pool void condition only triggers when outcomeStaked[winningOutcome] == 0. If all
outcomes have 0 staked but totalStaked is also 0, it doesn't void (no-op). Fine
logically, but worth noting.
Impact: No-op for empty pools.
Status: Acknowledged — correct behavior.
L2: SBETQuery Pool Simulation Uses Basic Division
Fixed
Description:
(totalStaked * userStake) / outcomeTotal uses basic division instead of
Math.mulDiv, risking overflow on large pools. The main contract uses
Math.mulDiv correctly.
Impact: Potential view-function revert on large values.
Fix: Math.mulDiv in SBETQuery pool simulation.
L3: getPositionValue No Bounds Check on currentPrice
Fixed
Description:
For short positions, the formula produces a negative value but uses
uint256(-position) * (MAX_PRICE - currentPrice) / MAX_PRICE which could overflow
if currentPrice > MAX_PRICE (no bounds check on the input currentPrice).
Impact: View function revert on out-of-range input.
Fix: currentPrice bounds check + zero-position early return.
SBETTreasury.sol Findings
TC1: receive() Accepts ETH While Paused/Locked
Fixed
Description:
The receive() fallback had no pause or lock guard. During an emergency pause or
migration lock, ETH could still be deposited into the treasury. If the contract is mid-migration,
these deposits would be stranded in the old contract after migration completes.
Impact: ETH permanently lost in a migrated or paused contract.
Fix: Added notPaused modifier and if (locked) revert ContractIsLocked() check to receive().
TC2: batchDeposit Skips Lock Check
Fixed
Description:
batchDeposit uses notPaused and nonReentrant but does not
check the locked flag. During migration, ERC-20 tokens can still be batch-deposited.
These deposits are stranded after executeMigration sweeps the contract.
Impact: Tokens permanently locked in a migrated contract.
Fix: Added if (locked) revert ContractIsLocked() at the start of batchDeposit.
TH1: Daily Withdrawal Limit Underflow
Fixed
Description:
The remaining withdrawal calculation dailyWithdrawalLimit - used underflows if
used > dailyWithdrawalLimit (possible when the limit is lowered after withdrawals
have already been made in the current period). The same issue exists for the global daily
withdrawal limit.
Impact: Revert on all withdrawal attempts until the daily period resets; temporary DoS.
Fix: Safe subtraction: uint256 remaining = used >= dailyWithdrawalLimit ? 0 : dailyWithdrawalLimit - used.
TH2: batchDeposit Allows Zero Amounts
Fixed
Description:
The batchDeposit loop does not reject amounts[i] == 0. Zero-amount
deposits generate misleading DepositERC20 events and waste gas while providing no
value. Combined with off-chain accounting that trusts deposit events, this can inflate reported
metrics.
Impact: Event spam; misleading on-chain accounting; wasted gas.
Fix: Added if (amounts[i] == 0) revert DepositAmountMustBeGreaterThanZero() in the loop.
TH3: payoutIntegrator EOA-Only Recipient Bypass
Fixed
Description:
The payout address verification only applied to EOAs (payout.code.length == 0),
meaning any contract address bypassed the verifiedPayoutAddresses check. An attacker
could deploy a malicious contract and receive integrator payouts without being verified.
Impact: Unauthorized fund extraction to unverified contract addresses.
Fix: Removed the EOA-only gate — all payout addresses must now be in verifiedPayoutAddresses.
TM1: accrueFeeFor Missing Reentrancy Guard
Fixed
Description:
accrueFeeFor performs an ERC-20 safeTransferFrom but is not guarded
by nonReentrant. If the token is a callback-enabled ERC-777 or similar, the caller
could reenter during the transfer and manipulate fee accounting.
Impact: Reentrancy-based fee inflation if a callback-enabled token is used.
Fix: Added nonReentrant modifier to accrueFeeFor.
TM2: emergencyWithdraw Corrupts Metrics for Non-Allowlisted Tokens
Fixed
Description:
emergencyWithdraw updates tokenMetrics[token] unconditionally, even
for tokens not in the allowlist. This creates phantom metric entries for arbitrary token addresses,
polluting the accounting data.
Impact: Corrupted token metrics; misleading accounting for non-allowlisted tokens.
Fix: Wrapped metrics update in if (_allowedTokens.contains(token)).
TM3: Module Setters Accept Zero Address
Fixed
Description:
All six module setter functions accept address(0) without validation. Setting a
module manager to the zero address effectively disables the module and causes fundModule
calls to that module to send funds to the burn address.
Impact: Accidental fund loss if a module is set to zero address and then funded.
Fix: Added if (_manager == address(0)) revert ZeroAddressNotAccepted() to all 6 setters.
TM4: Emergency Pause Stale State After Expiry
Fixed
Description:
After pauseEndTime passes, the notPaused modifier stopped blocking
but the emergencyPause flag remained true in storage. The
emergencyPaused() view function returned true even after the pause
expired, causing off-chain systems and UIs to incorrectly display the contract as paused.
Impact: Stale pause state misleads off-chain integrations and frontends.
Fix: notPaused modifier auto-clears expired pause; emergencyPaused() view checks both flag and timestamp.
TM5: fundModule Doesn't Update Token Metrics
Fixed
Description:
fundModule transfers tokens out of the treasury but does not update
tokenMetrics[token].totalWithdrawn or lastUpdated. This creates a
discrepancy between actual balances and tracked metrics, making the treasury appear to hold
more funds than it actually does.
Impact: Inaccurate treasury accounting; metrics drift from reality over time.
Fix: Added tokenMetrics[token].totalWithdrawn += amount and lastUpdated = block.timestamp in fundModule.
TM6: payoutIntegrator Event Uses Wrong Treasury Fee
Fixed
Description:
The fee split calculation returns (toIntegrator, toTreasury) but the event
emitted the second return value from a discarded variable. The actual treasury fee amount
was not captured, causing the event to log an incorrect value.
Impact: Misleading event data for off-chain analytics and accounting.
Fix: Captured toTreasury return value and used it in the event emission.
TM7: setGlobalDailyWithdrawalLimit Missing Event
Fixed
Description:
The per-token setDailyWithdrawalLimit emits a WithdrawalLimitUpdated
event, but the global variant does not. Changes to the global limit are invisible to off-chain
monitoring systems.
Impact: Silent configuration change; no audit trail for global limit updates.
Fix: Added WithdrawalLimitUpdated event emission to setGlobalDailyWithdrawalLimit.
TL1: batchDeposit Missing Per-Token Events
Fixed
Description:
batchDeposit only emitted a single BatchDeposit event with arrays.
Individual DepositERC20 events were not emitted per token, making it impossible
for indexers that filter on single-deposit events to track batch deposits.
Impact: Incomplete event coverage for off-chain indexing.
Fix: Added per-token DepositETH/DepositERC20 events inside the loop.
TL2: Scattered Storage Declarations
Fixed
Description:
lockSetTimestamp was declared at line 589 (inside the migration section) instead of
with other storage variables. The MigrationLockInterrupted error was similarly
declared far from the error section. Scattered declarations make the contract harder to audit
and increase the risk of missed state variables.
Impact: Readability and auditability concern; no direct exploit.
Fix: Moved lockSetTimestamp to the storage section and MigrationLockInterrupted to the errors section.
TL3: Migration Functions Use Manual Role Check
Acknowledged
Description:
The migration functions use if (!hasRole(ADMIN_ROLE, msg.sender)) revert AccountNotAdmin(msg.sender)
instead of the standard OpenZeppelin onlyRole(ADMIN_ROLE) modifier. This creates an
inconsistency with the rest of the contract's access control pattern and uses a custom error
instead of the standard AccessControlUnauthorizedAccount.
Impact: Style inconsistency; no security impact.
Status: Acknowledged — the onlyRole modifier does not work correctly through
the TreasuryFacade inheritance chain (which overrides hasRole for timed roles).
The manual pattern is kept for compatibility.
TreasuryFeeManager.sol Findings
FMH1: executeQueuedFeeOp Doesn't Re-Validate State at Execution Time
Fixed
Description:
When an Add/Update/Remove operation is queued, validation (recipient cap, total BPS, duplicate check,
index bounds) runs at queue time only. By the time the timelock expires and the operation is executed,
state may have changed: other ops may have been executed, recipients added/removed, or
recipientsTotalBps changed. The Add path doesn't re-check
MAX_FEE_RECIPIENTS, BPS cap, or duplicates. The Update and Remove paths don't re-check
op.index bounds — if a swap-and-pop removal occurred during the timelock,
the index may now point to a different recipient or be out of bounds.
Impact: BPS cap bypass (>100% total fees), array out-of-bounds revert or wrong-recipient modification, duplicate recipients.
Fix: All three operation paths now re-validate constraints at execution time: recipient cap + BPS limit + duplicate check for Add; index bounds + BPS cap for Update; index bounds for Remove.
FMH2: accumulateFees Accepts Zero Amount
Fixed
Description:
accumulateFees accepts amount == 0, generating a misleading
FeesAccumulated event and (for ERC-20) executing a no-op
safeTransferFrom. For ETH, msg.value == 0 also passes.
Impact: Event spam; inflated fee accounting optics.
Fix: Added if (amount == 0) revert BadFeeArgs() at function entry.
FMM1: distributeFees Rounding Dust Lost Permanently
Fixed
Description:
Each recipient's share is computed as (available * percentage) / activeTotalBps.
Due to integer division, the sum of all shares is less than available. The
remainder stays in accumulatedFees but is never explicitly distributed.
Over many distributions, dust accumulates and becomes permanently locked since
minDistributionAmount can prevent small amounts from being distributed.
Impact: Permanent token lockup of rounding dust; minor value leakage over time.
Fix: Last active recipient receives the remainder
(available - distributed) instead of a truncated share.
FMM2: Remove Op Swap-and-Pop Invalidates Queued Indices
Fixed
Description:
The swap-and-pop removal moves feeRecipients[lastIndex] to
op.index. Any queued operations referencing the old lastIndex
position now point to a non-existent slot (popped), and the moved recipient is at a
new index. Operations queued before the removal reference stale indices.
Impact: Stale indices in queued ops could modify or remove the wrong recipient.
Fix: Covered by FMH1 — execution-time re-validation of index bounds prevents out-of-bounds access and ensures the operation targets a valid entry.
FMM3: setRecipientActive Bypasses Timelock
Fixed
Description:
setRecipientActive toggles a recipient's active flag with
immediate effect, only requiring FEE_MANAGER_ROLE. All other recipient
modifications (add/update/remove) go through the timelock. Deactivating a recipient
zeroes their share in the next distribution — functionally equivalent to removing
them, but without the timelock delay.
Impact: Timelock bypass for effectively removing a recipient from distributions.
Fix: Elevated to FEE_ADMIN_ROLE (higher privilege than
FEE_MANAGER_ROLE) to match the sensitivity of the operation.
FMM4: emergencyWithdraw Doesn't Update accumulatedFees
Fixed
Description:
emergencyWithdraw transfers tokens out but does not reduce
accumulatedFees[token]. After an emergency withdrawal,
distributeFees will attempt to distribute fees that no longer exist in the
contract, causing ERC-20 transfers to fail or sending ETH that belongs to other tokens.
Impact: Broken fee distribution after emergency withdrawal; potential fund cross-contamination.
Fix: emergencyWithdraw now reduces
accumulatedFees[token] by the withdrawn amount (clamped to zero).
FMM5: receive() Accepts ETH With No Accounting
Fixed
Description:
The receive() fallback accepts any ETH sent to the contract but does not
update accumulatedFees[ETH_ADDRESS]. This ETH is stranded — held by
the contract but not tracked in any accounting variable. Only
emergencyWithdraw can recover it.
Impact: ETH permanently untracked; only recoverable via emergency withdrawal.
Fix: receive() now updates
accumulatedFees[ETH_ADDRESS] and emits FeesAccumulated.
FML1: Duplicate Check Includes Inactive Recipients
Acknowledged
Description:
The duplicate check iterates all recipients including inactive ones. A deactivated
recipient cannot be re-added at a new index with different BPS — the only way to
change their percentage is via queueUpdateFeeRecipient.
Impact: UX friction; no security issue.
Status: Acknowledged — design choice to prevent duplicate addresses regardless of active status.
FML2: No Expiry on Queued Operations
Fixed
Description:
Queued operations have an eta (earliest execution time) but no expiration.
An operation queued today can be executed months later when state has changed
significantly. Combined with stale-state risks, this amplifies the window for
unintended execution.
Impact: Operations can be executed in unexpected future states.
Fix: Added OP_EXPIRY_WINDOW (14 days) — operations
revert with OpExpired if not executed within 14 days of their ETA.
FML3: notPaused Modifier Depends on Immutable Treasury
Acknowledged
Description:
The notPaused modifier makes external calls to
ITreasury(treasury).emergencyPause() and
ITreasury(treasury).pauseEndTime() on every guarded function call. If the
treasury contract is compromised or self-destructs, all notPaused functions
revert permanently (DoS). The treasury is immutable, so it
cannot be updated.
Impact: Permanent DoS if treasury becomes unresponsive; dependency coupling risk.
Status: Acknowledged — immutable treasury reference is a deliberate design constraint. The FeeManager is tightly coupled to its treasury by design.
TreasuryVesting.sol Findings
VH1: createVesting Does Not Verify Contract Holds Sufficient Tokens
Fixed
Description:
createVesting increments totalVestedByToken and records a vesting schedule
without checking whether the contract actually holds enough tokens to cover all outstanding vesting
obligations. A VESTING_MANAGER can over-promise tokens, creating schedules whose releases will fail
later when the contract runs dry.
Impact: Beneficiaries may be unable to release vested tokens; insolvency risk for the vesting contract.
Fix: Added a post-creation balance check: the contract’s token balance must be
≥ totalVestedByToken[token] - totalReleasedByToken[token]. Reverts with
InsufficientVestingBalance if underfunded.
VM1: revokeVesting Missing Reentrancy Guard
Fixed
Description:
revokeVesting performs up to two external token transfers (vested amount to beneficiary,
unvested amount to treasury) but lacks the nonReentrant modifier. If either recipient is
a malicious contract, it could re-enter during the transfer.
Impact: Potential reentrancy during revocation if beneficiary or treasury is a contract.
Fix: Added nonReentrant modifier to revokeVesting.
VM2: releaseVested and batchRelease Not Paused by vestingPaused
Fixed
Description:
The contract defines a vestingPaused flag and notVestingPaused modifier,
but neither releaseVested nor batchRelease use it. The
pauseVesting() / unpauseVesting() admin functions have no effect on
actual releases.
Impact: Vesting pause mechanism is non-functional; admin cannot halt releases during emergencies.
Fix: Added notVestingPaused modifier to both releaseVested
and batchRelease.
VM3: depositForVesting Missing Validation and Events
Fixed
Description:
depositForVesting accepts zero-amount ERC-20 deposits (wasting gas) and emits no
event to confirm the deposit was received. Without an event, off-chain indexers cannot track
vesting deposits.
Impact: Wasted gas on zero deposits; no off-chain audit trail for funding.
Fix: Added if (amount == 0) revert InvalidAmount(); check and a new
VestingDeposit event emitted on successful deposit.
VM4: emergencyWithdraw Does Not Update Vesting Accounting
Fixed
Description:
emergencyWithdraw transfers tokens out but does not reduce
totalVestedByToken. After withdrawal, the accounting claims more tokens are owed
than the contract holds, causing future createVesting balance checks to fail and
releases to revert.
Impact: Stale accounting after emergency withdrawal blocks new schedules and breaks existing release flows.
Fix: emergencyWithdraw now reduces totalVestedByToken
by the withdrawn amount (capped at outstanding obligations).
VL1: receive() Does Not Emit Event
Fixed
Description:
The receive() fallback accepts ETH but emits no event. Direct ETH transfers are
invisible to off-chain indexers, making it impossible to track vesting funding via events alone.
Impact: Missing audit trail for direct ETH deposits.
Fix: receive() now emits VestingDeposit(ETH_ADDRESS, msg.sender, msg.value).
VL2: Unbounded beneficiarySchedules Array
Acknowledged
Description:
The beneficiarySchedules[beneficiary] array grows without bound as new schedules are
created. Functions like getActiveVestings and getTotalVestedForBeneficiary
iterate the entire array. A beneficiary with many schedules could eventually hit gas limits.
Impact: Theoretical gas limit risk for beneficiaries with very many schedules.
Status: Acknowledged — in practice, vesting schedules are created by privileged roles (VESTING_MANAGER) for a limited set of beneficiaries. The iteration cost is acceptable for the expected usage pattern.
TreasuryYield.sol Findings
YH1: Rebalance Inflates totalDeposited on Deposit Side
Fixed
Description:
rebalance moves capital between strategies but increments
ts.totalDeposited and lifetimeDeposited[token] on the deposit side.
Since the tokens were already counted when originally deposited, this double-counts them —
inflating global and per-strategy accounting with every rebalance call.
Impact: Accounting corruption — totalDeposited grows unbounded with each rebalance, making withdrawals appear to have negative returns.
Fix: Removed totalDeposited and lifetimeDeposited
increments from the rebalance deposit side, and removed the corresponding decrements from
the withdraw side. Rebalance now moves capital without affecting deposit/withdrawal accounting.
YM1: withdrawFromStrategy Holds Tokens Locally Instead of Returning to Treasury
Fixed
Description:
withdrawFromStrategy pulls tokens from the strategy into the TreasuryYield contract
but never transfers them back to the treasury. Tokens accumulate in the yield contract rather
than being returned to the treasury where they belong.
Impact: Tokens stranded in yield contract; treasury balance does not reflect actual holdings.
Fix: Added IERC20(token).safeTransfer(treasury, received) after
successful withdrawal from the strategy.
YM2: No Slippage Check on Rebalance Withdraw Side
FixedDescription: The rebalance function withdraws from one strategy and deposits into another, but only checks slippage on the deposit side. The withdrawal from the source strategy has no slippage protection, meaning the source strategy could return significantly fewer tokens than requested without detection.
Impact: Silent value loss if the source strategy returns fewer tokens during rebalance.
Fix: Added slippage check on the withdraw side:
if (slip > MAX_SLIPPAGE_BPS) revert SlippageTooHigh().
YM3: emergencyWithdrawFromStrategy Uses Requested Amount for Accounting
Fixed
Description:
emergencyWithdrawFromStrategy updates accounting using the requested
amount parameter rather than the actual tokens received from the strategy. If the
strategy returns fewer tokens (due to slippage, fees, or partial withdrawal), the accounting
will be overstated — believing more tokens were withdrawn than actually received.
Impact: Accounting desync — totalDeposited reduced by more
than actually received, corrupting strategy metrics.
Fix: Measures actual received tokens via balance-before/after pattern and uses
received for all accounting updates.
YM4: Swap-and-Pop Strategy Removal Changes Array Ordering
Acknowledged
Description:
removeStrategy uses swap-and-pop to remove a strategy from the
tokenStrategies array. This changes the order of remaining strategies, which
could affect any off-chain systems that rely on strategy array indices.
Impact: Off-chain index references become stale after removal.
Status: Acknowledged — swap-and-pop is O(1) and the standard Solidity pattern. On-chain logic uses strategy addresses (not indices), so ordering is irrelevant for contract correctness.
YL1: setTreasury Missing Event Emission
Fixed
Description:
setTreasury updates the treasury address but emits no event. Off-chain monitoring
tools cannot detect when the treasury reference changes, reducing auditability.
Impact: Missing audit trail for treasury address changes.
Fix: Added TreasuryUpdated(oldTreasury, newTreasury) event emission.
TreasuryBudgets.sol Findings
BH1: createBudget Does Not Verify Contract Holds Sufficient Tokens
Fixed
Description:
createBudget increments totalBudgetedByToken and records a budget without
checking whether the contract actually holds enough tokens to cover all outstanding budget obligations.
A BUDGET_MANAGER can create budgets far exceeding the contract’s balance, and spend()
will revert later when it tries to transfer.
Impact: Budgets can be over-promised; spending will fail when the contract runs dry.
Fix: Added a post-creation balance check: the contract’s token balance must be
≥ totalBudgetedByToken[token] - totalSpentByToken[token]. Reverts with
InsufficientBudgetBalance if underfunded.
BH2: emergencyWithdraw Does Not Update Budget Accounting
Fixed
Description:
emergencyWithdraw transfers tokens out but does not reduce
totalBudgetedByToken. After withdrawal, the accounting claims more tokens are
budgeted than the contract holds, causing future createBudget balance checks to
fail and spends to revert.
Impact: Stale accounting after emergency withdrawal blocks new budgets and breaks spending flows.
Fix: emergencyWithdraw now reduces totalBudgetedByToken
by the withdrawn amount (capped at outstanding obligations).
BM1: depositForBudget Missing Validation and Events
Fixed
Description:
depositForBudget accepts zero-amount ERC-20 deposits (wasting gas) and emits no
event to confirm the deposit was received. Without an event, off-chain indexers cannot track
budget funding.
Impact: Wasted gas on zero deposits; no off-chain audit trail for funding.
Fix: Added if (amount == 0) revert InvalidAmount(); check and a new
BudgetDeposit event emitted on successful deposit.
BM2: receive() Does Not Emit Event
Fixed
Description:
The receive() fallback accepts ETH but emits no event. Direct ETH transfers are
invisible to off-chain indexers, making it impossible to track budget funding via events alone.
Impact: Missing audit trail for direct ETH deposits.
Fix: receive() now emits BudgetDeposit(ETH_ADDRESS, msg.sender, msg.value).
BM3: extendBudgetPeriod Has No Upper Bound
Fixed
Description:
extendBudgetPeriod adds additionalTime to endTime without
any upper bound check. An admin could extend a budget indefinitely, and
additionalTime of 0 is also accepted (no-op with misleading event emission).
Impact: Unbounded extensions; zero-duration extension emits misleading event.
Fix: Added bounds check: additionalTime must be > 0 and
≤ MAX_BUDGET_DURATION.
BM4: Utilization Alert Fires in Wrong Function
Fixed
Description:
The BudgetUtilizationAlert event fires inside allocateToDepartment, which
does not change budget.spent. The alert triggers based on spending level during
allocation changes, which is misleading. It should fire in spend() where spending
actually occurs.
Impact: Misleading utilization alerts during allocation, not during actual spending.
Fix: Moved the utilization alert from allocateToDepartment to
spend().
BL1: Unbounded allBudgetIds Array
Acknowledged
Description:
allBudgetIds is append-only. Deactivated budgets are never removed.
getAllBudgetIds() will eventually hit gas limits if many budgets are created
over time.
Impact: Theoretical gas limit risk for view functions iterating all budget IDs.
Status: Acknowledged — budgets are created by privileged roles (BUDGET_MANAGER) and are expected to be limited in number.
BL2: Duplicate getAllBudgets / getAllBudgetIds Functions
Fixed
Description:
Both getAllBudgets() and getAllBudgetIds() return the same
allBudgetIds array. Duplicated dead code.
Impact: Code duplication and confusing API surface.
Fix: Removed getAllBudgets(); kept the more descriptive
getAllBudgetIds().
TreasuryMultiSig.sol Findings
MSH1: proposeChangeThreshold Rejects Unanimity
Fixed
Description:
The validation newThreshold >= signerCount prevents setting the threshold equal
to the number of signers (unanimity). With 3 signers, proposeChangeThreshold(3, ...)
reverts. This should be newThreshold > signerCount.
Impact: Cannot configure unanimity requirement; limits governance flexibility.
Fix: Changed >= to > in the threshold
validation, allowing threshold == signerCount.
MSH2: executeProposal Does Not Re-Validate State for Governance Changes
Fixed
Description:
Between proposal creation and execution (up to 7-day expiry), state may have changed.
AddSigner does not re-check that the signer doesn’t already exist or that
signerCount < MAX_SIGNERS. RemoveSigner does not re-check that the
signer still exists or that signerCount > MIN_SIGNERS. ChangeThreshold
does not re-validate against current signerCount.
Impact: Stale proposals can corrupt signer tracking or set invalid thresholds.
Fix: Added execution-time re-validation for all three governance proposal types: duplicate/count checks for AddSigner, existence/minimum/threshold checks for RemoveSigner, and bounds validation for ChangeThreshold.
MSM1: revokeRole Unconditionally Decrements signerCount
Fixed
Description:
revokeRole decrements signerCount whenever
role == SIGNER_ROLE, even if the account did not actually have the role (the parent
revokeRole is a no-op in that case). Repeated calls can underflow
signerCount, corrupting signer tracking.
Impact: signerCount desync; potential underflow corruption.
Fix: Checks hasRole(SIGNER_ROLE, account) before the parent call;
only decrements and removes from list if the account actually held the role.
MSM2: deposit and receive() Missing Events
Fixed
Description:
deposit() accepts tokens and receive() accepts ETH, but neither emits
any event. Incoming funds are invisible to off-chain monitoring tools.
Impact: No audit trail for deposits into the multi-sig contract.
Fix: Both functions now emit a Deposit event. Added zero-amount
check to deposit().
MSM3: emergencyWithdraw Does Not Update Metrics
Fixed
Description:
emergencyWithdraw transfers tokens but does not update
totalWithdrawn[token], breaking withdrawal metrics. Normal proposal-based transfers
do update this metric.
Impact: totalWithdrawn understates actual withdrawals after
emergency use.
Fix: Added totalWithdrawn[token] += amount; before the transfer.
MSM4: Signer Removal Can Break Threshold Invariant
Fixed
Description:
proposeRemoveSigner checks signerCount > MIN_SIGNERS but does not
check whether removal would leave signerCount < requiredSignatures. With 3
signers and threshold 3, removing one leaves 2 signers but threshold stays 3 — making
all future proposals unexecutable.
Impact: Governance deadlock if signer removal drops count below threshold.
Fix: Covered by MSH2 — execution-time re-validation now checks
signerCount - 1 ≥ requiredSignatures before removing a signer.
MSL1: Linear Scan in _removePendingProposal
Acknowledged
Description:
_removePendingProposal does a linear scan of the pendingProposals
array (up to MAX_PENDING_PROPOSALS = 50). Gas cost grows linearly with the
number of pending proposals.
Impact: Minor gas inefficiency; bounded by MAX_PENDING_PROPOSALS (50).
Status: Acknowledged — the array is bounded at 50 entries, keeping gas costs within acceptable limits. A mapping-based index would add complexity for minimal gain.
TreasuryNFTFees.sol Findings
NFH1: distributeFees Rounding Dust Loss
Fixed
Description:
distributeFees calculates each recipient’s share as
(total * bps) / 10_000. Integer division truncates, and the per-recipient
rounding errors accumulate. When the sum of all cuts is less than the total, the
difference remains stuck in the contract with no mechanism to recover it.
Impact: Rounding dust (up to recipients.length - 1 wei per
distribution) permanently locks in the contract, compounding over many distributions.
Fix: The last recipient now receives total - distributed
(the remainder) instead of a truncated BPS calculation, eliminating dust accumulation.
NFH2: accumulateFees Accepts Zero Amount
Fixed
Description:
accumulateFees had no check for amount == 0. A caller could
repeatedly invoke it with zero, emitting misleading FeesAccumulated events
and inflating event counts without any actual fee transfer.
Impact: Misleading accounting events and wasted gas. Off-chain indexers counting events would report inflated fee activity.
Fix: Added if (amount == 0) revert InvalidAmount(); at the
top of accumulateFees.
NFM1: emergencyWithdraw Does Not Update Fee Accounting
Fixed
Description:
emergencyWithdraw transfers tokens out but does not reduce
accumulatedFeesByToken or _accrued. After withdrawal, these
mappings claim more fees exist than the contract holds, causing future
distributeFees calls to attempt transfers exceeding the actual balance.
Impact: Stale accounting after emergency withdrawal breaks fee distribution and creates phantom balances.
Fix: emergencyWithdraw now reduces both
accumulatedFeesByToken and _accrued by the withdrawn amount
(capped at tracked totals).
NFM2: Dual Fee Accounting Paths
Acknowledged
Description:
The contract has two independent fee collection paths:
collectNFTFee→withdrawFees (per-collection fees withdrawn
by a recipient) and accumulateFees→distributeFees
(accumulated fees split among BPS-weighted recipients). These paths use separate
accounting mappings and do not interact, which could confuse integrators.
Impact: Potential confusion; no direct exploit since the paths track separate balances independently.
Status: Acknowledged — the dual paths serve distinct use cases (per-collection vs aggregated distribution) and are intentionally separate.
NFM3: receive() Does Not Emit Event
Fixed
Description:
The receive() function accepts ETH without emitting an event. Direct ETH
transfers are invisible to off-chain indexers that rely on event logs.
Impact: ETH deposits via direct transfer are not tracked off-chain, making accounting and reconciliation difficult.
Fix: receive() now emits
EthReceived(msg.sender, msg.value).
NFM4: setTreasury Missing Event
Fixed
Description:
setTreasury updates the treasury address without emitting an event.
Treasury address changes are high-impact governance actions that should be auditable
on-chain.
Impact: Treasury migrations are invisible to off-chain monitoring; malicious changes go undetected.
Fix: setTreasury now emits
TreasuryUpdated(oldTreasury, newTreasury).
NFL1: Misleading Balance Pre-Check in distributeFees
Fixed
Description:
distributeFees contained a balance pre-check comparing
IERC20(token).balanceOf(address(this)) against the distribution total.
This check is redundant since safeTransfer already reverts on insufficient
balance, and the pre-check creates a false sense of security for non-standard tokens.
Impact: No functional impact; the misleading check adds gas and could mask the real failure point.
Fix: Removed the redundant balance pre-check. The safeTransfer
call provides the authoritative revert on insufficient balance.
NFL2: Unused accumulatedFees State Variable
Acknowledged
Description:
The accumulatedFees state variable (a single uint256) is never written to
internally — the contract uses accumulatedFeesByToken (a mapping) for
actual accounting. The variable exists solely to satisfy the
INFTFeeManager interface requirement.
Impact: The variable always returns 0, which could confuse callers expecting aggregate fee data.
Status: Acknowledged — removing the variable would break the
INFTFeeManager interface. It is retained with a comment documenting the
interface constraint.
TreasuryNFTManager.sol Findings
NMM1: setNftVault Missing Zero-Address Check
Fixed
Description:
setNftVault accepted address(0) as a valid vault address,
which would disable all NFT operations since every function checks
nftVault != address(0).
Impact: An admin could accidentally brick all NFT deposit/withdrawal operations by setting the vault to the zero address.
Fix: Added if (vault == address(0)) revert ZeroAddress();
to setNftVault.
NMM2: Constructor Missing Input Validation
Fixed
Description:
The constructor accepted zero addresses for both _treasury and
_nftVault parameters. Since treasury is immutable, a zero
address would permanently break all treasury interactions with no way to fix it.
Impact: Deployment with zero addresses creates a permanently broken contract that must be redeployed.
Fix: Added if (_treasury == address(0)) revert ZeroAddress();
and if (_nftVault == address(0)) revert ZeroAddress(); to the constructor.
NML1: Unbounded Batch Operations
Fixed
Description:
batchDepositNFT and batchWithdrawNFT had no upper bound on
array length. A caller could pass arrays large enough to exceed the block gas limit,
causing the transaction to revert.
Impact: Denial-of-service risk via gas exhaustion on oversized batches.
Fix: Added MAX_BATCH_SIZE = 50 constant and
if (nfts.length > MAX_BATCH_SIZE) revert BatchTooLarge(); to both batch
functions.
IntegratorHub.sol Findings
IHH1: receive() Uses String Revert Instead of Custom Error
Fixed
Description:
The receive() function used revert("No direct ETH") which costs
more gas than a custom error and is inconsistent with the rest of the contract which uses
custom errors throughout (e.g., UnexpectedETH).
Impact: Wastes gas on every accidental ETH send. The string-based revert is also inconsistent with the contract’s error pattern, making ABI parsing harder for off-chain tools.
Fix: Replaced revert("No direct ETH") with
revert UnexpectedETH().
IHH2: batchConsume Unbounded Array Length
Fixed
Description:
batchConsume iterates over routers.length with no upper bound.
A treasury caller could pass arrays large enough to exhaust the block gas limit.
Impact: Gas exhaustion DoS on batch consume operations.
Fix: Added MAX_BATCH_SIZE = 50 constant and
if (routers.length > MAX_BATCH_SIZE) revert BatchTooLarge();.
IHM1: routerTokens Array Grows Without Bound
Acknowledged
Description:
Every new token accrued for a router is pushed to routerTokens[router].
Even after consumption zeroes the balance, the token entry remains. Over time this array
grows unbounded, and totalAccrued() must iterate the entire array including
stale zero-balance entries.
Impact: Gas cost of totalAccrued() increases monotonically.
Eventually the view function may exceed gas limits for routers that have traded many tokens.
Status: Acknowledged — the array is append-only by design and
totalAccrued() already filters zero balances. Adding cleanup logic would
increase accrue() gas cost.
IHM2: _consumeAccrued Sets lastSweep for Inactive Apps
Fixed
Description:
When an app is inactive/unpaid, _consumeAccrued still updated
a.lastSweep to block.timestamp. If the app was later reactivated,
the sweep timer reset from the last consume call rather than from reactivation, potentially
skipping an overdue payout period.
Impact: Reactivated apps lose their accrued sweep eligibility;
isDue() returns false immediately after reactivation even if the app was due
before deactivation.
Fix: _consumeAccrued no longer updates lastSweep
for inactive apps — only active apps get their sweep timer updated.
IHM3: minAccrualAmount Set Twice in Constructor
Fixed
Description:
minAccrualAmount was initialized to 1e6 at the storage
declaration, then immediately overwritten to 0 in the constructor. The
storage-level default was dead code and misleading.
Impact: No functional impact — the constructor value wins. But the contradictory initialization confuses auditors and future developers.
Fix: Removed the storage-level initialization (= 1e6),
keeping only the constructor assignment as the single source of truth.
IHM4: reactivateMyApp Does Not Reset lastSweep
Fixed
Description:
When a router reactivated its app, lastSweep retained its value from before
deactivation. If the app was inactive for a long time, isDue() would
immediately return true even though no new fees had accrued during the
inactive period, triggering premature consume cycles.
Impact: Premature sweep trigger on reactivation, creating unnecessary gas-wasting consume calls that return (0,0).
Fix: reactivateMyApp now resets
lastSweep = uint64(block.timestamp).
IHL1: getBatchAccrued Unbounded Array
Fixed
Description:
getBatchAccrued had no max batch size. While it’s a view function, extremely
large arrays could still fail due to gas limits on eth_call.
Impact: Minor — view function only, but inconsistent with the bounded
batchConsume.
Fix: Added the same MAX_BATCH_SIZE check for consistency.
NFTVault.sol Findings
NVH1: emergencyNFTTransfer Does Not Update _holdings
Fixed
Description:
emergencyNFTTransfer transferred an NFT out of the vault but did not remove
the tokenId from _holdings[nft]. After the transfer,
getNFTsByContract(nft) still reported the token as held, and
getNFTBalance for ERC-721 tokens returned 1 even though the vault no longer
owned it.
Impact: Stale holdings data after emergency transfer. Any code relying on
getNFTsByContract or getNFTBalance sees phantom tokens.
Subsequent withdrawTo calls for the token would revert since the vault
doesn’t own it.
Fix: emergencyNFTTransfer now removes the tokenId from
_holdings[nft] after the transfer (for ERC-1155, only when balance reaches 0).
NVH2: emergencyNFTTransfer Missing Zero-Address Check
Fixed
Description:
The to parameter was not validated against address(0). For ERC-721,
safeTransferFrom to address(0) reverts (OpenZeppelin checks), but
for ERC-1155 the behavior depends on the implementation — some may allow it, burning
the token permanently.
Impact: Accidental token burn if admin passes zero address for an ERC-1155 token.
Fix: Added if (to == address(0)) revert InvalidAddress();.
NVM1: setTokenAllowed Can Strand NFTs
Acknowledged
Description:
An owner can set allowedToken[nft] = false while the vault still holds tokens
from that NFT contract. Since withdrawTo checks allowedToken,
those tokens become locked — only emergencyNFTTransfer (which requires
locked = true) can recover them.
Impact: NFTs stranded in the vault if allowlist is updated carelessly. Emergency recovery is possible but requires locking the entire vault.
Status: Acknowledged — this is a policy decision. The emergency transfer path provides recovery. The risk is documented.
NVM2: Batch Operations Unbounded Array Length
Fixed
Description:
Neither batchDepositFrom nor batchWithdrawTo had an upper bound
on array length. Each iteration performs supportsInterface external calls
(2 per item), which are expensive. Large arrays can exceed the block gas limit.
Impact: Gas exhaustion DoS on batch operations.
Fix: Added MAX_BATCH_SIZE = 50 constant and check to both
batch functions.
NVL1: Repeated supportsInterface Calls in Batch Operations
Acknowledged
Description:
Each iteration in the batch loop calls supportsInterface twice (ERC-721 and
ERC-1155 checks) via external calls. If the same NFT contract appears multiple times in the
batch, the same interface checks are repeated, wasting gas.
Impact: Gas inefficiency when batching multiple tokens from the same contract.
Minor since the onlyTreasury caller controls the input.
Status: Acknowledged — caching would add complexity for a gas optimization that benefits only repeated-contract batches.
NVL2: getNFTBalance ERC-721 Check Uses _holdings Instead of ownerOf
Fixed
Description:
For ERC-721 tokens, getNFTBalance checks
_holdings[nft].contains(tokenId) instead of calling
IERC721(nft).ownerOf(tokenId). If _holdings gets out of sync
(e.g., after emergencyNFTTransfer), this returns stale data.
Impact: Returns incorrect balance if holdings are stale. Resolved by
NVH1 fix which ensures emergency transfers update _holdings.
Fix: Resolved by NVH1 — emergencyNFTTransfer now
keeps _holdings in sync, preventing the desync that caused stale data.
Donation.sol Findings
DH1: donateByCause Iterates All Organizations Without Bound
Fixed
Description:
donateByCause loops through ALL organizations twice — once in
_countVerifiedOrgsForCause and once in the main distribution loop. There
was no upper bound on organizationCount. As organizations accumulate, gas
cost grows linearly and eventually exceeds the block gas limit.
Impact: Permanent DoS — once enough organizations exist,
donateByCause becomes uncallable.
Fix: Added MAX_ORGANIZATIONS = 500 constant enforced in
addOrganization, bounding the iteration cost.
DM1: fundsRaised Mixes Token Denominations
Acknowledged
Description:
fundsRaised is a single uint256 that sums donations across all
tokens (USDC with 6 decimals, WETH with 18 decimals, native ETH). The total is
meaningless when mixing different decimal scales.
Impact: Misleading accounting metric. Off-chain tools reading
fundsRaised cannot derive meaningful totals.
Status: Acknowledged — the metric is informational and not used in any on-chain logic. A per-token tracking mapping would add storage cost for a cosmetic improvement.
DM2: donateETH Forwards ETH via call to Arbitrary Wallet
Acknowledged
Description:
donateETH sends ETH via org.wallet.call{value: msg.value}("").
If the wallet is a contract with a malicious receive() function, it could
revert to grief donors or consume excessive gas.
Impact: A malicious or buggy wallet contract can block ETH donations to that organization. The reentrancy guard mitigates re-entrancy, but griefing remains possible.
Status: Acknowledged — the wallet is set by a trusted admin role. Adding EOA-only checks would prevent legitimate multi-sig wallets from receiving donations.
DM3: donate Does Not Validate tokenAddress
Fixed
Description:
donate called IERC20(tokenAddress).safeTransferFrom(...) without
validating that tokenAddress is non-zero or a legitimate contract. Passing
address(0) results in unclear low-level revert messages.
Impact: Poor UX — unclear revert messages for invalid token addresses.
Fix: Added if (tokenAddress == address(0)) revert InvalidTokenAddress();
to all three donation functions.
DL1: Unnecessary unchecked Increment
Fixed
Description:
organizationCount++ was wrapped in unchecked. The gas savings
are immaterial relative to the function’s multiple storage writes and string operations.
Impact: Negligible gas savings with reduced safety clarity.
Fix: Removed the unchecked wrapper.
DL2: verifyOrganization Allows Redundant Unverify
Fixed
Description:
verifyOrganization checked for duplicate verification but not duplicate
unverification. Calling verifyOrganization(id, false) on an already-unverified
org emitted redundant events.
Impact: Redundant events cluttering off-chain logs.
Fix: Added symmetry check:
if (!verified && !org.verified) revert OrganizationNotVerified();
PredictionAMM.sol Findings
PAH1: redeem Pro-Rata Rounding Dust Loss
Fixed
Description:
The pro-rata payout (shares * pool.collateral) / total truncates on each
redemption. As totalWinningShares and pool.collateral decrease
with each redeemer, rounding compounds. The last redeemer receives less than their fair
share, and dust remains stranded in the pool.
Impact: Collateral dust permanently locked after all winners redeem. Winners collectively receive less than the full collateral.
Fix: When shares ≥ totalWinningShares (last redeemer),
the payout is set to all remaining pool.collateral, eliminating dust.
PAH2: seedLiquidity Missing Market Status Check
Fixed
Description:
seedLiquidity checked that the market exists (mv.creator != address(0))
but did not verify mv.status == MarketStatus.Active. A pool could be seeded
for a Resolved, Voided, or Disputed market, locking collateral with no way to trade.
Impact: Collateral locked in a pool for a non-active market. Provider
must wait for resolution to call removeLiquidity.
Fix: Added if (mv.status != MarketStatus.Active) revert PoolNotActive();
after the existence check.
PAH3: Constructor Does Not Validate _predictionMarket
Fixed
Description:
The constructor accepted _predictionMarket without validating it’s a
non-zero address. Since predictionMarket is immutable, a zero address
permanently breaks all functions that call predictionMarket.getMarket().
Impact: Deployment with zero address creates a permanently broken contract.
Fix: Added if (_predictionMarket == address(0)) revert ZeroAddress();.
PAM1: sell Can Underflow pool.collateral
Fixed
Description:
sell computed payout via LMSR math and then did
pool.collateral -= payout without checking that payout does not exceed
the available collateral. An underflow would panic-revert with an unclear error.
Impact: Unclear panic error instead of a descriptive revert when LMSR payout exceeds collateral.
Fix: Added
if (payout > pool.collateral) revert InsufficientLiquidity(); before the
subtraction.
PAM2: removeLiquidity Can Drain Collateral Before All Redeemers Claim
Fixed
Description:
After a market is Resolved, the provider could call removeLiquidity and drain
ALL remaining collateral, including collateral owed to winning share holders who hadn’t
redeemed yet.
Impact: Winners who haven’t redeemed yet lose their payout. The provider gets collateral that should go to winning share holders.
Fix: Added check:
if (winningSharesCounted[marketId] && totalWinningShares[marketId] > 0) revert WinningSharesPending();
PAM3: redeem Allows Claiming on ResolutionPending Markets
Fixed
Description:
redeem accepted both MarketStatus.Resolved and
MarketStatus.ResolutionPending. During ResolutionPending, the
winning outcome may not be finalized — it could change if a dispute is filed.
Impact: Premature redemption on potentially incorrect outcome. If the outcome changes via dispute, the redeemer keeps tokens they shouldn’t have.
Fix: Changed to only accept MarketStatus.Resolved.
PAL1: Uniform Price Rounding in getOutcomePrices
Fixed
Description:
When the pool is not active, getOutcomePrices returns uniform prices as
MAX_PRICE / n. For non-divisible outcome counts (e.g., n=3), prices don’t
sum to MAX_PRICE.
Impact: Cosmetic — prices don’t sum to MAX_PRICE
for non-divisible outcome counts.
Fix: Added rounding remainder to the last outcome, matching the pattern
used in _lmsrPrices.
PredictionMarket.sol Findings
PMC1: ecrecover Returns address(0) on Invalid Signatures
Fixed
Description:
ecrecover returns address(0) for malformed signatures. In the M-of-N
dispute council verification path, if any disputeCouncilMembers[j] were
address(0), an attacker could forge “valid” signatures. The legacy
single-council path had the same risk if disputeCouncil were ever set to
address(0) (partially mitigated by the check at the top, but not in the
recovery comparison itself).
Impact: Signature bypass — anyone could resolve disputes with forged signatures if any council member address is zero.
Fix: Added if (recovered == address(0)) continue; after each
ecrecover call in both M-of-N and legacy paths.
PMC2: Duplicate Signature Counting in M-of-N Verification
Fixed
Description:
The M-of-N dispute council verification iterated through signatures and incremented
validCount each time a match was found, but did not track which council members
had already been counted. A single council member’s signature submitted multiple times
in the disputeCouncilSigs array would increment validCount each time,
allowing a 1-of-N attack to pass an M-of-N quorum.
Impact: Complete bypass of multi-sig dispute council quorum — a single council member could unilaterally resolve disputes.
Fix: Added a usedMask bitmap that tracks which council member
indices have been matched, preventing duplicate counting.
PMH1: withdrawFees Uses require String Instead of Custom Error
Fixed
Description:
require(ok, "ETH transfer failed") is inconsistent with the rest of the contract
which uses custom errors. String reverts waste gas on deployment (stored in bytecode) and
at revert time (ABI-encoded).
Impact: Gas waste and inconsistent error handling pattern.
Fix: Replaced with if (!ok) revert EthTransferFailed();.
PMH2: resolveDispute ETH Transfers Use require Strings
Fixed
Description:
Both bond distribution paths (disputer refund and feeRecipient transfer) used
require(ok, "ETH transfer failed") instead of custom errors.
Impact: Same as PMH1 — gas waste and inconsistency.
Fix: Both paths now use if (!ok) revert EthTransferFailed();.
PMH3: setAMM Allows Setting to address(0)
Fixed
Description:
No zero-address check on setAMM. Setting AMM to address(0) silently
disables AMM functionality, inconsistent with setFeeRecipient and
setDisputeCouncil which both validate non-zero.
Impact: Silent AMM disablement; markets created after would have no market maker.
Fix: Added if (_amm == address(0)) revert ZeroAddress();.
PMH4: setDisputeCouncilMultisig Accepts Zero-Address and Duplicate Members
FixedDescription: The members array was not validated for zero addresses or duplicates. A zero-address member combined with PMC1 would allow anyone to forge valid signatures. Duplicate members reduce effective quorum (e.g., 3-of-5 with 2 duplicates becomes effectively 3-of-4).
Impact: Weakened dispute council security through zero-address or duplicate member acceptance.
Fix: Added validation loop: each member is checked for address(0)
and uniqueness against all preceding members.
PMH5: createMarket Does Not Validate Token Address
Fixed
Description:
params.token is stored without validation. If address(0), markets
created without initial liquidity would have a broken settlement token, and any later
AMM seeding or trading attempts would fail with confusing errors.
Impact: Markets created with invalid token address; downstream failures with poor error messages.
Fix: Added if (params.token == address(0)) revert ZeroAddress();.
PMM1: disputeWindow Can Be Set to Zero
Ack
Description:
No minimum bound on setDisputeWindow. Setting to 0 makes
finalizeResolution callable in the same block as resolution, effectively
eliminating the dispute protection period.
Impact: Dispute protection bypassed if owner sets window to 0.
Status: Acknowledged — admin-only function; considered a design decision for operational flexibility.
PMM2: creationFee and disputeBond Can Be Set to Zero
AckDescription: No minimum enforcement on creation fee or dispute bond. Setting either to 0 removes anti-spam protection (creation fee) or skin-in-the-game incentive (dispute bond).
Impact: Spam market creation or frivolous disputes without economic cost.
Status: Acknowledged — admin-only functions; zero values may be intentional during testing or promotional periods.
PMM3: Dispute Bond Refund Can Block Resolution Permanently
Fixed
Description:
If m.disputer is a contract that rejects ETH (no receive or
fallback), the bond refund .call{value: bondToReturn} fails,
causing resolveDispute to revert. This permanently blocks dispute resolution
for the market.
Impact: Market permanently stuck in Disputed state if disputer cannot receive ETH.
Fix: Added fallback: if disputer transfer fails, bond is sent to
feeRecipient instead. If both fail, reverts with EthTransferFailed.
PMM4: Bare receive() Accepts Arbitrary ETH Deposits
Fixed
Description:
The bare receive() external payable {} allowed anyone to send ETH to the
contract with no tracking. This ETH gets mixed with creation fees and dispute bonds,
inflating withdrawFees payouts. The contract only needs to receive ETH through
its payable functions (createMarket, disputeResolution).
Impact: Untracked ETH deposits inflate fee withdrawals.
Fix: Removed receive() — contract only accepts ETH
through its payable functions.
PML1: getMarketsByCategory Unbounded Return
Fixed
Description:
The limit parameter is user-controlled with no cap. A very large limit
could cause out-of-gas on view calls.
Impact: View call failures for large categories.
Fix: Added if (limit > MAX_BATCH_SIZE) limit = MAX_BATCH_SIZE;
with MAX_BATCH_SIZE = 100.
PML2: getCreatorMarkets Returns Unbounded Array
Fixed
Description:
Returns the entire creatorMarkets[creator] array with no pagination. A
prolific creator could cause view call failures.
Impact: View call failures for prolific creators.
Fix: Added offset/limit pagination matching getMarketsByCategory
pattern, with MAX_BATCH_SIZE cap.
PML3: Missing whenNotPaused on Resolution and Dispute Functions
FixedDescription: These lifecycle functions did not check pause state. If the contract is paused (emergency), resolution and disputes could still proceed, undermining the pause mechanism.
Impact: Emergency pause does not fully halt protocol operations.
Fix: Added whenNotPaused modifier to all three functions.
Conclusion
This deep audit identified 125 findings across 20 SBET Protocol contracts — 21 in the core betting contracts (SBET.sol and related modules), 15 in the treasury system (SBETTreasury.sol), 10 in the fee manager (TreasuryFeeManager.sol), 7 in the vesting contract (TreasuryVesting.sol), 6 in the yield contract (TreasuryYield.sol), 8 in the budget manager (TreasuryBudgets.sol), 7 in the multi-sig governance (TreasuryMultiSig.sol), 8 in the NFT fee manager (TreasuryNFTFees.sol), 3 in the NFT manager (TreasuryNFTManager.sol), 7 in the integrator hub (IntegratorHub.sol), 6 in the NFT vault (NFTVault.sol), 6 in the donation manager (Donation.sol), 7 in the prediction AMM (PredictionAMM.sol), and 14 in the prediction market lifecycle (PredictionMarket.sol). Of these, 105 have been fixed and 20 acknowledged as acceptable design decisions or compatibility constraints.
SBET.sol Critical Fixes
- C1: EIP-712 domain separator spacing corrected for standard library interop
- C2: Oracle price truncation now validated with bounds checks before uint32 cast
- C3: Claim path rerouted through _settlePosition to enforce grader fees
- C4/C5: NFT finalization now sets matchFinalPrice and supports multi-batch processing
- C6: matchOrders nonce marking moved outside the per-right-order loop
SBETTreasury.sol Critical & High Fixes
- TC1/TC2: Deposit paths (receive + batchDeposit) now enforce pause and lock guards
- TH1: Daily withdrawal limit arithmetic made underflow-safe
- TH2: Zero-amount deposits rejected in batchDeposit
- TH3: Payout address verification now applies universally (EOA + contracts)
TreasuryFeeManager.sol High Fixes
- FMH1: Timelocked operations now re-validate all constraints at execution time
- FMH2: Zero-amount fee accumulation rejected
TreasuryVesting.sol High Fix
- VH1: createVesting now verifies contract holds sufficient tokens for all outstanding obligations
TreasuryYield.sol High Fix
- YH1: Rebalance no longer inflates totalDeposited — capital moves without double-counting
TreasuryBudgets.sol High Fixes
- BH1: createBudget now verifies contract holds sufficient tokens for all outstanding obligations
- BH2: emergencyWithdraw reduces totalBudgetedByToken to keep accounting consistent
TreasuryMultiSig.sol High Fixes
- MSH1: proposeChangeThreshold now allows unanimity (threshold == signerCount)
- MSH2: executeProposal re-validates governance changes at execution time
TreasuryNFTFees.sol High Fixes
- NFH1: distributeFees last recipient gets remainder to eliminate rounding dust
- NFH2: accumulateFees rejects zero-amount accumulation
IntegratorHub.sol High Fixes
- IHH1: receive() uses custom error UnexpectedETH instead of string revert
- IHH2: batchConsume bounded by MAX_BATCH_SIZE (50)
NFTVault.sol High Fixes
- NVH1: emergencyNFTTransfer now updates _holdings after transfer
- NVH2: emergencyNFTTransfer validates to != address(0)
Donation.sol High Fix
- DH1: addOrganization enforces MAX_ORGANIZATIONS (500) to bound donateByCause loops
PredictionAMM.sol High Fixes
- PAH1: Last redeemer gets all remaining collateral (eliminates rounding dust)
- PAH2: seedLiquidity checks market status is Active
- PAH3: Constructor validates _predictionMarket != address(0)
PredictionMarket.sol Critical & High Fixes
- PMC1/PMC2: ecrecover address(0) bypass prevented; bitmap prevents duplicate signature counting in M-of-N dispute council
- PMH1/PMH2: All ETH transfer error handling converted to custom errors
- PMH3: setAMM validates non-zero address
- PMH4: Dispute council members validated for zero-address and uniqueness
- PMH5: createMarket validates settlement token address
All 16 critical and 34 high-severity findings have been fixed across both audit rounds. The 23 acknowledged items (M1, M4, M8, L1, TL3, FML1, FML3, VL2, YM4, BL1, MSL1, NFM2, NFL2, IHM1, NVM1, NVL1, DM1, DM2, PMM1, PMM2, M-01f, M-04f, M-08f) carry no direct exploit risk and are retained for architectural compatibility or as documented design decisions.
Follow-Up Audit — Round 2
Date: 2026-02-25 · Scope: Deep review of SBET.sol and all inherited modules (SBETStorage, SBETTrading, SBETPool, SBETNFT, SBETClaim, SBETMath, SBETQuery, ISBETInterfaces)
Commits: a1dd98b, df8f45f
Round 2 Findings Summary
| Severity | Count | Fixed | Acknowledged |
|---|---|---|---|
| CRITICAL | 6 | 6 | 0 |
| HIGH | 4 | 4 | 0 |
| MEDIUM | 8 | 5 | 3 |
| LOW | 3 | 3 | 0 |
| Total | 21 | 18 | 3 |
C-01f: EIP-712 Domain Separator Spacing Mismatch
Fixed
Description:
The EIP-712 domain type string contained spurious spaces after commas
("uint256 chainId, address verifyingContract, bytes32 salt"), deviating from the
canonical EIP-712 specification. Standard libraries produce the hash without spaces, causing
signature verification failures for standard-compliant signers.
Impact: Frontends using canonical EIP-712 spacing would produce mismatched domain hashes, breaking all signature verification.
Fix: Removed spurious spaces:
"uint256 chainId,address verifyingContract,bytes32 salt".
C-02f: Oracle Price Truncation and Missing Bounds Checks
Fixed
Description:
uint32(uint256(price)) silently truncated Chainlink int256 answers
(8–18 decimals) to 32 bits. No checks for price ≤ 0 or
price ≥ MAX_PRICE existed before the cast.
Impact: Match finalized at incorrect/garbage price; irreversible fund loss.
Fix: Added if (price ≤ 0) revert BadFinalPrice() and
if (uint256(price) ≥ MAX_PRICE) revert BadFinalPrice() before the uint32 cast.
C-03f: Standalone claim() Bypassed Grader Fees
Fixed
Description:
The base virtual claim() computed payoff and transferred directly without routing through
_settlePosition(), the only path that deducts grader fees. Also contained an unreachable
safeTransferFrom branch for negative payoffs.
Impact: Users could bypass grader fees entirely; dead code implied losers needed to cooperate for winners to claim.
Fix: Rerouted claim() and batchClaimPositions() through
_settlePosition(). Removed dead negative-payoff branch.
C-04f: NFT Finalization Missing matchFinalPrice
Fixed
Description:
_executeFinalizeMatchNFTs set matchFinalized[matchId] = true but never set
matchFinalPrice. P2P positions on the same matchId became unclaimable
(_computePayoff returned 0 for longs when matchFinalPrice == 0).
Impact: All P2P longs on NFT-finalized matches lost their entire position value.
Fix: Added matchFinalPrice assignment based on outcome:
SideA → MAX_PRICE - 1, SideB → 1,
None → MAX_PRICE / 2.
C-05f: Batch NFT Finalization Broken After First Call
Fixed
Description:
Every call set matchFinalized[matchId] = true unconditionally, then subsequent batch
calls reverted on if (matchFinalized[matchId]) revert MatchIsFinalized().
Only the first batch ever ran; remaining NFTs were permanently locked.
Impact: Permanent NFT lockup for any match requiring multiple finalization batches.
Fix: First call sets matchFinalized and matchFinalPrice.
Subsequent calls verify matchWinningSide[matchId] == outcome to prevent tampering,
and skip the finalization step.
C-06f: matchOrders Left Nonce Marked Per Right Order
Fixed
Description:
_markNonceUsed(left.maker, left.nonce) was called inside the loop for each right order
match. After the first right order filled, subsequent iterations operated on stale position
snapshots with the nonce already marked.
Impact: Double-counting of left order fills; position accounting corruption.
Fix: Moved _markNonceUsed(left.maker, left.nonce) to
_executeMatchOrders() after the loop, called once when
totalLeftFilled > 0.
H-01f: Grader Fee Distribution Order-Dependent
FixedDescription: The “last grader gets remainder” logic depended on claim order, not grader identity. Race conditions between graders determined who received the rounding dust bonus.
Impact: Non-deterministic fee allocation; unfair rounding advantage.
Fix: All graders now receive the same floor-divided share
(pool / numReporters). Rounding dust stays in the pool.
H-02f: joinPoolBet Validates poolId After Storage Dereference
Fixed
Description:
Pool storage p = pools[poolId] was dereferenced before the bounds check.
For non-existent pools, p.numOutcomes was 0, so outcome ≥ p.numOutcomes
fired InvalidOutcome instead of the correct InvalidPoolId.
Impact: Misleading error for invalid pool IDs; confusing UX and integration bugs.
Fix: Moved poolId bounds check before storage access in both functions.
H-03f: _finalizeMatch Allows finalPrice == 0
Fixed
Description:
The check was if (finalPrice > MAX_PRICE), not ≥ MAX_PRICE.
A finalPrice of 0 passed validation, giving longs a payoff of 0 and shorts their
full position. Combined with C-02f, exploitable via oracle price truncation.
Impact: Forced zero payoff for all longs; complete loss of position value.
Fix: Changed to
if (finalPrice == 0 || finalPrice ≥ MAX_PRICE) revert BadFinalPrice().
H-04f: Dead safeTransferFrom on Negative Payoff
Fixed
Description:
_computePayoff always returns ≥ 0 (uses absolute magnitude). The
else if (payoff < 0) branch calling safeTransferFrom was unreachable
dead code that implied losers needed to cooperate for winners to claim.
Impact: Dead code creating false security assumptions about settlement flow.
Fix: Removed dead branch. Added comment explaining payoff is always non-negative.
M-01f: Hardcoded SALT in EIP-712 Domain
Acknowledged
Description:
Fixed salt adds no entropy beyond chainId + verifyingContract.
Impact: Cosmetic — no direct exploit. The salt is part of the committed EIP-712 domain and changing it would break existing signatures.
Fix: Acknowledged — retained for backward compatibility.
M-02f: notEmergency Modifier Auto-Clears Emergency State
Fixed
Description:
The modifier silently set emergencyPaused = false as a side effect when any user
called a guarded function after emergencyEndTime. State changes in modifiers are
surprising, and this created a race window where the owner couldn’t extend an emergency.
Impact: Emergency could be silently cleared by any user; owner loses ability to extend pause window.
Fix: Modifier now reverts for both active and expired-but-uncleared emergencies.
Added explicit clearEmergency() owner-only function with
EmergencyCleared event.
M-03f: priceDivide Uses assert() Instead of Custom Error
Fixed
Description:
assert() consumes all remaining gas on failure.
effectiveBalance had the same issue.
Impact: Full gas consumption on revert; poor UX and wasted gas.
Fix: Replaced with revert InvalidPriceDivideArgs() and
revert InvalidEffectiveBalancePrice().
M-04f: Dead Code — Base Virtual Functions Duplicate _execute* Internals
Acknowledged
Description:
trade(), matchOrders(), cancelAll(), etc. have complete
implementations in the base abstract contract, duplicating the _execute* internal
variants. The SBET.sol overrides call _execute*, making the base implementations
dead code.
Impact: Maintenance risk — dead code may drift from actual behavior.
Fix: Acknowledged — the virtual/override pattern is the intended inheritance design. Base functions serve as the interface contract.
M-05f: Pool Claim Doesn’t Reset userStake
Fixed
Description:
Normal settlement only cleared userOutcomeStake[msg.sender][p.outcome] but not
userStake[msg.sender], causing getUserActivePools to return stale data.
Impact: Stale pool membership data; incorrect frontend state.
Fix: Added p.userStake[msg.sender] = 0 in both claim paths.
M-06f: Unchecked tradeSize Addition
Fixed
Description:
tradeSize = t.longAmount + t.shortAmount was in an unchecked block with
a manual overflow check after. The pattern was fragile and the comment was misleading.
Impact: Overflow risk in a critical path; unclear intent.
Fix: Replaced with checked arithmetic. Solidity 0.8.x handles overflow natively.
M-07f: Dead If-Block in finalizeWithGraders
Fixed
Description:
if (matchRecoveryDeadline[matchId] == 0) { /* no-op */ } — empty block
with only a comment.
Impact: Dead code; misleading intent.
Fix: Removed.
M-08f: Broken ETH Trading Path
Acknowledged
Description:
_applyTradeAll routes to _applyETHBalances when
o.token == address(0), but _adjustETH reverts on negative deltas.
The ETH path is broken for any trade where the short side owes.
Impact: Dead code — upstream checks ensure the ETH path is never reached.
Fix: Acknowledged — guarded by upstream validation that validates
token != address(0) and treasury.isAllowedToken(token).
L-01f: Duplicate Constants Between SBETMath and SBETStorage
Fixed
Description:
MAX_PRICE and MAX_POSITION_MAGNITUDE independently defined in both
files. Library internal constants can’t be accessed cross-contract,
so true deduplication is impossible.
Impact: Maintenance risk — constants may drift out of sync.
Fix: Added prominent sync comments in both files with
IMPORTANT annotation.
L-02f: SBETQuery.getPoolPayoutSimulation Overflow Risk
Fixed
Description:
(totalStaked * userStake) / outcomeTotal risks overflow on large pool values.
The main contract correctly uses Math.mulDiv.
Impact: Incorrect query results for large pools.
Fix: Replaced with Math.mulDiv(totalStaked, userStake, outcomeTotal)
for 512-bit intermediate math.
L-03f: getPositionValue Missing Price Bounds Check
Fixed
Description:
No validation on currentPrice input. If currentPrice > MAX_PRICE,
the short path underflows (MAX_PRICE - uint256(currentPrice)). No early return
for zero positions.
Impact: Potential underflow in view function; incorrect position valuation.
Fix: Added
if (currentPrice == 0 || currentPrice ≥ MAX_PRICE_U32) revert InvalidPriceRange(currentPrice)
and if (position == 0) return 0.
Round 2 Conclusion
All 6 critical and 4 high-severity follow-up findings have been fixed. The 3 acknowledged items (M-01f, M-04f, M-08f) carry no direct exploit risk and are retained for backward compatibility or as documented design decisions. Combined with the initial audit, the protocol has undergone 146 total findings across 2 audit rounds — all remediated or acknowledged with documented rationale.