diff --git a/.gitignore b/.gitignore index 1d21097f..1f3d8ec1 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,10 @@ out/ # Dotenv file .env -/node_modules \ No newline at end of file +/node_modules + +# macOS Finder metadata +.DS_Store + +# Local gas snapshot generated by prediction market integration tests +snapshots/PredictionMarketIntegration.json diff --git a/docs/PredictionMarketIntegrationGuide.md b/docs/PredictionMarketIntegrationGuide.md new file mode 100644 index 00000000..cd683d16 --- /dev/null +++ b/docs/PredictionMarketIntegrationGuide.md @@ -0,0 +1,151 @@ +# Prediction Market Integration Guide + +This guide describes how to integrate prediction markets with `Airlock` using `PredictionMigrator`. + +## Overview + +The prediction market flow has 3 stages: + +1. `create`: register each market entry (`token`) with `(oracle, entryId)` in `PredictionMigrator`. +2. `migrate`: move proceeds into the market pot after oracle finalization. +3. `claim`: winning token holders claim pro-rata numeraire. + +Reference implementation: +- `src/migrators/PredictionMigrator.sol` +- `test/integration/PredictionMarket.t.sol` + +## Required Components + +- `Airlock` +- `PredictionMigrator` as a whitelisted `LiquidityMigrator` module +- Token factory that creates burnable entry tokens (`burn(uint256)` required) +- Pool initializer (`DopplerHookInitializer` in current integration) +- Prediction oracle implementing `IPredictionOracle` +- Optional governance factory (`NoOpGovernanceFactory` is commonly used) + +## Critical Integration Requirements + +- Entry tokens must support `burn(uint256)`. + - If burn is unavailable/restricted, `migrate` reverts. +- A market is keyed by `oracle` and has exactly one numeraire. + - First entry sets numeraire; later entries for same oracle must match. +- `migrate` expects exactly one registered entry token in `(token0, token1)`. + - Both registered or neither registered reverts. +- Oracle must be finalized before migration and before first successful claim. + +## Step-By-Step Integration + +### 1) Deploy and Whitelist Modules + +Deploy `PredictionMigrator(airlock)` and whitelist it with: + +```solidity +address[] memory modules = new address[](1); +modules[0] = address(predictionMigrator); +ModuleState[] memory states = new ModuleState[](1); +states[0] = ModuleState.LiquidityMigrator; +airlock.setModuleState(modules, states); +``` + +For the hook-based flow, deploy and register `NoSellDopplerHook` on `DopplerHookInitializer`. + +### 2) Create Each Entry + +For each market entry token, call `airlock.create` with: + +- `liquidityMigrator = predictionMigrator` +- `liquidityMigratorData = abi.encode(oracle, entryId)` +- shared market numeraire for all entries under the same `oracle` + +Example: + +```solidity +CreateParams memory params = CreateParams({ + initialSupply: 1_000_000 ether, + numTokensToSell: 1_000_000 ether, + numeraire: numeraire, + tokenFactory: tokenFactory, + tokenFactoryData: tokenFactoryData, + governanceFactory: governanceFactory, + governanceFactoryData: governanceFactoryData, + poolInitializer: dopplerInitializer, + poolInitializerData: poolInitializerData, + liquidityMigrator: predictionMigrator, + liquidityMigratorData: abi.encode(address(oracle), entryId), + integrator: address(0), + salt: salt +}); +``` + +During `create`, `PredictionMigrator.initialize` stores: +- token -> oracle mapping +- `(oracle, token) -> entryId` +- entry state (unmigrated) + +### 3) Finalize the Oracle + +Before migration, oracle must return `(winner, isFinalized=true)` from: + +```solidity +getWinner(address oracle) returns (address winningToken, bool isFinalized) +``` + +### 4) Migrate Each Entry + +Call `airlock.migrate(entryToken)` per entry after graduation/finalization conditions are met. + +`PredictionMigrator.migrate`: +- validates pair has exactly one registered entry token +- checks oracle finalization +- checks pair numeraire matches market numeraire +- computes proceeds delta using global per-numeraire accounting +- computes `claimableSupply = totalSupply - unsoldBalance` +- burns unsold entry tokens +- updates entry contribution and market pot + +### 5) Claim Winnings + +Users claim with winning tokens: + +1. approve winning token to `PredictionMigrator` +2. optionally `previewClaim(oracle, tokenAmount)` +3. call `claim(oracle, tokenAmount)` + +Payout math: + +```text +claimAmount = mulDiv(tokenAmount, market.totalPot, winningEntry.claimableSupply) +``` + +## Shared-Numeraire Accounting Model + +`PredictionMigrator` tracks a global per-numeraire accounted balance: + +- migration contribution = `currentBalance(numeraire) - accounted[numeraire]` +- after migration: `accounted[numeraire] = currentBalance` +- on claim payout: `accounted[numeraire] -= claimAmount` + +This prevents cross-market contamination when multiple markets share the same numeraire. + +## Common Reverts and Meaning + +- `EntryNotRegistered`: token pair does not include a registered entry +- `InvalidTokenPair`: both tokens (or ambiguous pair) are registered entries +- `NumeraireMismatch`: entry migrated with wrong quote token +- `OracleNotFinalized`: oracle is not finalized yet +- `AlreadyMigrated`: entry already migrated +- `WinningEntryNotMigrated`: claim attempted before winning entry migration +- `AccountingInvariant`: unexpected external balance drift vs accounted state + +## Test Checklist + +Run these before production: + +```bash +forge test --match-path test/unit/migrators/PredictionMigrator.t.sol +forge test --match-path test/integration/PredictionMarket.t.sol +forge test --match-path test/invariant/PredictionMigrator/PredictionMigratorInvariants.t.sol +forge test --match-path test/invariant/PredictionMigrator/PredictionMigratorEthInvariants.t.sol +FOUNDRY_PROFILE=deep forge test --match-path test/invariant/PredictionMigrator/PredictionMigratorInvariants.t.sol +FOUNDRY_PROFILE=deep forge test --match-path test/invariant/PredictionMigrator/PredictionMigratorEthInvariants.t.sol +``` diff --git a/specs/SPEC-prediction-markets-high-level-v1.0.1.md b/specs/SPEC-prediction-markets-high-level-v1.0.1.md new file mode 100644 index 00000000..23025a24 --- /dev/null +++ b/specs/SPEC-prediction-markets-high-level-v1.0.1.md @@ -0,0 +1,152 @@ +# Doppler Prediction Markets High-Level Spec (v1.0.1) + +## 1. Purpose + +Define the high-level behavior and integration requirements for prediction markets built on Doppler + Airlock using `PredictionMigrator`. + +This spec captures the production posture after: + +- global per-numeraire accounting hardening +- strict burnable-asset migration requirement +- invariant harness expansion (ERC20 and ETH numeraires) + +## 2. Scope + +In scope: + +- market entry registration at `create` +- proceeds migration at `migrate` +- winner claims +- multi-market shared-numeraire correctness + +Out of scope: + +- oracle design details beyond `IPredictionOracle` surface +- front-end UX implementation details +- non-Doppler auction mechanics + +## 3. Architecture + +Core contracts: + +- `Airlock` orchestrates create/migrate lifecycle and module dispatch +- `PredictionMigrator` tracks entries, pots, claims, and payout accounting +- `IPredictionOracle` reports winner + finalization state +- Doppler hook stack (including `NoSellDopplerHook`) constrains market behavior + +Market identity: + +- A market is keyed by `oracle` address. +- Each market contains one or more entries (`entryId`, token pairs). +- All entries in a market must share a single numeraire. + +## 4. Lifecycle + +### 4.1 Create / Initialize + +For each entry creation: + +1. Airlock calls `PredictionMigrator.initialize(asset, numeraire, abi.encode(oracle, entryId))`. +2. Migrator enforces uniqueness: + - `(oracle, asset)` must not already map to an entry + - `(oracle, entryId)` must be unused +3. First entry sets market numeraire; later entries must match. +4. Entry is registered but not migrated. + +### 4.2 Migrate + +When Airlock migrates an entry: + +1. Pair validation: exactly one token in `(token0, token1)` must be a registered entry token. +2. Oracle must be finalized. +3. Pair numeraire must match market numeraire. +4. Contribution is inferred via global per-numeraire balance delta: + - `delta = currentBalance(numeraire) - accounted[numeraire]` + - `accounted[numeraire] = currentBalance(numeraire)` +5. Unsold entry tokens are removed via strict `burn(assetBalance)`. +6. Entry and market accounting updates: + - `entry.contribution += delta` (single migration per entry in current model) + - `entry.claimableSupply = totalSupply - unsold` + - `entry.isMigrated = true` + - `market.totalPot += delta` + +### 4.3 Claim + +For winning token holders: + +1. Market resolves lazily on first claim if not cached: + - fetch winner from oracle + - require finalized +2. Winning entry must already be migrated. +3. Claim payout: + - `claimAmount = mulDiv(tokenAmount, market.totalPot, winningEntry.claimableSupply)` +4. Winning tokens are transferred from claimer to migrator. +5. Accounting updates before payout transfer: + - `market.totalClaimed += claimAmount` + - `accounted[numeraire] -= claimAmount` +6. Numeraire is transferred to claimer. + +## 5. Security and Correctness Properties + +1. Shared-numeraire isolation: + - migrations in market A do not contaminate market B contributions. +2. Claim/migrate interleaving safety: + - claims between migrations preserve correct later migration deltas. +3. Pair-shape safety: + - both-registered and neither-registered pair shapes revert. +4. Numeraire consistency: + - market-level numeraire cannot drift across entries. +5. Burnability enforcement: + - non-burnable entry tokens are unsupported and migration reverts. +6. Reentrancy hardening: + - `claim` is protected by `ReentrancyGuard`. + +## 6. Integration Requirements + +Required: + +- whitelist `PredictionMigrator` as a `LiquidityMigrator` module in Airlock +- use burnable entry tokens implementing `burn(uint256)` +- ensure all entries under one oracle use same numeraire +- ensure oracle finalization semantics are trustworthy + +Recommended: + +- keep sell paths disabled with `NoSellDopplerHook` where applicable +- surface claim timing UX warning: + - claiming before all entries migrate can produce lower payout than waiting + +## 7. Error Surface (High Level) + +- `EntryAlreadyExists` +- `EntryIdAlreadyUsed` +- `EntryNotRegistered` +- `InvalidTokenPair` +- `NumeraireMismatch` +- `OracleNotFinalized` +- `AlreadyMigrated` +- `WinningEntryNotMigrated` +- `AccountingInvariant` + +## 8. Verification Strategy + +Production gating test stack: + +- unit: `test/unit/migrators/PredictionMigrator.t.sol` +- integration: `test/integration/PredictionMarket.t.sol` +- invariants (ERC20): `test/invariant/PredictionMigrator/PredictionMigratorInvariants.t.sol` +- invariants (ETH): `test/invariant/PredictionMigrator/PredictionMigratorEthInvariants.t.sol` +- deep invariant profile (`FOUNDRY_PROFILE=deep`) for both invariant suites + +Invariant themes: + +- on-chain market/entry state matches ghost state +- global migrator numeraire balance equals net contributed minus claimed +- global ghost sums equal sum of per-market ghosts + +## 9. References + +- Implementation: `src/migrators/PredictionMigrator.sol` +- Interface: `src/interfaces/IPredictionMigrator.sol` +- Integration guide: `docs/PredictionMarketIntegrationGuide.md` +- Invariant harness spec: `specs/prediction-migrator-invariant-harness-spec.md` diff --git a/specs/prediction-migrator-invariant-harness-spec.md b/specs/prediction-migrator-invariant-harness-spec.md new file mode 100644 index 00000000..02df4576 --- /dev/null +++ b/specs/prediction-migrator-invariant-harness-spec.md @@ -0,0 +1,235 @@ +# PredictionMigrator Invariant Harness Spec + +## Goal + +Provide a reusable invariant-testing harness for `PredictionMigrator` that stress-tests multi-market accounting with a shared numeraire under arbitrary migrate/claim/transfer action sequences. + +This spec is intended for agents extending the harness before production. + +## Files + +- `test/invariant/PredictionMigrator/PredictionMigratorInvariantHandler.sol` +- `test/invariant/PredictionMigrator/PredictionMigratorInvariants.t.sol` +- `test/invariant/PredictionMigrator/PredictionMigratorEthInvariantHandler.sol` +- `test/invariant/PredictionMigrator/PredictionMigratorEthInvariants.t.sol` +- `test/invariant/PredictionMigrator/PredictionMigratorMultiEntryInvariantHandler.sol` +- `test/invariant/PredictionMigrator/PredictionMigratorMultiEntryInvariants.t.sol` +- `test/invariant/PredictionMigrator/PredictionMigratorMultiEntryEthInvariantHandler.sol` +- `test/invariant/PredictionMigrator/PredictionMigratorMultiEntryEthInvariants.t.sol` + +## Harness Architecture + +### Core Components + +1. `PredictionMigratorAirlockHarness` +- Minimal wrapper that is set as `airlock` for `PredictionMigrator`. +- Exposes `initialize()` and `migrate()` passthroughs so handler actions can trigger `onlyAirlock` code paths. + +2. `InvariantPredictionERC20` +- Minimal ERC20 for invariant tests (mint + burn included). +- Used for entry tokens and shared numeraire. + +3. `PredictionMigratorInvariantHandlerBase` +- Shared abstract handler with reusable ghost state and common actions (`claim*`, token transfers). +- Exposes helper methods for bounded migration amount and consistent ghost updates. +- Only migration funding logic is variant-specific. + +4. `PredictionMigratorInvariantHandler` +- ERC20-numeraire implementation of the shared base. +- Owns large ERC20 numeraire balance used to fund migrations. + +5. `PredictionMigratorInvariantsTest` +- Deploys two independent markets (`oracleA`, `oracleB`) sharing one numeraire. +- Registers one entry per market and finalizes both oracles. +- Configures target selectors and invariant assertions. + +6. `PredictionMigratorEthInvariantHandler` + `PredictionMigratorEthInvariantsTest` +- ETH-numeraire implementation of the same shared handler base (`address(0)` numeraire). +- Migrations fund `PredictionMigrator` via direct ETH transfer before `migrate()`. + +7. `PredictionMigratorMultiEntryInvariantHandler` + `PredictionMigratorMultiEntryInvariantsTest` +- ERC20-numeraire multi-entry scenario with two entries per market (winner + loser). +- Adds loser migration and loser transfer selectors. +- Adds per-market "pot equals sum of entry contributions" invariant. + +8. `PredictionMigratorMultiEntryEthInvariantHandler` + `PredictionMigratorMultiEntryEthInvariantsTest` +- ETH-numeraire variant of the multi-entry scenario. +- Mirrors multi-entry invariants for native ETH accounting. + +## Scenario Model + +- Shared numeraire across multiple markets. +- One winning entry token per market. +- Entry supply held entirely by users (`alice`, `bob`) so migration has zero unsold tokens. +- Claims can happen between migrations across markets. + +This specifically targets the contamination class fixed by global per-numeraire accounting. + +### Active Variants + +1. ERC20 numeraire base variant: +- `PredictionMigratorInvariants.t.sol` +- Global balance invariant uses `IERC20(numeraire).balanceOf(migrator)`. + +2. ETH numeraire base variant: +- `PredictionMigratorEthInvariants.t.sol` +- Global balance invariant uses `address(migrator).balance`. + +3. ERC20 numeraire multi-entry variant: +- `PredictionMigratorMultiEntryInvariants.t.sol` +- Two entries per market (winner + loser), shared numeraire. + +4. ETH numeraire multi-entry variant: +- `PredictionMigratorMultiEntryEthInvariants.t.sol` +- Two entries per market (winner + loser), ETH numeraire. + +## Handler Actions + +- `migrateOracleA(uint128 amountSeed, uint8 orderingSeed)` +- `migrateOracleB(uint128 amountSeed, uint8 orderingSeed)` +- `claimOracleA(uint128 amountSeed, uint8 actorSeed)` +- `claimOracleB(uint128 amountSeed, uint8 actorSeed)` +- `transferOracleATokens(uint128 amountSeed, uint8 fromSeed)` +- `transferOracleBTokens(uint128 amountSeed, uint8 fromSeed)` + +Multi-entry handlers add: + +- `migrateWinnerOracleA(uint128 amountSeed, uint8 orderingSeed)` +- `migrateLoserOracleA(uint128 amountSeed, uint8 orderingSeed)` +- `migrateWinnerOracleB(uint128 amountSeed, uint8 orderingSeed)` +- `migrateLoserOracleB(uint128 amountSeed, uint8 orderingSeed)` +- `transferWinnerOracleATokens(uint128 amountSeed, uint8 fromSeed)` +- `transferLoserOracleATokens(uint128 amountSeed, uint8 fromSeed)` +- `transferWinnerOracleBTokens(uint128 amountSeed, uint8 fromSeed)` +- `transferLoserOracleBTokens(uint128 amountSeed, uint8 fromSeed)` + +### Action Safety Rules + +- Handler actions must not revert (`foundry.toml` sets `invariant.fail_on_revert = true`). +- Every action must be preconditioned/bounded: + - Skip if entry already migrated. + - Skip claim if entry not migrated. + - Bound claim amount by holder balance. + - Bound migration amount by configured max. + +## Ghost State + +- `ghost_totalContributed` +- `ghost_totalClaimed` +- `ghost_marketPot[oracle]` +- `ghost_marketClaimed[oracle]` +- `ghost_entryMigrated[oracle][entryId]` +- `ghost_entryContribution[oracle][entryId]` + +Ghost state is updated only after successful action execution. + +## Invariants + +1. Market + entry accounting mirrors ghost state. +- `market.totalPot == ghost_marketPot[oracle]` +- `market.totalClaimed == ghost_marketClaimed[oracle]` +- `entry.contribution == ghost_entryContribution[oracle][entryId]` +- `entry.isMigrated == ghost_entryMigrated[oracle][entryId]` +- `market.totalClaimed <= market.totalPot` + +2. Global migrator numeraire balance equals net flow. +- `numeraire.balanceOf(migrator) == ghost_totalContributed - ghost_totalClaimed` + +3. Global ghost totals equal per-market ghost sums. +- `ghost_totalContributed == sum(ghost_marketPot)` +- `ghost_totalClaimed == sum(ghost_marketClaimed)` + +4. Claimable supply shape for this harness scenario. +- Migrated entry: `claimableSupply == ENTRY_SUPPLY` +- Unmigrated entry: `claimableSupply == 0` + +5. Differential claim payout check (in handler action path). +- Claimer-observed payout delta matches `previewClaim(...)` before claim. + +6. Multi-entry pot aggregation check. +- `market.totalPot == sum(entry.contribution for all entries in market)` + +## How to Run + +- Standard: +```bash +forge test --offline --match-path test/invariant/PredictionMigrator/PredictionMigratorInvariants.t.sol +``` + +- Standard (ETH variant): +```bash +forge test --offline --match-path test/invariant/PredictionMigrator/PredictionMigratorEthInvariants.t.sol +``` + +- Deep profile: +```bash +FOUNDRY_PROFILE=deep forge test --offline --match-path test/invariant/PredictionMigrator/PredictionMigratorInvariants.t.sol +``` + +- Deep profile (ETH variant): +```bash +FOUNDRY_PROFILE=deep forge test --offline --match-path test/invariant/PredictionMigrator/PredictionMigratorEthInvariants.t.sol +``` + +- Standard (multi-entry ERC20 variant): +```bash +forge test --offline --match-path test/invariant/PredictionMigrator/PredictionMigratorMultiEntryInvariants.t.sol +``` + +- Standard (multi-entry ETH variant): +```bash +forge test --offline --match-path test/invariant/PredictionMigrator/PredictionMigratorMultiEntryEthInvariants.t.sol +``` + +- Deep profile (multi-entry ERC20 variant): +```bash +FOUNDRY_PROFILE=deep forge test --offline --match-path test/invariant/PredictionMigrator/PredictionMigratorMultiEntryInvariants.t.sol +``` + +- Deep profile (multi-entry ETH variant): +```bash +FOUNDRY_PROFILE=deep forge test --offline --match-path test/invariant/PredictionMigrator/PredictionMigratorMultiEntryEthInvariants.t.sol +``` + +## Extension Guide For Agents + +When adding actions/invariants: + +1. Keep changes additive; do not remove existing invariants. +2. Preserve non-reverting handler behavior. +3. Put shared behavior in `PredictionMigratorInvariantHandlerBase`; keep only numeraire-specific migration logic in concrete handlers. +4. Add any new action selector to `targetSelector(...)`. +5. Update ghost state in the same transaction path as the state-changing action. +6. Prefer invariant formulas over example assertions. +7. If scenario assumptions change (e.g., non-zero unsold), update the claimable-supply invariant accordingly. + +## Completed Additions + +1. Multi-entry-per-market scenario. +- Added winner/loser entries per market in both ERC20 and ETH variants. +- Added loser migration and loser transfer selectors. +- Added per-market pot aggregation invariants. +- Validated with standard and deep invariant runs (zero reverts). + +2. Differential claim-payout invariant. +- Handler action path now checks claimer-observed payout delta against `previewClaim(...)`. +- Applied in both ERC20 and ETH multi-entry variants. +- Validated across deep runs. + +## Next Steps + +1. Add a non-zero-unsold migration variant. +- Add a dedicated invariant setup where migrator receives unsold tokens before `migrate`. +- Verify `claimableSupply` follows `totalSupply - unsold` for migrated entries. +- Keep the strict burnability assumption (this variant should only use burnable test tokens). +- Exit criteria: invariants remain non-reverting and claimable-supply assertions hold. + +2. Add selector-weight tuning and reporting. +- Bias action selection to increase claim-between-migrations and cross-market interleavings. +- Record selector call counts in run artifacts and verify meaningful coverage of each action. +- Exit criteria: each selector is exercised at high volume in deep runs. + +3. Add CI production gate commands. +- Add/update CI steps for both standard and `FOUNDRY_PROFILE=deep` invariant runs (ERC20 + ETH suites). +- Fail CI on any invariant revert or failure. +- Exit criteria: all invariant suites are required checks before release tagging. diff --git a/src/base/MockPredictionOracle.sol b/src/base/MockPredictionOracle.sol new file mode 100644 index 00000000..d205f91d --- /dev/null +++ b/src/base/MockPredictionOracle.sol @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.24; + +import { IPredictionOracle } from "src/interfaces/IPredictionOracle.sol"; + +/// @notice Thrown when a non-owner attempts to set the winner +error OnlyOwner(); + +/// @notice Thrown when attempting to set a winner after already finalized +error AlreadyFinalized(); + +/** + * @title MockPredictionOracle + * @author Whetstone Research + * @custom:security-contact security@whetstone.cc + * @notice A simple oracle for testing prediction markets. Owner can set the winning token once. + * @dev In production, this would be replaced by a more sophisticated oracle (e.g., UMA, Chainlink, etc.) + */ +contract MockPredictionOracle is IPredictionOracle { + /// @notice Address of the winning token (address(0) until set) + address public winningToken; + + /// @notice Whether the winner has been declared and result is final + bool public isFinalized; + + /// @notice Owner who can set the winner + address public owner; + + constructor() { + owner = msg.sender; + } + + /** + * @notice Sets the winning token for this oracle/market + * @dev Can only be called once by the owner + * @param _winningToken Address of the winning entry's token + */ + function setWinner(address _winningToken) external { + require(msg.sender == owner, OnlyOwner()); + require(!isFinalized, AlreadyFinalized()); + + winningToken = _winningToken; + isFinalized = true; + + emit WinnerDeclared(address(this), _winningToken); + } + + /// @inheritdoc IPredictionOracle + function getWinner(address) external view override returns (address, bool) { + return (winningToken, isFinalized); + } +} diff --git a/src/dopplerHooks/NoSellDopplerHook.sol b/src/dopplerHooks/NoSellDopplerHook.sol new file mode 100644 index 00000000..5b696bcd --- /dev/null +++ b/src/dopplerHooks/NoSellDopplerHook.sol @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.26; + +import { IPoolManager } from "@v4-core/interfaces/IPoolManager.sol"; +import { BalanceDelta } from "@v4-core/types/BalanceDelta.sol"; +import { Currency } from "@v4-core/types/Currency.sol"; +import { PoolId, PoolIdLibrary } from "@v4-core/types/PoolId.sol"; +import { PoolKey } from "@v4-core/types/PoolKey.sol"; +import { BaseDopplerHook } from "src/base/BaseDopplerHook.sol"; + +/// @notice Thrown when a user attempts to sell tokens back to the pool +error SellsNotAllowed(); + +/** + * @title NoSellDopplerHook + * @author Whetstone Research + * @custom:security-contact security@whetstone.cc + * @notice DopplerHook that blocks sells (asset -> numeraire) while allowing buys (numeraire -> asset). + * @dev Used for prediction markets where the pot must remain locked after purchases. + * In a parimutuel system, if users could sell tokens back: + * - The pot would decrease, breaking pro-rata calculations + * - Arbitrage between selling back and peer-to-peer trading would be possible + * - The "all-in" commitment property of prediction markets would be lost + */ +contract NoSellDopplerHook is BaseDopplerHook { + using PoolIdLibrary for PoolKey; + + /// @notice Returns true if the asset token is `currency0` of the Uniswap V4 pool + mapping(PoolId poolId => bool isToken0) public isAssetToken0; + + /// @param initializer Address of the DopplerHookInitializer contract + constructor(address initializer) BaseDopplerHook(initializer) { } + + /// @inheritdoc BaseDopplerHook + function _onInitialization(address asset, PoolKey calldata key, bytes calldata) internal override { + PoolId poolId = key.toId(); + isAssetToken0[poolId] = Currency.unwrap(key.currency0) == asset; + } + + /// @inheritdoc BaseDopplerHook + /// @dev Reverts if the swap direction is a sell (asset -> numeraire). + /// While the swap executes before the revert, the entire transaction fails atomically. + /// The gas cost of the failed swap is borne by the seller, which is the correct incentive structure. + function _onSwap( + address, + PoolKey calldata key, + IPoolManager.SwapParams calldata params, + BalanceDelta, + bytes calldata + ) internal view override returns (Currency, int128) { + bool isToken0 = isAssetToken0[key.toId()]; + + // Selling asset means: + // - If asset is token0: zeroForOne = true (selling token0 for token1) + // - If asset is token1: zeroForOne = false (selling token1 for token0) + // So: isSell = (isToken0 == zeroForOne) + bool isSell = (isToken0 == params.zeroForOne); + + require(!isSell, SellsNotAllowed()); + + return (Currency.wrap(address(0)), 0); + } +} diff --git a/src/interfaces/IBurnable.sol b/src/interfaces/IBurnable.sol new file mode 100644 index 00000000..d7a27c40 --- /dev/null +++ b/src/interfaces/IBurnable.sol @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.24; + +/// @notice Minimal interface for burnable tokens +/// @dev Both DERC20 and CloneERC20 implement burn(uint256) +interface IBurnable { + /// @notice Burns `amount` of tokens from the caller's balance + /// @param amount Amount of tokens to burn + function burn(uint256 amount) external; +} diff --git a/src/interfaces/IPredictionMigrator.sol b/src/interfaces/IPredictionMigrator.sol new file mode 100644 index 00000000..17932dd1 --- /dev/null +++ b/src/interfaces/IPredictionMigrator.sol @@ -0,0 +1,133 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.24; + +/** + * @title IPredictionMigrator + * @notice Interface for prediction market migrator that handles entry registration and claims + * @dev This interface defines prediction-market-specific functions. The contract also implements + * ILiquidityMigrator for Airlock compatibility. + */ +interface IPredictionMigrator { + // ==================== Errors ==================== + + /// @notice Thrown when the oracle has not finalized the winner + error OracleNotFinalized(); + + /// @notice Thrown when attempting to register an entry that already exists for this token + error EntryAlreadyExists(); + + /// @notice Thrown when attempting to use an entryId that's already taken in the market + error EntryIdAlreadyUsed(); + + /// @notice Thrown when attempting to migrate an entry that wasn't registered + error EntryNotRegistered(); + + /// @notice Thrown when attempting to migrate an entry that was already migrated + error AlreadyMigrated(); + + /// @notice Thrown when an entry's numeraire doesn't match the market's numeraire + error NumeraireMismatch(); + + /// @notice Thrown when both pair tokens are registered entries and numeraire cannot be inferred + error InvalidTokenPair(); + + /// @notice Thrown when global numeraire accounting invariants are violated + error AccountingInvariant(); + + /// @notice Thrown when attempting to claim but the winning entry hasn't been migrated yet + error WinningEntryNotMigrated(); + + /// @notice Thrown when attempting to claim with a non-winning token + error NotWinningToken(); + + // ==================== Events ==================== + + /// @notice Emitted when a new entry is registered for a market + /// @param oracle The oracle address (market identifier) + /// @param entryId Unique identifier for this entry within the market + /// @param token The DERC20 token address for this entry + /// @param numeraire The quote asset used for this market + event EntryRegistered(address indexed oracle, bytes32 indexed entryId, address token, address numeraire); + + /// @notice Emitted when an entry's proceeds are migrated to the pot + /// @param oracle The oracle address (market identifier) + /// @param entryId Unique identifier for this entry + /// @param token The DERC20 token address for this entry + /// @param contribution Amount of numeraire contributed to the pot + /// @param claimableSupply Token supply available for claims (excludes unsold tokens) + event EntryMigrated( + address indexed oracle, bytes32 indexed entryId, address token, uint256 contribution, uint256 claimableSupply + ); + + /// @notice Emitted when a winner claims their share of the pot + /// @param oracle The oracle address (market identifier) + /// @param claimer Address of the user claiming + /// @param tokensBurned Amount of winning tokens transferred for claim + /// @param numeraireReceived Amount of numeraire received + event Claimed(address indexed oracle, address indexed claimer, uint256 tokensBurned, uint256 numeraireReceived); + + // ==================== Structs ==================== + + /// @notice View struct for market state + /// @param totalPot Sum of all migrated numeraire for this market + /// @param totalClaimed Sum of all claimed numeraire from this market + /// @param winningToken Address of the winning token (set after resolution) + /// @param numeraire The quote asset for this market + /// @param isResolved Whether the market has been resolved (winner determined) + struct MarketView { + uint256 totalPot; + uint256 totalClaimed; + address winningToken; + address numeraire; + bool isResolved; + } + + /// @notice View struct for entry state + /// @param token The DERC20 token address for this entry + /// @param oracle The oracle address this entry belongs to + /// @param entryId Unique identifier within the market + /// @param contribution Numeraire contributed by this entry (set at migration) + /// @param claimableSupply Token supply available for claims (set at migration) + /// @param isMigrated Whether this entry has been migrated + struct EntryView { + address token; + address oracle; + bytes32 entryId; + uint256 contribution; + uint256 claimableSupply; + bool isMigrated; + } + + // ==================== View Functions ==================== + + /// @notice Returns the market state for a given oracle + /// @param oracle The oracle address (market identifier) + /// @return Market state struct + function getMarket(address oracle) external view returns (MarketView memory); + + /// @notice Returns the entry state for a given oracle and entryId + /// @param oracle The oracle address (market identifier) + /// @param entryId The entry identifier + /// @return Entry state struct + function getEntry(address oracle, bytes32 entryId) external view returns (EntryView memory); + + /// @notice Returns the entry state for a given oracle and token address + /// @param oracle The oracle address (market identifier) + /// @param token The entry's token address + /// @return Entry state struct + function getEntryByToken(address oracle, address token) external view returns (EntryView memory); + + /// @notice Preview the claim amount for a given token amount without executing + /// @param oracle The oracle address (market identifier) + /// @param tokenAmount Amount of winning tokens to claim with + /// @return Amount of numeraire that would be received + function previewClaim(address oracle, uint256 tokenAmount) external view returns (uint256); + + // ==================== State-Changing Functions ==================== + + /// @notice Claims a pro-rata share of the pot by transferring winning tokens + /// @dev Requires prior approval of tokens to this contract + /// @param oracle The oracle address (market identifier) + /// @param tokenAmount Amount of winning tokens to exchange for numeraire + function claim(address oracle, uint256 tokenAmount) external; +} diff --git a/src/interfaces/IPredictionOracle.sol b/src/interfaces/IPredictionOracle.sol new file mode 100644 index 00000000..8969062f --- /dev/null +++ b/src/interfaces/IPredictionOracle.sol @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +/** + * @title IPredictionOracle + * @notice Interface for prediction market oracles that determine winning entries + * @dev Oracles must implement this interface to be compatible with PredictionMigrator + */ +interface IPredictionOracle { + /// @notice Emitted when a winner is declared for a market + /// @param oracle The oracle address (which defines the market) + /// @param winningToken The address of the winning entry's token + event WinnerDeclared(address indexed oracle, address indexed winningToken); + + /// @notice Returns the winning token for a market + /// @param oracle The oracle address (which defines the market) + /// @return winningToken The address of the winning entry's token (address(0) if not yet declared) + /// @return isFinalized Whether the result is final and claims can proceed + function getWinner(address oracle) external view returns (address winningToken, bool isFinalized); +} diff --git a/src/migrators/PredictionMigrator.sol b/src/migrators/PredictionMigrator.sol new file mode 100644 index 00000000..059aa4f1 --- /dev/null +++ b/src/migrators/PredictionMigrator.sol @@ -0,0 +1,304 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.24; + +import { IERC20 } from "@openzeppelin/token/ERC20/IERC20.sol"; +import { ReentrancyGuard } from "@openzeppelin/utils/ReentrancyGuard.sol"; +import { FullMath } from "@v4-core/libraries/FullMath.sol"; +import { ERC20, SafeTransferLib } from "@solmate/utils/SafeTransferLib.sol"; +import { ImmutableAirlock } from "src/base/ImmutableAirlock.sol"; +import { IBurnable } from "src/interfaces/IBurnable.sol"; +import { ILiquidityMigrator } from "src/interfaces/ILiquidityMigrator.sol"; +import { IPredictionMigrator } from "src/interfaces/IPredictionMigrator.sol"; +import { IPredictionOracle } from "src/interfaces/IPredictionOracle.sol"; + +/** + * @title PredictionMigrator + * @author Whetstone Research + * @custom:security-contact security@whetstone.cc + * @notice Handles registration of prediction market entries at creation time and + * distribution of proceeds to winners at claim time. + * @dev Implements both ILiquidityMigrator (for Airlock compatibility) and + * IPredictionMigrator (for prediction-market-specific functions). + * + * Key responsibilities: + * 1. Register oracle + entryId at creation time (via initialize()) + * 2. Receive numeraire from Airlock during migration + * 3. Track total pot per market (oracle) + * 4. Enforce numeraire consistency within a market + * 5. Verify oracle resolution before allowing migration + * 6. Process claims for winning token holders + */ +contract PredictionMigrator is ILiquidityMigrator, IPredictionMigrator, ImmutableAirlock, ReentrancyGuard { + using SafeTransferLib for ERC20; + + // ==================== Storage ==================== + + /// @dev Internal market state + struct MarketState { + uint256 totalPot; + uint256 totalClaimed; + address winningToken; + address numeraire; + bool isResolved; + bool isNumeraireSet; + } + + /// @dev Internal entry state + struct EntryState { + address token; + address oracle; + bytes32 entryId; + uint256 contribution; + uint256 claimableSupply; + bool isMigrated; + } + + /// @notice Market-level state keyed by oracle address + mapping(address oracle => MarketState) internal _markets; + + /// @notice Entry-level state keyed by oracle and entryId + mapping(address oracle => mapping(bytes32 entryId => EntryState)) internal _entries; + + /// @notice Token to oracle lookup (set at initialize, used at migrate) + mapping(address token => address oracle) internal _tokenToOracle; + + /// @notice Reverse lookup within market: oracle => token => entryId + mapping(address oracle => mapping(address token => bytes32 entryId)) internal _marketTokenToEntry; + + /// @notice Global per-numeraire accounting for migration deltas + mapping(address numeraire => uint256 balance) internal _accountedNumeraireBalance; + + // ==================== Constructor ==================== + + /// @notice Anyone can send ETH to this contract (for ETH numeraire migrations) + receive() external payable { } + + /// @param airlock_ Address of the Airlock contract + constructor(address airlock_) ImmutableAirlock(airlock_) { } + + // ==================== ILiquidityMigrator Implementation ==================== + + /// @inheritdoc ILiquidityMigrator + /// @notice Registers a new entry for a prediction market + /// @dev Called by Airlock during create(). Decodes oracle and entryId from data. + /// @param asset The DERC20 token address for this entry + /// @param numeraire The quote asset (must match existing entries in the same market) + /// @param data ABI-encoded (address oracle, bytes32 entryId) + /// @return Always returns address(0) since prediction markets don't have a migration pool + function initialize(address asset, address numeraire, bytes calldata data) external onlyAirlock returns (address) { + // Decode oracle and entryId from data + (address oracle, bytes32 entryId) = abi.decode(data, (address, bytes32)); + + // Verify entry uniqueness within this market (by token) + require(_marketTokenToEntry[oracle][asset] == bytes32(0), EntryAlreadyExists()); + + // Verify entryId uniqueness within this market + require(_entries[oracle][entryId].token == address(0), EntryIdAlreadyUsed()); + + // Check/set market numeraire (first entry sets it, subsequent must match) + MarketState storage market = _markets[oracle]; + if (!market.isNumeraireSet) { + market.numeraire = numeraire; + market.isNumeraireSet = true; + } else { + require(market.numeraire == numeraire, NumeraireMismatch()); + } + + // Register entry (not yet migrated) + _tokenToOracle[asset] = oracle; + _marketTokenToEntry[oracle][asset] = entryId; + + _entries[oracle][entryId] = EntryState({ + token: asset, oracle: oracle, entryId: entryId, contribution: 0, claimableSupply: 0, isMigrated: false + }); + + emit EntryRegistered(oracle, entryId, asset, numeraire); + + // Return value not used for prediction markets + return address(0); + } + + /// @inheritdoc ILiquidityMigrator + /// @notice Migrates an entry's proceeds to the pot after oracle finalization + /// @dev Called by Airlock during migrate(). Airlock transfers tokens before calling. + /// @param token0 First token of the pair + /// @param token1 Second token of the pair + /// @return Always returns 0 (liquidity not applicable for prediction markets) + function migrate(uint160, address token0, address token1, address) external payable onlyAirlock returns (uint256) { + bool token0IsEntry = _tokenToOracle[token0] != address(0); + bool token1IsEntry = _tokenToOracle[token1] != address(0); + + require(token0IsEntry || token1IsEntry, EntryNotRegistered()); + require(!(token0IsEntry && token1IsEntry), InvalidTokenPair()); + + // Determine which token is the asset (the one we registered) + address asset = token0IsEntry ? token0 : token1; + address numeraire = asset == token0 ? token1 : token0; + + address oracle = _tokenToOracle[asset]; + bytes32 entryId = _marketTokenToEntry[oracle][asset]; + + EntryState storage entry = _entries[oracle][entryId]; + require(!entry.isMigrated, AlreadyMigrated()); + + // Check oracle is finalized + (, bool isFinalized) = IPredictionOracle(oracle).getWinner(oracle); + require(isFinalized, OracleNotFinalized()); + + MarketState storage market = _markets[oracle]; + require(market.numeraire == numeraire, NumeraireMismatch()); + + // Infer new proceeds by using per-numeraire global accounting. + uint256 currentNumeraireBalance = _getNumeraireBalance(numeraire); + uint256 accountedNumeraireBalance = _accountedNumeraireBalance[numeraire]; + require(currentNumeraireBalance >= accountedNumeraireBalance, AccountingInvariant()); + uint256 numeraireAmount = currentNumeraireBalance - accountedNumeraireBalance; + _accountedNumeraireBalance[numeraire] = currentNumeraireBalance; + + // Get asset tokens transferred to us (unsold tokens from pool) + uint256 assetBalance = IERC20(asset).balanceOf(address(this)); + + // Calculate claimable supply BEFORE pseudo-burning + // claimableSupply = tokens in user hands = totalSupply - unsold tokens we hold + uint256 claimableSupply = IERC20(asset).totalSupply() - assetBalance; + + // Burn unsold tokens. Entry assets are expected to implement burn(). + if (assetBalance > 0) { + IBurnable(asset).burn(assetBalance); + } + + // Update entry + entry.contribution = numeraireAmount; + entry.claimableSupply = claimableSupply; + entry.isMigrated = true; + + // Update market pot + market.totalPot += numeraireAmount; + + emit EntryMigrated(oracle, entryId, asset, numeraireAmount, claimableSupply); + + // Return value not used for prediction markets + return 0; + } + + // ==================== IPredictionMigrator Implementation ==================== + + /// @inheritdoc IPredictionMigrator + function claim(address oracle, uint256 tokenAmount) external nonReentrant { + MarketState storage market = _markets[oracle]; + _resolveMarketIfNeeded(oracle, market); + + address winningToken = market.winningToken; + bytes32 winningEntryId = _marketTokenToEntry[oracle][winningToken]; + EntryState storage winningEntry = _entries[oracle][winningEntryId]; + + require(winningEntry.isMigrated, WinningEntryNotMigrated()); + + // Calculate claim amount + // claimAmount = (tokenAmount / claimableSupply) * totalPot + uint256 claimAmount = FullMath.mulDiv(tokenAmount, market.totalPot, winningEntry.claimableSupply); + + // Transfer tokens from user to this contract (requires prior approval) + // Tokens are held here permanently (pseudo-burned) + ERC20(winningToken).safeTransferFrom(msg.sender, address(this), tokenAmount); + + // Update totalClaimed before transfer + market.totalClaimed += claimAmount; + uint256 accountedNumeraireBalance = _accountedNumeraireBalance[market.numeraire]; + require(accountedNumeraireBalance >= claimAmount, AccountingInvariant()); + _accountedNumeraireBalance[market.numeraire] = accountedNumeraireBalance - claimAmount; + + // Transfer numeraire to user + _transferNumeraire(market.numeraire, msg.sender, claimAmount); + + emit Claimed(oracle, msg.sender, tokenAmount, claimAmount); + } + + /// @inheritdoc IPredictionMigrator + function getMarket(address oracle) external view returns (MarketView memory) { + MarketState storage market = _markets[oracle]; + return MarketView({ + totalPot: market.totalPot, + totalClaimed: market.totalClaimed, + winningToken: market.winningToken, + numeraire: market.numeraire, + isResolved: market.isResolved + }); + } + + /// @inheritdoc IPredictionMigrator + function getEntry(address oracle, bytes32 entryId) external view returns (EntryView memory) { + EntryState storage entry = _entries[oracle][entryId]; + return EntryView({ + token: entry.token, + oracle: entry.oracle, + entryId: entry.entryId, + contribution: entry.contribution, + claimableSupply: entry.claimableSupply, + isMigrated: entry.isMigrated + }); + } + + /// @inheritdoc IPredictionMigrator + function getEntryByToken(address oracle, address token) external view returns (EntryView memory) { + bytes32 entryId = _marketTokenToEntry[oracle][token]; + EntryState storage entry = _entries[oracle][entryId]; + return EntryView({ + token: entry.token, + oracle: entry.oracle, + entryId: entry.entryId, + contribution: entry.contribution, + claimableSupply: entry.claimableSupply, + isMigrated: entry.isMigrated + }); + } + + /// @inheritdoc IPredictionMigrator + function previewClaim(address oracle, uint256 tokenAmount) external view returns (uint256) { + MarketState storage market = _markets[oracle]; + + address winningToken; + if (market.isResolved) { + winningToken = market.winningToken; + } else { + (winningToken,) = IPredictionOracle(oracle).getWinner(oracle); + } + + bytes32 winningEntryId = _marketTokenToEntry[oracle][winningToken]; + EntryState storage winningEntry = _entries[oracle][winningEntryId]; + + if (winningEntry.claimableSupply == 0) { + return 0; + } + + return FullMath.mulDiv(tokenAmount, market.totalPot, winningEntry.claimableSupply); + } + + // ==================== Internal Helpers ==================== + + /// @dev Lazily resolves market winner on first claim. + function _resolveMarketIfNeeded(address oracle, MarketState storage market) internal { + if (market.isResolved) return; + (address winner, bool isFinalized) = IPredictionOracle(oracle).getWinner(oracle); + require(isFinalized, OracleNotFinalized()); + market.winningToken = winner; + market.isResolved = true; + } + + /// @dev Helper to get numeraire balance, handling ETH case + function _getNumeraireBalance(address numeraire) internal view returns (uint256) { + if (numeraire == address(0)) { + return address(this).balance; + } + return IERC20(numeraire).balanceOf(address(this)); + } + + /// @dev Helper to transfer numeraire, handling ETH case + function _transferNumeraire(address numeraire, address to, uint256 amount) internal { + if (numeraire == address(0)) { + SafeTransferLib.safeTransferETH(to, amount); + } else { + ERC20(numeraire).safeTransfer(to, amount); + } + } +} diff --git a/test/.DS_Store b/test/.DS_Store new file mode 100644 index 00000000..9a59c111 Binary files /dev/null and b/test/.DS_Store differ diff --git a/test/integration/PredictionMarket.t.sol b/test/integration/PredictionMarket.t.sol new file mode 100644 index 00000000..36c57767 --- /dev/null +++ b/test/integration/PredictionMarket.t.sol @@ -0,0 +1,849 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +import { Test, console } from "forge-std/Test.sol"; +import { Vm } from "forge-std/Vm.sol"; + +import { IERC20 } from "@openzeppelin/token/ERC20/IERC20.sol"; +import { Deployers } from "@v4-core-test/utils/Deployers.sol"; +import { IHooks } from "@v4-core/interfaces/IHooks.sol"; +import { IPoolManager } from "@v4-core/interfaces/IPoolManager.sol"; +import { Hooks } from "@v4-core/libraries/Hooks.sol"; +import { LPFeeLibrary } from "@v4-core/libraries/LPFeeLibrary.sol"; +import { StateLibrary } from "@v4-core/libraries/StateLibrary.sol"; +import { TickMath } from "@v4-core/libraries/TickMath.sol"; +import { PoolSwapTest } from "@v4-core/test/PoolSwapTest.sol"; +import { TestERC20 } from "@v4-core/test/TestERC20.sol"; +import { Currency, greaterThan } from "@v4-core/types/Currency.sol"; +import { PoolId, PoolIdLibrary } from "@v4-core/types/PoolId.sol"; +import { PoolKey } from "@v4-core/types/PoolKey.sol"; + +import { Airlock, CreateParams, ModuleState } from "src/Airlock.sol"; +import { ON_INITIALIZATION_FLAG, ON_SWAP_FLAG } from "src/base/BaseDopplerHook.sol"; +import { MockPredictionOracle } from "src/base/MockPredictionOracle.sol"; +import { NoSellDopplerHook, SellsNotAllowed } from "src/dopplerHooks/NoSellDopplerHook.sol"; +import { NoOpGovernanceFactory } from "src/governance/NoOpGovernanceFactory.sol"; +import { DopplerHookInitializer, InitData, PoolStatus } from "src/initializers/DopplerHookInitializer.sol"; +import { IPredictionMigrator } from "src/interfaces/IPredictionMigrator.sol"; +import { Curve } from "src/libraries/Multicurve.sol"; +import { PredictionMigrator } from "src/migrators/PredictionMigrator.sol"; +import { CloneERC20Factory } from "src/tokens/CloneERC20Factory.sol"; +import { BeneficiaryData } from "src/types/BeneficiaryData.sol"; +import { WAD } from "src/types/Wad.sol"; + +import { + BaseIntegrationTest, + deployNoOpGovernanceFactory, + deployTokenFactory +} from "test/integration/BaseIntegrationTest.sol"; +import { deployCloneERC20Factory, prepareCloneERC20FactoryData } from "test/integration/CloneERC20Factory.t.sol"; + +/** + * @title Prediction Market Integration Test + * @notice Tests the full prediction market flow: + * 1. Create two entries (tokens) for a prediction market + * 2. Users buy entry tokens (sells blocked by NoSellDopplerHook) + * 3. Oracle finalizes winner + * 4. Entries are migrated + * 5. Winners claim their share of the pot + */ +contract PredictionMarketIntegrationTest is BaseIntegrationTest { + MockPredictionOracle public oracle; + NoSellDopplerHook public noSellHook; + PredictionMigrator public predictionMigrator; + DopplerHookInitializer public dopplerInitializer; + CloneERC20Factory public tokenFactory; + NoOpGovernanceFactory public governanceFactory; + + // Entry tokens + address public entryA; + address public entryB; + address public poolA; + address public poolB; + + // Entry IDs + bytes32 public entryIdA = keccak256("entry_a"); + bytes32 public entryIdB = keccak256("entry_b"); + + // Users + address public alice = makeAddr("alice"); + address public bob = makeAddr("bob"); + + // Test numeraire (WETH-like, address(0) for ETH) + address public numeraire; + + function setUp() public override { + super.setUp(); + numeraire = address(0); // Use ETH as numeraire + + // Deploy oracle + oracle = new MockPredictionOracle(); + + // Deploy token factory + tokenFactory = deployCloneERC20Factory(vm, airlock, AIRLOCK_OWNER); + + // Deploy governance factory (no-op for prediction markets) + governanceFactory = deployNoOpGovernanceFactory(vm, airlock, AIRLOCK_OWNER); + + // Deploy DopplerHookInitializer + dopplerInitializer = _deployDopplerHookInitializer(); + + // Deploy NoSellDopplerHook and register it + noSellHook = new NoSellDopplerHook(address(dopplerInitializer)); + _registerDopplerHook(address(noSellHook), ON_INITIALIZATION_FLAG | ON_SWAP_FLAG); + + // Deploy PredictionMigrator + predictionMigrator = _deployPredictionMigrator(); + + // Configure base integration params so inherited test_create/test_migrate are well-formed. + // test_migrate still skips via BaseIntegrationTest._beforeMigrate. + name = "PredictionMarketIntegration"; + createParams = CreateParams({ + initialSupply: 1_000_000 ether, + numTokensToSell: 1_000_000 ether, + numeraire: numeraire, + tokenFactory: tokenFactory, + tokenFactoryData: abi.encode("Base Entry", "BENT", 0, 0, new address[](0), new uint256[](0), ""), + governanceFactory: governanceFactory, + governanceFactoryData: new bytes(0), + poolInitializer: dopplerInitializer, + poolInitializerData: _preparePoolInitializerData(), + liquidityMigrator: predictionMigrator, + liquidityMigratorData: abi.encode(address(oracle), entryIdA), + integrator: address(0), + salt: keccak256("prediction_market_base_create") + }); + } + + function _deployDopplerHookInitializer() internal returns (DopplerHookInitializer initializer) { + initializer = DopplerHookInitializer( + payable(address( + uint160( + Hooks.BEFORE_INITIALIZE_FLAG | Hooks.AFTER_ADD_LIQUIDITY_FLAG + | Hooks.AFTER_REMOVE_LIQUIDITY_FLAG | Hooks.AFTER_SWAP_FLAG + | Hooks.AFTER_SWAP_RETURNS_DELTA_FLAG + ) ^ (0x4444 << 144) + )) + ); + + deployCodeTo("DopplerHookInitializer", abi.encode(address(airlock), address(manager)), address(initializer)); + + address[] memory modules = new address[](1); + modules[0] = address(initializer); + ModuleState[] memory states = new ModuleState[](1); + states[0] = ModuleState.PoolInitializer; + vm.prank(AIRLOCK_OWNER); + airlock.setModuleState(modules, states); + } + + function _registerDopplerHook(address hook, uint256 flags) internal { + address[] memory hooks = new address[](1); + hooks[0] = hook; + uint256[] memory flagsArr = new uint256[](1); + flagsArr[0] = flags; + + vm.prank(AIRLOCK_OWNER); + dopplerInitializer.setDopplerHookState(hooks, flagsArr); + } + + function _deployPredictionMigrator() internal returns (PredictionMigrator migrator) { + migrator = new PredictionMigrator(address(airlock)); + + address[] memory modules = new address[](1); + modules[0] = address(migrator); + ModuleState[] memory states = new ModuleState[](1); + states[0] = ModuleState.LiquidityMigrator; + vm.prank(AIRLOCK_OWNER); + airlock.setModuleState(modules, states); + } + + function _preparePoolInitializerData() internal view returns (bytes memory) { + Curve[] memory curves = new Curve[](10); + int24 tickSpacing = 8; + + for (uint256 i; i < 10; ++i) { + curves[i].tickLower = int24(uint24(0 + i * 16_000)); + curves[i].tickUpper = 240_000; + curves[i].numPositions = 10; + curves[i].shares = WAD / 10; + } + + // For prediction markets, farTick = startingTick (0) so graduation is immediate + // and migration is only gated by oracle finalization. + return abi.encode( + InitData({ + fee: 0, + tickSpacing: tickSpacing, + curves: curves, + beneficiaries: new BeneficiaryData[](0), + dopplerHook: address(noSellHook), + onInitializationDopplerHookCalldata: new bytes(0), + graduationDopplerHookCalldata: new bytes(0), + farTick: 0 // Immediate graduation (farTick == startingTick) + }) + ); + } + + function _createEntry( + bytes32 entryId, + string memory tokenName, + string memory tokenSymbol + ) internal returns (address token, address pool) { + CreateParams memory params = CreateParams({ + initialSupply: 1_000_000 ether, + numTokensToSell: 1_000_000 ether, + numeraire: numeraire, + tokenFactory: tokenFactory, + tokenFactoryData: abi.encode(tokenName, tokenSymbol, 0, 0, new address[](0), new uint256[](0), ""), + governanceFactory: governanceFactory, + governanceFactoryData: new bytes(0), + poolInitializer: dopplerInitializer, + poolInitializerData: _preparePoolInitializerData(), + liquidityMigrator: predictionMigrator, + liquidityMigratorData: abi.encode(address(oracle), entryId), + integrator: address(0), + salt: keccak256(abi.encodePacked(tokenName, block.timestamp)) + }); + + (token, pool,,,) = airlock.create(params); + } + + // Note: _buyTokens is complex due to DopplerHookInitializer pool state management + // Skipping direct swap tests - the NoSellDopplerHook behavior is verified in unit tests + + /* -------------------------------------------------------------------------------- */ + /* Integration Test: Full Flow */ + /* -------------------------------------------------------------------------------- */ + + function test_predictionMarket_FullFlow() public { + // Step 1: Create two entries + (entryA, poolA) = _createEntry(entryIdA, "Entry A", "ENTA"); + (entryB, poolB) = _createEntry(entryIdB, "Entry B", "ENTB"); + + // Verify entries are registered + IPredictionMigrator.EntryView memory entryViewA = predictionMigrator.getEntry(address(oracle), entryIdA); + IPredictionMigrator.EntryView memory entryViewB = predictionMigrator.getEntry(address(oracle), entryIdB); + assertEq(entryViewA.token, entryA); + assertEq(entryViewB.token, entryB); + assertFalse(entryViewA.isMigrated); + assertFalse(entryViewB.isMigrated); + + // Verify market state + IPredictionMigrator.MarketView memory market = predictionMigrator.getMarket(address(oracle)); + assertEq(market.numeraire, numeraire); + assertEq(market.totalPot, 0); + assertFalse(market.isResolved); + + // Step 2: Users buy entry tokens + // Note: In a real test we'd swap through the pool, but the DopplerHookInitializer + // multicurve setup is complex. For this integration test, we verify the components work. + + // Step 3: Oracle finalizes winner (Entry A wins) + oracle.setWinner(entryA); + + // Verify oracle state + (address winner, bool isFinalized) = oracle.getWinner(address(oracle)); + assertEq(winner, entryA); + assertTrue(isFinalized); + + // Step 4: Migration would happen via Airlock.migrate() + // This requires the pool to reach graduation conditions (farTick) + // For unit testing purposes, we've verified the individual components work + } + + function test_predictionMarket_EntryRegistration() public { + // Create first entry + (entryA, poolA) = _createEntry(entryIdA, "Entry A", "ENTA"); + + // Verify entry is registered in migrator + IPredictionMigrator.EntryView memory entry = predictionMigrator.getEntry(address(oracle), entryIdA); + assertEq(entry.token, entryA); + assertEq(entry.oracle, address(oracle)); + assertEq(entry.entryId, entryIdA); + assertFalse(entry.isMigrated); + + // Verify market numeraire is set + IPredictionMigrator.MarketView memory market = predictionMigrator.getMarket(address(oracle)); + assertEq(market.numeraire, numeraire); + } + + function test_predictionMarket_MultipleEntries() public { + // Create multiple entries for the same market + (entryA,) = _createEntry(entryIdA, "Entry A", "ENTA"); + (entryB,) = _createEntry(entryIdB, "Entry B", "ENTB"); + + // Create a third entry + bytes32 entryIdC = keccak256("entry_c"); + (address entryC,) = _createEntry(entryIdC, "Entry C", "ENTC"); + + // Verify all entries are registered + assertEq(predictionMigrator.getEntry(address(oracle), entryIdA).token, entryA); + assertEq(predictionMigrator.getEntry(address(oracle), entryIdB).token, entryB); + assertEq(predictionMigrator.getEntry(address(oracle), entryIdC).token, entryC); + } + + function test_predictionMarket_NoSellHookIntegration() public { + // Create entry with NoSellHook + (entryA, poolA) = _createEntry(entryIdA, "Entry A", "ENTA"); + + // Verify entry was created (pool is actually the asset address for V4) + assertTrue(entryA != address(0)); + + // The NoSellHook should be registered and will block sells + // This is verified at the hook level in unit tests + // Here we just verify the hook is enabled in the initializer + assertTrue(dopplerInitializer.isDopplerHookEnabled(address(noSellHook)) > 0); + } + + function test_predictionMarket_OracleResolution() public { + // Create entries + (entryA,) = _createEntry(entryIdA, "Entry A", "ENTA"); + (entryB,) = _createEntry(entryIdB, "Entry B", "ENTB"); + + // Oracle not finalized yet + (address winner, bool isFinalized) = oracle.getWinner(address(oracle)); + assertEq(winner, address(0)); + assertFalse(isFinalized); + + // Finalize with Entry B as winner + oracle.setWinner(entryB); + + (winner, isFinalized) = oracle.getWinner(address(oracle)); + assertEq(winner, entryB); + assertTrue(isFinalized); + + // Market should still show not resolved until first claim/migration triggers lazy resolution + IPredictionMigrator.MarketView memory market = predictionMigrator.getMarket(address(oracle)); + assertFalse(market.isResolved); + } + + // Skip actual migration test since it requires complex pool state management +} + +/* -------------------------------------------------------------------------------- */ +/* Full Flow Integration Test (with Swaps) */ +/* -------------------------------------------------------------------------------- */ + +/** + * @title Prediction Market Full Flow Test + * @notice Tests the complete prediction market lifecycle with actual swaps: + * 1. Deploy and setup all contracts + * 2. Create entry tokens + * 3. Perform buy swaps (verify sells are blocked) + * 4. Reach graduation threshold (farTick) + * 5. Finalize oracle + * 6. Migrate entries + * 7. Winners claim proceeds + */ +contract PredictionMarketFullFlowTest is Deployers { + using StateLibrary for IPoolManager; + using PoolIdLibrary for PoolKey; + + address internal AIRLOCK_OWNER = makeAddr("AIRLOCK_OWNER"); + Airlock public airlock; + + MockPredictionOracle public oracle; + NoSellDopplerHook public noSellHook; + PredictionMigrator public predictionMigrator; + DopplerHookInitializer public dopplerInitializer; + CloneERC20Factory public cloneTokenFactory; + NoOpGovernanceFactory public noOpGovernanceFactory; + TestERC20 public testNumeraire; + + // Entry IDs + bytes32 public entryIdA = keccak256("entry_a"); + bytes32 public entryIdB = keccak256("entry_b"); + + // Users + address public alice = makeAddr("alice"); + address public bob = makeAddr("bob"); + + // Pool configuration + int24 public constant TICK_SPACING = 8; + // For prediction markets, farTick = startingTick (0) so graduation is immediate + // and migration is only gated by oracle finalization. + int24 public constant FAR_TICK = 0; // Immediate graduation (farTick == startingTick) + + function setUp() public { + // Deploy V4 infrastructure (from Deployers) + deployFreshManagerAndRouters(); + + // Deploy Airlock + airlock = new Airlock(AIRLOCK_OWNER); + + // Deploy ERC20 numeraire (simpler than ETH for swaps) + testNumeraire = new TestERC20(1e36); + vm.label(address(testNumeraire), "Numeraire"); + + // Deploy oracle + oracle = new MockPredictionOracle(); + + // Deploy token factory + cloneTokenFactory = deployCloneERC20Factory(vm, airlock, AIRLOCK_OWNER); + + // Deploy governance factory (no-op for prediction markets) + noOpGovernanceFactory = deployNoOpGovernanceFactory(vm, airlock, AIRLOCK_OWNER); + + // Deploy DopplerHookInitializer + dopplerInitializer = _deployDopplerHookInitializer(); + + // Deploy NoSellDopplerHook and register it + noSellHook = new NoSellDopplerHook(address(dopplerInitializer)); + _registerDopplerHook(address(noSellHook), ON_INITIALIZATION_FLAG | ON_SWAP_FLAG); + + // Deploy PredictionMigrator + predictionMigrator = _deployPredictionMigrator(); + + // Fund users generously for graduation tests + testNumeraire.transfer(alice, 1e33); + testNumeraire.transfer(bob, 1e33); + } + + function _deployDopplerHookInitializer() internal returns (DopplerHookInitializer initializer) { + initializer = DopplerHookInitializer( + payable(address( + uint160( + Hooks.BEFORE_INITIALIZE_FLAG | Hooks.AFTER_ADD_LIQUIDITY_FLAG + | Hooks.AFTER_REMOVE_LIQUIDITY_FLAG | Hooks.AFTER_SWAP_FLAG + | Hooks.AFTER_SWAP_RETURNS_DELTA_FLAG + ) ^ (0x4444 << 144) + )) + ); + + deployCodeTo("DopplerHookInitializer", abi.encode(address(airlock), address(manager)), address(initializer)); + + address[] memory modules = new address[](1); + modules[0] = address(initializer); + ModuleState[] memory states = new ModuleState[](1); + states[0] = ModuleState.PoolInitializer; + vm.prank(AIRLOCK_OWNER); + airlock.setModuleState(modules, states); + } + + function _registerDopplerHook(address hook, uint256 flags) internal { + address[] memory hooks = new address[](1); + hooks[0] = hook; + uint256[] memory flagsArr = new uint256[](1); + flagsArr[0] = flags; + + vm.prank(AIRLOCK_OWNER); + dopplerInitializer.setDopplerHookState(hooks, flagsArr); + } + + function _deployPredictionMigrator() internal returns (PredictionMigrator migrator_) { + migrator_ = new PredictionMigrator(address(airlock)); + + address[] memory modules = new address[](1); + modules[0] = address(migrator_); + ModuleState[] memory states = new ModuleState[](1); + states[0] = ModuleState.LiquidityMigrator; + vm.prank(AIRLOCK_OWNER); + airlock.setModuleState(modules, states); + } + + function _preparePoolInitializerData() internal view returns (bytes memory) { + Curve[] memory curves = new Curve[](10); + + for (uint256 i; i < 10; ++i) { + curves[i].tickLower = int24(uint24(0 + i * 16_000)); + curves[i].tickUpper = 240_000; + curves[i].numPositions = 10; + curves[i].shares = WAD / 10; + } + + return abi.encode( + InitData({ + fee: 0, + tickSpacing: TICK_SPACING, + curves: curves, + beneficiaries: new BeneficiaryData[](0), // No beneficiaries = Initialized status (not Locked) + dopplerHook: address(noSellHook), + onInitializationDopplerHookCalldata: new bytes(0), + graduationDopplerHookCalldata: new bytes(0), + farTick: FAR_TICK + }) + ); + } + + function _createEntry( + bytes32 entryId, + string memory tokenName, + string memory tokenSymbol + ) internal returns (address token) { + return _createEntryWithOracle(address(oracle), entryId, tokenName, tokenSymbol); + } + + function _createEntryWithOracle( + address oracleAddress, + bytes32 entryId, + string memory tokenName, + string memory tokenSymbol + ) internal returns (address token) { + CreateParams memory params = CreateParams({ + initialSupply: 1_000_000 ether, + numTokensToSell: 1_000_000 ether, + numeraire: address(testNumeraire), + tokenFactory: cloneTokenFactory, + tokenFactoryData: abi.encode(tokenName, tokenSymbol, 0, 0, new address[](0), new uint256[](0), ""), + governanceFactory: noOpGovernanceFactory, + governanceFactoryData: new bytes(0), + poolInitializer: dopplerInitializer, + poolInitializerData: _preparePoolInitializerData(), + liquidityMigrator: predictionMigrator, + liquidityMigratorData: abi.encode(oracleAddress, entryId), + integrator: address(0), + salt: keccak256(abi.encodePacked(tokenName, block.timestamp)) + }); + + (token,,,,) = airlock.create(params); + } + + function _getPoolKey(address asset) internal view returns (PoolKey memory) { + address numeraireAddr = address(testNumeraire); + bool isToken0 = asset < numeraireAddr; + + return PoolKey({ + currency0: isToken0 ? Currency.wrap(asset) : Currency.wrap(numeraireAddr), + currency1: isToken0 ? Currency.wrap(numeraireAddr) : Currency.wrap(asset), + hooks: IHooks(address(dopplerInitializer)), + fee: LPFeeLibrary.DYNAMIC_FEE_FLAG, // Because we have a dopplerHook + tickSpacing: TICK_SPACING + }); + } + + function _buyTokens(address asset, address buyer, int256 amount) internal { + PoolKey memory poolKey = _getPoolKey(asset); + bool isToken0 = asset < address(testNumeraire); + + // To buy asset: swap numeraire -> asset + // If asset is token0: zeroForOne = false (we want token0, give token1) + // If asset is token1: zeroForOne = true (we want token1, give token0) + // Price limits: + // zeroForOne = true: price goes DOWN, limit is minimum (MIN_SQRT_PRICE + 1) + // zeroForOne = false: price goes UP, limit is maximum (MAX_SQRT_PRICE - 1) + IPoolManager.SwapParams memory swapParams = IPoolManager.SwapParams({ + zeroForOne: !isToken0, + amountSpecified: amount, // Negative = exact output, positive = exact input + sqrtPriceLimitX96: !isToken0 ? TickMath.MIN_SQRT_PRICE + 1 : TickMath.MAX_SQRT_PRICE - 1 + }); + + vm.startPrank(buyer); + testNumeraire.approve(address(swapRouter), type(uint256).max); + swapRouter.swap(poolKey, swapParams, PoolSwapTest.TestSettings(false, false), new bytes(0)); + vm.stopPrank(); + } + + function _sellTokens(address asset, address seller, int256 amount) internal { + PoolKey memory poolKey = _getPoolKey(asset); + bool isToken0 = asset < address(testNumeraire); + + // To sell asset: swap asset -> numeraire + // If asset is token0: zeroForOne = true (give token0, get token1) + // If asset is token1: zeroForOne = false (give token1, get token0) + // Price limits: + // zeroForOne = true: price goes DOWN, limit is minimum (MIN_SQRT_PRICE + 1) + // zeroForOne = false: price goes UP, limit is maximum (MAX_SQRT_PRICE - 1) + IPoolManager.SwapParams memory swapParams = IPoolManager.SwapParams({ + zeroForOne: isToken0, + amountSpecified: amount, + sqrtPriceLimitX96: isToken0 ? TickMath.MIN_SQRT_PRICE + 1 : TickMath.MAX_SQRT_PRICE - 1 + }); + + vm.startPrank(seller); + IERC20(asset).approve(address(swapRouter), type(uint256).max); + swapRouter.swap(poolKey, swapParams, PoolSwapTest.TestSettings(false, false), new bytes(0)); + vm.stopPrank(); + } + + function _getCurrentTick(address asset) internal view returns (int24) { + PoolKey memory poolKey = _getPoolKey(asset); + (, int24 tick,,) = manager.getSlot0(poolKey.toId()); + return tick; + } + + /* -------------------------------------------------------------------------------- */ + /* Test: Simple Swap */ + /* -------------------------------------------------------------------------------- */ + + function test_fullFlow_Step1_CreateEntryAndSwap() public { + // Step 1: Create entry + address entryA = _createEntry(entryIdA, "Entry A", "ENTA"); + + // Verify entry was created + assertNotEq(entryA, address(0), "Entry should be created"); + + // Check initial tick + int24 initialTick = _getCurrentTick(entryA); + console.log("Initial tick:"); + console.logInt(initialTick); + + // Step 2: Alice buys some tokens + uint256 aliceNumeraireBefore = testNumeraire.balanceOf(alice); + uint256 aliceAssetBefore = IERC20(entryA).balanceOf(alice); + + _buyTokens(entryA, alice, 1 ether); // Buy with 1 numeraire + + uint256 aliceNumeraireAfter = testNumeraire.balanceOf(alice); + uint256 aliceAssetAfter = IERC20(entryA).balanceOf(alice); + + console.log("Alice spent numeraire:", aliceNumeraireBefore - aliceNumeraireAfter); + console.log("Alice received tokens:", aliceAssetAfter - aliceAssetBefore); + + // Alice should have spent numeraire and received tokens + assertLt(aliceNumeraireAfter, aliceNumeraireBefore, "Alice should have spent numeraire"); + assertGt(aliceAssetAfter, aliceAssetBefore, "Alice should have received tokens"); + + // Check tick moved + int24 tickAfterBuy = _getCurrentTick(entryA); + console.log("Tick after buy:"); + console.logInt(tickAfterBuy); + } + + /* -------------------------------------------------------------------------------- */ + /* Test: Sells Are Blocked */ + /* -------------------------------------------------------------------------------- */ + + function test_fullFlow_Step2_SellsBlocked() public { + // Create entry + address entryA = _createEntry(entryIdA, "Entry A", "ENTA"); + + // Alice buys tokens first + _buyTokens(entryA, alice, 1 ether); + + uint256 aliceTokens = IERC20(entryA).balanceOf(alice); + assertGt(aliceTokens, 0, "Alice should have tokens"); + + // Setup sell params + PoolKey memory poolKey = _getPoolKey(entryA); + bool isToken0 = entryA < address(testNumeraire); + + IPoolManager.SwapParams memory swapParams = IPoolManager.SwapParams({ + zeroForOne: isToken0, // Selling asset + amountSpecified: int256(aliceTokens / 2), + sqrtPriceLimitX96: isToken0 ? TickMath.MIN_SQRT_PRICE + 1 : TickMath.MAX_SQRT_PRICE - 1 + }); + + // Approve before expectRevert (approve won't revert) + vm.startPrank(alice); + IERC20(entryA).approve(address(swapRouter), type(uint256).max); + + // Try to sell - should revert with SellsNotAllowed (wrapped by V4 hook error handling) + // In V4, hook errors are wrapped, so we just verify it reverts + vm.expectRevert(); + swapRouter.swap(poolKey, swapParams, PoolSwapTest.TestSettings(false, false), new bytes(0)); + vm.stopPrank(); + + // Verify Alice still has her tokens (sell didn't go through) + assertEq(IERC20(entryA).balanceOf(alice), aliceTokens, "Alice should still have all tokens"); + } + + /* -------------------------------------------------------------------------------- */ + /* Test: Buys Move Tick Towards Graduation */ + /* -------------------------------------------------------------------------------- */ + + function test_fullFlow_Step3_BuyMovesTick() public { + // Create entry + address entryA = _createEntry(entryIdA, "Entry A", "ENTA"); + + int24 tickBefore = _getCurrentTick(entryA); + console.log("Tick before buy:"); + console.logInt(tickBefore); + + // Buy tokens + _buyTokens(entryA, alice, 100 ether); + + int24 tickAfter = _getCurrentTick(entryA); + console.log("Tick after buy:"); + console.logInt(tickAfter); + + // Verify tick changed (buy moves price) + assertTrue(tickAfter != tickBefore, "Tick should change after buy"); + + // Note: For prediction markets, farTick should be set to startingTick + // so migration is only gated by oracle finalization. The full flow + // test (test_fullFlow_Complete) verifies the complete migration works. + } + + /* -------------------------------------------------------------------------------- */ + /* Test: Full Flow - Create, Trade, Migrate, Claim */ + /* -------------------------------------------------------------------------------- */ + + function test_fullFlow_Complete() public { + // ===== PHASE 1: Create entries ===== + address entryA = _createEntry(entryIdA, "Entry A", "ENTA"); + address entryB = _createEntry(entryIdB, "Entry B", "ENTB"); + + console.log("Entry A:", entryA); + console.log("Entry B:", entryB); + + // Verify entries registered in PredictionMigrator + IPredictionMigrator.EntryView memory viewA = predictionMigrator.getEntry(address(oracle), entryIdA); + IPredictionMigrator.EntryView memory viewB = predictionMigrator.getEntry(address(oracle), entryIdB); + assertEq(viewA.token, entryA, "Entry A should be registered"); + assertEq(viewB.token, entryB, "Entry B should be registered"); + + // ===== PHASE 2: Trading - users buy tokens ===== + // Alice buys Entry A tokens (she thinks A will win) + // Bob buys Entry B tokens (he thinks B will win) + // With small farTick, a single buy reaches graduation + _buyTokens(entryA, alice, 100 ether); + _buyTokens(entryB, bob, 50 ether); + + // Record balances after trading + uint256 aliceEntryABalance = IERC20(entryA).balanceOf(alice); + uint256 bobEntryBBalance = IERC20(entryB).balanceOf(bob); + + console.log("Alice Entry A balance:", aliceEntryABalance); + console.log("Bob Entry B balance:", bobEntryBBalance); + + assertGt(aliceEntryABalance, 0, "Alice should have Entry A tokens"); + assertGt(bobEntryBBalance, 0, "Bob should have Entry B tokens"); + + // ===== PHASE 3: Oracle finalization - Entry A wins ===== + oracle.setWinner(entryA); + + (address winner, bool isFinalized) = oracle.getWinner(address(oracle)); + assertEq(winner, entryA, "Entry A should be winner"); + assertTrue(isFinalized, "Oracle should be finalized"); + + // ===== PHASE 4: Migration ===== + // Migrate Entry A first (winner) + vm.prank(AIRLOCK_OWNER); + airlock.migrate(entryA); + + // Migrate Entry B (loser) + vm.prank(AIRLOCK_OWNER); + airlock.migrate(entryB); + + // Verify both entries migrated + viewA = predictionMigrator.getEntry(address(oracle), entryIdA); + viewB = predictionMigrator.getEntry(address(oracle), entryIdB); + assertTrue(viewA.isMigrated, "Entry A should be migrated"); + assertTrue(viewB.isMigrated, "Entry B should be migrated"); + + // Check total pot + IPredictionMigrator.MarketView memory market = predictionMigrator.getMarket(address(oracle)); + console.log("Total pot:", market.totalPot); + assertGt(market.totalPot, 0, "Total pot should be > 0"); + + // ===== PHASE 5: Claims ===== + // Alice claims with her winning tokens + uint256 aliceNumeraireBefore = testNumeraire.balanceOf(alice); + + vm.startPrank(alice); + IERC20(entryA).approve(address(predictionMigrator), aliceEntryABalance); + predictionMigrator.claim(address(oracle), aliceEntryABalance); + vm.stopPrank(); + + uint256 aliceNumeraireAfter = testNumeraire.balanceOf(alice); + uint256 aliceReceived = aliceNumeraireAfter - aliceNumeraireBefore; + + console.log("Alice claimed:", aliceReceived); + assertGt(aliceReceived, 0, "Alice should have received numeraire"); + + // Verify final state + market = predictionMigrator.getMarket(address(oracle)); + console.log("Total claimed:", market.totalClaimed); + assertEq(market.totalClaimed, aliceReceived, "Total claimed should match Alice's claim"); + } + + function test_fullFlow_CrossMarketSharedNumeraire_ContributionUsesObservedDelta() public { + MockPredictionOracle oracle2 = new MockPredictionOracle(); + bytes32 marketAEntryId = keccak256("market_a_entry"); + bytes32 marketBEntryId = keccak256("market_b_entry"); + + address marketAEntry = _createEntryWithOracle(address(oracle), marketAEntryId, "Market A Entry", "MKA"); + address marketBEntry = _createEntryWithOracle(address(oracle2), marketBEntryId, "Market B Entry", "MKB"); + + // Drive both pools so each migration has non-zero proceeds. + _buyTokens(marketAEntry, alice, 100 ether); + _buyTokens(marketBEntry, bob, 50 ether); + + oracle.setWinner(marketAEntry); + oracle2.setWinner(marketBEntry); + + uint256 balanceBeforeA = testNumeraire.balanceOf(address(predictionMigrator)); + vm.prank(AIRLOCK_OWNER); + airlock.migrate(marketAEntry); + uint256 balanceAfterA = testNumeraire.balanceOf(address(predictionMigrator)); + uint256 deltaA = balanceAfterA - balanceBeforeA; + + IPredictionMigrator.EntryView memory entryAView = predictionMigrator.getEntry(address(oracle), marketAEntryId); + assertGt(deltaA, 0, "Market A delta should be non-zero"); + assertEq(entryAView.contribution, deltaA, "Market A contribution should equal observed migration delta"); + + uint256 balanceBeforeB = testNumeraire.balanceOf(address(predictionMigrator)); + vm.prank(AIRLOCK_OWNER); + airlock.migrate(marketBEntry); + uint256 balanceAfterB = testNumeraire.balanceOf(address(predictionMigrator)); + uint256 deltaB = balanceAfterB - balanceBeforeB; + + IPredictionMigrator.EntryView memory entryBView = predictionMigrator.getEntry(address(oracle2), marketBEntryId); + assertGt(deltaB, 0, "Market B delta should be non-zero"); + assertEq(entryBView.contribution, deltaB, "Market B contribution should equal observed migration delta"); + } + + function test_fullFlow_CrossMarketSharedNumeraire_ClaimBetweenMigrations_ContributionUsesObservedDelta() public { + MockPredictionOracle oracle2 = new MockPredictionOracle(); + bytes32 marketAEntryId = keccak256("market_a_entry_claim_gap"); + bytes32 marketBEntryId = keccak256("market_b_entry_claim_gap"); + + address marketAEntry = _createEntryWithOracle(address(oracle), marketAEntryId, "Market A Entry Claim Gap", "MKAC"); + address marketBEntry = _createEntryWithOracle(address(oracle2), marketBEntryId, "Market B Entry Claim Gap", "MKBC"); + + // Drive both pools so each migration has non-zero proceeds. + _buyTokens(marketAEntry, alice, 100 ether); + _buyTokens(marketBEntry, bob, 50 ether); + + uint256 aliceMarketABalance = IERC20(marketAEntry).balanceOf(alice); + assertGt(aliceMarketABalance, 0, "Alice should hold market A winning tokens"); + + oracle.setWinner(marketAEntry); + oracle2.setWinner(marketBEntry); + + // Migrate market A first. + vm.prank(AIRLOCK_OWNER); + airlock.migrate(marketAEntry); + + // Claim in market A before migrating market B. + vm.startPrank(alice); + IERC20(marketAEntry).approve(address(predictionMigrator), aliceMarketABalance / 2); + predictionMigrator.claim(address(oracle), aliceMarketABalance / 2); + vm.stopPrank(); + + uint256 balanceBeforeB = testNumeraire.balanceOf(address(predictionMigrator)); + vm.prank(AIRLOCK_OWNER); + airlock.migrate(marketBEntry); + uint256 balanceAfterB = testNumeraire.balanceOf(address(predictionMigrator)); + uint256 deltaB = balanceAfterB - balanceBeforeB; + + IPredictionMigrator.EntryView memory entryBView = predictionMigrator.getEntry(address(oracle2), marketBEntryId); + IPredictionMigrator.MarketView memory marketBView = predictionMigrator.getMarket(address(oracle2)); + assertGt(deltaB, 0, "Market B delta should be non-zero"); + assertEq(entryBView.contribution, deltaB, "Market B contribution should equal observed migration delta"); + assertEq(marketBView.totalPot, deltaB, "Market B pot should equal observed migration delta"); + } +} + +/* -------------------------------------------------------------------------------- */ +/* Helper Functions for Deployment */ +/* -------------------------------------------------------------------------------- */ + +function deployPredictionMigrator(Vm vm, Airlock airlock, address airlockOwner) returns (PredictionMigrator migrator) { + migrator = new PredictionMigrator(address(airlock)); + + address[] memory modules = new address[](1); + modules[0] = address(migrator); + ModuleState[] memory states = new ModuleState[](1); + states[0] = ModuleState.LiquidityMigrator; + vm.prank(airlockOwner); + airlock.setModuleState(modules, states); +} + +function deployNoSellDopplerHook(address dopplerInitializer) returns (NoSellDopplerHook hook) { + hook = new NoSellDopplerHook(dopplerInitializer); +} + +function preparePredictionMigratorData(address oracle, bytes32 entryId) pure returns (bytes memory) { + return abi.encode(oracle, entryId); +} diff --git a/test/invariant/PredictionMigrator/PredictionMigratorEthInvariantHandler.sol b/test/invariant/PredictionMigrator/PredictionMigratorEthInvariantHandler.sol new file mode 100644 index 00000000..8e96018a --- /dev/null +++ b/test/invariant/PredictionMigrator/PredictionMigratorEthInvariantHandler.sol @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.24; + +import { PredictionMigrator } from "src/migrators/PredictionMigrator.sol"; +import { + InvariantPredictionERC20, + PredictionMigratorAirlockHarness, + PredictionMigratorInvariantHandlerBase +} from "test/invariant/PredictionMigrator/PredictionMigratorInvariantHandler.sol"; + +/// @dev Handler exposing bounded actions for ETH-numeraire PredictionMigrator invariants. +contract PredictionMigratorEthInvariantHandler is PredictionMigratorInvariantHandlerBase { + constructor( + PredictionMigratorAirlockHarness airlock_, + PredictionMigrator migrator_, + InvariantPredictionERC20 tokenA_, + InvariantPredictionERC20 tokenB_, + address oracleA_, + address oracleB_, + bytes32 entryIdA_, + bytes32 entryIdB_, + address alice_, + address bob_ + ) + PredictionMigratorInvariantHandlerBase( + airlock_, migrator_, tokenA_, tokenB_, oracleA_, oracleB_, entryIdA_, entryIdB_, alice_, bob_ + ) + { } + + receive() external payable { } + + // ========================================================================= + // Migration Actions + // ========================================================================= + + function migrateOracleA(uint128 amountSeed, uint8 orderingSeed) external override { + _migrate(oracleA, entryIdA, tokenA, amountSeed, orderingSeed); + } + + function migrateOracleB(uint128 amountSeed, uint8 orderingSeed) external override { + _migrate(oracleB, entryIdB, tokenB, amountSeed, orderingSeed); + } + + function _migrate( + address oracle, + bytes32 entryId, + InvariantPredictionERC20 asset, + uint128 amountSeed, + uint8 orderingSeed + ) internal { + if (_entryAlreadyMigrated(oracle, entryId)) return; + + uint256 amount = _boundMigrationAmount(amountSeed); + payable(address(migrator)).transfer(amount); + + if (orderingSeed % 2 == 0) { + airlock.migrate(address(asset), address(0)); + } else { + airlock.migrate(address(0), address(asset)); + } + + _recordMigration(oracle, entryId, amount); + } +} diff --git a/test/invariant/PredictionMigrator/PredictionMigratorEthInvariants.t.sol b/test/invariant/PredictionMigrator/PredictionMigratorEthInvariants.t.sol new file mode 100644 index 00000000..f1d50ce9 --- /dev/null +++ b/test/invariant/PredictionMigrator/PredictionMigratorEthInvariants.t.sol @@ -0,0 +1,127 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.24; + +import { Test } from "forge-std/Test.sol"; +import { MockPredictionOracle } from "src/base/MockPredictionOracle.sol"; +import { IPredictionMigrator } from "src/interfaces/IPredictionMigrator.sol"; +import { PredictionMigrator } from "src/migrators/PredictionMigrator.sol"; +import { + PredictionMigratorEthInvariantHandler +} from "test/invariant/PredictionMigrator/PredictionMigratorEthInvariantHandler.sol"; +import { + InvariantPredictionERC20, + PredictionMigratorAirlockHarness +} from "test/invariant/PredictionMigrator/PredictionMigratorInvariantHandler.sol"; + +contract PredictionMigratorEthInvariantsTest is Test { + uint256 public constant ENTRY_SUPPLY = 1_000_000 ether; + uint256 public constant HANDLER_ETH_BALANCE = 1_000_000_000 ether; + + PredictionMigratorAirlockHarness public airlock; + PredictionMigrator public migrator; + PredictionMigratorEthInvariantHandler public handler; + MockPredictionOracle public oracleA; + MockPredictionOracle public oracleB; + InvariantPredictionERC20 public tokenA; + InvariantPredictionERC20 public tokenB; + + bytes32 public entryIdA = keccak256("invariant_eth_entry_a"); + bytes32 public entryIdB = keccak256("invariant_eth_entry_b"); + + address public alice = makeAddr("alice"); + address public bob = makeAddr("bob"); + + function setUp() public { + airlock = new PredictionMigratorAirlockHarness(); + migrator = new PredictionMigrator(address(airlock)); + airlock.setMigrator(migrator); + + oracleA = new MockPredictionOracle(); + oracleB = new MockPredictionOracle(); + + tokenA = new InvariantPredictionERC20("Invariant ETH Entry A", "IETHA", ENTRY_SUPPLY); + tokenB = new InvariantPredictionERC20("Invariant ETH Entry B", "IETHB", ENTRY_SUPPLY); + + airlock.initialize(address(tokenA), address(0), abi.encode(address(oracleA), entryIdA)); + airlock.initialize(address(tokenB), address(0), abi.encode(address(oracleB), entryIdB)); + + oracleA.setWinner(address(tokenA)); + oracleB.setWinner(address(tokenB)); + + // All winning tokens are in user hands; unsold balance at migration is zero. + tokenA.transfer(alice, ENTRY_SUPPLY / 2); + tokenA.transfer(bob, ENTRY_SUPPLY / 2); + tokenB.transfer(alice, ENTRY_SUPPLY / 2); + tokenB.transfer(bob, ENTRY_SUPPLY / 2); + + handler = new PredictionMigratorEthInvariantHandler( + airlock, migrator, tokenA, tokenB, address(oracleA), address(oracleB), entryIdA, entryIdB, alice, bob + ); + + vm.deal(address(handler), HANDLER_ETH_BALANCE); + + bytes4[] memory selectors = new bytes4[](6); + selectors[0] = handler.migrateOracleA.selector; + selectors[1] = handler.migrateOracleB.selector; + selectors[2] = handler.claimOracleA.selector; + selectors[3] = handler.claimOracleB.selector; + selectors[4] = handler.transferOracleATokens.selector; + selectors[5] = handler.transferOracleBTokens.selector; + + targetSelector(FuzzSelector({ addr: address(handler), selectors: selectors })); + targetContract(address(handler)); + + excludeSender(address(0)); + excludeSender(address(this)); + excludeSender(address(handler)); + excludeSender(address(airlock)); + excludeSender(address(migrator)); + excludeSender(address(oracleA)); + excludeSender(address(oracleB)); + excludeSender(address(tokenA)); + excludeSender(address(tokenB)); + excludeSender(alice); + excludeSender(bob); + } + + function invariant_MarketAndEntryAccountingMatchesGhostState_ETH() public view { + _assertMarketAndEntryAccounting(address(oracleA), entryIdA); + _assertMarketAndEntryAccounting(address(oracleB), entryIdB); + } + + function invariant_GlobalEthBalanceMatchesNetGhostFlows() public view { + uint256 contributed = handler.ghost_totalContributed(); + uint256 claimed = handler.ghost_totalClaimed(); + + assertLe(claimed, contributed, "ghost claimed exceeds ghost contributed"); + assertEq(address(migrator).balance, contributed - claimed, "migrator ETH balance does not match net ghost flow"); + } + + function invariant_GlobalGhostSumsMatchPerMarketGhostSums_ETH() public view { + uint256 potSum = handler.ghost_marketPot(address(oracleA)) + handler.ghost_marketPot(address(oracleB)); + uint256 claimedSum = + handler.ghost_marketClaimed(address(oracleA)) + handler.ghost_marketClaimed(address(oracleB)); + + assertEq(potSum, handler.ghost_totalContributed(), "global contributed ghost does not match per-market sum"); + assertEq(claimedSum, handler.ghost_totalClaimed(), "global claimed ghost does not match per-market sum"); + } + + function _assertMarketAndEntryAccounting(address oracle, bytes32 entryId) internal view { + IPredictionMigrator.MarketView memory market = migrator.getMarket(oracle); + IPredictionMigrator.EntryView memory entry = migrator.getEntry(oracle, entryId); + + assertEq(market.numeraire, address(0), "market numeraire should be ETH"); + assertEq(market.totalPot, handler.ghost_marketPot(oracle), "market totalPot mismatch"); + assertEq(market.totalClaimed, handler.ghost_marketClaimed(oracle), "market totalClaimed mismatch"); + assertLe(market.totalClaimed, market.totalPot, "market claimed exceeds pot"); + + assertEq(entry.contribution, handler.ghost_entryContribution(oracle, entryId), "entry contribution mismatch"); + assertEq(entry.isMigrated, handler.ghost_entryMigrated(oracle, entryId), "entry migrated mismatch"); + + if (entry.isMigrated) { + assertEq(entry.claimableSupply, ENTRY_SUPPLY, "claimable supply mismatch for migrated entry"); + } else { + assertEq(entry.claimableSupply, 0, "claimable supply should be zero before migration"); + } + } +} diff --git a/test/invariant/PredictionMigrator/PredictionMigratorInvariantHandler.sol b/test/invariant/PredictionMigrator/PredictionMigratorInvariantHandler.sol new file mode 100644 index 00000000..1295aee5 --- /dev/null +++ b/test/invariant/PredictionMigrator/PredictionMigratorInvariantHandler.sol @@ -0,0 +1,232 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.24; + +import { ERC20 } from "@solmate/tokens/ERC20.sol"; +import { Test } from "forge-std/Test.sol"; +import { PredictionMigrator } from "src/migrators/PredictionMigrator.sol"; + +/// @dev Minimal ERC20 used by prediction migrator invariant tests. +contract InvariantPredictionERC20 is ERC20 { + constructor(string memory name_, string memory symbol_, uint256 initialSupply) ERC20(name_, symbol_, 18) { + _mint(msg.sender, initialSupply); + } + + function mint(address to, uint256 amount) external { + _mint(to, amount); + } + + function burn(uint256 amount) external { + _burn(msg.sender, amount); + } +} + +/// @dev Minimal harness that acts as Airlock for PredictionMigrator invariant tests. +contract PredictionMigratorAirlockHarness { + PredictionMigrator public migrator; + bool public isConfigured; + + function setMigrator(PredictionMigrator migrator_) external { + require(!isConfigured, "already configured"); + migrator = migrator_; + isConfigured = true; + } + + function initialize(address asset, address numeraire, bytes calldata data) external returns (address) { + return migrator.initialize(asset, numeraire, data); + } + + function migrate(address token0, address token1) external returns (uint256) { + return migrator.migrate(0, token0, token1, address(0)); + } +} + +/// @dev Shared base for PredictionMigrator invariant handlers across numeraire variants. +abstract contract PredictionMigratorInvariantHandlerBase is Test { + uint256 public constant ENTRY_SUPPLY = 1_000_000 ether; + uint256 public constant MAX_MIGRATION_AMOUNT = 250_000 ether; + + PredictionMigratorAirlockHarness public airlock; + PredictionMigrator public migrator; + InvariantPredictionERC20 public tokenA; + InvariantPredictionERC20 public tokenB; + address public oracleA; + address public oracleB; + bytes32 public entryIdA; + bytes32 public entryIdB; + address public alice; + address public bob; + + uint256 public ghost_totalContributed; + uint256 public ghost_totalClaimed; + mapping(address oracle => uint256 totalPot) public ghost_marketPot; + mapping(address oracle => uint256 totalClaimed) public ghost_marketClaimed; + mapping(address oracle => mapping(bytes32 entryId => bool migrated)) public ghost_entryMigrated; + mapping(address oracle => mapping(bytes32 entryId => uint256 contribution)) public ghost_entryContribution; + + constructor( + PredictionMigratorAirlockHarness airlock_, + PredictionMigrator migrator_, + InvariantPredictionERC20 tokenA_, + InvariantPredictionERC20 tokenB_, + address oracleA_, + address oracleB_, + bytes32 entryIdA_, + bytes32 entryIdB_, + address alice_, + address bob_ + ) { + airlock = airlock_; + migrator = migrator_; + tokenA = tokenA_; + tokenB = tokenB_; + oracleA = oracleA_; + oracleB = oracleB_; + entryIdA = entryIdA_; + entryIdB = entryIdB_; + alice = alice_; + bob = bob_; + } + + // ========================================================================= + // Migration Actions (variant-specific implementations) + // ========================================================================= + + function migrateOracleA(uint128 amountSeed, uint8 orderingSeed) external virtual; + + function migrateOracleB(uint128 amountSeed, uint8 orderingSeed) external virtual; + + // ========================================================================= + // Claim Actions + // ========================================================================= + + function claimOracleA(uint128 amountSeed, uint8 actorSeed) external { + _claim(oracleA, entryIdA, tokenA, amountSeed, actorSeed); + } + + function claimOracleB(uint128 amountSeed, uint8 actorSeed) external { + _claim(oracleB, entryIdB, tokenB, amountSeed, actorSeed); + } + + function _claim( + address oracle, + bytes32 entryId, + InvariantPredictionERC20 winningToken, + uint128 amountSeed, + uint8 actorSeed + ) internal { + if (!ghost_entryMigrated[oracle][entryId]) return; + + address actor = actorSeed % 2 == 0 ? alice : bob; + uint256 actorBalance = winningToken.balanceOf(actor); + if (actorBalance == 0) return; + + uint256 claimTokenAmount = bound(uint256(amountSeed), 1, actorBalance); + uint256 expectedPayout = migrator.previewClaim(oracle, claimTokenAmount); + if (expectedPayout == 0) return; + + vm.startPrank(actor); + winningToken.approve(address(migrator), claimTokenAmount); + migrator.claim(oracle, claimTokenAmount); + vm.stopPrank(); + + ghost_marketClaimed[oracle] += expectedPayout; + ghost_totalClaimed += expectedPayout; + } + + // ========================================================================= + // Token Transfer Actions + // ========================================================================= + + function transferOracleATokens(uint128 amountSeed, uint8 fromSeed) external { + _transferBetweenClaimants(tokenA, amountSeed, fromSeed); + } + + function transferOracleBTokens(uint128 amountSeed, uint8 fromSeed) external { + _transferBetweenClaimants(tokenB, amountSeed, fromSeed); + } + + function _transferBetweenClaimants(InvariantPredictionERC20 token, uint128 amountSeed, uint8 fromSeed) internal { + address from = fromSeed % 2 == 0 ? alice : bob; + address to = from == alice ? bob : alice; + + uint256 fromBalance = token.balanceOf(from); + if (fromBalance == 0) return; + + uint256 amount = bound(uint256(amountSeed), 1, fromBalance); + vm.prank(from); + token.transfer(to, amount); + } + + // ========================================================================= + // Shared Migration Helpers + // ========================================================================= + + function _recordMigration(address oracle, bytes32 entryId, uint256 amount) internal { + ghost_entryMigrated[oracle][entryId] = true; + ghost_entryContribution[oracle][entryId] = amount; + ghost_marketPot[oracle] += amount; + ghost_totalContributed += amount; + } + + function _entryAlreadyMigrated(address oracle, bytes32 entryId) internal view returns (bool) { + return ghost_entryMigrated[oracle][entryId]; + } + + function _boundMigrationAmount(uint128 amountSeed) internal pure returns (uint256) { + return bound(uint256(amountSeed), 1, MAX_MIGRATION_AMOUNT); + } +} + +/// @dev ERC20-numeraire implementation of PredictionMigrator invariant handler. +contract PredictionMigratorInvariantHandler is PredictionMigratorInvariantHandlerBase { + InvariantPredictionERC20 public numeraire; + + constructor( + PredictionMigratorAirlockHarness airlock_, + PredictionMigrator migrator_, + InvariantPredictionERC20 numeraire_, + InvariantPredictionERC20 tokenA_, + InvariantPredictionERC20 tokenB_, + address oracleA_, + address oracleB_, + bytes32 entryIdA_, + bytes32 entryIdB_, + address alice_, + address bob_ + ) + PredictionMigratorInvariantHandlerBase( + airlock_, migrator_, tokenA_, tokenB_, oracleA_, oracleB_, entryIdA_, entryIdB_, alice_, bob_ + ) + { + numeraire = numeraire_; + } + + function migrateOracleA(uint128 amountSeed, uint8 orderingSeed) external override { + _migrate(oracleA, entryIdA, tokenA, amountSeed, orderingSeed); + } + + function migrateOracleB(uint128 amountSeed, uint8 orderingSeed) external override { + _migrate(oracleB, entryIdB, tokenB, amountSeed, orderingSeed); + } + + function _migrate( + address oracle, + bytes32 entryId, + InvariantPredictionERC20 asset, + uint128 amountSeed, + uint8 orderingSeed + ) internal { + if (_entryAlreadyMigrated(oracle, entryId)) return; + + uint256 amount = _boundMigrationAmount(amountSeed); + numeraire.transfer(address(migrator), amount); + + if (orderingSeed % 2 == 0) { + airlock.migrate(address(asset), address(numeraire)); + } else { + airlock.migrate(address(numeraire), address(asset)); + } + + _recordMigration(oracle, entryId, amount); + } +} diff --git a/test/invariant/PredictionMigrator/PredictionMigratorInvariants.t.sol b/test/invariant/PredictionMigrator/PredictionMigratorInvariants.t.sol new file mode 100644 index 00000000..e7e1222a --- /dev/null +++ b/test/invariant/PredictionMigrator/PredictionMigratorInvariants.t.sol @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.24; + +import { Test } from "forge-std/Test.sol"; +import { MockPredictionOracle } from "src/base/MockPredictionOracle.sol"; +import { IPredictionMigrator } from "src/interfaces/IPredictionMigrator.sol"; +import { PredictionMigrator } from "src/migrators/PredictionMigrator.sol"; +import { + InvariantPredictionERC20, + PredictionMigratorAirlockHarness, + PredictionMigratorInvariantHandler +} from "test/invariant/PredictionMigrator/PredictionMigratorInvariantHandler.sol"; + +contract PredictionMigratorInvariantsTest is Test { + uint256 public constant ENTRY_SUPPLY = 1_000_000 ether; + uint256 public constant HANDLER_NUMERAIRE_BALANCE = 1_000_000_000 ether; + + PredictionMigratorAirlockHarness public airlock; + PredictionMigrator public migrator; + PredictionMigratorInvariantHandler public handler; + MockPredictionOracle public oracleA; + MockPredictionOracle public oracleB; + InvariantPredictionERC20 public numeraire; + InvariantPredictionERC20 public tokenA; + InvariantPredictionERC20 public tokenB; + + bytes32 public entryIdA = keccak256("invariant_entry_a"); + bytes32 public entryIdB = keccak256("invariant_entry_b"); + + address public alice = makeAddr("alice"); + address public bob = makeAddr("bob"); + + function setUp() public { + airlock = new PredictionMigratorAirlockHarness(); + migrator = new PredictionMigrator(address(airlock)); + airlock.setMigrator(migrator); + + oracleA = new MockPredictionOracle(); + oracleB = new MockPredictionOracle(); + + numeraire = new InvariantPredictionERC20("Invariant Numeraire", "INUM", 0); + tokenA = new InvariantPredictionERC20("Invariant Entry A", "IENTA", ENTRY_SUPPLY); + tokenB = new InvariantPredictionERC20("Invariant Entry B", "IENTB", ENTRY_SUPPLY); + + airlock.initialize(address(tokenA), address(numeraire), abi.encode(address(oracleA), entryIdA)); + airlock.initialize(address(tokenB), address(numeraire), abi.encode(address(oracleB), entryIdB)); + + oracleA.setWinner(address(tokenA)); + oracleB.setWinner(address(tokenB)); + + // All winning tokens are in user hands; unsold balance at migration is zero. + tokenA.transfer(alice, ENTRY_SUPPLY / 2); + tokenA.transfer(bob, ENTRY_SUPPLY / 2); + tokenB.transfer(alice, ENTRY_SUPPLY / 2); + tokenB.transfer(bob, ENTRY_SUPPLY / 2); + + handler = new PredictionMigratorInvariantHandler( + airlock, + migrator, + numeraire, + tokenA, + tokenB, + address(oracleA), + address(oracleB), + entryIdA, + entryIdB, + alice, + bob + ); + + numeraire.mint(address(handler), HANDLER_NUMERAIRE_BALANCE); + + bytes4[] memory selectors = new bytes4[](6); + selectors[0] = handler.migrateOracleA.selector; + selectors[1] = handler.migrateOracleB.selector; + selectors[2] = handler.claimOracleA.selector; + selectors[3] = handler.claimOracleB.selector; + selectors[4] = handler.transferOracleATokens.selector; + selectors[5] = handler.transferOracleBTokens.selector; + + targetSelector(FuzzSelector({ addr: address(handler), selectors: selectors })); + targetContract(address(handler)); + + excludeSender(address(0)); + excludeSender(address(this)); + excludeSender(address(handler)); + excludeSender(address(airlock)); + excludeSender(address(migrator)); + excludeSender(address(oracleA)); + excludeSender(address(oracleB)); + excludeSender(address(numeraire)); + excludeSender(address(tokenA)); + excludeSender(address(tokenB)); + excludeSender(alice); + excludeSender(bob); + } + + function invariant_MarketAndEntryAccountingMatchesGhostState() public view { + _assertMarketAndEntryAccounting(address(oracleA), entryIdA); + _assertMarketAndEntryAccounting(address(oracleB), entryIdB); + } + + function invariant_GlobalNumeraireBalanceMatchesNetGhostFlows() public view { + uint256 contributed = handler.ghost_totalContributed(); + uint256 claimed = handler.ghost_totalClaimed(); + + assertLe(claimed, contributed, "ghost claimed exceeds ghost contributed"); + assertEq( + numeraire.balanceOf(address(migrator)), + contributed - claimed, + "migrator numeraire balance does not match net ghost flow" + ); + } + + function invariant_GlobalGhostSumsMatchPerMarketGhostSums() public view { + uint256 potSum = handler.ghost_marketPot(address(oracleA)) + handler.ghost_marketPot(address(oracleB)); + uint256 claimedSum = + handler.ghost_marketClaimed(address(oracleA)) + handler.ghost_marketClaimed(address(oracleB)); + + assertEq(potSum, handler.ghost_totalContributed(), "global contributed ghost does not match per-market sum"); + assertEq(claimedSum, handler.ghost_totalClaimed(), "global claimed ghost does not match per-market sum"); + } + + function _assertMarketAndEntryAccounting(address oracle, bytes32 entryId) internal view { + IPredictionMigrator.MarketView memory market = migrator.getMarket(oracle); + IPredictionMigrator.EntryView memory entry = migrator.getEntry(oracle, entryId); + + assertEq(market.numeraire, address(numeraire), "market numeraire mismatch"); + assertEq(market.totalPot, handler.ghost_marketPot(oracle), "market totalPot mismatch"); + assertEq(market.totalClaimed, handler.ghost_marketClaimed(oracle), "market totalClaimed mismatch"); + assertLe(market.totalClaimed, market.totalPot, "market claimed exceeds pot"); + + assertEq(entry.contribution, handler.ghost_entryContribution(oracle, entryId), "entry contribution mismatch"); + assertEq(entry.isMigrated, handler.ghost_entryMigrated(oracle, entryId), "entry migrated mismatch"); + + if (entry.isMigrated) { + assertEq(entry.claimableSupply, ENTRY_SUPPLY, "claimable supply mismatch for migrated entry"); + } else { + assertEq(entry.claimableSupply, 0, "claimable supply should be zero before migration"); + } + } +} diff --git a/test/invariant/PredictionMigrator/PredictionMigratorMultiEntryEthInvariantHandler.sol b/test/invariant/PredictionMigrator/PredictionMigratorMultiEntryEthInvariantHandler.sol new file mode 100644 index 00000000..b80e6f71 --- /dev/null +++ b/test/invariant/PredictionMigrator/PredictionMigratorMultiEntryEthInvariantHandler.sol @@ -0,0 +1,197 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.24; + +import { Test } from "forge-std/Test.sol"; +import { PredictionMigrator } from "src/migrators/PredictionMigrator.sol"; +import { + InvariantPredictionERC20, + PredictionMigratorAirlockHarness +} from "test/invariant/PredictionMigrator/PredictionMigratorInvariantHandler.sol"; + +/// @dev ETH-numeraire handler for multi-entry-per-market PredictionMigrator invariants. +contract PredictionMigratorMultiEntryEthInvariantHandler is Test { + uint256 public constant ENTRY_SUPPLY = 1_000_000 ether; + uint256 public constant MAX_MIGRATION_AMOUNT = 250_000 ether; + + PredictionMigratorAirlockHarness public airlock; + PredictionMigrator public migrator; + InvariantPredictionERC20 public winnerTokenA; + InvariantPredictionERC20 public loserTokenA; + InvariantPredictionERC20 public winnerTokenB; + InvariantPredictionERC20 public loserTokenB; + address public oracleA; + address public oracleB; + bytes32 public winnerEntryIdA; + bytes32 public loserEntryIdA; + bytes32 public winnerEntryIdB; + bytes32 public loserEntryIdB; + address public alice; + address public bob; + + uint256 public ghost_totalContributed; + uint256 public ghost_totalClaimed; + mapping(address oracle => uint256 totalPot) public ghost_marketPot; + mapping(address oracle => uint256 totalClaimed) public ghost_marketClaimed; + mapping(address oracle => mapping(bytes32 entryId => bool migrated)) public ghost_entryMigrated; + mapping(address oracle => mapping(bytes32 entryId => uint256 contribution)) public ghost_entryContribution; + + constructor( + PredictionMigratorAirlockHarness airlock_, + PredictionMigrator migrator_, + InvariantPredictionERC20 winnerTokenA_, + InvariantPredictionERC20 loserTokenA_, + InvariantPredictionERC20 winnerTokenB_, + InvariantPredictionERC20 loserTokenB_, + address oracleA_, + address oracleB_, + bytes32 winnerEntryIdA_, + bytes32 loserEntryIdA_, + bytes32 winnerEntryIdB_, + bytes32 loserEntryIdB_, + address alice_, + address bob_ + ) { + airlock = airlock_; + migrator = migrator_; + winnerTokenA = winnerTokenA_; + loserTokenA = loserTokenA_; + winnerTokenB = winnerTokenB_; + loserTokenB = loserTokenB_; + oracleA = oracleA_; + oracleB = oracleB_; + winnerEntryIdA = winnerEntryIdA_; + loserEntryIdA = loserEntryIdA_; + winnerEntryIdB = winnerEntryIdB_; + loserEntryIdB = loserEntryIdB_; + alice = alice_; + bob = bob_; + } + + receive() external payable { } + + // ========================================================================= + // Migration Actions + // ========================================================================= + + function migrateWinnerOracleA(uint128 amountSeed, uint8 orderingSeed) external { + _migrate(oracleA, winnerEntryIdA, winnerTokenA, amountSeed, orderingSeed); + } + + function migrateLoserOracleA(uint128 amountSeed, uint8 orderingSeed) external { + _migrate(oracleA, loserEntryIdA, loserTokenA, amountSeed, orderingSeed); + } + + function migrateWinnerOracleB(uint128 amountSeed, uint8 orderingSeed) external { + _migrate(oracleB, winnerEntryIdB, winnerTokenB, amountSeed, orderingSeed); + } + + function migrateLoserOracleB(uint128 amountSeed, uint8 orderingSeed) external { + _migrate(oracleB, loserEntryIdB, loserTokenB, amountSeed, orderingSeed); + } + + function _migrate( + address oracle, + bytes32 entryId, + InvariantPredictionERC20 asset, + uint128 amountSeed, + uint8 orderingSeed + ) internal { + if (ghost_entryMigrated[oracle][entryId]) return; + + uint256 amount = bound(uint256(amountSeed), 1, MAX_MIGRATION_AMOUNT); + payable(address(migrator)).transfer(amount); + + if (orderingSeed % 2 == 0) { + airlock.migrate(address(asset), address(0)); + } else { + airlock.migrate(address(0), address(asset)); + } + + _recordMigration(oracle, entryId, amount); + } + + // ========================================================================= + // Claim Actions + // ========================================================================= + + function claimOracleA(uint128 amountSeed, uint8 actorSeed) external { + _claimWinner(oracleA, winnerEntryIdA, winnerTokenA, amountSeed, actorSeed); + } + + function claimOracleB(uint128 amountSeed, uint8 actorSeed) external { + _claimWinner(oracleB, winnerEntryIdB, winnerTokenB, amountSeed, actorSeed); + } + + function _claimWinner( + address oracle, + bytes32 winnerEntryId, + InvariantPredictionERC20 winningToken, + uint128 amountSeed, + uint8 actorSeed + ) internal { + if (!ghost_entryMigrated[oracle][winnerEntryId]) return; + + address actor = actorSeed % 2 == 0 ? alice : bob; + uint256 actorBalance = winningToken.balanceOf(actor); + if (actorBalance == 0) return; + + uint256 claimTokenAmount = bound(uint256(amountSeed), 1, actorBalance); + uint256 expectedPayout = migrator.previewClaim(oracle, claimTokenAmount); + if (expectedPayout == 0) return; + + uint256 actorEthBefore = actor.balance; + vm.startPrank(actor); + winningToken.approve(address(migrator), claimTokenAmount); + migrator.claim(oracle, claimTokenAmount); + vm.stopPrank(); + + uint256 actorEthDelta = actor.balance - actorEthBefore; + assertEq(actorEthDelta, expectedPayout, "claim payout mismatch"); + + ghost_marketClaimed[oracle] += expectedPayout; + ghost_totalClaimed += expectedPayout; + } + + // ========================================================================= + // Token Transfer Actions + // ========================================================================= + + function transferWinnerOracleATokens(uint128 amountSeed, uint8 fromSeed) external { + _transferBetweenClaimants(winnerTokenA, amountSeed, fromSeed); + } + + function transferLoserOracleATokens(uint128 amountSeed, uint8 fromSeed) external { + _transferBetweenClaimants(loserTokenA, amountSeed, fromSeed); + } + + function transferWinnerOracleBTokens(uint128 amountSeed, uint8 fromSeed) external { + _transferBetweenClaimants(winnerTokenB, amountSeed, fromSeed); + } + + function transferLoserOracleBTokens(uint128 amountSeed, uint8 fromSeed) external { + _transferBetweenClaimants(loserTokenB, amountSeed, fromSeed); + } + + function _transferBetweenClaimants(InvariantPredictionERC20 token, uint128 amountSeed, uint8 fromSeed) internal { + address from = fromSeed % 2 == 0 ? alice : bob; + address to = from == alice ? bob : alice; + + uint256 fromBalance = token.balanceOf(from); + if (fromBalance == 0) return; + + uint256 amount = bound(uint256(amountSeed), 1, fromBalance); + vm.prank(from); + token.transfer(to, amount); + } + + // ========================================================================= + // Shared Helpers + // ========================================================================= + + function _recordMigration(address oracle, bytes32 entryId, uint256 amount) internal { + ghost_entryMigrated[oracle][entryId] = true; + ghost_entryContribution[oracle][entryId] = amount; + ghost_marketPot[oracle] += amount; + ghost_totalContributed += amount; + } +} diff --git a/test/invariant/PredictionMigrator/PredictionMigratorMultiEntryEthInvariants.t.sol b/test/invariant/PredictionMigrator/PredictionMigratorMultiEntryEthInvariants.t.sol new file mode 100644 index 00000000..40269233 --- /dev/null +++ b/test/invariant/PredictionMigrator/PredictionMigratorMultiEntryEthInvariants.t.sol @@ -0,0 +1,179 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.24; + +import { Test } from "forge-std/Test.sol"; +import { MockPredictionOracle } from "src/base/MockPredictionOracle.sol"; +import { IPredictionMigrator } from "src/interfaces/IPredictionMigrator.sol"; +import { PredictionMigrator } from "src/migrators/PredictionMigrator.sol"; +import { + PredictionMigratorMultiEntryEthInvariantHandler +} from "test/invariant/PredictionMigrator/PredictionMigratorMultiEntryEthInvariantHandler.sol"; +import { + InvariantPredictionERC20, + PredictionMigratorAirlockHarness +} from "test/invariant/PredictionMigrator/PredictionMigratorInvariantHandler.sol"; + +contract PredictionMigratorMultiEntryEthInvariantsTest is Test { + uint256 public constant ENTRY_SUPPLY = 1_000_000 ether; + uint256 public constant HANDLER_ETH_BALANCE = 2_000_000_000 ether; + + PredictionMigratorAirlockHarness public airlock; + PredictionMigrator public migrator; + PredictionMigratorMultiEntryEthInvariantHandler public handler; + MockPredictionOracle public oracleA; + MockPredictionOracle public oracleB; + InvariantPredictionERC20 public winnerTokenA; + InvariantPredictionERC20 public loserTokenA; + InvariantPredictionERC20 public winnerTokenB; + InvariantPredictionERC20 public loserTokenB; + + bytes32 public winnerEntryIdA = keccak256("invariant_multi_eth_winner_a"); + bytes32 public loserEntryIdA = keccak256("invariant_multi_eth_loser_a"); + bytes32 public winnerEntryIdB = keccak256("invariant_multi_eth_winner_b"); + bytes32 public loserEntryIdB = keccak256("invariant_multi_eth_loser_b"); + + address public alice = makeAddr("alice"); + address public bob = makeAddr("bob"); + + function setUp() public { + airlock = new PredictionMigratorAirlockHarness(); + migrator = new PredictionMigrator(address(airlock)); + airlock.setMigrator(migrator); + + oracleA = new MockPredictionOracle(); + oracleB = new MockPredictionOracle(); + + winnerTokenA = new InvariantPredictionERC20("Invariant Multi ETH Winner A", "IMEWA", ENTRY_SUPPLY); + loserTokenA = new InvariantPredictionERC20("Invariant Multi ETH Loser A", "IMELA", ENTRY_SUPPLY); + winnerTokenB = new InvariantPredictionERC20("Invariant Multi ETH Winner B", "IMEWB", ENTRY_SUPPLY); + loserTokenB = new InvariantPredictionERC20("Invariant Multi ETH Loser B", "IMELB", ENTRY_SUPPLY); + + // Oracle A market: winner + loser entries sharing ETH numeraire. + airlock.initialize(address(winnerTokenA), address(0), abi.encode(address(oracleA), winnerEntryIdA)); + airlock.initialize(address(loserTokenA), address(0), abi.encode(address(oracleA), loserEntryIdA)); + + // Oracle B market: winner + loser entries sharing ETH numeraire. + airlock.initialize(address(winnerTokenB), address(0), abi.encode(address(oracleB), winnerEntryIdB)); + airlock.initialize(address(loserTokenB), address(0), abi.encode(address(oracleB), loserEntryIdB)); + + oracleA.setWinner(address(winnerTokenA)); + oracleB.setWinner(address(winnerTokenB)); + + _distributeToClaimants(winnerTokenA); + _distributeToClaimants(loserTokenA); + _distributeToClaimants(winnerTokenB); + _distributeToClaimants(loserTokenB); + + handler = new PredictionMigratorMultiEntryEthInvariantHandler( + airlock, + migrator, + winnerTokenA, + loserTokenA, + winnerTokenB, + loserTokenB, + address(oracleA), + address(oracleB), + winnerEntryIdA, + loserEntryIdA, + winnerEntryIdB, + loserEntryIdB, + alice, + bob + ); + + vm.deal(address(handler), HANDLER_ETH_BALANCE); + + bytes4[] memory selectors = new bytes4[](10); + selectors[0] = handler.migrateWinnerOracleA.selector; + selectors[1] = handler.migrateLoserOracleA.selector; + selectors[2] = handler.migrateWinnerOracleB.selector; + selectors[3] = handler.migrateLoserOracleB.selector; + selectors[4] = handler.claimOracleA.selector; + selectors[5] = handler.claimOracleB.selector; + selectors[6] = handler.transferWinnerOracleATokens.selector; + selectors[7] = handler.transferLoserOracleATokens.selector; + selectors[8] = handler.transferWinnerOracleBTokens.selector; + selectors[9] = handler.transferLoserOracleBTokens.selector; + + targetSelector(FuzzSelector({ addr: address(handler), selectors: selectors })); + targetContract(address(handler)); + + excludeSender(address(0)); + excludeSender(address(this)); + excludeSender(address(handler)); + excludeSender(address(airlock)); + excludeSender(address(migrator)); + excludeSender(address(oracleA)); + excludeSender(address(oracleB)); + excludeSender(address(winnerTokenA)); + excludeSender(address(loserTokenA)); + excludeSender(address(winnerTokenB)); + excludeSender(address(loserTokenB)); + excludeSender(alice); + excludeSender(bob); + } + + function invariant_MarketAndEntryAccountingMatchesGhostState_MultiEntryETH() public view { + _assertMarket(address(oracleA)); + _assertMarket(address(oracleB)); + _assertEntry(address(oracleA), winnerEntryIdA); + _assertEntry(address(oracleA), loserEntryIdA); + _assertEntry(address(oracleB), winnerEntryIdB); + _assertEntry(address(oracleB), loserEntryIdB); + } + + function invariant_GlobalEthBalanceMatchesNetGhostFlows_MultiEntryETH() public view { + uint256 contributed = handler.ghost_totalContributed(); + uint256 claimed = handler.ghost_totalClaimed(); + + assertLe(claimed, contributed, "ghost claimed exceeds ghost contributed"); + assertEq(address(migrator).balance, contributed - claimed, "migrator ETH balance does not match net ghost flow"); + } + + function invariant_GlobalGhostSumsMatchPerMarketGhostSums_MultiEntryETH() public view { + uint256 potSum = handler.ghost_marketPot(address(oracleA)) + handler.ghost_marketPot(address(oracleB)); + uint256 claimedSum = + handler.ghost_marketClaimed(address(oracleA)) + handler.ghost_marketClaimed(address(oracleB)); + + assertEq(potSum, handler.ghost_totalContributed(), "global contributed ghost does not match per-market sum"); + assertEq(claimedSum, handler.ghost_totalClaimed(), "global claimed ghost does not match per-market sum"); + } + + function invariant_MarketPotEqualsSumOfEntryContributions_MultiEntryETH() public view { + _assertMarketPotEqualsEntryContributions(address(oracleA), winnerEntryIdA, loserEntryIdA); + _assertMarketPotEqualsEntryContributions(address(oracleB), winnerEntryIdB, loserEntryIdB); + } + + function _assertMarket(address oracle) internal view { + IPredictionMigrator.MarketView memory market = migrator.getMarket(oracle); + assertEq(market.numeraire, address(0), "market numeraire should be ETH"); + assertEq(market.totalPot, handler.ghost_marketPot(oracle), "market totalPot mismatch"); + assertEq(market.totalClaimed, handler.ghost_marketClaimed(oracle), "market totalClaimed mismatch"); + assertLe(market.totalClaimed, market.totalPot, "market claimed exceeds pot"); + } + + function _assertEntry(address oracle, bytes32 entryId) internal view { + IPredictionMigrator.EntryView memory entry = migrator.getEntry(oracle, entryId); + assertEq(entry.contribution, handler.ghost_entryContribution(oracle, entryId), "entry contribution mismatch"); + assertEq(entry.isMigrated, handler.ghost_entryMigrated(oracle, entryId), "entry migrated mismatch"); + + if (entry.isMigrated) { + assertEq(entry.claimableSupply, ENTRY_SUPPLY, "claimable supply mismatch for migrated entry"); + } else { + assertEq(entry.claimableSupply, 0, "claimable supply should be zero before migration"); + } + } + + function _assertMarketPotEqualsEntryContributions(address oracle, bytes32 entryIdOne, bytes32 entryIdTwo) internal view { + IPredictionMigrator.MarketView memory market = migrator.getMarket(oracle); + IPredictionMigrator.EntryView memory entryOne = migrator.getEntry(oracle, entryIdOne); + IPredictionMigrator.EntryView memory entryTwo = migrator.getEntry(oracle, entryIdTwo); + + assertEq(market.totalPot, entryOne.contribution + entryTwo.contribution, "market pot != sum of entry contributions"); + } + + function _distributeToClaimants(InvariantPredictionERC20 token) internal { + token.transfer(alice, ENTRY_SUPPLY / 2); + token.transfer(bob, ENTRY_SUPPLY / 2); + } +} diff --git a/test/invariant/PredictionMigrator/PredictionMigratorMultiEntryInvariantHandler.sol b/test/invariant/PredictionMigrator/PredictionMigratorMultiEntryInvariantHandler.sol new file mode 100644 index 00000000..bdd911f4 --- /dev/null +++ b/test/invariant/PredictionMigrator/PredictionMigratorMultiEntryInvariantHandler.sol @@ -0,0 +1,198 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.24; + +import { Test } from "forge-std/Test.sol"; +import { PredictionMigrator } from "src/migrators/PredictionMigrator.sol"; +import { + InvariantPredictionERC20, + PredictionMigratorAirlockHarness +} from "test/invariant/PredictionMigrator/PredictionMigratorInvariantHandler.sol"; + +/// @dev ERC20-numeraire handler for multi-entry-per-market PredictionMigrator invariants. +contract PredictionMigratorMultiEntryInvariantHandler is Test { + uint256 public constant ENTRY_SUPPLY = 1_000_000 ether; + uint256 public constant MAX_MIGRATION_AMOUNT = 250_000 ether; + + PredictionMigratorAirlockHarness public airlock; + PredictionMigrator public migrator; + InvariantPredictionERC20 public numeraire; + InvariantPredictionERC20 public winnerTokenA; + InvariantPredictionERC20 public loserTokenA; + InvariantPredictionERC20 public winnerTokenB; + InvariantPredictionERC20 public loserTokenB; + address public oracleA; + address public oracleB; + bytes32 public winnerEntryIdA; + bytes32 public loserEntryIdA; + bytes32 public winnerEntryIdB; + bytes32 public loserEntryIdB; + address public alice; + address public bob; + + uint256 public ghost_totalContributed; + uint256 public ghost_totalClaimed; + mapping(address oracle => uint256 totalPot) public ghost_marketPot; + mapping(address oracle => uint256 totalClaimed) public ghost_marketClaimed; + mapping(address oracle => mapping(bytes32 entryId => bool migrated)) public ghost_entryMigrated; + mapping(address oracle => mapping(bytes32 entryId => uint256 contribution)) public ghost_entryContribution; + + constructor( + PredictionMigratorAirlockHarness airlock_, + PredictionMigrator migrator_, + InvariantPredictionERC20 numeraire_, + InvariantPredictionERC20 winnerTokenA_, + InvariantPredictionERC20 loserTokenA_, + InvariantPredictionERC20 winnerTokenB_, + InvariantPredictionERC20 loserTokenB_, + address oracleA_, + address oracleB_, + bytes32 winnerEntryIdA_, + bytes32 loserEntryIdA_, + bytes32 winnerEntryIdB_, + bytes32 loserEntryIdB_, + address alice_, + address bob_ + ) { + airlock = airlock_; + migrator = migrator_; + numeraire = numeraire_; + winnerTokenA = winnerTokenA_; + loserTokenA = loserTokenA_; + winnerTokenB = winnerTokenB_; + loserTokenB = loserTokenB_; + oracleA = oracleA_; + oracleB = oracleB_; + winnerEntryIdA = winnerEntryIdA_; + loserEntryIdA = loserEntryIdA_; + winnerEntryIdB = winnerEntryIdB_; + loserEntryIdB = loserEntryIdB_; + alice = alice_; + bob = bob_; + } + + // ========================================================================= + // Migration Actions + // ========================================================================= + + function migrateWinnerOracleA(uint128 amountSeed, uint8 orderingSeed) external { + _migrate(oracleA, winnerEntryIdA, winnerTokenA, amountSeed, orderingSeed); + } + + function migrateLoserOracleA(uint128 amountSeed, uint8 orderingSeed) external { + _migrate(oracleA, loserEntryIdA, loserTokenA, amountSeed, orderingSeed); + } + + function migrateWinnerOracleB(uint128 amountSeed, uint8 orderingSeed) external { + _migrate(oracleB, winnerEntryIdB, winnerTokenB, amountSeed, orderingSeed); + } + + function migrateLoserOracleB(uint128 amountSeed, uint8 orderingSeed) external { + _migrate(oracleB, loserEntryIdB, loserTokenB, amountSeed, orderingSeed); + } + + function _migrate( + address oracle, + bytes32 entryId, + InvariantPredictionERC20 asset, + uint128 amountSeed, + uint8 orderingSeed + ) internal { + if (ghost_entryMigrated[oracle][entryId]) return; + + uint256 amount = bound(uint256(amountSeed), 1, MAX_MIGRATION_AMOUNT); + numeraire.transfer(address(migrator), amount); + + if (orderingSeed % 2 == 0) { + airlock.migrate(address(asset), address(numeraire)); + } else { + airlock.migrate(address(numeraire), address(asset)); + } + + _recordMigration(oracle, entryId, amount); + } + + // ========================================================================= + // Claim Actions + // ========================================================================= + + function claimOracleA(uint128 amountSeed, uint8 actorSeed) external { + _claimWinner(oracleA, winnerEntryIdA, winnerTokenA, amountSeed, actorSeed); + } + + function claimOracleB(uint128 amountSeed, uint8 actorSeed) external { + _claimWinner(oracleB, winnerEntryIdB, winnerTokenB, amountSeed, actorSeed); + } + + function _claimWinner( + address oracle, + bytes32 winnerEntryId, + InvariantPredictionERC20 winningToken, + uint128 amountSeed, + uint8 actorSeed + ) internal { + if (!ghost_entryMigrated[oracle][winnerEntryId]) return; + + address actor = actorSeed % 2 == 0 ? alice : bob; + uint256 actorBalance = winningToken.balanceOf(actor); + if (actorBalance == 0) return; + + uint256 claimTokenAmount = bound(uint256(amountSeed), 1, actorBalance); + uint256 expectedPayout = migrator.previewClaim(oracle, claimTokenAmount); + if (expectedPayout == 0) return; + + uint256 actorNumeraireBefore = numeraire.balanceOf(actor); + vm.startPrank(actor); + winningToken.approve(address(migrator), claimTokenAmount); + migrator.claim(oracle, claimTokenAmount); + vm.stopPrank(); + + uint256 actorNumeraireDelta = numeraire.balanceOf(actor) - actorNumeraireBefore; + assertEq(actorNumeraireDelta, expectedPayout, "claim payout mismatch"); + + ghost_marketClaimed[oracle] += expectedPayout; + ghost_totalClaimed += expectedPayout; + } + + // ========================================================================= + // Token Transfer Actions + // ========================================================================= + + function transferWinnerOracleATokens(uint128 amountSeed, uint8 fromSeed) external { + _transferBetweenClaimants(winnerTokenA, amountSeed, fromSeed); + } + + function transferLoserOracleATokens(uint128 amountSeed, uint8 fromSeed) external { + _transferBetweenClaimants(loserTokenA, amountSeed, fromSeed); + } + + function transferWinnerOracleBTokens(uint128 amountSeed, uint8 fromSeed) external { + _transferBetweenClaimants(winnerTokenB, amountSeed, fromSeed); + } + + function transferLoserOracleBTokens(uint128 amountSeed, uint8 fromSeed) external { + _transferBetweenClaimants(loserTokenB, amountSeed, fromSeed); + } + + function _transferBetweenClaimants(InvariantPredictionERC20 token, uint128 amountSeed, uint8 fromSeed) internal { + address from = fromSeed % 2 == 0 ? alice : bob; + address to = from == alice ? bob : alice; + + uint256 fromBalance = token.balanceOf(from); + if (fromBalance == 0) return; + + uint256 amount = bound(uint256(amountSeed), 1, fromBalance); + vm.prank(from); + token.transfer(to, amount); + } + + // ========================================================================= + // Shared Helpers + // ========================================================================= + + function _recordMigration(address oracle, bytes32 entryId, uint256 amount) internal { + ghost_entryMigrated[oracle][entryId] = true; + ghost_entryContribution[oracle][entryId] = amount; + ghost_marketPot[oracle] += amount; + ghost_totalContributed += amount; + } +} diff --git a/test/invariant/PredictionMigrator/PredictionMigratorMultiEntryInvariants.t.sol b/test/invariant/PredictionMigrator/PredictionMigratorMultiEntryInvariants.t.sol new file mode 100644 index 00000000..a320a723 --- /dev/null +++ b/test/invariant/PredictionMigrator/PredictionMigratorMultiEntryInvariants.t.sol @@ -0,0 +1,187 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.24; + +import { Test } from "forge-std/Test.sol"; +import { MockPredictionOracle } from "src/base/MockPredictionOracle.sol"; +import { IPredictionMigrator } from "src/interfaces/IPredictionMigrator.sol"; +import { PredictionMigrator } from "src/migrators/PredictionMigrator.sol"; +import { + PredictionMigratorMultiEntryInvariantHandler +} from "test/invariant/PredictionMigrator/PredictionMigratorMultiEntryInvariantHandler.sol"; +import { + InvariantPredictionERC20, + PredictionMigratorAirlockHarness +} from "test/invariant/PredictionMigrator/PredictionMigratorInvariantHandler.sol"; + +contract PredictionMigratorMultiEntryInvariantsTest is Test { + uint256 public constant ENTRY_SUPPLY = 1_000_000 ether; + uint256 public constant HANDLER_NUMERAIRE_BALANCE = 2_000_000_000 ether; + + PredictionMigratorAirlockHarness public airlock; + PredictionMigrator public migrator; + PredictionMigratorMultiEntryInvariantHandler public handler; + MockPredictionOracle public oracleA; + MockPredictionOracle public oracleB; + InvariantPredictionERC20 public numeraire; + InvariantPredictionERC20 public winnerTokenA; + InvariantPredictionERC20 public loserTokenA; + InvariantPredictionERC20 public winnerTokenB; + InvariantPredictionERC20 public loserTokenB; + + bytes32 public winnerEntryIdA = keccak256("invariant_multi_winner_a"); + bytes32 public loserEntryIdA = keccak256("invariant_multi_loser_a"); + bytes32 public winnerEntryIdB = keccak256("invariant_multi_winner_b"); + bytes32 public loserEntryIdB = keccak256("invariant_multi_loser_b"); + + address public alice = makeAddr("alice"); + address public bob = makeAddr("bob"); + + function setUp() public { + airlock = new PredictionMigratorAirlockHarness(); + migrator = new PredictionMigrator(address(airlock)); + airlock.setMigrator(migrator); + + oracleA = new MockPredictionOracle(); + oracleB = new MockPredictionOracle(); + + numeraire = new InvariantPredictionERC20("Invariant Multi Numeraire", "IMNUM", 0); + winnerTokenA = new InvariantPredictionERC20("Invariant Multi Winner A", "IMWA", ENTRY_SUPPLY); + loserTokenA = new InvariantPredictionERC20("Invariant Multi Loser A", "IMLA", ENTRY_SUPPLY); + winnerTokenB = new InvariantPredictionERC20("Invariant Multi Winner B", "IMWB", ENTRY_SUPPLY); + loserTokenB = new InvariantPredictionERC20("Invariant Multi Loser B", "IMLB", ENTRY_SUPPLY); + + // Oracle A market: winner + loser entries sharing one numeraire. + airlock.initialize(address(winnerTokenA), address(numeraire), abi.encode(address(oracleA), winnerEntryIdA)); + airlock.initialize(address(loserTokenA), address(numeraire), abi.encode(address(oracleA), loserEntryIdA)); + + // Oracle B market: winner + loser entries sharing one numeraire. + airlock.initialize(address(winnerTokenB), address(numeraire), abi.encode(address(oracleB), winnerEntryIdB)); + airlock.initialize(address(loserTokenB), address(numeraire), abi.encode(address(oracleB), loserEntryIdB)); + + oracleA.setWinner(address(winnerTokenA)); + oracleB.setWinner(address(winnerTokenB)); + + _distributeToClaimants(winnerTokenA); + _distributeToClaimants(loserTokenA); + _distributeToClaimants(winnerTokenB); + _distributeToClaimants(loserTokenB); + + handler = new PredictionMigratorMultiEntryInvariantHandler( + airlock, + migrator, + numeraire, + winnerTokenA, + loserTokenA, + winnerTokenB, + loserTokenB, + address(oracleA), + address(oracleB), + winnerEntryIdA, + loserEntryIdA, + winnerEntryIdB, + loserEntryIdB, + alice, + bob + ); + + numeraire.mint(address(handler), HANDLER_NUMERAIRE_BALANCE); + + bytes4[] memory selectors = new bytes4[](10); + selectors[0] = handler.migrateWinnerOracleA.selector; + selectors[1] = handler.migrateLoserOracleA.selector; + selectors[2] = handler.migrateWinnerOracleB.selector; + selectors[3] = handler.migrateLoserOracleB.selector; + selectors[4] = handler.claimOracleA.selector; + selectors[5] = handler.claimOracleB.selector; + selectors[6] = handler.transferWinnerOracleATokens.selector; + selectors[7] = handler.transferLoserOracleATokens.selector; + selectors[8] = handler.transferWinnerOracleBTokens.selector; + selectors[9] = handler.transferLoserOracleBTokens.selector; + + targetSelector(FuzzSelector({ addr: address(handler), selectors: selectors })); + targetContract(address(handler)); + + excludeSender(address(0)); + excludeSender(address(this)); + excludeSender(address(handler)); + excludeSender(address(airlock)); + excludeSender(address(migrator)); + excludeSender(address(oracleA)); + excludeSender(address(oracleB)); + excludeSender(address(numeraire)); + excludeSender(address(winnerTokenA)); + excludeSender(address(loserTokenA)); + excludeSender(address(winnerTokenB)); + excludeSender(address(loserTokenB)); + excludeSender(alice); + excludeSender(bob); + } + + function invariant_MarketAndEntryAccountingMatchesGhostState_MultiEntry() public view { + _assertMarket(address(oracleA)); + _assertMarket(address(oracleB)); + _assertEntry(address(oracleA), winnerEntryIdA); + _assertEntry(address(oracleA), loserEntryIdA); + _assertEntry(address(oracleB), winnerEntryIdB); + _assertEntry(address(oracleB), loserEntryIdB); + } + + function invariant_GlobalNumeraireBalanceMatchesNetGhostFlows_MultiEntry() public view { + uint256 contributed = handler.ghost_totalContributed(); + uint256 claimed = handler.ghost_totalClaimed(); + + assertLe(claimed, contributed, "ghost claimed exceeds ghost contributed"); + assertEq( + numeraire.balanceOf(address(migrator)), + contributed - claimed, + "migrator numeraire balance does not match net ghost flow" + ); + } + + function invariant_GlobalGhostSumsMatchPerMarketGhostSums_MultiEntry() public view { + uint256 potSum = handler.ghost_marketPot(address(oracleA)) + handler.ghost_marketPot(address(oracleB)); + uint256 claimedSum = + handler.ghost_marketClaimed(address(oracleA)) + handler.ghost_marketClaimed(address(oracleB)); + + assertEq(potSum, handler.ghost_totalContributed(), "global contributed ghost does not match per-market sum"); + assertEq(claimedSum, handler.ghost_totalClaimed(), "global claimed ghost does not match per-market sum"); + } + + function invariant_MarketPotEqualsSumOfEntryContributions_MultiEntry() public view { + _assertMarketPotEqualsEntryContributions(address(oracleA), winnerEntryIdA, loserEntryIdA); + _assertMarketPotEqualsEntryContributions(address(oracleB), winnerEntryIdB, loserEntryIdB); + } + + function _assertMarket(address oracle) internal view { + IPredictionMigrator.MarketView memory market = migrator.getMarket(oracle); + assertEq(market.numeraire, address(numeraire), "market numeraire mismatch"); + assertEq(market.totalPot, handler.ghost_marketPot(oracle), "market totalPot mismatch"); + assertEq(market.totalClaimed, handler.ghost_marketClaimed(oracle), "market totalClaimed mismatch"); + assertLe(market.totalClaimed, market.totalPot, "market claimed exceeds pot"); + } + + function _assertEntry(address oracle, bytes32 entryId) internal view { + IPredictionMigrator.EntryView memory entry = migrator.getEntry(oracle, entryId); + assertEq(entry.contribution, handler.ghost_entryContribution(oracle, entryId), "entry contribution mismatch"); + assertEq(entry.isMigrated, handler.ghost_entryMigrated(oracle, entryId), "entry migrated mismatch"); + + if (entry.isMigrated) { + assertEq(entry.claimableSupply, ENTRY_SUPPLY, "claimable supply mismatch for migrated entry"); + } else { + assertEq(entry.claimableSupply, 0, "claimable supply should be zero before migration"); + } + } + + function _assertMarketPotEqualsEntryContributions(address oracle, bytes32 entryIdOne, bytes32 entryIdTwo) internal view { + IPredictionMigrator.MarketView memory market = migrator.getMarket(oracle); + IPredictionMigrator.EntryView memory entryOne = migrator.getEntry(oracle, entryIdOne); + IPredictionMigrator.EntryView memory entryTwo = migrator.getEntry(oracle, entryIdTwo); + + assertEq(market.totalPot, entryOne.contribution + entryTwo.contribution, "market pot != sum of entry contributions"); + } + + function _distributeToClaimants(InvariantPredictionERC20 token) internal { + token.transfer(alice, ENTRY_SUPPLY / 2); + token.transfer(bob, ENTRY_SUPPLY / 2); + } +} diff --git a/test/unit/dopplerHooks/NoSellDopplerHook.t.sol b/test/unit/dopplerHooks/NoSellDopplerHook.t.sol new file mode 100644 index 00000000..4d3b8f78 --- /dev/null +++ b/test/unit/dopplerHooks/NoSellDopplerHook.t.sol @@ -0,0 +1,227 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +import { IPoolManager } from "@v4-core/interfaces/IPoolManager.sol"; +import { BalanceDeltaLibrary, toBalanceDelta } from "@v4-core/types/BalanceDelta.sol"; +import { Currency } from "@v4-core/types/Currency.sol"; +import { PoolId, PoolIdLibrary } from "@v4-core/types/PoolId.sol"; +import { PoolKey } from "@v4-core/types/PoolKey.sol"; +import { Test } from "forge-std/Test.sol"; +import { SenderNotInitializer } from "src/base/BaseDopplerHook.sol"; +import { NoSellDopplerHook, SellsNotAllowed } from "src/dopplerHooks/NoSellDopplerHook.sol"; + +contract NoSellDopplerHookTest is Test { + using PoolIdLibrary for PoolKey; + + NoSellDopplerHook internal dopplerHook; + address internal initializer = makeAddr("initializer"); + + function setUp() public { + dopplerHook = new NoSellDopplerHook(initializer); + } + + /* -------------------------------------------------------------------------------- */ + /* constructor() */ + /* -------------------------------------------------------------------------------- */ + + function test_constructor_SetsInitializer() public view { + assertEq(dopplerHook.INITIALIZER(), initializer); + } + + /* -------------------------------------------------------------------------------- */ + /* onInitialization() */ + /* -------------------------------------------------------------------------------- */ + + function test_onInitialization_RevertsWhenSenderNotInitializer(address asset, PoolKey calldata poolKey) public { + vm.expectRevert(SenderNotInitializer.selector); + dopplerHook.onInitialization(asset, poolKey, new bytes(0)); + } + + function test_onInitialization_StoresIsAssetToken0_WhenAssetIsToken0(PoolKey calldata poolKey) public { + vm.assume(Currency.unwrap(poolKey.currency0) != Currency.unwrap(poolKey.currency1)); + + address asset = Currency.unwrap(poolKey.currency0); + PoolId poolId = poolKey.toId(); + + vm.prank(initializer); + dopplerHook.onInitialization(asset, poolKey, new bytes(0)); + + assertTrue(dopplerHook.isAssetToken0(poolId)); + } + + function test_onInitialization_StoresIsAssetToken0_WhenAssetIsToken1(PoolKey calldata poolKey) public { + vm.assume(Currency.unwrap(poolKey.currency0) != Currency.unwrap(poolKey.currency1)); + + address asset = Currency.unwrap(poolKey.currency1); + PoolId poolId = poolKey.toId(); + + vm.prank(initializer); + dopplerHook.onInitialization(asset, poolKey, new bytes(0)); + + assertFalse(dopplerHook.isAssetToken0(poolId)); + } + + /* -------------------------------------------------------------------------------- */ + /* onSwap() */ + /* -------------------------------------------------------------------------------- */ + + function test_onSwap_RevertsWhenSenderNotInitializer( + PoolKey calldata poolKey, + IPoolManager.SwapParams calldata swapParams + ) public { + vm.expectRevert(SenderNotInitializer.selector); + dopplerHook.onSwap(address(0), poolKey, swapParams, BalanceDeltaLibrary.ZERO_DELTA, new bytes(0)); + } + + function test_onSwap_AllowsBuy_WhenAssetIsToken0(PoolKey calldata poolKey) public { + vm.assume(Currency.unwrap(poolKey.currency0) != Currency.unwrap(poolKey.currency1)); + + // Asset is token0, so buying asset means zeroForOne = false (selling token1 for token0) + address asset = Currency.unwrap(poolKey.currency0); + + vm.prank(initializer); + dopplerHook.onInitialization(asset, poolKey, new bytes(0)); + + // Buy: zeroForOne = false (getting token0/asset by giving token1/numeraire) + IPoolManager.SwapParams memory swapParams = + IPoolManager.SwapParams({ zeroForOne: false, amountSpecified: -1e18, sqrtPriceLimitX96: 0 }); + + vm.prank(initializer); + (Currency feeCurrency, int128 feeAmount) = dopplerHook.onSwap( + address(0x123), + poolKey, + swapParams, + toBalanceDelta(int128(1e18), int128(-1e18)), // got token0, gave token1 + new bytes(0) + ); + + // Should succeed without revert, returning zero fee + assertEq(Currency.unwrap(feeCurrency), address(0)); + assertEq(feeAmount, 0); + } + + function test_onSwap_AllowsBuy_WhenAssetIsToken1(PoolKey calldata poolKey) public { + vm.assume(Currency.unwrap(poolKey.currency0) != Currency.unwrap(poolKey.currency1)); + + // Asset is token1, so buying asset means zeroForOne = true (selling token0 for token1) + address asset = Currency.unwrap(poolKey.currency1); + + vm.prank(initializer); + dopplerHook.onInitialization(asset, poolKey, new bytes(0)); + + // Buy: zeroForOne = true (getting token1/asset by giving token0/numeraire) + IPoolManager.SwapParams memory swapParams = + IPoolManager.SwapParams({ zeroForOne: true, amountSpecified: -1e18, sqrtPriceLimitX96: 0 }); + + vm.prank(initializer); + (Currency feeCurrency, int128 feeAmount) = dopplerHook.onSwap( + address(0x123), + poolKey, + swapParams, + toBalanceDelta(int128(-1e18), int128(1e18)), // gave token0, got token1 + new bytes(0) + ); + + // Should succeed without revert, returning zero fee + assertEq(Currency.unwrap(feeCurrency), address(0)); + assertEq(feeAmount, 0); + } + + function test_onSwap_RevertsSell_WhenAssetIsToken0(PoolKey calldata poolKey) public { + vm.assume(Currency.unwrap(poolKey.currency0) != Currency.unwrap(poolKey.currency1)); + + // Asset is token0, so selling asset means zeroForOne = true (selling token0 for token1) + address asset = Currency.unwrap(poolKey.currency0); + + vm.prank(initializer); + dopplerHook.onInitialization(asset, poolKey, new bytes(0)); + + // Sell: zeroForOne = true (selling token0/asset for token1/numeraire) + IPoolManager.SwapParams memory swapParams = + IPoolManager.SwapParams({ zeroForOne: true, amountSpecified: -1e18, sqrtPriceLimitX96: 0 }); + + vm.expectRevert(SellsNotAllowed.selector); + vm.prank(initializer); + dopplerHook.onSwap( + address(0x123), + poolKey, + swapParams, + toBalanceDelta(int128(-1e18), int128(1e18)), // gave token0, got token1 + new bytes(0) + ); + } + + function test_onSwap_RevertsSell_WhenAssetIsToken1(PoolKey calldata poolKey) public { + vm.assume(Currency.unwrap(poolKey.currency0) != Currency.unwrap(poolKey.currency1)); + + // Asset is token1, so selling asset means zeroForOne = false (selling token1 for token0) + address asset = Currency.unwrap(poolKey.currency1); + + vm.prank(initializer); + dopplerHook.onInitialization(asset, poolKey, new bytes(0)); + + // Sell: zeroForOne = false (selling token1/asset for token0/numeraire) + IPoolManager.SwapParams memory swapParams = + IPoolManager.SwapParams({ zeroForOne: false, amountSpecified: -1e18, sqrtPriceLimitX96: 0 }); + + vm.expectRevert(SellsNotAllowed.selector); + vm.prank(initializer); + dopplerHook.onSwap( + address(0x123), + poolKey, + swapParams, + toBalanceDelta(int128(1e18), int128(-1e18)), // got token0, gave token1 + new bytes(0) + ); + } + + /* -------------------------------------------------------------------------------- */ + /* Fuzz Tests for Swap Logic */ + /* -------------------------------------------------------------------------------- */ + + function testFuzz_onSwap_AllowsBuys(bool isToken0, PoolKey calldata poolKey) public { + vm.assume(Currency.unwrap(poolKey.currency0) != Currency.unwrap(poolKey.currency1)); + + address asset = Currency.unwrap(isToken0 ? poolKey.currency0 : poolKey.currency1); + + vm.prank(initializer); + dopplerHook.onInitialization(asset, poolKey, new bytes(0)); + + // Buying asset means: + // - If asset is token0: zeroForOne = false (sell token1 to get token0) + // - If asset is token1: zeroForOne = true (sell token0 to get token1) + bool zeroForOne = !isToken0; + + IPoolManager.SwapParams memory swapParams = + IPoolManager.SwapParams({ zeroForOne: zeroForOne, amountSpecified: -1e18, sqrtPriceLimitX96: 0 }); + + vm.prank(initializer); + (Currency feeCurrency, int128 feeAmount) = + dopplerHook.onSwap(address(0x123), poolKey, swapParams, BalanceDeltaLibrary.ZERO_DELTA, new bytes(0)); + + // Should succeed without revert + assertEq(Currency.unwrap(feeCurrency), address(0)); + assertEq(feeAmount, 0); + } + + function testFuzz_onSwap_RevertsSells(bool isToken0, PoolKey calldata poolKey) public { + vm.assume(Currency.unwrap(poolKey.currency0) != Currency.unwrap(poolKey.currency1)); + + address asset = Currency.unwrap(isToken0 ? poolKey.currency0 : poolKey.currency1); + + vm.prank(initializer); + dopplerHook.onInitialization(asset, poolKey, new bytes(0)); + + // Selling asset means: + // - If asset is token0: zeroForOne = true (sell token0 to get token1) + // - If asset is token1: zeroForOne = false (sell token1 to get token0) + bool zeroForOne = isToken0; + + IPoolManager.SwapParams memory swapParams = + IPoolManager.SwapParams({ zeroForOne: zeroForOne, amountSpecified: -1e18, sqrtPriceLimitX96: 0 }); + + vm.expectRevert(SellsNotAllowed.selector); + vm.prank(initializer); + dopplerHook.onSwap(address(0x123), poolKey, swapParams, BalanceDeltaLibrary.ZERO_DELTA, new bytes(0)); + } +} diff --git a/test/unit/migrators/PredictionMigrator.t.sol b/test/unit/migrators/PredictionMigrator.t.sol new file mode 100644 index 00000000..67326ef0 --- /dev/null +++ b/test/unit/migrators/PredictionMigrator.t.sol @@ -0,0 +1,956 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +import { ERC20 } from "@solmate/tokens/ERC20.sol"; +import { Test } from "forge-std/Test.sol"; +import { SenderNotAirlock } from "src/base/ImmutableAirlock.sol"; +import { AlreadyFinalized, MockPredictionOracle, OnlyOwner } from "src/base/MockPredictionOracle.sol"; +import { IPredictionMigrator } from "src/interfaces/IPredictionMigrator.sol"; +import { IPredictionOracle } from "src/interfaces/IPredictionOracle.sol"; +import { PredictionMigrator } from "src/migrators/PredictionMigrator.sol"; + +/// @dev Simple ERC20 mock for testing with totalSupply support and burn() +contract MockERC20 is ERC20 { + constructor(string memory name_, string memory symbol_, uint256 initialSupply) ERC20(name_, symbol_, 18) { + _mint(msg.sender, initialSupply); + } + + function mint(address to, uint256 amount) external { + _mint(to, amount); + } + + /// @notice Burns tokens from the caller's balance (matches DERC20/CloneERC20 interface) + function burn(uint256 amount) external { + _burn(msg.sender, amount); + } +} + +/// @dev ERC20 mock intentionally missing burn() to test strict burn requirement. +contract MockERC20NoBurn is ERC20 { + constructor(string memory name_, string memory symbol_, uint256 initialSupply) ERC20(name_, symbol_, 18) { + _mint(msg.sender, initialSupply); + } + + function mint(address to, uint256 amount) external { + _mint(to, amount); + } +} + +contract PredictionMigratorTest is Test { + PredictionMigrator public migrator; + MockPredictionOracle public oracle; + MockPredictionOracle public oracle2; + + address public airlock; + address public alice = makeAddr("alice"); + address public bob = makeAddr("bob"); + + MockERC20 public tokenA; + MockERC20 public tokenB; + MockERC20 public tokenC; + MockERC20 public numeraire; + + bytes32 public entryIdA; + bytes32 public entryIdB; + bytes32 public entryIdC; + + function setUp() public { + airlock = address(this); // Test contract acts as Airlock + migrator = new PredictionMigrator(airlock); + oracle = new MockPredictionOracle(); + oracle2 = new MockPredictionOracle(); + + tokenA = new MockERC20("Token A", "TKNA", 1_000_000 ether); + tokenB = new MockERC20("Token B", "TKNB", 1_000_000 ether); + tokenC = new MockERC20("Token C", "TKNC", 1_000_000 ether); + numeraire = new MockERC20("Numeraire", "NUM", 1_000_000 ether); + + entryIdA = keccak256(abi.encodePacked("entry_a")); + entryIdB = keccak256(abi.encodePacked("entry_b")); + entryIdC = keccak256(abi.encodePacked("entry_c")); + } + + /* -------------------------------------------------------------------------------- */ + /* constructor() */ + /* -------------------------------------------------------------------------------- */ + + function test_constructor_SetsAirlock() public view { + assertEq(address(migrator.airlock()), airlock); + } + + function test_receive_AcceptsETH() public { + deal(address(this), 1 ether); + payable(address(migrator)).transfer(1 ether); + assertEq(address(migrator).balance, 1 ether); + } + + /* -------------------------------------------------------------------------------- */ + /* initialize() */ + /* -------------------------------------------------------------------------------- */ + + function test_initialize_RevertsWhenSenderNotAirlock() public { + vm.prank(alice); + vm.expectRevert(SenderNotAirlock.selector); + migrator.initialize(address(tokenA), address(numeraire), abi.encode(address(oracle), entryIdA)); + } + + function test_initialize_RegistersEntry() public { + migrator.initialize(address(tokenA), address(numeraire), abi.encode(address(oracle), entryIdA)); + + IPredictionMigrator.EntryView memory entry = migrator.getEntry(address(oracle), entryIdA); + assertEq(entry.token, address(tokenA)); + assertEq(entry.oracle, address(oracle)); + assertEq(entry.entryId, entryIdA); + assertEq(entry.contribution, 0); + assertEq(entry.claimableSupply, 0); + assertFalse(entry.isMigrated); + } + + function test_initialize_SetsMarketNumeraireOnFirstEntry() public { + migrator.initialize(address(tokenA), address(numeraire), abi.encode(address(oracle), entryIdA)); + + IPredictionMigrator.MarketView memory market = migrator.getMarket(address(oracle)); + assertEq(market.numeraire, address(numeraire)); + assertEq(market.totalPot, 0); + assertFalse(market.isResolved); + } + + function test_initialize_AllowsMultipleEntriesWithSameNumeraire() public { + migrator.initialize(address(tokenA), address(numeraire), abi.encode(address(oracle), entryIdA)); + migrator.initialize(address(tokenB), address(numeraire), abi.encode(address(oracle), entryIdB)); + + IPredictionMigrator.EntryView memory entryA = migrator.getEntry(address(oracle), entryIdA); + IPredictionMigrator.EntryView memory entryB = migrator.getEntry(address(oracle), entryIdB); + + assertEq(entryA.token, address(tokenA)); + assertEq(entryB.token, address(tokenB)); + } + + function test_initialize_AllowsMultipleEntriesWithETHNumeraire() public { + migrator.initialize(address(tokenA), address(0), abi.encode(address(oracle), entryIdA)); + migrator.initialize(address(tokenB), address(0), abi.encode(address(oracle), entryIdB)); + + IPredictionMigrator.EntryView memory entryA = migrator.getEntry(address(oracle), entryIdA); + IPredictionMigrator.EntryView memory entryB = migrator.getEntry(address(oracle), entryIdB); + IPredictionMigrator.MarketView memory market = migrator.getMarket(address(oracle)); + + assertEq(entryA.token, address(tokenA)); + assertEq(entryB.token, address(tokenB)); + assertEq(market.numeraire, address(0)); + } + + function test_initialize_RevertsOnDuplicateToken() public { + migrator.initialize(address(tokenA), address(numeraire), abi.encode(address(oracle), entryIdA)); + + vm.expectRevert(IPredictionMigrator.EntryAlreadyExists.selector); + migrator.initialize(address(tokenA), address(numeraire), abi.encode(address(oracle), entryIdB)); + } + + function test_initialize_RevertsOnDuplicateEntryId() public { + migrator.initialize(address(tokenA), address(numeraire), abi.encode(address(oracle), entryIdA)); + + vm.expectRevert(IPredictionMigrator.EntryIdAlreadyUsed.selector); + migrator.initialize(address(tokenB), address(numeraire), abi.encode(address(oracle), entryIdA)); + } + + function test_initialize_RevertsOnNumeraireMismatch() public { + MockERC20 otherNumeraire = new MockERC20("Other", "OTH", 1_000_000 ether); + + migrator.initialize(address(tokenA), address(numeraire), abi.encode(address(oracle), entryIdA)); + + vm.expectRevert(IPredictionMigrator.NumeraireMismatch.selector); + migrator.initialize(address(tokenB), address(otherNumeraire), abi.encode(address(oracle), entryIdB)); + } + + function test_initialize_RevertsOnNumeraireMismatch_WhenMarketNumeraireIsETH() public { + migrator.initialize(address(tokenA), address(0), abi.encode(address(oracle), entryIdA)); + + vm.expectRevert(IPredictionMigrator.NumeraireMismatch.selector); + migrator.initialize(address(tokenB), address(numeraire), abi.encode(address(oracle), entryIdB)); + } + + function test_initialize_ReturnsZeroAddress() public { + address pool = migrator.initialize(address(tokenA), address(numeraire), abi.encode(address(oracle), entryIdA)); + assertEq(pool, address(0)); + } + + function test_initialize_EmitsEntryRegistered() public { + vm.expectEmit(true, true, false, true); + emit IPredictionMigrator.EntryRegistered(address(oracle), entryIdA, address(tokenA), address(numeraire)); + + migrator.initialize(address(tokenA), address(numeraire), abi.encode(address(oracle), entryIdA)); + } + + /* -------------------------------------------------------------------------------- */ + /* migrate() */ + /* -------------------------------------------------------------------------------- */ + + function test_migrate_RevertsWhenSenderNotAirlock() public { + vm.prank(alice); + vm.expectRevert(SenderNotAirlock.selector); + migrator.migrate(0, address(tokenA), address(numeraire), address(0)); + } + + function test_migrate_RevertsWhenEntryNotRegistered() public { + oracle.setWinner(address(tokenA)); + + vm.expectRevert(IPredictionMigrator.EntryNotRegistered.selector); + migrator.migrate(0, address(tokenA), address(numeraire), address(0)); + } + + function test_migrate_RevertsWhenOracleNotFinalized() public { + migrator.initialize(address(tokenA), address(numeraire), abi.encode(address(oracle), entryIdA)); + + // Oracle not finalized yet + vm.expectRevert(IPredictionMigrator.OracleNotFinalized.selector); + migrator.migrate(0, address(tokenA), address(numeraire), address(0)); + } + + function test_migrate_RevertsWhenAlreadyMigrated() public { + migrator.initialize(address(tokenA), address(numeraire), abi.encode(address(oracle), entryIdA)); + oracle.setWinner(address(tokenA)); + + // Transfer tokens to migrator (simulating Airlock behavior) + numeraire.transfer(address(migrator), 100 ether); + tokenA.transfer(address(migrator), 500_000 ether); // unsold tokens + + migrator.migrate(0, address(tokenA), address(numeraire), address(0)); + + vm.expectRevert(IPredictionMigrator.AlreadyMigrated.selector); + migrator.migrate(0, address(tokenA), address(numeraire), address(0)); + } + + function test_migrate_UpdatesEntryState() public { + migrator.initialize(address(tokenA), address(numeraire), abi.encode(address(oracle), entryIdA)); + oracle.setWinner(address(tokenA)); + + // Transfer tokens to migrator (simulating Airlock behavior) + uint256 numeraireAmount = 100 ether; + uint256 unsoldTokens = 400_000 ether; + numeraire.transfer(address(migrator), numeraireAmount); + tokenA.transfer(address(migrator), unsoldTokens); + + migrator.migrate(0, address(tokenA), address(numeraire), address(0)); + + IPredictionMigrator.EntryView memory entry = migrator.getEntry(address(oracle), entryIdA); + assertEq(entry.contribution, numeraireAmount); + assertEq(entry.claimableSupply, 1_000_000 ether - unsoldTokens); // totalSupply - unsold + assertTrue(entry.isMigrated); + } + + function test_migrate_UpdatesMarketPot() public { + migrator.initialize(address(tokenA), address(numeraire), abi.encode(address(oracle), entryIdA)); + oracle.setWinner(address(tokenA)); + + numeraire.transfer(address(migrator), 100 ether); + tokenA.transfer(address(migrator), 400_000 ether); + + migrator.migrate(0, address(tokenA), address(numeraire), address(0)); + + IPredictionMigrator.MarketView memory market = migrator.getMarket(address(oracle)); + assertEq(market.totalPot, 100 ether); + } + + function test_migrate_BurnsUnsoldTokens() public { + migrator.initialize(address(tokenA), address(numeraire), abi.encode(address(oracle), entryIdA)); + oracle.setWinner(address(tokenA)); + + uint256 unsoldTokens = 400_000 ether; + numeraire.transfer(address(migrator), 100 ether); + tokenA.transfer(address(migrator), unsoldTokens); + + uint256 totalSupplyBefore = tokenA.totalSupply(); + + migrator.migrate(0, address(tokenA), address(numeraire), address(0)); + + // Unsold tokens should be burned (supply decreased) + assertEq(tokenA.totalSupply(), totalSupplyBefore - unsoldTokens); + assertEq(tokenA.balanceOf(address(migrator)), 0); + } + + function test_migrate_HandlesZeroUnsoldTokens() public { + migrator.initialize(address(tokenA), address(numeraire), abi.encode(address(oracle), entryIdA)); + oracle.setWinner(address(tokenA)); + + // All tokens sold, only numeraire transferred + numeraire.transfer(address(migrator), 100 ether); + + migrator.migrate(0, address(tokenA), address(numeraire), address(0)); + + IPredictionMigrator.EntryView memory entry = migrator.getEntry(address(oracle), entryIdA); + assertEq(entry.claimableSupply, 1_000_000 ether); // All tokens claimable + } + + function test_migrate_EmitsEntryMigrated() public { + migrator.initialize(address(tokenA), address(numeraire), abi.encode(address(oracle), entryIdA)); + oracle.setWinner(address(tokenA)); + + uint256 numeraireAmount = 100 ether; + uint256 unsoldTokens = 400_000 ether; + numeraire.transfer(address(migrator), numeraireAmount); + tokenA.transfer(address(migrator), unsoldTokens); + + vm.expectEmit(true, true, false, true); + emit IPredictionMigrator.EntryMigrated( + address(oracle), entryIdA, address(tokenA), numeraireAmount, 1_000_000 ether - unsoldTokens + ); + + migrator.migrate(0, address(tokenA), address(numeraire), address(0)); + } + + function test_migrate_AggregatesPotAcrossMultipleEntries() public { + migrator.initialize(address(tokenA), address(numeraire), abi.encode(address(oracle), entryIdA)); + migrator.initialize(address(tokenB), address(numeraire), abi.encode(address(oracle), entryIdB)); + oracle.setWinner(address(tokenA)); + + // Migrate first entry + numeraire.transfer(address(migrator), 100 ether); + tokenA.transfer(address(migrator), 400_000 ether); + migrator.migrate(0, address(tokenA), address(numeraire), address(0)); + + // Migrate second entry + numeraire.transfer(address(migrator), 50 ether); + tokenB.transfer(address(migrator), 600_000 ether); + migrator.migrate(0, address(tokenB), address(numeraire), address(0)); + + IPredictionMigrator.MarketView memory market = migrator.getMarket(address(oracle)); + assertEq(market.totalPot, 150 ether); // 100 + 50 + } + + function test_migrate_RevertsWhenBothPairTokensAreRegisteredEntries() public { + migrator.initialize(address(tokenA), address(numeraire), abi.encode(address(oracle), entryIdA)); + migrator.initialize(address(tokenB), address(numeraire), abi.encode(address(oracle), entryIdB)); + oracle.setWinner(address(tokenA)); + + vm.expectRevert(IPredictionMigrator.InvalidTokenPair.selector); + migrator.migrate(0, address(tokenA), address(tokenB), address(0)); + } + + function test_migrate_RevertsOnPairNumeraireMismatch() public { + MockERC20 otherNumeraire = new MockERC20("Other Numeraire", "ONUM", 1_000_000 ether); + + migrator.initialize(address(tokenA), address(numeraire), abi.encode(address(oracle), entryIdA)); + oracle.setWinner(address(tokenA)); + + vm.expectRevert(IPredictionMigrator.NumeraireMismatch.selector); + migrator.migrate(0, address(tokenA), address(otherNumeraire), address(0)); + } + + function test_migrate_HandlesTokenOrdering_WhenAssetIsToken1() public { + migrator.initialize(address(tokenA), address(numeraire), abi.encode(address(oracle), entryIdA)); + oracle.setWinner(address(tokenA)); + + uint256 numeraireAmount = 100 ether; + uint256 unsoldTokens = 400_000 ether; + numeraire.transfer(address(migrator), numeraireAmount); + tokenA.transfer(address(migrator), unsoldTokens); + + // Asset is token1 in this ordering. + migrator.migrate(0, address(numeraire), address(tokenA), address(0)); + + IPredictionMigrator.EntryView memory entry = migrator.getEntry(address(oracle), entryIdA); + IPredictionMigrator.MarketView memory market = migrator.getMarket(address(oracle)); + assertEq(entry.contribution, numeraireAmount); + assertEq(entry.claimableSupply, 1_000_000 ether - unsoldTokens); + assertEq(market.totalPot, numeraireAmount); + } + + function test_migrate_RevertsWhenBurnUnavailable() public { + MockERC20NoBurn tokenNoBurn = new MockERC20NoBurn("Token No Burn", "NOBURN", 1_000_000 ether); + bytes32 entryIdNoBurn = keccak256(abi.encodePacked("entry_no_burn")); + + migrator.initialize(address(tokenNoBurn), address(numeraire), abi.encode(address(oracle), entryIdNoBurn)); + oracle.setWinner(address(tokenNoBurn)); + + uint256 unsoldTokens = 400_000 ether; + numeraire.transfer(address(migrator), 100 ether); + tokenNoBurn.transfer(address(migrator), unsoldTokens); + + vm.expectRevert(); + migrator.migrate(0, address(tokenNoBurn), address(numeraire), address(0)); + } + + function test_migrate_MultiMarketSharedNumeraire_AttributesOnlyPerMigrationDelta() public { + migrator.initialize(address(tokenA), address(numeraire), abi.encode(address(oracle), entryIdA)); + migrator.initialize(address(tokenC), address(numeraire), abi.encode(address(oracle2), entryIdC)); + oracle.setWinner(address(tokenA)); + oracle2.setWinner(address(tokenC)); + + numeraire.transfer(address(migrator), 100 ether); + tokenA.transfer(address(migrator), 400_000 ether); + migrator.migrate(0, address(tokenA), address(numeraire), address(0)); + + numeraire.transfer(address(migrator), 50 ether); + tokenC.transfer(address(migrator), 500_000 ether); + migrator.migrate(0, address(tokenC), address(numeraire), address(0)); + + IPredictionMigrator.EntryView memory entryA = migrator.getEntry(address(oracle), entryIdA); + IPredictionMigrator.EntryView memory entryC = migrator.getEntry(address(oracle2), entryIdC); + IPredictionMigrator.MarketView memory marketA = migrator.getMarket(address(oracle)); + IPredictionMigrator.MarketView memory marketC = migrator.getMarket(address(oracle2)); + + assertEq(entryA.contribution, 100 ether); + assertEq(entryC.contribution, 50 ether); + assertEq(marketA.totalPot, 100 ether); + assertEq(marketC.totalPot, 50 ether); + } + + function test_migrate_MultiMarketSharedNumeraire_ClaimInMarketADoesNotContaminateMarketB() public { + migrator.initialize(address(tokenA), address(numeraire), abi.encode(address(oracle), entryIdA)); + migrator.initialize(address(tokenC), address(numeraire), abi.encode(address(oracle2), entryIdC)); + oracle.setWinner(address(tokenA)); + oracle2.setWinner(address(tokenC)); + + // Migrate market A entry + numeraire.transfer(address(migrator), 100 ether); + tokenA.transfer(address(migrator), 400_000 ether); + migrator.migrate(0, address(tokenA), address(numeraire), address(0)); + + // Claim from market A before market B migration + tokenA.transfer(alice, 300_000 ether); + vm.startPrank(alice); + tokenA.approve(address(migrator), 300_000 ether); + migrator.claim(address(oracle), 300_000 ether); // 50 ether payout + vm.stopPrank(); + assertEq(numeraire.balanceOf(alice), 50 ether); + + // Migrate market B entry and verify only this migration's transfer is attributed. + numeraire.transfer(address(migrator), 40 ether); + tokenC.transfer(address(migrator), 500_000 ether); + migrator.migrate(0, address(tokenC), address(numeraire), address(0)); + + IPredictionMigrator.EntryView memory entryC = migrator.getEntry(address(oracle2), entryIdC); + IPredictionMigrator.MarketView memory marketC = migrator.getMarket(address(oracle2)); + assertEq(entryC.contribution, 40 ether); + assertEq(marketC.totalPot, 40 ether); + } + + function testFuzz_migrate_MultiMarketSharedNumeraire_ClaimGapIsolation( + uint128 amountASeed, + uint128 amountBSeed, + uint128 claimTokenSeed + ) public { + uint256 amountA = bound(uint256(amountASeed), 1, 250_000 ether); + uint256 amountB = bound(uint256(amountBSeed), 1, 250_000 ether); + uint256 claimTokens = bound(uint256(claimTokenSeed), 1, 300_000 ether); + + migrator.initialize(address(tokenA), address(numeraire), abi.encode(address(oracle), entryIdA)); + migrator.initialize(address(tokenC), address(numeraire), abi.encode(address(oracle2), entryIdC)); + oracle.setWinner(address(tokenA)); + oracle2.setWinner(address(tokenC)); + + // Market A migrates first. + numeraire.transfer(address(migrator), amountA); + tokenA.transfer(address(migrator), 400_000 ether); + migrator.migrate(0, address(tokenA), address(numeraire), address(0)); + + // Claim from market A before market B migration. + tokenA.transfer(alice, claimTokens); + vm.startPrank(alice); + tokenA.approve(address(migrator), claimTokens); + migrator.claim(address(oracle), claimTokens); + vm.stopPrank(); + + // Market B migration contribution must equal only its own transfer. + numeraire.transfer(address(migrator), amountB); + tokenC.transfer(address(migrator), 500_000 ether); + migrator.migrate(0, address(tokenC), address(numeraire), address(0)); + + IPredictionMigrator.EntryView memory entryB = migrator.getEntry(address(oracle2), entryIdC); + IPredictionMigrator.MarketView memory marketB = migrator.getMarket(address(oracle2)); + assertEq(entryB.contribution, amountB); + assertEq(marketB.totalPot, amountB); + } + + function testFuzz_claim_PreviewMatchesPayout_AndClaimedNeverExceedsPot( + uint128 migratedAmountSeed, + uint128 aliceClaimSeed, + uint128 bobClaimSeed + ) public { + uint256 migratedAmount = bound(uint256(migratedAmountSeed), 1, 500_000 ether); + + migrator.initialize(address(tokenA), address(numeraire), abi.encode(address(oracle), entryIdA)); + oracle.setWinner(address(tokenA)); + + // Fixed unsold amount => fixed claimable supply of 600k. + numeraire.transfer(address(migrator), migratedAmount); + tokenA.transfer(address(migrator), 400_000 ether); + migrator.migrate(0, address(tokenA), address(numeraire), address(0)); + + uint256 aliceClaimTokens = bound(uint256(aliceClaimSeed), 0, 600_000 ether); + uint256 bobClaimTokens = bound(uint256(bobClaimSeed), 0, 600_000 ether - aliceClaimTokens); + + uint256 expectedAlice = migrator.previewClaim(address(oracle), aliceClaimTokens); + uint256 expectedBob = migrator.previewClaim(address(oracle), bobClaimTokens); + + if (aliceClaimTokens > 0) { + tokenA.transfer(alice, aliceClaimTokens); + vm.startPrank(alice); + tokenA.approve(address(migrator), aliceClaimTokens); + migrator.claim(address(oracle), aliceClaimTokens); + vm.stopPrank(); + } + + if (bobClaimTokens > 0) { + tokenA.transfer(bob, bobClaimTokens); + vm.startPrank(bob); + tokenA.approve(address(migrator), bobClaimTokens); + migrator.claim(address(oracle), bobClaimTokens); + vm.stopPrank(); + } + + IPredictionMigrator.MarketView memory market = migrator.getMarket(address(oracle)); + assertEq(numeraire.balanceOf(alice), expectedAlice); + assertEq(numeraire.balanceOf(bob), expectedBob); + assertEq(market.totalClaimed, expectedAlice + expectedBob); + assertLe(market.totalClaimed, market.totalPot); + } + + function testFuzz_migrate_TokenOrderingAssetAsToken1_ContributionMatchesTransfer(uint128 amountSeed) public { + uint256 amount = bound(uint256(amountSeed), 1, 500_000 ether); + + migrator.initialize(address(tokenA), address(numeraire), abi.encode(address(oracle), entryIdA)); + oracle.setWinner(address(tokenA)); + + numeraire.transfer(address(migrator), amount); + tokenA.transfer(address(migrator), 400_000 ether); + + // Asset is token1 in this ordering. + migrator.migrate(0, address(numeraire), address(tokenA), address(0)); + + IPredictionMigrator.EntryView memory entry = migrator.getEntry(address(oracle), entryIdA); + assertEq(entry.contribution, amount); + } + + /* -------------------------------------------------------------------------------- */ + /* claim() */ + /* -------------------------------------------------------------------------------- */ + + function _setupWinningEntry() internal returns (uint256 claimableSupply) { + migrator.initialize(address(tokenA), address(numeraire), abi.encode(address(oracle), entryIdA)); + migrator.initialize(address(tokenB), address(numeraire), abi.encode(address(oracle), entryIdB)); + oracle.setWinner(address(tokenA)); + + // Migrate winning entry + numeraire.transfer(address(migrator), 100 ether); + tokenA.transfer(address(migrator), 400_000 ether); + migrator.migrate(0, address(tokenA), address(numeraire), address(0)); + + // Migrate losing entry (adds to pot) + numeraire.transfer(address(migrator), 50 ether); + tokenB.transfer(address(migrator), 600_000 ether); + migrator.migrate(0, address(tokenB), address(numeraire), address(0)); + + claimableSupply = 600_000 ether; // tokenA totalSupply - unsold + } + + function test_claim_RevertsWhenOracleNotFinalized() public { + migrator.initialize(address(tokenA), address(numeraire), abi.encode(address(oracle), entryIdA)); + // Oracle not finalized + + vm.expectRevert(IPredictionMigrator.OracleNotFinalized.selector); + migrator.claim(address(oracle), 1 ether); + } + + function test_claim_RevertsWhenWinningEntryNotMigrated() public { + migrator.initialize(address(tokenA), address(numeraire), abi.encode(address(oracle), entryIdA)); + oracle.setWinner(address(tokenA)); + + // Entry not migrated yet + tokenA.transfer(alice, 100 ether); + vm.startPrank(alice); + tokenA.approve(address(migrator), 100 ether); + + vm.expectRevert(IPredictionMigrator.WinningEntryNotMigrated.selector); + migrator.claim(address(oracle), 100 ether); + } + + function test_claim_TransfersCorrectProRataAmount() public { + uint256 claimableSupply = _setupWinningEntry(); + uint256 totalPot = 150 ether; // 100 + 50 + + // Give alice some winning tokens (directly, simulating purchase) + tokenA.transfer(alice, 100_000 ether); + + vm.startPrank(alice); + tokenA.approve(address(migrator), 100_000 ether); + + uint256 expectedClaim = (100_000 ether * totalPot) / claimableSupply; + uint256 aliceNumeraireBefore = numeraire.balanceOf(alice); + + migrator.claim(address(oracle), 100_000 ether); + + assertEq(numeraire.balanceOf(alice) - aliceNumeraireBefore, expectedClaim); + vm.stopPrank(); + } + + function test_claim_TransfersTokensToMigrator() public { + _setupWinningEntry(); + + tokenA.transfer(alice, 100_000 ether); + + vm.startPrank(alice); + tokenA.approve(address(migrator), 100_000 ether); + + uint256 migratorBalanceBefore = tokenA.balanceOf(address(migrator)); + + migrator.claim(address(oracle), 100_000 ether); + + assertEq(tokenA.balanceOf(address(migrator)) - migratorBalanceBefore, 100_000 ether); + assertEq(tokenA.balanceOf(alice), 0); + vm.stopPrank(); + } + + function test_claim_EmitsClaimed() public { + uint256 claimableSupply = _setupWinningEntry(); + uint256 totalPot = 150 ether; + uint256 claimAmount = 100_000 ether; + uint256 expectedNumeraire = (claimAmount * totalPot) / claimableSupply; + + tokenA.transfer(alice, claimAmount); + + vm.startPrank(alice); + tokenA.approve(address(migrator), claimAmount); + + vm.expectEmit(true, true, false, true); + emit IPredictionMigrator.Claimed(address(oracle), alice, claimAmount, expectedNumeraire); + + migrator.claim(address(oracle), claimAmount); + vm.stopPrank(); + } + + function test_claim_LazilyResolvesMarket() public { + _setupWinningEntry(); + + IPredictionMigrator.MarketView memory marketBefore = migrator.getMarket(address(oracle)); + assertFalse(marketBefore.isResolved); + + tokenA.transfer(alice, 100_000 ether); + vm.startPrank(alice); + tokenA.approve(address(migrator), 100_000 ether); + migrator.claim(address(oracle), 100_000 ether); + vm.stopPrank(); + + IPredictionMigrator.MarketView memory marketAfter = migrator.getMarket(address(oracle)); + assertTrue(marketAfter.isResolved); + assertEq(marketAfter.winningToken, address(tokenA)); + } + + function test_claim_AllowsMultipleClaims() public { + uint256 claimableSupply = _setupWinningEntry(); + uint256 totalPot = 150 ether; + + tokenA.transfer(alice, 200_000 ether); + tokenA.transfer(bob, 100_000 ether); + + // Alice claims + vm.startPrank(alice); + tokenA.approve(address(migrator), 200_000 ether); + migrator.claim(address(oracle), 200_000 ether); + vm.stopPrank(); + + // Bob claims + vm.startPrank(bob); + tokenA.approve(address(migrator), 100_000 ether); + migrator.claim(address(oracle), 100_000 ether); + vm.stopPrank(); + + uint256 aliceExpected = (200_000 ether * totalPot) / claimableSupply; + uint256 bobExpected = (100_000 ether * totalPot) / claimableSupply; + + assertEq(numeraire.balanceOf(alice), aliceExpected); + assertEq(numeraire.balanceOf(bob), bobExpected); + } + + /* -------------------------------------------------------------------------------- */ + /* previewClaim() */ + /* -------------------------------------------------------------------------------- */ + + function test_previewClaim_ReturnsCorrectAmount() public { + uint256 claimableSupply = _setupWinningEntry(); + uint256 totalPot = 150 ether; + uint256 tokenAmount = 100_000 ether; + + uint256 expected = (tokenAmount * totalPot) / claimableSupply; + uint256 preview = migrator.previewClaim(address(oracle), tokenAmount); + + assertEq(preview, expected); + } + + function test_previewClaim_ReturnsZeroWhenNoClaimableSupply() public { + migrator.initialize(address(tokenA), address(numeraire), abi.encode(address(oracle), entryIdA)); + // Entry exists but not migrated, so claimableSupply is 0 + + uint256 preview = migrator.previewClaim(address(oracle), 100_000 ether); + assertEq(preview, 0); + } + + /* -------------------------------------------------------------------------------- */ + /* getEntryByToken() */ + /* -------------------------------------------------------------------------------- */ + + function test_getEntryByToken_ReturnsCorrectEntry() public { + migrator.initialize(address(tokenA), address(numeraire), abi.encode(address(oracle), entryIdA)); + + IPredictionMigrator.EntryView memory entry = migrator.getEntryByToken(address(oracle), address(tokenA)); + assertEq(entry.token, address(tokenA)); + assertEq(entry.entryId, entryIdA); + } + + /* -------------------------------------------------------------------------------- */ + /* ETH Numeraire Tests */ + /* -------------------------------------------------------------------------------- */ + + function test_migrate_WithETHNumeraire() public { + // Use address(0) for ETH + migrator.initialize(address(tokenA), address(0), abi.encode(address(oracle), entryIdA)); + oracle.setWinner(address(tokenA)); + + // Send ETH to migrator + deal(address(this), 100 ether); + payable(address(migrator)).transfer(100 ether); + tokenA.transfer(address(migrator), 400_000 ether); + + migrator.migrate(0, address(tokenA), address(0), address(0)); + + IPredictionMigrator.MarketView memory market = migrator.getMarket(address(oracle)); + assertEq(market.totalPot, 100 ether); + assertEq(market.numeraire, address(0)); + } + + function test_claim_WithETHNumeraire() public { + // Setup with ETH numeraire + migrator.initialize(address(tokenA), address(0), abi.encode(address(oracle), entryIdA)); + oracle.setWinner(address(tokenA)); + + deal(address(this), 100 ether); + payable(address(migrator)).transfer(100 ether); + tokenA.transfer(address(migrator), 400_000 ether); + + migrator.migrate(0, address(tokenA), address(0), address(0)); + + uint256 claimableSupply = 600_000 ether; + uint256 claimAmount = 100_000 ether; + uint256 expectedETH = (claimAmount * 100 ether) / claimableSupply; + + tokenA.transfer(alice, claimAmount); + + vm.startPrank(alice); + tokenA.approve(address(migrator), claimAmount); + + uint256 aliceETHBefore = alice.balance; + migrator.claim(address(oracle), claimAmount); + uint256 aliceETHAfter = alice.balance; + + assertEq(aliceETHAfter - aliceETHBefore, expectedETH); + vm.stopPrank(); + } + + /* -------------------------------------------------------------------------------- */ + /* BUG TESTS: Claim Before All Migrate */ + /* -------------------------------------------------------------------------------- */ + + /** + * @notice This test verifies the fix for claiming before all entries migrate. + * Global per-numeraire accounting ensures correct numeraire amount calculation. + * + * Scenario: + * 1. Entry A (winner) and Entry B (loser) both register + * 2. Oracle finalizes, Entry A wins + * 3. Entry A migrates with 100 ETH -> totalPot = 100 + * 4. User claims 50 ETH -> balance = 50, totalClaimed = 50 + * 5. Entry B migrates with 40 ETH -> balance = 90 + * numeraireAmount = 90 - 50(accounted after claim) = 40 ✓ + */ + function test_migrate_ClaimBeforeAllMigrate_Works() public { + // Setup: Register both entries + migrator.initialize(address(tokenA), address(numeraire), abi.encode(address(oracle), entryIdA)); + migrator.initialize(address(tokenB), address(numeraire), abi.encode(address(oracle), entryIdB)); + + // Oracle finalizes - Entry A wins + oracle.setWinner(address(tokenA)); + + // Entry A migrates with 100 numeraire + numeraire.transfer(address(migrator), 100 ether); + tokenA.transfer(address(migrator), 400_000 ether); // unsold tokens + migrator.migrate(0, address(tokenA), address(numeraire), address(0)); + + // Verify Entry A migrated correctly + IPredictionMigrator.MarketView memory marketAfterA = migrator.getMarket(address(oracle)); + assertEq(marketAfterA.totalPot, 100 ether); + assertEq(marketAfterA.totalClaimed, 0); + + // User claims 50% of their tokens (gets 50 ETH out) + tokenA.transfer(alice, 300_000 ether); // Give alice half the claimable supply (600k) + + vm.startPrank(alice); + tokenA.approve(address(migrator), 300_000 ether); + migrator.claim(address(oracle), 300_000 ether); // Claims 50 ETH + vm.stopPrank(); + + // Verify claim worked - alice should have 50 ETH + assertEq(numeraire.balanceOf(alice), 50 ether); + + // Verify totalClaimed was incremented + IPredictionMigrator.MarketView memory marketAfterClaim = migrator.getMarket(address(oracle)); + assertEq(marketAfterClaim.totalPot, 100 ether); + assertEq(marketAfterClaim.totalClaimed, 50 ether); + + // Entry B migrates with 40 numeraire - should work now! + numeraire.transfer(address(migrator), 40 ether); + tokenB.transfer(address(migrator), 600_000 ether); // unsold tokens + migrator.migrate(0, address(tokenB), address(numeraire), address(0)); + + // Verify Entry B migrated correctly + IPredictionMigrator.MarketView memory marketFinal = migrator.getMarket(address(oracle)); + assertEq(marketFinal.totalPot, 140 ether); // 100 + 40 + assertEq(marketFinal.totalClaimed, 50 ether); + + // Verify entry B contribution + IPredictionMigrator.EntryView memory entryB = migrator.getEntry(address(oracle), entryIdB); + assertEq(entryB.contribution, 40 ether); + assertTrue(entryB.isMigrated); + } + + /** + * @notice Test that demonstrates the correct behavior when all entries + * migrate BEFORE any claims happen. + */ + function test_migrate_AllEntriesMigrateBeforeClaims_Works() public { + // Setup: Register both entries + migrator.initialize(address(tokenA), address(numeraire), abi.encode(address(oracle), entryIdA)); + migrator.initialize(address(tokenB), address(numeraire), abi.encode(address(oracle), entryIdB)); + + // Oracle finalizes - Entry A wins + oracle.setWinner(address(tokenA)); + + // Entry A migrates + numeraire.transfer(address(migrator), 100 ether); + tokenA.transfer(address(migrator), 400_000 ether); + migrator.migrate(0, address(tokenA), address(numeraire), address(0)); + + // Entry B migrates BEFORE any claims + numeraire.transfer(address(migrator), 50 ether); + tokenB.transfer(address(migrator), 600_000 ether); + migrator.migrate(0, address(tokenB), address(numeraire), address(0)); + + // Verify total pot + IPredictionMigrator.MarketView memory market = migrator.getMarket(address(oracle)); + assertEq(market.totalPot, 150 ether); // 100 + 50 + + // Now claims should work correctly + tokenA.transfer(alice, 300_000 ether); + + vm.startPrank(alice); + tokenA.approve(address(migrator), 300_000 ether); + migrator.claim(address(oracle), 300_000 ether); + vm.stopPrank(); + + // Alice should get (300k / 600k) * 150 = 75 ETH + assertEq(numeraire.balanceOf(alice), 75 ether); + } + + /** + * @notice Test with ETH numeraire verifies the fix works for native ETH too + */ + function test_migrate_ClaimBeforeAllMigrate_ETH_Works() public { + // Setup with ETH as numeraire + migrator.initialize(address(tokenA), address(0), abi.encode(address(oracle), entryIdA)); + migrator.initialize(address(tokenB), address(0), abi.encode(address(oracle), entryIdB)); + + oracle.setWinner(address(tokenA)); + + // Entry A migrates with 100 ETH + deal(address(this), 200 ether); + payable(address(migrator)).transfer(100 ether); + tokenA.transfer(address(migrator), 400_000 ether); + migrator.migrate(0, address(tokenA), address(0), address(0)); + + // Verify Entry A state + IPredictionMigrator.MarketView memory marketAfterA = migrator.getMarket(address(oracle)); + assertEq(marketAfterA.totalPot, 100 ether); + assertEq(marketAfterA.totalClaimed, 0); + + // User claims + tokenA.transfer(alice, 300_000 ether); + + uint256 aliceBalanceBefore = alice.balance; + vm.startPrank(alice); + tokenA.approve(address(migrator), 300_000 ether); + migrator.claim(address(oracle), 300_000 ether); // Claims 50 ETH + vm.stopPrank(); + + assertEq(alice.balance - aliceBalanceBefore, 50 ether); + + // Verify totalClaimed + IPredictionMigrator.MarketView memory marketAfterClaim = migrator.getMarket(address(oracle)); + assertEq(marketAfterClaim.totalClaimed, 50 ether); + + // Entry B migrates - should work now! + payable(address(migrator)).transfer(40 ether); + tokenB.transfer(address(migrator), 600_000 ether); + migrator.migrate(0, address(tokenB), address(0), address(0)); + + // Verify final state + IPredictionMigrator.MarketView memory marketFinal = migrator.getMarket(address(oracle)); + assertEq(marketFinal.totalPot, 140 ether); + assertEq(marketFinal.totalClaimed, 50 ether); + + IPredictionMigrator.EntryView memory entryB = migrator.getEntry(address(oracle), entryIdB); + assertEq(entryB.contribution, 40 ether); + assertTrue(entryB.isMigrated); + } +} + +/* -------------------------------------------------------------------------------- */ +/* MockPredictionOracle Tests */ +/* -------------------------------------------------------------------------------- */ + +contract MockPredictionOracleTest is Test { + MockPredictionOracle public oracle; + + function setUp() public { + oracle = new MockPredictionOracle(); + } + + function test_constructor_SetsOwner() public view { + assertEq(oracle.owner(), address(this)); + } + + function test_getWinner_ReturnsZeroBeforeFinalized() public view { + (address winner, bool isFinalized) = oracle.getWinner(address(oracle)); + assertEq(winner, address(0)); + assertFalse(isFinalized); + } + + function test_setWinner_SetsWinnerAndFinalizes() public { + address winningToken = address(0x1234); + oracle.setWinner(winningToken); + + (address winner, bool isFinalized) = oracle.getWinner(address(oracle)); + assertEq(winner, winningToken); + assertTrue(isFinalized); + } + + function test_setWinner_EmitsWinnerDeclared() public { + address winningToken = address(0x1234); + + vm.expectEmit(true, true, false, false); + emit IPredictionOracle.WinnerDeclared(address(oracle), winningToken); + + oracle.setWinner(winningToken); + } + + function test_setWinner_RevertsWhenNotOwner() public { + vm.prank(address(0xbeef)); + vm.expectRevert(OnlyOwner.selector); + oracle.setWinner(address(0x1234)); + } + + function test_setWinner_RevertsWhenAlreadyFinalized() public { + oracle.setWinner(address(0x1234)); + + vm.expectRevert(AlreadyFinalized.selector); + oracle.setWinner(address(0x5678)); + } +}