Polymarket v2

QuantstampCode version: not recorded (see the report)

Quantstamp reviewed Polymarket (Polymarket v2) on 5 Oct 2026: 12 findings, 0 critical; 7 marked fixed, 5 acknowledged.

No open critical findings
Open the reportOpen Polymarket’s security record

Report hosted on certificate.quantstamp.com.

Trustblock does not review code.

Findings12

Statuses are the security firm’s own, from its report.

SeverityOpenAcknowledgedFixed
Critical000
High001
Medium001
Low055
HighNon-Canonical Migration Condition IDs Allow pUSD DrainageFixed

Migrated NegRisk events can be resolved through `resolveMigrationCondition()`, which is permissionless once the corresponding legacy condition has resolved. The function takes a structured V2 `conditionId`, reads the legacy result by deriving the event and condition index from that id, and then stores the V2 result under the raw `conditionId` key. This creates an identity mismatch. A non-canonical alias such as `canonicalCondition | 1` is a different raw key for V2 result storage, but it still decodes to the same event and condition index for legacy lookup. If the legacy market resolves normally with one YES winner, an attacker can call `resolveMigrationCondition(aliasId)` for that YES condition. This increases `resultsSum[eventId]` to `1_000_000`, while the canonical winning condition remains unresolved in V2. Once `resultsSum[eventId] == 1_000_000`, anyone can call `resolveConditionToNo()` on the canonical conditions. This lets the canonical V2 event appear all-NO even though the legacy event resolved normally with one YES winner. Because already resolved positions can still be split, a user can repeat the flow by splitting canonical conditions, redeeming all NO positions for principal, and horizontally merging the YES basket for extra pUSD each time. The PoC below demonstrates one iteration ending with `totalInput + amount` after supplying only `totalInput`, but the damage is not capped to that single iteration and can continue draining pUSD until the system is paused. The impact can extend across chains. Once the non-canonical alias has a stored result on the source chain, `bridgeResult()` can bridge that raw alias result to supported spoke chains because it only checks `module.getResult(_conditionId).length == 2`. On the receiving chain, `_handleResultReceive()` forwards the raw `conditionId` to `reportResult()` from the bridge role. Since bridged results skip the NegRisk sum invariant inside `reportResult()`, the alias can seed the same corrupted event state on the spoke chain. An attacker can then repeat the same `resolveConditionToNo()`, split, redeem, and horizontal merge flow on each affected chain, draining pUSD from multiple deployments before detection and pause. The source of the regression is that the older implementation resolved through `BaseModule._resolveCondition()`, which called `_requireConditionPrepared()`. Since NegRisk event preparation set an oracle only for each canonical condition index, non-canonical aliases had `oracle[alias] == address(0)` and reverted. The newer migration path no longer enforces this canonical preparation check before storing the result, while legacy lookup still maps the alias back to the same legacy condition.

MediumLegacy All NO Migration Can Mint Unbacked pUSDFixed

Legacy NegRisk event resolution can finalize with zero winners, meaning every condition resolves to NO. This differs from the one-winner economic assumption used by the new NegRisk design. If users are able to split positions after such an all NO legacy resolution, they can hold both legacy YES and legacy NO for each condition, redeem the winning NO side for full collateral recovery, and still migrate the corresponding YES side into the new module. The new module YES basket can then be horizontally merged for additional collateral value. This means a user can recover collateral through the legacy NO redemption path and extract additional value through the new module YES merge path from the same post resolution split set. Even when strict one-winner checks prevent full migration resolution finalization, the collateral extraction path remains reachable before that finalization boundary. This creates a state where migration safety depends on a legacy oracle invariant that may not hold in practice, rather than being fully enforced by migration time economic constraints. The same attack is also feasible if an already resolved legacy NegRisk event with all NO outcomes is migrated, because migration enrollment does not change the underlying all NO payoff asymmetry between legacy redemption and migrated horizontal merge paths.

LowBatch Buy Surplus Can Over-Consume Remaining Taker Order AmountFixed

Batch order matching updates the taker order status in the `_matchBatchOrders()` function with the gross `takerFillAmount` before settlement completes. For batch buy matches that produce a collateral refund, `_executeBatchBuyMatch()` later computes the net `takerMakingAmount` as `takerFillAmount - collateralRefund`, but the stored order remaining value has already been reduced by the gross amount. The remaining calculation consumes a taker order faster than the net settled amount, potentially marking an order as filled even though only part of the signed economic size was executed.

LowBatch Taker Events Can Overstate Builder Reward VolumeFixed

Batch matches can settle a taker order for less than the gross amount reported in the taker `OrderFilled` and `OrdersMatched` events. In the batch path, `_matchOrders()` sets `takerMakingAmount` to `takerFillAmount` before settlement completes, then `_emitTakerEvents()` emits that gross amount even if the batch settlement refunds collateral or positions back to the taker. If builder rewards or volume accounting are calculated from taker `OrderFilled` events keyed by `builder`, downstream systems can attribute rewards using the gross requested fill instead of the net settled amount.

LowBridged Markets Not Handled Correctly By External Helper FunctionsFixed

Structured position IDs derive their `conditionId` / `eventId` from a `baseHash`, and the `baseHash` includes the condition's original chain ID. The functions `NegRiskModule.getEventId()` and `BinaryModule.getConditionId()` hard-code the `_chainId` component of the computed `conditionId` to be `block.chainid`. For conditions that originate on one chain and are later bridged to another, the bridged positions retain their original source-chain IDs. Calling these helpers on the destination chain with the same market data will instead compute a different destination-chain ID. As a result, external integrations, indexers, frontends, or other offchain systems relying on these helpers may display incorrect market IDs, query incorrect balances/results, or build transactions against the wrong condition/event.

LowMissing Storage Gaps in Upgradeable Inheritance ChainFixed

Several upgradeable contracts inherit from stateful base contracts/mixins that do not reserve storage space for future upgrades. This is especially relevant for `NegRiskModule`, whose storage layout places inherited state before state declared directly in `NegRiskModule`. If a future upgrade adds a state variable to `NegRiskMigrationMixin`, it will be inserted before the `NegRiskModule` contract’s own storage variables. This would shift the existing storage layout and corrupt the expected values stored in these slots. The same upgrade-safety concern applies to other stateful inherited contracts used by upgradeable implementations, including: - `BaseModule.sol` - `BaseMigrationMixin.sol` - `BinaryMigrationMixin.sol` - `OracleModule.sol` - `oracle/mixins/Pausable.sol` These contracts would also benefit from reserved storage space or an alternative storage isolation pattern.

LowMutable NegRisk Migration Enrollment Can Create Insolvency RiskFixed

`prepareMigrationEvent()` binds migration state to a structured `eventId` that includes the current legacy condition count (encoded as arity), then snapshots legacy condition index mappings for that count. The same legacy event can later be enrolled again under a different structured `eventId` if the legacy condition count changes, because uniqueness is enforced per structured `eventId` key rather than per legacy event ID. This creates a moving migration target for a single legacy market: previously-migrated positions continue to exist under the earlier condition-count namespace, while future migrations of the same legacy conditions can be redirected into a new condition-count namespace. If the legacy layout changes after initial enrollment (for example, additional conditions are appended), migrated accounting and resolution assumptions can diverge from the original snapshot, and the protocol can end up operating multiple incompatible migrated views of one legacy event. Under specific outcome patterns, this divergence can produce migrated states that conflict with one-winner assumptions used for pUSD safety and can reintroduce the same collateral extraction risk class as all NO style migration outcomes.

Contracts in scope

No deployed address is recorded for this audit.

The report names a repository or files. Open the report to see its scope.

Part of Polymarket’s security record

Polymarket
Audits7 audits by 2 firmsLast audit Oct 2026, by Quantstamp.
Critical findingsNo open critical findingsAcross the 7 reports, 2 critical findings were reported. Each is marked fixed by the firm that reported it.
Open Polymarket’s security record

Security firm

Quantstamp

236 projects reviewed · 332 reports · latest Oct 2026

See Quantstamp’s audits

Something wrong on this page? A missing report, a wrong date, a finding that was fixed since. Tell us and we check it against its source.

Tell us