Re-Audit: Fix Verification
Protocol
MarkIt — Binary Prediction Market on Base
Auditor
Claude Code (Anthropic)
Date
2026-02-23
Commit
f718e58fdc718aaf2a53ba79a033912270a1d0a7
Compiler
Solidity 0.8.24 (via_ir = true, evm_version = paris)
Scope
Audit fix verification + new attack surface from architectural changes
Baseline
Prior audits 2026-02-21 (LP Fee Accumulator) and 2026-02-23 (Full Protocol)
Test Suite
257 tests, 0 failures, 0 skipped (5 suites)
Status
PASS WITH NOTES
1. Fix Verification Summary
M-1: Withdrawal queue pays fulfillment-time NAV
Medium
FIXED
VERIFIED
None
test_audit_navSnapshotLocksInPrice, test_audit_navSnapshotStaleProof, test_audit_partialFulfillmentOnInsolvency, test_audit_partialFulfillmentFloatExactlyZero
M-2: emergencyRemoveMarket orphans capital
Medium
FIXED
VERIFIED
None
test_audit_recoverMarketLifecycle, test_audit_recoverMarketRevertsIfActive, test_audit_recoverMarketOnlyOwner, test_audit_recoverMarketRevertsAfterCollected
L-1: Fee underflow with extreme parameters
Low
FIXED
VERIFIED
None
test_audit_feeCapBoundary10000Succeeds, test_audit_feeCapBoundary10001Reverts
L-2: currentNAV() iterates all markets (gas)
Low
FIXED
VERIFIED
None
test_audit_totalDeployedTracksLifecycle
L-5: Dynamic LP fee has no hard cap
Low
FIXED
VERIFIED
None
Existing fee fuzz tests cover boundary
I-1: No two-step ownership transfer
Info
FIXED
VERIFIED
None
test_audit_ownable2StepRequiresAcceptance, test_audit_acceptFactoryOwnership
I-2: setEngine accepts address(0)
Info
FIXED
VERIFIED
None
test_audit_setEngineZeroReverts
I-3: sweepLosing accepts 0 shares
Info
FIXED
VERIFIED
None
test_audit_sweepLosingZeroReverts
All 8 fixes are present, correct, and tested. No new vulnerabilities introduced.
2. Fix Verification Details
M-1: NAV Snapshot at Request Time — VERIFIED
Fix location: LpVault.sol:263-266 (requestWithdrawal), LpVault.sol:319 (processQueue)
Implementation:
The navPerShareAtRequest is computed BEFORE shares[msg.sender] and totalShares are decremented (line 269-270). This correctly captures the share price at request time.
At fulfillment time (line 319):
This uses the stored snapshot, not the current share price. Correct.
M-2: recoverMarket — VERIFIED
Fix location: LpVault.sol:529-550
The recoverMarket() function:
Reads amount from
removedDeployments[market](no admin-supplied value) — line 531Guards against phantom NAV: checks if engine is Withdrawable with 0 vault LP shares — lines 537-539
Restores active tracking and deletes
removedDeployments[market]— line 547
Correct. The phantom NAV guard prevents the most dangerous attack vector.
L-1: Fee Cap Validation — VERIFIED
Fix location: MarketEngine.sol:212
Prevents construction with parameters that could cause underflow in _calculateFee. Correct.
L-2: O(1) NAV via totalDeployed — VERIFIED
Fix location: LpVault.sol:570-572
totalDeployed is maintained via increments/decrements in createAndFund (line 382), createAndFundBatch (line 410), _collect (line 487), emergencyRemoveMarket (line 518), and recoverMarket (line 546). All paths covered. Correct.
L-5: Dynamic LP Fee Hard Cap — VERIFIED
Fix location: MarketEngine.sol:418
Caps the LP fee to the theoretical maximum. Prevents unbounded fee growth at extreme imbalance ratios. Correct.
I-1: Ownable2Step — VERIFIED
Both MarketEngine (line 16) and LpVault (line 49) and MarketFactory (line 12) now inherit Ownable2Step. The acceptFactoryOwnership() function (line 554) enables 2-step factory ownership transfer through the vault. Correct.
I-2: setEngine Rejects Zero — VERIFIED
Fix location: OutcomeToken.sol:41
Correct.
I-3: sweepLosing Rejects Zero — VERIFIED
Fix location: MarketEngine.sol:673
Correct.
3. M-1 Deep Dive: NAV Snapshot Architecture
3.1 Ordering Analysis
In requestWithdrawal():
Line 264:
navPerSharecomputed using currenttotalSharesandcurrentNAV()Line 269:
shares[msg.sender] -= sharesToRedeemLine 270:
totalShares -= sharesToRedeem
The snapshot is taken BEFORE share decrement. This matches sharePrice() (line 576-578) which uses the same formula: (currentNAV().toWad() * WAD) / totalShares. Ordering is correct.
3.2 totalShares Decrement Timing
Shares are decremented at request time, not fulfillment. This prevents ghost share dilution.
Path analysis — functions that read totalShares between request and fulfillment:
sharePrice()(line 576): Returns(NAV.toWad() * WAD) / totalShares. After request, totalShares is smaller, but NAV is unchanged (no USDC left the vault yet). This means sharePrice for remaining LPs increases slightly — correct, as the queued shares are economically burned.deposit()(line 228-229): New depositor gets(usdcAmount.toWad() * totalShares) / nav.toWad(). With lower totalShares and same NAV, they get fewer shares per USDC — correct, as each remaining share represents more value.currentNAV()(line 570): Returnsfloat + totalDeployed. Not affected by totalShares. Correct.userValue()(line 583-584): Returns(currentNAV() * shares[user]) / totalShares. After request, user's shares decreased and totalShares decreased proportionally. Correct.
No path produces incorrect results from the early decrement.
3.3 Partial Fulfillment Analysis
In _processQueue() (lines 317-337):
(a) queueHead advances: Line 335 queueHead++ executes after fulfillment. Correct.
(b) float hits 0 not negative: If usdcOwed > float, then usdcPaid = float, and float -= float = 0. No underflow possible. If usdcOwed <= float, subtraction is safe. Correct.
(c) entry cannot be re-processed: req.fulfilled = true on line 326. The loop skips fulfilled entries on line 305-309. Even if processQueue() is called again, queueHead has advanced past this entry. Correct.
(d) next entry processes normally: After partial fulfillment, float may be 0. The next entry checks _allMarketsCollected() first — if that passes and float is 0, it gets usdcPaid = 0 and is marked fulfilled. This is the correct "partial at zero" behavior. The LP accepted snapshot risk. Correct.
3.4 Ghost Share Fix Verification
After requestWithdrawal(5000e18) by LP1 who had 10000e18 shares:
totalSharesdrops from 10000e18 to 5000e18shares[lp1]drops from 10000e18 to 5000e18floatunchanged,totalDeployedunchanged, so NAV unchanged
sharePrice() = NAV.toWad() * WAD / 5000e18 — exactly 2x the original price per remaining share.
LP2 depositing gets usdcAmount.toWad() * 5000e18 / NAV.toWad() — at the new price. No dilution from ghost shares. Verified by test_audit_depositDuringPendingWithdrawal.
3.5 Multiple Sequential Requests
If LP1 requests withdrawal, then LP2 requests withdrawal:
LP2's
navPerShareAtRequestis computed with post-LP1-decrementtotalSharesSince NAV is unchanged but totalShares is lower, LP2's snapshot price is higher
This is correct: LP2's remaining shares are worth more per share since LP1's shares are economically burned
Verified by test_audit_navSnapshotLocksInPrice (lines 1517-1522) which confirms navPerShare2 >= navPerShare1.
3.6 removedDeployments Staleness Check
recoverMarket() deletes removedDeployments[market] on line 547. After recovery, the market is back in activeMarkets and isActiveMarket. If emergency-removed again, a fresh value is written to removedDeployments. If recovered again, it's deleted again. No stale value persists across cycles.
The phantom NAV guard (lines 537-539) prevents recovery after the underlying capital has already been withdrawn. Correct.
4. Invariant Status
Invariant 1: sum(lpShares[all users]) == totalLpShares (MarketEngine)
HOLDS. depositLp() increments both lpShares[msg.sender] and totalLpShares by the same amount. withdrawLp() decrements both by the same amount. No other function modifies either. Verified by all LP accounting tests and fuzz test test_fuzz_lpDepositShareAccounting.
Invariant 2: feePerShare monotonically non-decreasing
HOLDS. feePerShare is only modified in placeBet() (line 530): feePerShare += lpFee.toWad().wadDiv(totalLpShares). The addition of a non-negative quantity (fee is always >= 0, and totalLpShares > 0 when reached) ensures monotonic increase. No code path decrements it.
Invariant 3: No LP receives fees before their deposit
HOLDS. The Synthetix _accrueRewards() pattern (line 327-334) snapshots feePerShare at deposit time via userFeePerSharePaid[user] = feePerShare. Pending fees are computed as lpShares[user].wadMul(feePerShare - userFeePerSharePaid[user]). A new LP with userFeePerSharePaid[user] == feePerShare earns zero pre-existing fees. Verified by test_feeSniping_lateDepositorGetsZeroPreExistingFees.
Invariant 4: USDC.balanceOf(engine) >= winningLiabilities
HOLDS. The solvency check in placeBet() (lines 582-584) runs after every bet:
Winner redemptions decrease liability proportionally to USDC transferred out. LP withdrawals compute lpPool = balance - winningLiabUsdc and never touch reserved winner funds. Verified by 16 fuzz tests including test_fuzz_solvencyInvariant and test_fuzz_dynamicSkewCapSolvency.
Invariant 5 [NEW]: totalShares (vault) == sum of all shares[] mappings
HOLDS. deposit() increments both shares[msg.sender] and totalShares. requestWithdrawal() decrements both. No other function modifies either. Queued shares are economically burned at request time — they do NOT count toward totalShares. Verified by test_audit_depositDuringPendingWithdrawal and the _sumUserShares() helper in vault tests.
Invariant 6 [NEW]: totalDeployed == sum of deployments[market] for all activeMarkets
HOLDS. Every createAndFund and createAndFundBatch call increments both totalDeployed and deployments[market]. _collect() decrements both. emergencyRemoveMarket() decrements both (moves to removedDeployments). recoverMarket() increments both. The _assertInvariants() helper in LpVault.t.sol (line 144-148) verifies totalDeployed == _sumDeployments() throughout tests. Verified by test_audit_totalDeployedTracksLifecycle and test_invariant_navEqualsFloatPlusDeployments.
5. New Attack Surface Assessment
5.1 Front-running requestWithdrawal() to manipulate navPerShareAtRequest
SAFE.
navPerShareAtRequest is computed from currentNAV() = float + totalDeployed. An attacker cannot change totalDeployed (owner-only market operations). They could deposit USDC to increase float, which increases NAV and thus navPerShareAtRequest — but this also increases totalShares proportionally (deposit mints shares at current price), so navPerShareAtRequest is unchanged.
A large bet in an active market does not change float or totalDeployed — bets go directly to the MarketEngine, not the vault. The vault values deployed capital at cost until collect(), so bet activity cannot inflate NAV.
Conclusion: No manipulation vector exists.
5.2 Griefing partial fulfillment by draining float
SAFE (with documented caveat).
An attacker could drain float by front-running processQueue() with... nothing. Float can only decrease via createAndFund (owner-only) or withdrawal fulfillment itself. Non-owner users cannot reduce float.
The only scenario where float is near-zero at fulfillment time is when the owner deployed most capital. In that case, partial fulfillment pays whatever is available. Each LP gets paid (possibly partially) and the queue advances. This is by design — LPs accept snapshot risk.
Caveat: If the owner intentionally deploys all float just before processQueue, they could cause all pending withdrawals to receive $0. This is a centralization risk inherent in the owner-operated vault design, not a new vulnerability from the fixes.
5.3 recoverMarket() on already-collected market
SAFE.
The phantom NAV guard at LpVault.sol:537-539 explicitly checks:
This prevents recovering a market whose capital has already been extracted. Verified by test_audit_recoverMarketRevertsAfterCollected.
Edge case considered: What if the market is not yet Withdrawable but vault has 0 LP shares? This shouldn't happen — the vault LP deposits are only removed by withdrawLp() which requires Withdrawable state. The guard correctly covers the only dangerous scenario.
5.4 totalShares decrement with zero remaining shares
SAFE.
If all LPs request withdrawal, totalShares drops to 0. At this point:
sharePrice()returnsWAD(1e18) — theif (totalShares == 0)guard on line 577deposit()uses theif (totalShares == 0)branch for 1:1 minting — line 222-224currentNAV()still returnsfloat + totalDeployed— independent of sharesprocessQueue()uses storednavPerShareAtRequest, not current sharePrice — line 319
No division by zero. No incorrect calculation. Correct.
5.5 Two-step ownership intermediate state
SAFE.
Between transferOwnership(newOwner) and acceptOwnership():
The current owner retains full control (
owner()still returns old owner)pendingOwner()is set but has no privilegesAll
onlyOwnerfunctions still work for the current ownerThe pending owner cannot do anything until they call
acceptOwnership()
For the vault's factory ownership (via emergencyTransferFactoryOwnership):
The vault calls
factory.transferOwnership(newOwner)The factory is in pending state — the vault is still the factory owner
The vault can still create markets until the new owner calls
factory.acceptOwnership()
No dangerous intermediate state exists.
6. Unresolved Findings — Status
L-3: withdrawalQueue array grows unboundedly — STILL ACCEPTABLE
The withdrawalQueue array only grows (push, never pop). queueHead advances to skip processed entries, but storage is never reclaimed.
Analysis: This is storage bloat, not a gas DoS. processQueue() iterates from queueHead and is bounded by MAX_QUEUE_ITERATIONS = 50. User-facing functions (deposit, requestWithdrawal) don't iterate the queue. The only gas cost is the withdrawalQueue.push() in requestWithdrawal(), which is O(1).
Severity unchanged. Still just a storage concern. No functional impact.
L-4: Markets created outside vault are disconnected — STILL ACCEPTABLE
If the factory owner creates a market outside the vault (directly via factory), the vault has no record of it. This is documented and by design — the vault is not intended to track externally-created markets.
No new code changes make bypass easier. The factory is onlyOwner for createMarket, and the vault holds factory ownership. Only the vault owner (via emergencyTransferFactoryOwnership) could temporarily reclaim factory ownership to create external markets.
Severity unchanged.
I-4: depositTimestamp reset on every deposit — STILL ACCEPTABLE
depositTimestamp[msg.sender] = block.timestamp in deposit() (line 235) resets on every deposit. A user who deposited 1 day ago and deposits 1 wei today gets a fresh holding period.
This is by design — prevents circumventing the holding period with pre-existing deposits. The UX trade-off (full reset vs. weighted average) is acknowledged and acceptable for Phase 1.
Severity unchanged.
I-5: Test coverage gap (multi-deposit + interleaved bets + partial withdraw) — PARTIALLY ADDRESSED
The original finding flagged a missing test for: LP1 deposits, bets happen, LP1 deposits again, more bets, LP1 partially withdraws.
New coverage: The M-1 tests add significant interleaved operation coverage:
test_audit_navSnapshotLocksInPrice— two LPs request at different timestest_audit_partialFulfillmentOnInsolvency— three markets, two LPs, partial paytest_audit_depositDuringPendingWithdrawal— deposit after withdrawal request
Additionally, test_multiDeposit_interleavedBets_partialWithdrawal in SecurityAudit.t.sol (existing) directly covers the flagged scenario for MarketEngine-level LP operations.
For the vault specifically, the exact multi-deposit + interleaved bets + partial vault withdrawal sequence is still not explicitly tested as a single end-to-end scenario. However, the individual components are well-covered.
Severity reduced to informational; no functional risk identified.
7. New Findings
No new Critical, High, or Medium findings.
INFO-1: Sequential requestWithdrawal() price increase
Severity: Informational Location: LpVault.sol:264-266
When multiple LPs request withdrawal in the same transaction context (or sequential blocks), each subsequent request sees a slightly higher navPerShareAtRequest because totalShares decreases while NAV stays constant.
In test_audit_navSnapshotLocksInPrice (line 1522): assertGe(navPerShare2, navPerShare1).
This is mathematically correct — each remaining share IS worth more after some shares are burned. However, it means later withdrawers get a slightly better price than earlier ones for the same NAV. The difference is proportional to the fraction of shares being withdrawn and is small in practice.
Impact: Negligible. The price increase accurately reflects the remaining shares' value. No exploit path exists — an attacker cannot time their request to manipulate the ordering since requests are processed in FIFO order regardless.
Recommendation: Document this behavior for LPs. No code change needed.
INFO-2: Partial fulfillment loss is permanent
Severity: Informational Location: LpVault.sol:323-326
When partial fulfillment occurs (usdcPaid < usdcOwed), the LP permanently loses the difference. The entry is marked fulfilled = true and cannot be re-processed.
This is by design — the LP accepted the snapshot price risk. However, the shortfall may not be communicated clearly to the LP.
Recommendation: Emit the shortfall amount in the WithdrawalFulfilled event (already emits both usdcOwed and usdcPaid, so the shortfall is derivable). Frontend should display partial fulfillment status clearly.
8. Test Coverage Assessment
New Tests Added (in LpVault.t.sol)
test_audit_navSnapshotLocksInPrice
M-1: NAV snapshot
test_audit_navSnapshotStaleProof
M-1: Stale-proof snapshot
test_audit_partialFulfillmentOnInsolvency
M-1: Partial fulfillment
test_audit_partialFulfillmentFloatExactlyZero
M-1: Float drain to zero
test_audit_floatDrainAndRecovery
M-1: Queue blocking + recovery
test_audit_recoverMarketLifecycle
M-2: Full recovery cycle
test_audit_recoverMarketRevertsIfActive
M-2: Active guard
test_audit_recoverMarketOnlyOwner
M-2: Access control
test_audit_recoverMarketRevertsAfterCollected
M-2: Phantom NAV guard
test_audit_totalDeployedTracksLifecycle
L-2: Counter integrity
test_audit_ownable2StepRequiresAcceptance
I-1: 2-step ownership
test_audit_acceptFactoryOwnership
I-1: Factory 2-step
test_audit_depositDuringPendingWithdrawal
Ghost share fix
Tests in SecurityAudit.t.sol
test_audit_feeCapBoundary10000Succeeds
L-1: Fee cap boundary (success)
test_audit_feeCapBoundary10001Reverts
L-1: Fee cap boundary (revert)
test_audit_sweepLosingZeroReverts
I-3: Zero sweep guard
test_audit_setEngineZeroReverts
I-2: Zero engine guard
Final Test Counts
MarketEngine.t.sol
63
63
0
MarketFactory.t.sol
7
7
0
CreateMarket.t.sol
12
12
0
SecurityAudit.t.sol
94
94
0
LpVault.t.sol
81
81
0
Total
257
257
0
Remaining Gaps
Vault-level multi-deposit + interleaved bets + partial withdrawal — individual components tested but not as single E2E flow
Fuzz testing of navPerShareAtRequest — the snapshot math is tested with concrete values but could benefit from fuzz coverage with random deposit/withdrawal/resolution sequences
Concurrent withdrawal requests from same user — not tested (user requests withdrawal, deposits again, requests again)
None of these gaps represent high-risk scenarios.
9. Overall Assessment
PASS WITH NOTES
All 8 audit fixes are correctly implemented and tested. The M-1 fix (NAV snapshot) is architecturally sound with correct ordering of state mutations, proper partial fulfillment handling, and no ghost share dilution. The M-2 fix (recoverMarket) includes a phantom NAV guard that prevents the most dangerous attack vector. No new vulnerabilities were introduced by any fix.
The two informational notes (sequential price increase, permanent partial fulfillment loss) are inherent to the snapshot-based withdrawal design and require no code changes — only documentation.
10. Conclusion
The MarkIt protocol's fix implementation is thorough and well-tested. The M-1 NAV snapshot architecture correctly addresses the most critical finding from the prior audit without introducing new attack surfaces. All 6 core invariants hold. The vault's centralization risk (owner-controlled market operations) remains the primary trust assumption, which is acknowledged and appropriate for Phase 1. The protocol is ready for continued testnet operation with the fixes in place.
Last updated