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.

Severity
Count
Exploitable

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

Actor
Capabilities
Trust Assumption

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:

  1. Market: "Will Team A win?" — Team A wins (correct outcome = YES).

  2. Admin resolves as NO.

  3. NO token holders redeem 1:1. YES holders get nothing.

  4. 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:

  1. Vault: NAV = $100K, totalShares = 100K. Two LPs each request withdrawal of 50K shares.

  2. LP-A's request processes first: gets $100K * 50K / 100K = $50K. NAV drops to $50K, totalShares drops to 50K.

  3. A market resolves at a $10K loss. collect() returns $40K instead of $50K deployed. NAV = $40K.

  4. LP-B's request processes: gets $40K * 50K / 50K = $40K.

  5. 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

Impact: 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

Impact: 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

Impact: 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)

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

Code 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

Code 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

Code 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

#
Invariant
Status
Notes

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)

  1. M-1 — Queue NAV fairness: Snapshot sharePrice() at request time in the WithdrawalRequest struct. Pay shares * snapshotPrice / 1e18 at fulfillment. This is the highest-priority change — it affects fairness between LPs during normal operation.

  2. M-2 — Emergency capital recovery: Add a recoverMarket() function to re-register a previously removed market so collect() can recover the capital after the market resolves.

  3. H-1 — Resolution trust: Add a timelock or multi-sig for resolution. Even a short delay (e.g., 1 hour) with a publicly visible pendingResolution mapping 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:

Previous Finding
Status

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