Full Protocol Audit
Date: 2026-02-23 Contracts in Scope: MarketEngine.sol, MarketFactory.sol, LpVault.sol, OutcomeToken.sol, WadMath.sol Compiler: Solidity 0.8.24 (checked arithmetic) Dependencies: OpenZeppelin Ownable, ReentrancyGuard, SafeERC20, ERC20 Baseline: Previous audit (2026-02-21) scoped to LP Fee Accumulator only — this audit covers the full protocol
Executive Summary
The MarkIt protocol implements a single-contract-per-market binary prediction market with LP-underwritten risk. The core MarketEngine is well-constructed with sound mathematical invariants. The Synthetix-style fee accumulator (previously audited) remains correct. The LpVault, which aggregates LP capital across markets, introduces the majority of new risk surface.
No critical exploitable vulnerabilities found. One high-severity trust concern (centralized resolution), two medium findings in the vault's withdrawal queue economics and emergency capital handling, and several low/informational items.
High
1
Trust/design concern, not code bug
Medium
2
Economic unfairness under specific conditions
Low
5
Edge cases, gas, parameter validation
Informational
5
Best-practice gaps
Audit Methodology
Phase 1 — Reconnaissance
Full read of all 6 source files (~1,500 lines total). Mapped state machines, trust boundaries, capital flows, and actor roles (admin, LP, bettor, permissionless caller).
Phase 2 — Threat Modeling
Enumerated capabilities, prohibitions, and malicious incentives for each actor class.
Phase 3 — Vulnerability Hunting
Systematic check of every category: reentrancy, access control, math/precision, state machine, oracle/resolution, economic attacks, vault-specific vectors, integration, DoS, upgradeability.
Phase 4 — Invariant Verification
Verified 7 core protocol invariants against the code.
Phase 5 — Report Generation
Findings below, ordered by severity.
Actors & Trust Model
Admin
Resolve markets, create markets, set params, emergency remove
Honest and competent (single EOA)
LP (Engine)
Deposit/withdraw LP capital, earn fees
Rational economic actor
LP (Vault)
Deposit to vault, request withdrawal
Rational economic actor
Bettor
Place bets, redeem winnings, sweep losing tokens
Rational economic actor
Permissionless
collect(), processQueue(), sweepLosing()
Untrusted
Findings
[H-1] High: Centralized Resolution with No Oracle, Dispute Period, or Timelock
Impact: Admin (or compromised admin key) can resolve any market to an incorrect outcome, stealing bettor capital. The LpVault amplifies this — a single key controls resolution for all vault-managed markets via resolveAndCollect().
Likelihood: Medium — requires admin key compromise or malicious admin action.
Code Path: MarketEngine.sol:601-624
Attack Scenario:
Market: "Will Team A win?" — Team A wins (correct outcome = YES).
Admin resolves as NO.
NO token holders redeem 1:1. YES holders get nothing.
Admin had purchased NO tokens beforehand via a separate address.
Recommendation:
Add a dispute window (e.g., 24h after resolution where outcome can be challenged).
Add a timelock on resolution so the pending outcome is publicly visible before finalization.
For production: integrate UMA or another oracle.
Consider multi-sig for the admin role.
Note: Acknowledged as a Phase 1 design decision. Flagged here because the vault is live and the blast radius of a compromised key is all vault-managed markets simultaneously.
[M-1] Medium: Withdrawal Queue Pays at Fulfillment-Time NAV, Not Request-Time NAV
Impact: Earlier queue entries extract value at the expense of later entries when NAV changes between request and fulfillment. LPs who request withdrawal at the same time receive different payouts depending on queue position and when markets resolve.
Likelihood: Medium — occurs during normal operation when multiple withdrawals are pending and markets resolve with gains or losses between queue processing rounds.
Code Path: LpVault.sol:298-308
Scenario:
Vault: NAV = $100K, totalShares = 100K. Two LPs each request withdrawal of 50K shares.
LP-A's request processes first: gets
$100K * 50K / 100K = $50K. NAV drops to $50K, totalShares drops to 50K.A market resolves at a $10K loss.
collect()returns $40K instead of $50K deployed. NAV = $40K.LP-B's request processes: gets
$40K * 50K / 50K = $40K.Result: LP-A got $50K, LP-B got $40K for the same number of shares — the loss was borne entirely by LP-B.
Recommendation: Snapshot sharePrice() at request time in the WithdrawalRequest struct. Fulfillment pays shares * snapshotPrice / 1e18 rather than recomputing at current NAV. This ensures equal treatment regardless of queue position.
[M-2] Medium: emergencyRemoveMarket() Permanently Orphans Vault Capital with No Recovery Path
emergencyRemoveMarket() Permanently Orphans Vault Capital with No Recovery PathImpact: When admin calls emergencyRemoveMarket(), the deployment amount is deleted from NAV accounting, but the vault's LP shares in the engine contract still exist. After the market eventually resolves, there is no way to call collect() (it checks isActiveMarket) — the capital is permanently lost to vault LPs even if recoverable on-chain.
Likelihood: Low — requires admin to use emergency function, then the market resolves normally.
Code Path: LpVault.sol:499-507
Post-condition: collect(market) will revert with MarketNotActive even after the market resolves and capital is recoverable. The USDC remains locked in the engine assigned to the vault's address but inaccessible through the vault's accounting flow.
Recommendation: Add a recovery function:
Alternatively, add a directCollect(address market) that bypasses the isActiveMarket check and simply withdraws LP shares from any engine the vault holds shares in.
[L-1] Low: Fee Underflow Possible with Extreme Parameter Combinations
Impact: placeBet() reverts unexpectedly if combined fee parameters exceed 100%.
Code Path: MarketEngine.sol:428
Both lpFee and protocolFee round UP independently. The constructor validates protocolFeeBps <= 1000 (10%) but does not validate the combined maximum of baseFeeBps + feeK + protocolFeeBps.
With extreme but technically valid parameters (e.g., baseFeeBps=1000, feeK=9000, protocolFeeBps=1000), the combined fees at maximum skew could exceed 100% of usdcIn, causing a Solidity 0.8 underflow revert.
Current defaults are safe: baseFeeBps(100) + feeK(400) + protocolFeeBps(200) = 700 bps = 7% max.
Recommendation: Add constructor validation:
[L-2] Low: currentNAV() Iterates All Active Markets — Linear Gas Growth
currentNAV() Iterates All Active Markets — Linear Gas GrowthImpact: currentNAV() is called in deposit(), processQueue(), createAndFundBatch(), and sharePrice(). Gas cost grows linearly with the number of active markets.
Code Path: LpVault.sol:521-527
With 100 active markets: ~10K gas per SLOAD iteration = ~1M gas for NAV alone, plus it's called multiple times per transaction.
Recommendation: Maintain a running totalDeployed counter. Increment on createAndFund / createAndFundBatch, decrement on _collect and emergencyRemoveMarket. Replace the loop with return float + totalDeployed.
[L-3] Low: withdrawalQueue Array Grows Unboundedly
withdrawalQueue Array Grows UnboundedlyImpact: Storage bloat. The array is append-only — queueHead advances past fulfilled entries but they are never deleted. Over months of operation, the array grows without bound.
Code Path: LpVault.sol:260-266
Recommendation: Periodically compact the array, or switch to a mapping-based queue indexed by queueHead/queueTail.
[L-4] Low: Markets Created Outside Vault Are Disconnected from Vault Accounting
Impact: If admin calls factory.createMarket() directly (bypassing the vault), the engine is owned by the admin EOA, not the vault. The vault cannot resolve or collect from it. This is by design but creates an operational footgun.
Code Path: MarketFactory.sol:105-109
Recommendation: Document this clearly. Optionally, add a factory-level guard that only the registered vault can create markets when a vault is active.
[L-5] Low: Dynamic LP Fee Has No Hard Cap
Impact: The dynamic LP fee formula can theoretically exceed baseFeeBps + feeK if the imbalance-to-backing ratio exceeds 1.0 (possible when seedImbalance contributes to effImbalance).
Code Path: MarketEngine.sol:414
Recommendation:
[I-1] Informational: No Two-Step Ownership Transfer
Both MarketEngine and MarketFactory use OpenZeppelin Ownable with single-step transferOwnership(). A typo in the new owner address permanently loses admin access and locks all funds (LP capital, unresolved markets).
Recommendation: Use Ownable2Step for all Ownable contracts.
[I-2] Informational: OutcomeToken.setEngine() Accepts address(0)
OutcomeToken.setEngine() Accepts address(0)Code Path: OutcomeToken.sol:36-41
If setEngine(address(0)) is called, no one can ever mint or burn tokens (the onlyEngine modifier checks msg.sender != engine, and no contract can call from address(0)). The engine is permanently bricked.
Recommendation: Add require(_engine != address(0), "Zero engine address").
[I-3] Informational: sweepLosing() Accepts shares = 0
sweepLosing() Accepts shares = 0Code Path: MarketEngine.sol:663-678
sweepLosing(0) is a no-op that wastes gas without reverting. All other functions guard against zero amounts.
Recommendation: Add if (shares == 0) revert ZeroAmount();
[I-4] Informational: LpVault.deposit() Resets Holding Period on Every Deposit
LpVault.deposit() Resets Holding Period on Every DepositCode Path: LpVault.sol:223
An LP who deposited early and adds even 1 USDC of additional capital has their anti-snipe timer completely reset. This is defensive (prevents circumvention) but may surprise users.
Recommendation: Document this behavior. Consider tracking per-deposit timestamps or using a weighted average.
[I-5] Informational: resolveTime Allows Exact-Match Resolution
resolveTime Allows Exact-Match ResolutionCode Path: MarketEngine.sol:608
Resolution is permitted at exactly resolveTime, which could be before a real-world event concludes if parameters are set too aggressively. This is a parameter-setting concern, not a code bug.
Invariant Verification
1
sum(lpShares[all users]) == totalLpShares
HOLDS
Updated in lockstep in depositLp (L475-476) and withdrawLp (L740-741). No other paths modify these.
2
sum(pendingFees) + unmaterialized == accumulatedFees - totalFeesClaimed
HOLDS
Bounded dust from wadMul truncation. Underflow guard at L718-720 handles edge cases.
3
feePerShare is monotonically non-decreasing
HOLDS
Only modified at L526 via +=. Never decremented.
4
userFeePerSharePaid[u] <= feePerShare always
HOLDS
Set to current feePerShare in _accrueRewards (L331). feePerShare only increases.
5
No LP receives fees for periods before their deposit
HOLDS
First _accrueRewards call with 0 shares skips materialization but sets snapshot. Verified by previous audit.
6
USDC.balanceOf(engine) >= max(yesLiability, noLiability) in active state
HOLDS
Enforced by solvency check at L578-580 after every bet. Deposits only increase balance. Protocol fee transfer happens before the check, which correctly uses post-transfer balance.
7
A resolved market can complete all pending redemptions
HOLDS
Winners redeem 1:1, decrementing liability and transferring equal USDC. LP withdrawal is gated to lpPool = balance - winningLiabUsdc, ensuring winners are always paid before LPs. Caveat: USDC blacklisted addresses cannot redeem but do not affect other users.
Security Checklist
Reentrancy
Access Control
Math & Precision
State Machine
Oracle & Resolution
Economic Attacks
Vault-Specific
Integration
Denial of Service
Immediate Mitigation Recommendations (Vault is Live)
M-1 — Queue NAV fairness: Snapshot
sharePrice()at request time in theWithdrawalRequeststruct. Payshares * snapshotPrice / 1e18at fulfillment. This is the highest-priority change — it affects fairness between LPs during normal operation.M-2 — Emergency capital recovery: Add a
recoverMarket()function to re-register a previously removed market socollect()can recover the capital after the market resolves.H-1 — Resolution trust: Add a timelock or multi-sig for resolution. Even a short delay (e.g., 1 hour) with a publicly visible
pendingResolutionmapping allows community verification before finalization.
Comparison with Previous Audit (2026-02-21)
The previous audit focused exclusively on the LP Fee Accumulator (Synthetix StakingRewards pattern) within MarketEngine. Its findings remain valid:
I-1: Orphaned fees when totalLpShares == 0
Still informational, unreachable in practice
I-2: fromWad() truncation in fee share
Still informational, sub-microcent
I-3: accumulatedFees vs totalFeesClaimed drift
Still low, conservative direction
Coverage gap: multi-deposit + interleaved bets + partial withdrawal
Still recommended to add test
New coverage in this audit: Full MarketEngine lifecycle (state machine, pricing, solvency), MarketFactory deployment flow, LpVault capital management, withdrawal queue economics, cross-contract trust boundaries, OutcomeToken access control.
Conclusion
The MarketEngine core is well-designed with strong solvency guarantees and correct fee distribution. The main risk vectors are operational (centralized admin resolution) and economic (vault withdrawal queue fairness). The LpVault's explicit float-based accounting successfully mitigates common ERC-4626 inflation attacks, but the withdrawal queue's pay-at-fulfillment-time NAV creates measurable unfairness between LPs that should be addressed.
Overall Assessment: PASS with recommendations. No funds are at immediate risk. The medium findings (M-1, M-2) should be addressed before significant TVL growth.
Last updated