Skip to content

feat(dpm): Dynamic Pari-Mutuel (Pool) market mode - #23

Merged
crevelop merged 27 commits into
mainfrom
cr/dpm-markets
Jun 3, 2026
Merged

feat(dpm): Dynamic Pari-Mutuel (Pool) market mode#23
crevelop merged 27 commits into
mainfrom
cr/dpm-markets

Conversation

@crevelop

@crevelop crevelop commented Jun 3, 2026

Copy link
Copy Markdown
Contributor

Adds a self-funded Dynamic Pari-Mutuel trading mode (Pennock 2004 §4, "DPM I")
alongside the existing CLOB. Pools are pure redistribution: losers fund winners
plus an outcome-independent entry fee, so the operator never holds risk. The
load-bearing invariant is Σ M_i == collateral in custody at all times.

Scope

  • Market types: binary (UMA or Pyth price) and categorical N-outcome (UMA),
    with addDpmOutcome to append a late outcome.
  • Lifecycle: intent/lobby (refundable, 1:1) → pool seed at openTime → dynamic
    entry (slippage-guarded) → claim. The entry fee is charged once, at the
    intent→pool transition.
  • Resolution reuses the shared CTF condition (UMA assert-truth or Pyth); pools
    hold NO CTF outcome tokens and pay out from their own accounting.

Surface

  • New facets: DpmMarketFacet (create / addOutcome / read views) and
    DpmTradingFacet (enterIntent / exitIntent / enter / claim).
  • New namespace oddmaki.storage.dpm; no migration to existing structs.
  • Layered as usual: validator → service(s) → aggregate → storage, with
    fixed-point pricing in LibDpmPricingService (prb-math).
  • Cross-mode guards: CLOB entry points (market/limit/batch/matching) and Vault
    split/merge reject DPM markets, applied atomically with the new facets.

Deploy

  • Diamond upgrade script 20260530_DPM_MarketMode.s.sol (2 Add + 7 Replace).
    Replace selectors are unchanged; only bytecode changes (inlined libraries).
  • Deployed to Base Sepolia and Base (v1.6.0 snapshots included).

Tests

  • DpmMarket.t.sol (end-to-end) + LibDpmPricingService.t.sol. Full suite green:
    542 passed, 0 failed, 1 skipped. Covers par-until-contested, pro-rata payout,
    intent/transition, invalid + no-contest refunds, N-outcome, Pyth price, and
    the worked $50 example.

No storage migration. No changes to existing CLOB selectors or behavior beyond
the cross-mode guards.

claude and others added 27 commits May 28, 2026 00:04
Captures the agreed design for a self-funded Dynamic Pari-Mutuel (DPM)
market mode alongside the CLOB: lifecycle (lobby/locked via openTime/
closeTime), DPM-shares pricing, entry-time transaction fee (neutral, reuses
existing fee snapshot), separate DpmFacet.createDpmMarket reusing base
creation services, no DPM-specific resolve (reads existing oracle outcome at
claim), and support for binary/market-group/price markets.

https://claude.ai/code/session_01LQLFuR75STtmzwQthTivnq
…cing math

Updates the DPM plan to specify exactly DPM I (Pennock 2004 §4 — losing
money redistributed) as the canonical mechanism. Adds an optional pre-
openTime "intent pool" phase with 1:1 refundable stake (no pricing math),
which transitions lazily into DPM at par on the first post-openTime
interaction. Removes the lobby exit (and the prior LMSR cost-function
detour) — Pennock §3.1 itself has only a buy-side MM, so v1 matches the
paper exactly. Adds the par-until-contested boundary rule (replaces the
"operator seed wager" Pennock suggests, preserving the never-loses-capital
property) and the no-contest refund rule for resolution to an outcome with
zero backing.

Adds docs/dpm-pricing-math.md with the canonical Pennock equations
(eq. 6 / eq. 7), the inverse share function n(m), the DPM I payoff,
the self-funding identity proof, the par boundary, the intent transition,
the no-contest case, fixed-point implementation notes (rounding direction
per call-site, domain checks, identity assertions), and a worked binary
walkthrough.

https://claude.ai/code/session_01LQLFuR75STtmzwQthTivnq
First implementation increment for DPM markets per docs/dpm-markets-plan.md.
Purely additive — no existing files touched, no behavior change.

- Types.sol: append DpmMarket struct (outcomeCount, openTime, closeTime,
  poolInitialized). outcomeCount == 0 means "not a DPM market" — same isX
  sentinel pattern PriceMarket uses.
- LibDpmStorage: namespace oddmaki.storage.dpm. Flat mappings for per-outcome
  aggregates (M, N, intent totals) and per-user state (intent stake, shares,
  paid, claimed, transitioned flags) so N-outcome groups need no array allocs.
  Helper accessors getPool / getOtherShares / getOtherCollateral for the
  Pennock price function's denominators.
- LibDpmValidator: mode guards (requireIsDpmMarket / requireNotDpmMarket for
  the cross-mode protection planned for CLOB entry points / requireDpmMarketNotExists),
  creation-time input guards, and lifecycle guards (requireIntentPhase / requireOpenPhase
  / requireClosed) keyed off openTime/closeTime.

Implementation continues with LibDpmPricingService (Pennock 2004 §4 eq. 6/7).

https://claude.ai/code/session_01LQLFuR75STtmzwQthTivnq
Add LibDpmPricingService implementing the DPM I price math:
  - sharesForCollateral(M_i, N_other, m) = N_other * ln(1 + m/M_i), rounded
    down (protocol-favorable: fewer shares can only raise per-share payout).
  - collateralForShares(M_i, N_other, n) = M_i * (exp(n/N_other) - 1), rounded
    up; the forward used by tests for round-trip and identity checks.
  - Par-until-contested boundary: N_other == 0 OR M_i == 0 => $1/share (n == m).
  - exp-domain overflow guard (DpmExpInputTooLarge) for whale-sized single buys.

Uses PRBMath UD60x18 (pinned submodule v4.1.1) for vetted exp/ln. Internal
math is dimensionless in 1e18 fixed point; M/N/m/n stay in native collateral
(6-dec USDC) scale, so the 1e18 factors cancel and par stays exact 1:1.

Tests (test/LibDpmPricingService.t.sol): par branches, protocol-favorable
round-trip, self-funding identity (Σ M_i == deposits) over fuzzed interleaved
buys, the worked binary example from docs/dpm-pricing-math.md (payouts sum to
pool within floor dust), formula-level payout identity, and the exp-domain
revert. forge build + full forge test green (506 tests).
Add LibDpmAggregate, the mutation-only writes for the DPM overlay:
  - initPool / recordIntentStake / releaseIntentStake (intent phase),
  - seedMarketFromIntent (par seed M_i = N_i = intentTotals_i),
  - transitionUserFromIntent (par fold of a user's intent into shares/paid),
  - recordDpmEntry (dynamic buy: M_i += net m, N_i += n),
  - markClaimed.

Add LibDpmValidator.requireSufficientIntentStake (+ InsufficientIntentStake)
for a friendly exitIntent underflow guard.

Intent transition is gross par reallocation (no fee), which keeps the
self-funding sums exact (Σ userPaid == M_i, Σ userShares == N_i) with no M/N
mutation during the claim phase. The plan's "charge a fee at transition" is
rejected as insolvent/drift-prone; fees are charged only on dynamic enter
(see the LibDpmService commit). forge build + test green.
Add LibDpmService, the internal sagas the DpmFacet will wrap:
  - initPool: validate no overlay exists, then write it.
  - enterIntent / exitIntent: 1:1 refundable intent (no fee, no pricing). exit
    has no pause/access gate so a refundable stake is always retrievable.
  - enter: lazy par seed + per-user transition (fee-free), charge the entry fee
    on the deposited amount via the shared fee distributor, deposit only the net
    into the pool, price shares via the Pennock inverse, record the entry, and
    update shared volume/last-trade-tick stats.
  - claim: read the winner from the CTF payout numerators (one non-zero = winner,
    else invalid/split); refund userPaid + un-transitioned intent on invalid or
    no-contest (N_w == 0); otherwise pay userPaid + floor(M_other·userShares/N_w).
    Effects-before-interaction: marks claimed before withdrawing.

Add LibDpmValidator.requireResolved (+ NotResolved): claim reads the shared
registry status set by the UMA/Pyth/group resolution paths (DPM has no resolve
function of its own). forge build + full forge test green (506).
… cut

Add DpmFacet, the diamond entry surface for DPM markets (v1 binary, UMA):
  - createDpmMarket: mirrors MarketsFacet.createMarket (venue/access/collateral
    guards, creation fee + UMA reward collection) with a nominal 1e16 tick, then
    initializes the DPM overlay with the resolved open/close window (openTime 0 =>
    immediate, no intent phase).
  - enterIntent / exitIntent / enter / claim: nonReentrant wrappers over
    LibDpmService that emit indexing events.
  - Views: isDpmMarket, getDpmMarket, getMarketCollateral, getMarketShares,
    getIntentStake, getUserShares, getUserPaid, and quoteEntryShares (advisory,
    net-of-fee, simulates the lazy intent seed).

Register the 13 selectors in both DeployOddMaki.s.sol and the test DiamondSetup
(facet index 21, array 21 -> 22). forge build + full forge test green (506); no
selector clashes.
Add LibDpmValidator.requireNotDpmMarket(marketId) as the first statement of
every CLOB trading entry point, so a DPM market (which carries a base
MarketTradingData row) can never be traded through the order book. One line each:

  - MarketOrdersFacet.placeMarketBuy, placeMarketSell
  - LimitOrdersFacet.placeOrder, expireOrders
  - BatchOrdersFacet.batchPlaceOrders, cancelAndReplace
  - MatchingFacet.matchOrders

Cancel-only paths (cancelOrder, batchCancelOrders, cancelOrdersOnResolvedMarket)
take no marketId and can't reference a DPM market (placeOrder is guarded, so no
DPM order can exist), so they need no guard. MarketGroupFacet has no per-market
trading entry point and DPM v1 markets are never grouped. forge build + full
forge test green (506).
Add test/DpmMarket.t.sol driving the full lifecycle through the diamond:
  - intent enter/exit (1:1 refund), exit-overdraw and post-open reverts,
  - lazy intent -> DPM transition at par (per-market seed + per-user fold),
  - par-until-contested then dynamic open-phase pricing,
  - the worked binary example resolving to exactly $50 (payouts within floor dust),
  - no-contest (winning side empty) and invalid ([1,1]) full refunds, including
    gross refund of un-transitioned intent,
  - double-claim / claim-before-resolved reverts,
  - entry-fee path with the Σ M_i == custody solvency check (fees enabled),
  - cross-mode guard: all seven CLOB entry points reject a DPM market.

Extend MockCTF with the real-CTF payoutNumerators(conditionId, index) read
(populated in reportPayouts via the oracle/questionId/slot-count conditionId),
which the DPM claim path uses to read the winner. forge build + full forge test
green (519 tests, +13).
Replace the (rejected) "fee only on dynamic enter" model: free intent let
everyone route deposits through the pre-openTime phase to dodge fees. The fee is
now charged at the intent->DPM transition, kept exactly solvent by construction.

Mechanism (see LibDpmService doc):
  - Seed once (first post-open enter or first claim): charge the fee on the whole
    intent pool. Per outcome M_i = intentTotals_i - floor(intentTotals_i*feeBps)
    (collateral net), N_i = intentTotals_i (shares GROSS — never fee-reduced).
    Distribute the summed fee.
  - Per-user transition: pure allocation, never touches M/N — userShares += stake
    (gross), userPaid += stake - ceil(stake*feeBps) (net).
  - Dynamic enter: fee on amount as before.
  - Operator slice folded into the protocol bucket (DPM has no keeper) via _splitFee.

Why solvent: gross shares freeze N_i; taking the fee once at seed (not lazily per
claim) freezes M_i/N_i before the first payout, so sequential winner claims can't
drift (M_other/N_w)*shares. seed-floor paired with per-user-ceil keeps
Sigma userPaid <= M_i exactly (sub-unit dust stays locked — the safe direction).
Winners always pay (fee taken at seed regardless of when they claim) -> no free ride.

LibDpmAggregate: seedMarketFromIntent/transitionUserFromIntent replaced by
seedOutcome + markPoolInitialized + recordIntentTransition + markTransitioned.

Tests: intent fee charged at seed (winner pays, operator->protocol exact),
no-free-ride + solvency with mixed intent/dynamic, and a 256-run fuzz proving
payouts never exceed deposits across random intent splits. forge build + full
forge test green (522).
DPM was binary-UMA only at creation; add a Pyth-resolved price-market path.
claim() needed no change — it reads the winner from the CTF payout numerators,
and Pyth resolution (resolvePriceMarketPyth) + invalidation (markPriceMarketInvalid)
already funnel through the same LibResolutionService.resolveMarket() as UMA, writing
the same numerators + Resolved status. So DPM claim is resolution-source-agnostic.

  - Extract the shared price-market creation (feed-exponent read, base market with no
    UMA reward escrowed, oracle.reward = 0 footgun, price overlay write) into
    LibPriceMarketService.createPriceMarketBase, and refactor PythResolutionFacet.
    createPriceMarketPyth to call it (behavior-preserving; the reward-zeroing lives in
    one place now). 59 PriceMarket tests still green.
  - Add DpmFacet.createDpmPriceMarket (Up/Down or explicit strike): venue/access/
    collateral guards + creation fee (no reward escrow), shared base creation with the
    nominal DPM tick, then initPool. DPM open/close double as the Pyth observation window.
  - Register the 14th selector in DiamondSetup + DeployOddMaki.

Test: strike DPM price market entered on both sides, resolved via the Pyth mock,
winner paid from the pool exactly as in the UMA path. forge build + full forge test
green (523).

Note: in the test's Pyth VAA build, the uint64 timestamp casts are hoisted into
locals — nesting uint64(closeTime - 1) in the call args trips a via_ir codegen quirk.
Address the high private-helper count in LibDpmService (11 — far more than any
other service) by moving two cohesive concerns into their own services:
  - LibDpmFeeService: feeBps() + distribute() (the exact operator->protocol fee
    split). Reused by the intent seed, dynamic enter, and future market types.
  - LibDpmResolutionService: winningOutcome() (CTF payout-numerator read +
    winner/invalid classification). Isolates the oracle-read dependency and is
    where the group (neg-risk) winner read will branch.

LibDpmService's remaining private helpers are all multi-use (_seedIfNeeded,
_transitionIfNeeded, _requireTradingAllowed, _collateralToken, _refundAll) except
_impliedTick (a guarded cosmetic stat kept for hot-path readability).

Also tighten enter() to strict effects-before-interaction: record the DPM entry
and stats first, then route the entry fee out as the final external call.

Behavior unchanged; forge build + full forge test green (523).
DpmFacet was 19,794 bytes — 80% of the 24,576 EVM code-size limit — with the
N-outcome group variant still to add. Split it along its natural seam:
  - DpmMarketFacet: createDpmMarket, createDpmPriceMarket, + 8 read views (14,511
    bytes; group creation lands here).
  - DpmTradingFacet: enterIntent, exitIntent, enter, claim (7,728 bytes).

Both now sit far under the limit (10KB / 16.8KB headroom). Register them as two
diamond cuts in DeployOddMaki + DiamondSetup (array 22 -> 23). quoteEntryShares
now reads the fee rate via LibDpmFeeService.feeBps (0 when fees are disabled),
matching enter() instead of the raw snapshot.

All four trading mutators remain nonReentrant. forge build + full forge test
green (523).
The createPriceMarketBase extraction left PriceMarketCreatedPyth behind in
PythResolutionFacet, so createDpmPriceMarket created a Pyth overlay WITHOUT
emitting the event. The subgraph's handlePriceMarketCreatedPyth is what sets
isPriceMarket, builds the PriceMarket overlay entity, and orders the series by
closeTime — so a DPM price market would have been mis-indexed and its
PriceMarketResolvedPyth would have had no overlay to update.

Fix it the way the codebase already handles MarketCreated (owned by
LibMarketCreationService): move the PriceMarketCreatedPyth declaration + emission
INTO LibPriceMarketService.createPriceMarketBase, so BOTH createPriceMarketPyth
and createDpmPriceMarket emit it identically. Verified the event still appears in
both facets' ABIs (library events propagate to the emitting contract's ABI, as
MarketCreated already does) with an unchanged topic0
(0xccc6017e...), so the combined OddMaki.json ABI and the subgraph are unaffected.

Remove the now-duplicate declaration/emission from PythResolutionFacet. New test
asserts createDpmPriceMarket emits the event carrying the feed id. 59 PriceMarket
tests still green; full suite green (524).
…ed indexer

The subgraph reconstructs state from event params (it only calls views for static
config), and CLOB trade events carry resulting state. DPM had two gaps: the lazy
intent->pool seed (a market-level M/N initialization + fee charge) emitted nothing,
and DpmEntered carried only the trade delta, so probabilities weren't a clean
projection.

  - Add DpmPoolSeeded(marketId, collateral[], shares[]): emitted once at the seed
    with the initialized per-outcome M_i (net) / N_i (gross). Arrays so it covers
    the N-outcome group variant too.
  - Enrich DpmEntered with (newCollateral, newShares): the traded outcome's pool
    state after the fill, so the indexer projects M_i/pool without recomputing the
    fee or replaying Pennock pricing.

Move all four trade events into LibDpmService (service-owned, like MarketCreated /
PriceMarketCreatedPyth) so they carry post-state and DpmTradingFacet becomes a pure
nonReentrant wrapper. Events still appear in the facet ABI (verified) since it emits
them. Per-user intent transition stays indexer-derivable from the intent events +
the snapshotted fee bps (trivial ceil, not Pennock). New test asserts the seed event
fires and DpmEntered carries live M/N. forge build + full forge test green (525).
…ntation

Remove speculative "N-outcome group variant still to come / will branch / future
market types" notes and "v1" version framing from the new DPM facets and services
(DpmMarketFacet, DpmTradingFacet, LibDpmFeeService, LibDpmResolutionService,
LibDpmStorage). Comments now describe only current behavior. No code change.
… DPM [2] stats

Prep for N-outcome DPM. The oracle/condition layer already sizes from
outcomes.length; the only binary-specific creation step is the CTF outcome-token
vault registration (positionIds), which DPM never uses.

  - LibMarketCreationService.createMarket: relax `outcomes.length != 2` to `< 2`,
    and register vault positions only for binary markets (length == 2). N-outcome
    markets carry empty positionIds. CLOB/binary creation is unchanged (still
    registers); only N > 2 (DPM-only) skips it.
  - LibDpmService.enter: stop writing the CLOB MarketTradingData volume/lastTradeTick
    — those are uint256[2] arrays and would be out of bounds for outcome >= 2. The
    DpmEntered event already carries volume + resulting pool state for the indexer.
    Remove the now-unused _impliedTick helper + LibMarketTradingAggregate import.

Binary DPM still gets a MarketTradingData row (active flag + collateralToken) via
createMarket, so _collateralToken / requireActiveMarket are unaffected. forge build
+ full forge test green (525).
The DPM engine was already N-outcome (pricing/seed/transition/claim sum over all
other outcomes; winningOutcome counts non-zero CTF numerators). Wire creation up:

  - createDpmMarket now accepts N outcomes (2..MAX_OUTCOMES): validates the count,
    passes outcomes.length to the condition (N CTF slots) and to initPool. Vault
    registration is skipped for N > 2 (Stage 1).
  - LibDpmValidator.MAX_OUTCOMES = 64 (bounds the O(N) per-op loops; well under the
    CTF protocol limit of 256).
  - createDpmPriceMarket stays binary (price resolves to a 2-slot payout) — rejects
    non-2 outcome sets.

Resolution is unchanged and already N-ready: UMA asserts ONE outcome string, the
DVM verifies it true/false (binary per assertion), computePayouts maps the string
to the slot index, claim reads the N numerators. Tests: a 3-candidate election
(create -> trade -> resolve -> claim), pro-rata split among winners, outcome-count
bounds, and binary-price rejection. forge build + full forge test green.
Support adding an outcome to a live DPM market — e.g. a candidate who enters the
race after the market opened. DPM holds no CTF outcome tokens, so this is pure
bookkeeping + a fresh CTF condition (nothing to split/migrate).

  - LibDpmOutcomeService.addOutcome: prepare an (N+1)-slot CTF condition, re-point
    the oracle at the new conditionId, append the outcome string, bump outcomeCount.
    The questionId is stable so UMA assertions are unaffected; the new outcome starts
    at M=N=0 (par-until-contested). Guards: before closeTime, non-empty + unique
    label, <= MAX_OUTCOMES. Emits DpmOutcomeAdded (carries the NEW conditionId — it
    changes — so the indexer can re-point the market).
  - DpmMarketFacet.addDpmOutcome: venue-operator-only wrapper; registered as the
    11th DpmMarketFacet selector.
  - LibMarketOracleAggregate.appendOutcome + LibDpmAggregate.setOutcomeCount.

At resolution the asserter asserts the late candidate by name; computePayouts maps
it to the new slot and claim reads the (N+1) numerators. Tests: add Dave mid-market
-> bet -> resolve to Dave -> claim the full pool; plus guards (non-operator,
duplicate, empty, post-close). forge build + full forge test green.
Relaxing createMarket's outcome check for DPM had removed the guard that kept
regular binary markets at exactly 2 outcomes — MarketsFacet.createMarket and the
price path never validated length themselves, they relied on createMarket's old
`!= 2`. A CLOB/price market created with 3+ outcomes would have been a broken,
vault-less market.

Add an explicit `allowMultiOutcome` flag to LibMarketCreationService.createMarket:
binary callers pass false (require exactly 2), only DpmMarketFacet.createDpmMarket
passes true (2..MAX, validated in the facet). Vault registration stays gated on
length == 2, so binary behavior is byte-for-byte unchanged. Regression test asserts
the CLOB createMarket rejects 3 outcomes. forge build + full forge test green.
…serting markets

Audit findings before testnet:
  - enter() had NO slippage protection: the Pennock price moves with flow, so a buy
    could be sandwiched into far fewer shares. Add minSharesOut (revert if
    sharesOut < minSharesOut; 0 opts out). Signature: enter(marketId, outcome,
    amount, minSharesOut).
  - addDpmOutcome on a price (Pyth) market would corrupt resolution: Pyth always
    writes a fixed 2-slot payout, so growing the outcome set points claim at a
    mismatched condition. Block it (PriceMarketOutcomesAreFixed).
  - addDpmOutcome while an outcome assertion is already open could change the set
    out from under the asserter. Block it (AssertionInFlight).

Tests added: slippage revert; addDpmOutcome blocked on price + while asserting;
N-outcome no-contest (winner unbacked -> refund) and invalid ([1,1,1] -> refund);
late outcome added during the intent phase seeds + resolves; and an N-outcome
"never drains" fuzz with fees (payouts <= deposits over random bets). forge build
+ full forge test green.
script/upgrades/20260530_DPM_MarketMode.s.sol — modeled on the prior upgrade
scripts (SAFE_MODE calldata print + EOA broadcast). One atomic diamondCut:

  ADD     DpmMarketFacet, DpmTradingFacet
  REPLACE MarketsFacet (allowMultiOutcome), MarketOrdersFacet / LimitOrdersFacet /
          BatchOrdersFacet / MatchingFacet (cross-mode guard), PythResolutionFacet
          (PriceMarketCreatedPyth now library-owned)

Replaced selectors are unchanged — only the bytecode (inlined libraries) is new.
Storage migration: none (DPM uses a fresh namespace). The cut is atomic so the
cross-mode guard is live before any DPM market can be created. Compiles clean.
…solvency)

Audit (adversarial) found a real insolvency: the no-contest branch calls
_seedIfNeeded, which charges the intent fee and sends it OUT of custody, but then
_refundAll paid back the GROSS intentStake. Shortfall == the seed fee, so the last
claimant's withdraw reverted (TransferFailed) and the market was insolvent. Same in
the invalid branch when an open-phase enter had already seeded the pool.

The existing void tests missed it because they were either fee-free or had no intent.

Fix: refund the NET basis whenever the pool is seeded — fold the claimer's intent
into their net userPaid (transition) before _refundAll. An unseeded market never paid
a fee, so it still refunds gross. Regression tests: pure-intent no-contest with fees
(both backers refunded net 98.7, neither reverts) and seeded-then-invalid with fees
(un-transitioned intent refunded net). Σ refunds <= deposits in both. forge build +
full forge test green.
…mulDiv claim

Deep adversarial audit (5 parallel reviewers). Beyond the critical void-refund
solvency fix, three more items, all verified and fixed:

  - VaultFacet.splitPosition/mergePositions had no cross-mode guard. A binary DPM
    market has a prepared CTF condition + positionIds, so split/merge executed on
    it. The adversarial PoC confirmed it is NET-ZERO (pool custody stays intact; the
    split collateral round-trips against the CTF's own balance, separate from the
    Diamond-held DPM pool) — not a drain — but DPM markets must not expose a CTF
    token side. Add requireNotDpmMarket to both. (VaultFacet joins the upgrade
    REPLACE set.)
  - enter() could mint 0 shares for a non-zero deposit on a deep/imbalanced book
    (floored Pennock inverse), silently donating the deposit. Revert ZeroShares.
  - claim slice (mOther*userShares)/nW was a plain checked multiply; overflow only
    at ~2^128 units (unreachable for 6-dec USDC) but switch to 512-bit mulDiv so
    18-decimal collateral can never lock a payout.

Audit confirmed SOUND: solvency identity, access control, reentrancy (global
slot-0 guard + CEI), fee math, fixed-point scaling for 6/18-dec, exp/ln domains,
dust direction, conditionId/outcomeCount consistency across late outcomes + UMA
dispute/re-assert. Tests: Vault guard reverts, zero-share reverts. forge build +
full forge test green.
@crevelop crevelop self-assigned this Jun 3, 2026
@crevelop
crevelop requested a review from a team as a code owner June 3, 2026 23:39
@crevelop
crevelop merged commit db46997 into main Jun 3, 2026
2 checks passed
@crevelop
crevelop deleted the cr/dpm-markets branch June 3, 2026 23:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants