From 1dc6fa0fca1b235c4964152789a387d52037aa44 Mon Sep 17 00:00:00 2001 From: Hamdi Allam Date: Tue, 29 Oct 2024 16:03:11 -0400 Subject: [PATCH] prediction market contracts --- .../script/predictionmarket/Deploy.s.sol | 70 +++++ .../src/predictionmarket/MarketResolver.sol | 17 ++ .../src/predictionmarket/PredictionMarket.sol | 261 ++++++++++++++++++ .../factories/BlockHashMarketFactory.sol | 36 +++ .../factories/TicTacToeMarketFactory.sol | 53 ++++ .../resolvers/BlockHashResolver.sol | 65 +++++ .../resolvers/MockResolver.sol | 27 ++ .../resolvers/TicTacToeResolver.sol | 71 +++++ .../utils/BlockHashEmitter.sol | 15 + .../utils/MintableBurnableERC20.sol | 18 ++ .../test/pingpong/CrossChainPingPong.t.sol | 6 +- .../predictionmarket/PredictionMarket.t.sol | 121 ++++++++ contracts/test/tictactoe/TicTacToe.t.sol | 2 + 13 files changed, 759 insertions(+), 3 deletions(-) create mode 100644 contracts/script/predictionmarket/Deploy.s.sol create mode 100644 contracts/src/predictionmarket/MarketResolver.sol create mode 100644 contracts/src/predictionmarket/PredictionMarket.sol create mode 100644 contracts/src/predictionmarket/factories/BlockHashMarketFactory.sol create mode 100644 contracts/src/predictionmarket/factories/TicTacToeMarketFactory.sol create mode 100644 contracts/src/predictionmarket/resolvers/BlockHashResolver.sol create mode 100644 contracts/src/predictionmarket/resolvers/MockResolver.sol create mode 100644 contracts/src/predictionmarket/resolvers/TicTacToeResolver.sol create mode 100644 contracts/src/predictionmarket/utils/BlockHashEmitter.sol create mode 100644 contracts/src/predictionmarket/utils/MintableBurnableERC20.sol create mode 100644 contracts/test/predictionmarket/PredictionMarket.t.sol diff --git a/contracts/script/predictionmarket/Deploy.s.sol b/contracts/script/predictionmarket/Deploy.s.sol new file mode 100644 index 00000000..2d0e16da --- /dev/null +++ b/contracts/script/predictionmarket/Deploy.s.sol @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.25; + +import {console, Script} from "forge-std/Script.sol"; +import {PredictionMarket} from "../../src/predictionmarket/PredictionMarket.sol"; + +import {TicTacToe} from "../../src/tictactoe/TicTacToe.sol"; +import {TicTacToeMarketFactory} from "../../src/predictionmarket/factories/TicTacToeMarketFactory.sol"; + +import {BlockHashEmitter} from "../../src/predictionmarket/utils/BlockHashEmitter.sol"; +import {BlockHashMarketFactory} from "../../src/predictionmarket/factories/BlockHashMarketFactory.sol"; + +contract DeployScript is Script { + uint256 constant APP_CHAIN_ID = 901; + + bytes32 constant _BLOCKHASH_EMITTER_SALT = "blockhashemitter"; + bytes32 constant _TIC_TAC_TOE_SALT = "tictactoe"; + + function setUp() public {} + + function run() public { + bytes32 emitterBytecodehash = keccak256(abi.encodePacked(type(BlockHashEmitter).creationCode)); + address emitter = vm.computeCreate2Address(_BLOCKHASH_EMITTER_SALT, emitterBytecodehash); + if (emitter.code.length == 0) { + deployBlockHashEmitter(); + } else { + console.log("BlockHashEmitter address: ", emitter); + } + + bytes32 tictactoeBytecodehash = keccak256(abi.encodePacked(type(TicTacToe).creationCode)); + address tictactoe = vm.computeCreate2Address(_TIC_TAC_TOE_SALT, tictactoeBytecodehash); + if (tictactoe.code.length == 0) { + deployTicTacToe(); + } else { + console.log("TicTacToe address: ", tictactoe); + } + + // Deploy Prediction Market contracts on the + if (block.chainid == APP_CHAIN_ID) { + vm.startBroadcast(); + PredictionMarket market = new PredictionMarket{salt: "predictionmarket"}(); + + // Deploy the factories for the types of markets + BlockHashMarketFactory blockHashFactory = new BlockHashMarketFactory{salt: "blockhashfactory"}(market, BlockHashEmitter(emitter)); + TicTacToeMarketFactory ticTacToeFactory = new TicTacToeMarketFactory{salt: "tictactoe"}(market, TicTacToe(tictactoe)); + + + console.log("PredictionMarket Deployed at: ", address(market)); + console.log("BlockHashMarketFactory Deployed at: ", address(blockHashFactory)); + console.log("TicTacToeMarketFactory Deployed at: ", address(ticTacToeFactory)); + vm.stopBroadcast(); + } + } + + function deployBlockHashEmitter() public { + vm.startBroadcast(); + BlockHashEmitter emitter = new BlockHashEmitter{salt: _BLOCKHASH_EMITTER_SALT}(); + vm.stopBroadcast(); + + console.log("BlockHashEmitter Deployed at: ", address(emitter)); + } + + function deployTicTacToe() public { + vm.startBroadcast(); + TicTacToe tictactoe = new TicTacToe{salt: _TIC_TAC_TOE_SALT}(); + vm.stopBroadcast(); + + console.log("TicTacToe Deployed at: ", address(tictactoe)); + } +} diff --git a/contracts/src/predictionmarket/MarketResolver.sol b/contracts/src/predictionmarket/MarketResolver.sol new file mode 100644 index 00000000..3749fd4a --- /dev/null +++ b/contracts/src/predictionmarket/MarketResolver.sol @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.25; + +enum MarketOutcome { + UNDECIDED, + YES, + NO +} + +interface IMarketResolver { + // @notice the origin chain that this resolver was instantiated from + function chainId() external returns (uint256); + + // @notice get the outcome of the market. This MUST be deterministic as the prediction market + // will use this value to determine the outcome of the market ONCE when resolved. + function outcome() external returns (MarketOutcome); +} diff --git a/contracts/src/predictionmarket/PredictionMarket.sol b/contracts/src/predictionmarket/PredictionMarket.sol new file mode 100644 index 00000000..13415520 --- /dev/null +++ b/contracts/src/predictionmarket/PredictionMarket.sol @@ -0,0 +1,261 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.25; + +import { IMarketResolver, MarketOutcome } from "./MarketResolver.sol"; +import { MintableBurnableERC20 } from "./utils/MintableBurnableERC20.sol"; + +enum MarketStatus { + OPEN, + CLOSED +} + +struct Market { + MarketStatus status; + MarketOutcome outcome; + + // Outcome Tokens + MintableBurnableERC20 yesToken; + MintableBurnableERC20 noToken; + MintableBurnableERC20 lpToken; + + // Liquidity held in the market + uint256 ethBalance; + uint256 yesBalance; + uint256 noBalance; +} + +// @notice thrown when the caller is not authorized to perform an action +error Unauthorized(); + +// @notice thrown when the market is in an open state +error MarketOpen(); + +// @notice thrown when the market is in a closed state +error MarketClosed(); + +// @notice thrown when a resolver has decided the outcome for a market +error ResolverOutcomeDecided(); + +// @notice thrown when no value is sent to a payable function +error NoValue(); + +// @notice thrown when there is insufficient liquidity in the market +error InsufficientLiquidity(); + +// @notice A very basic implementation of a prediction market. +// 1. We only support markets with a yes or no outcome. +// 2. Once a bet is placed, we do not allow swapping out of a position with the market directly. +// 3. No incentive to provide liquidity since swap fees are not collected. LP tokens can only be redeemed +// when the market has resolved. +// +// Open to Pull Requests! This is simply reference :) +contract PredictionMarket { + // @notice created markets, addressed by their resolver + mapping(IMarketResolver => Market) public markets; + + // @notice emitted when a new market has been created + event NewMarket(IMarketResolver _resolver, address yesToken, address noToken, address lpToken); + + // @notice emitted when a market has been resolved + event MarketResolved(IMarketResolver indexed _resolver, MarketOutcome outcome); + + // @notice emitted when liquidity has been added to a market + event LiquidityAdded(IMarketResolver indexed resolver, address indexed provider, uint256 ethAmountIn); + + // @notice emitted when liquidity has been redeemed from a market + event LiquidityRedeemed(IMarketResolver indexed resolver, address indexed redeemer, uint256 lpAmount); + + // @notice emitted when a bet has been placed on a market + event BetPlaced(IMarketResolver indexed resolver, address indexed bettor, MarketOutcome outcome, uint256 ethAmountIn, uint256 amountOut); + + // @notice emitted when an outcome token has been redeemed + event OutcomeRedeemed(IMarketResolver indexed resolver, address indexed redeemer, MarketOutcome outcome, uint256 amount, uint256 ethPayout); + + // @notice create and seed a new prediction market with liquidity + // @param _resolver contract identifying the outcome for an open market + // @param _to address to mint LP tokens to + function newMarket(IMarketResolver _resolver, address _to) public payable { + if (msg.value == 0) revert NoValue(); + if (_resolver.outcome() != MarketOutcome.UNDECIDED) revert ResolverOutcomeDecided(); + + Market storage market = markets[_resolver]; + market.status = MarketStatus.OPEN; + market.outcome = MarketOutcome.UNDECIDED; + + market.yesToken = new MintableBurnableERC20("Yes", "Yes"); + market.noToken = new MintableBurnableERC20("No", "No"); + market.lpToken = new MintableBurnableERC20("LP", "LP"); + + addLiquidity(_resolver, _to); + + emit NewMarket(_resolver, address(market.yesToken), address(market.noToken), address(market.lpToken)); + } + + // @notice resolve the market + // @param _resolver contract identifying the outcome for an open market + function resolveMarket(IMarketResolver _resolver) external { + if (msg.sender != address(_resolver)) revert Unauthorized(); + + Market storage market = markets[_resolver]; + if (market.status == MarketStatus.CLOSED) revert MarketClosed(); + + // Market must be resolvable + MarketOutcome outcome = _resolver.outcome(); + require(outcome != MarketOutcome.UNDECIDED, "outcome must be decided"); + + // Resolve this market + market.outcome = outcome; + market.status = MarketStatus.CLOSED; + + emit MarketResolved(_resolver, outcome); + } + + // @notice Entry point for adding liquidity into the specified market + // @param _resolver contract identifying the outcome for an open market + // @param _to address to mint LP tokens to + function addLiquidity(IMarketResolver _resolver, address _to) public payable { + if (msg.value == 0) revert NoValue(); + + Market storage market = markets[_resolver]; + if (market.status == MarketStatus.CLOSED) revert MarketClosed(); + + uint256 ethAmount = msg.value; + uint256 lpSupply = market.lpToken.totalSupply(); + + uint256 yesAmount; + uint256 noAmount; + + if (lpSupply == 0) { + // Initial liquidity + yesAmount = ethAmount; + noAmount = ethAmount; + } else { + // Calculate tokens to mint based on current pool ratios + yesAmount = (ethAmount * market.yesBalance) / lpSupply; + noAmount = (ethAmount * market.noBalance) / lpSupply; + } + + // Hold pool assets + market.yesToken.mint(address(this), yesAmount); + market.noToken.mint(address(this), noAmount); + + market.ethBalance += ethAmount; + market.yesBalance += yesAmount; + market.noBalance += noAmount; + + // Mint LP tokens to the sender + market.lpToken.mint(_to, ethAmount); + emit LiquidityAdded(_resolver, _to, ethAmount); + } + + // @notice calculate the amount of outcome tokens to receive for a given amount of ETH + // @param _resolver contract identifying the outcome for an open market + // @param _outcome the outcome to buy + // @param ethAmountIn the amount of ETH to buy + // @dev Held Invariant: `yes_balance * no_balance = k` Where k is the total amount of liquidity. The outcome tokens + // are backed 1:1 with eth which allows eth to always be the input for the swap for either outcome token. + function calcOutcomeOut(IMarketResolver _resolver, MarketOutcome _outcome, uint256 ethAmountIn) public view returns (uint256) { + Market memory market = markets[_resolver]; + if (_outcome == MarketOutcome.YES) { + return (ethAmountIn * market.yesBalance) / (ethAmountIn + market.noBalance); + } else { + return (ethAmountIn * market.noBalance) / (ethAmountIn + market.yesBalance); + } + } + + // @notice calculate the current ETH payout when placing a bet on an outcome + function calcOutcomePayout(IMarketResolver _resolver, MarketOutcome _outcome, uint256 ethAmountIn) public view returns (uint256) { + uint256 amountOut = calcOutcomeOut(_resolver, _outcome, ethAmountIn); + Market memory market = markets[_resolver]; + if (_outcome == MarketOutcome.YES) { + return (market.ethBalance + ethAmountIn) * amountOut / market.yesToken.totalSupply(); + } else { + return (market.ethBalance + ethAmountIn) * amountOut / market.noToken.totalSupply(); + } + } + + // @notice buy an outcome token + // @param _resolver contract identifying the outcome for an open market + // @param _outcome the outcome to buy + function buyOutcome(IMarketResolver _resolver, MarketOutcome _outcome) public payable { + if (msg.value == 0) revert NoValue(); + require(_outcome == MarketOutcome.YES || _outcome == MarketOutcome.NO); + + Market storage market = markets[_resolver]; + if (market.status == MarketStatus.CLOSED) revert MarketClosed(); + + // Compute trade amounts & swap + uint256 amountIn = msg.value; + uint256 amountOut = calcOutcomeOut(_resolver, _outcome, amountIn); + if (_outcome == MarketOutcome.YES) { + if (amountOut > market.yesBalance) revert InsufficientLiquidity(); + market.yesBalance -= amountOut; + market.yesToken.transfer(msg.sender, amountOut); + } else { + if (amountOut > market.noBalance) revert InsufficientLiquidity(); + market.noBalance -= amountOut; + market.noToken.transfer(msg.sender, amountOut); + } + + market.ethBalance += amountIn; + emit BetPlaced(_resolver, msg.sender, _outcome, amountIn, amountOut); + } + + // @notice redeem outcome tokens for ETH + // @param _resolver contract identifying the outcome for an open market + function redeem(IMarketResolver _resolver) public { + Market storage market = markets[_resolver]; + if (market.status == MarketStatus.OPEN) revert MarketOpen(); + + MintableBurnableERC20 outcomeToken = market.outcome == MarketOutcome.YES ? market.yesToken : market.noToken; + uint256 outcomeBalance = outcomeToken.balanceOf(msg.sender); + uint256 outcomeSupply = outcomeToken.totalSupply(); + + // Transfer & burn the winning outcome tokens + outcomeToken.burnFrom(msg.sender, outcomeBalance); + + // Payout is directly proportional to the amount of tokens (eth balance > supply as bets are placed) + uint256 ethPayout = (market.ethBalance * outcomeBalance) / outcomeSupply; + require(ethPayout <= market.ethBalance); + + market.ethBalance -= ethPayout; + + (bool success, ) = payable(msg.sender).call{value: ethPayout}(""); + require(success); + + emit OutcomeRedeemed(_resolver, msg.sender, market.outcome, outcomeBalance, ethPayout); + } + + // @notice remove liquidity from the specified market (outcome tokens only) + // @param _resolver contract identifying the outcome for an open market + function redeemLP(IMarketResolver _resolver) public { + Market storage market = markets[_resolver]; + if (market.status == MarketStatus.OPEN) revert MarketOpen(); + + uint256 lpSupply = market.lpToken.totalSupply(); + uint256 lpBalance = market.lpToken.balanceOf(msg.sender); + + // Burn LP tokens + market.lpToken.burnFrom(msg.sender, lpBalance); + + // Return appropriate share the winning outcome. + if (market.outcome == MarketOutcome.YES) { + uint256 yesAmount = (market.yesBalance * lpBalance) / lpSupply; + require(market.yesBalance <= yesAmount); + + market.yesBalance -= yesAmount; + market.yesToken.transfer(msg.sender, yesAmount); + } else { + uint256 noAmount = (market.noBalance * lpBalance) / lpSupply; + require(market.noBalance <= noAmount); + + market.noBalance -= noAmount; + market.noToken.transfer(msg.sender, noAmount); + } + + // Redeem the transferred outcome tokens + redeem(_resolver); + + emit LiquidityRedeemed(_resolver, msg.sender, lpBalance); + } +} diff --git a/contracts/src/predictionmarket/factories/BlockHashMarketFactory.sol b/contracts/src/predictionmarket/factories/BlockHashMarketFactory.sol new file mode 100644 index 00000000..69f6cb64 --- /dev/null +++ b/contracts/src/predictionmarket/factories/BlockHashMarketFactory.sol @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.25; + +import {BlockHashEmitter} from "../utils/BlockHashEmitter.sol"; +import {BlockHashResolver} from "../resolvers/BlockHashResolver.sol"; + +import {PredictionMarket} from "../PredictionMarket.sol"; +import {IMarketResolver} from "../MarketResolver.sol"; + +contract BlockHashMarketFactory { + // @notice BlockHashEmitter contract + BlockHashEmitter public emitter; + + // @notice PredictionMarket contract + PredictionMarket public predictionMarket; + + // @notice Emitted when a new market for a block hash is created + event NewMarket(IMarketResolver resolver); + + // @notice indiciator if a resolver originated from this factory + mapping(IMarketResolver => bool) public fromFactory; + + constructor(PredictionMarket _predictionMarket, BlockHashEmitter _emitter) { + predictionMarket = _predictionMarket; + emitter = _emitter; + } + + function newMarket(uint256 _chainId, uint256 _blockNumber) public payable { + IMarketResolver resolver = new BlockHashResolver(predictionMarket, emitter, _chainId, _blockNumber); + predictionMarket.newMarket{ value: msg.value }(resolver, msg.sender); + + fromFactory[resolver] = true; + + emit NewMarket(resolver); + } +} \ No newline at end of file diff --git a/contracts/src/predictionmarket/factories/TicTacToeMarketFactory.sol b/contracts/src/predictionmarket/factories/TicTacToeMarketFactory.sol new file mode 100644 index 00000000..903261ae --- /dev/null +++ b/contracts/src/predictionmarket/factories/TicTacToeMarketFactory.sol @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.25; + +import {Predeploys} from "@contracts-bedrock/libraries/Predeploys.sol"; +import {ICrossL2Inbox} from "@contracts-bedrock/L2/interfaces/ICrossL2Inbox.sol"; + +import { IMarketResolver, MarketOutcome } from "../MarketResolver.sol"; +import { PredictionMarket } from "../PredictionMarket.sol"; +import { TicTacToeGameResolver } from "../resolvers/TicTacToeResolver.sol"; + +import { TicTacToe } from "../../tictactoe/TicTacToe.sol"; + +contract TicTacToeMarketFactory { + // @notice TicTacToe contract + TicTacToe public tictactoe; + + // @notice PredictionMarket contract + PredictionMarket public market; + + // @notice Emitted when a new market for tictactoe is created + event NewMarket(IMarketResolver resolver); + + // @notice indiciator if a resolver originated from this factory + mapping(IMarketResolver => bool) public fromFactory; + + // @notice create a new factory instantiating prediction markets based on the outcome of the TicTacToe games + constructor(PredictionMarket _market, TicTacToe _tictactoe) { + market = _market; + tictactoe = _tictactoe; + } + + // @notice create a new market for an accepted TicTacToe game. The game creator being the yes outcome. + function newMarket(ICrossL2Inbox.Identifier calldata _id, bytes calldata _data) public payable { + // Validate Log + require(_id.origin == address(tictactoe)); + ICrossL2Inbox(Predeploys.CROSS_L2_INBOX).validateMessage(_id, keccak256(_data)); + + // Ensure this is an accepted game event + bytes32 selector = abi.decode(_data[:32], (bytes32)); + require(selector == TicTacToe.AcceptedGame.selector); + + // Decode game identifying fields + (uint256 chainId, uint256 gameId, address creator,) = + abi.decode(_data[32:], (uint256, uint256, address, address)); + + IMarketResolver resolver = new TicTacToeGameResolver(market, tictactoe, chainId, gameId, creator); + market.newMarket{ value: msg.value }(resolver, msg.sender); + + fromFactory[resolver] = true; + + emit NewMarket(resolver); + } +} diff --git a/contracts/src/predictionmarket/resolvers/BlockHashResolver.sol b/contracts/src/predictionmarket/resolvers/BlockHashResolver.sol new file mode 100644 index 00000000..d39458ca --- /dev/null +++ b/contracts/src/predictionmarket/resolvers/BlockHashResolver.sol @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.25; + +import {Predeploys} from "@contracts-bedrock/libraries/Predeploys.sol"; +import {ICrossL2Inbox} from "@contracts-bedrock/L2/interfaces/ICrossL2Inbox.sol"; + +import {BlockHashEmitter} from "../utils/BlockHashEmitter.sol"; + +import { IMarketResolver, MarketOutcome } from "../MarketResolver.sol"; +import {PredictionMarket} from "../PredictionMarket.sol"; + +contract BlockHashResolver is IMarketResolver { + // @notice prediction market + PredictionMarket public market; + + // @notice BlockHashEmitter contract + BlockHashEmitter public emitter; + + // @notice Chain ID that will resolve this market + uint256 public chainId; + + // @notice block height of this bet + uint256 public blockNumber; + + // @notice current outcome of this bet + MarketOutcome public outcome; + + constructor(PredictionMarket _market, BlockHashEmitter _emitter, uint256 _chainId, uint256 _blockNumber) { + outcome = MarketOutcome.UNDECIDED; + + market = _market; + emitter = _emitter; + + chainId = _chainId; + blockNumber = _blockNumber; + } + + function resolve(ICrossL2Inbox.Identifier calldata _id, bytes calldata _data) external { + require(outcome == MarketOutcome.UNDECIDED, "already resolved"); + + // Validate Log + require(_id.origin == address(emitter), "event not from the emitter"); + require(_id.chainId == chainId, "event not from the correct chain"); + ICrossL2Inbox(Predeploys.CROSS_L2_INBOX).validateMessage(_id, keccak256(_data)); + + bytes32 selector = abi.decode(_data[:32], (bytes32)); + require(selector == BlockHashEmitter.BlockHash.selector, "event not a block hash"); + + // Event should correspond to the right market + uint256 dataBlockNumber = abi.decode(_data[32:64], (uint256)); + require(dataBlockNumber == blockNumber, "event not for the right height"); + + bytes32 blockHash = abi.decode(_data[64:], (bytes32)); + bool isOdd = uint256(blockHash) % 2 == 0; + + // Resolve the market (yes if odd, no if even) + if (isOdd) { + outcome = MarketOutcome.YES; + } else { + outcome = MarketOutcome.NO; + } + + market.resolveMarket(this); + } +} diff --git a/contracts/src/predictionmarket/resolvers/MockResolver.sol b/contracts/src/predictionmarket/resolvers/MockResolver.sol new file mode 100644 index 00000000..9effa024 --- /dev/null +++ b/contracts/src/predictionmarket/resolvers/MockResolver.sol @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.25; + +import {IMarketResolver, MarketOutcome} from "../MarketResolver.sol"; +import {PredictionMarket} from "../PredictionMarket.sol"; + +contract MockResolver is IMarketResolver { + // @notice prediction market + PredictionMarket public market; + + // @notice outcome of the resolver + MarketOutcome public outcome; + + // @notice chain of this resolver + uint256 public chainId = block.chainid; + + constructor(PredictionMarket _market) { + market = _market; + } + + function setOutcome(MarketOutcome _outcome) public { + require(outcome == MarketOutcome.UNDECIDED && _outcome != MarketOutcome.UNDECIDED, "outcome must be undecided"); + + outcome = _outcome; + market.resolveMarket(this); + } +} \ No newline at end of file diff --git a/contracts/src/predictionmarket/resolvers/TicTacToeResolver.sol b/contracts/src/predictionmarket/resolvers/TicTacToeResolver.sol new file mode 100644 index 00000000..644b7be2 --- /dev/null +++ b/contracts/src/predictionmarket/resolvers/TicTacToeResolver.sol @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.25; + +import {Predeploys} from "@contracts-bedrock/libraries/Predeploys.sol"; +import {ICrossL2Inbox} from "@contracts-bedrock/L2/interfaces/ICrossL2Inbox.sol"; + +import { IMarketResolver, MarketOutcome } from "../MarketResolver.sol"; +import { PredictionMarket } from "../PredictionMarket.sol"; + +import { TicTacToe } from "../../tictactoe/TicTacToe.sol"; + +struct Game { + uint256 chainId; + uint256 gameId; + address creator; +} + +contract TicTacToeGameResolver is IMarketResolver { + // @notice prediction market + PredictionMarket public market; + + // @notice TicTacToe contract + TicTacToe public tictactoe; + + // @notice Current outcome of the game + MarketOutcome public outcome; + + // @notice Game for this resolver + Game public game; + + // @notice create a resolved for an accepted TicTacToe game + constructor(PredictionMarket _market, TicTacToe _tictactoe, uint256 _chainId, uint256 _gameId, address _creator) { + market = _market; + tictactoe = _tictactoe; + + game = Game({chainId: _chainId, gameId: _gameId, creator: _creator}); + outcome = MarketOutcome.UNDECIDED; + } + + // @notice resolve this game by providing the game ending event + function resolve(ICrossL2Inbox.Identifier calldata _id, bytes calldata _data) external { + require(outcome == MarketOutcome.UNDECIDED); + + // Validate Log + require(_id.origin == address(tictactoe)); + ICrossL2Inbox(Predeploys.CROSS_L2_INBOX).validateMessage(_id, keccak256(_data)); + + // Ensure this is a finalizing event + bytes32 selector = abi.decode(_data[:32], (bytes32)); + require(selector == TicTacToe.GameWon.selector || selector == TicTacToe.GameDraw.selector, "event not a game outcome"); + + // Decode game attributes & ensure it matches the game we are resolving + (uint256 _chainId, uint256 gameId, address winner,,) = abi.decode(_data[32:], (uint256, uint256, address, uint8, uint8)); + require(_chainId == game.chainId && gameId == game.gameId); + + // Set outcome based on if the creator has won (non-draw) + if (winner == game.creator && selector != TicTacToe.GameDraw.selector) { + outcome = MarketOutcome.YES; + } else { + outcome = MarketOutcome.NO; + } + + // Resolve the prediction market + market.resolveMarket(this); + } + + function chainId() external view returns (uint256) { + return game.chainId; + } +} + diff --git a/contracts/src/predictionmarket/utils/BlockHashEmitter.sol b/contracts/src/predictionmarket/utils/BlockHashEmitter.sol new file mode 100644 index 00000000..05f8bfec --- /dev/null +++ b/contracts/src/predictionmarket/utils/BlockHashEmitter.sol @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.25; + + +contract BlockHashEmitter { + event BlockHash(uint256 indexed blockNumber, bytes32 blockHash); + + function emitBlockHash(uint256 _blockNumber) public { + bytes32 hash = blockhash(_blockNumber); + require(hash != bytes32(0), "block hash too old"); + + + emit BlockHash(_blockNumber, hash); + } +} diff --git a/contracts/src/predictionmarket/utils/MintableBurnableERC20.sol b/contracts/src/predictionmarket/utils/MintableBurnableERC20.sol new file mode 100644 index 00000000..d146f099 --- /dev/null +++ b/contracts/src/predictionmarket/utils/MintableBurnableERC20.sol @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.25; + +import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol"; +import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; + +contract MintableBurnableERC20 is ERC20, Ownable { + + constructor(string memory _name, string memory _symbol) Ownable() ERC20(_name, _symbol) {} + + function mint(address recipient, uint256 value) external onlyOwner { + _mint(recipient, value); + } + + function burnFrom(address from, uint256 value) external onlyOwner { + _burn(from, value); + } +} diff --git a/contracts/test/pingpong/CrossChainPingPong.t.sol b/contracts/test/pingpong/CrossChainPingPong.t.sol index 3a30b6dd..584e5a7f 100644 --- a/contracts/test/pingpong/CrossChainPingPong.t.sol +++ b/contracts/test/pingpong/CrossChainPingPong.t.sol @@ -1,10 +1,10 @@ // SPDX-License-Identifier: MIT pragma solidity 0.8.25; -import {Test} from "forge-std/Test.sol"; +import { Test } from "forge-std/Test.sol"; -import {IL2ToL2CrossDomainMessenger} from "@contracts-bedrock/L2/interfaces/IL2ToL2CrossDomainMessenger.sol"; -import {Predeploys} from "@contracts-bedrock/libraries/Predeploys.sol"; +import { IL2ToL2CrossDomainMessenger } from "@contracts-bedrock/L2/interfaces/IL2ToL2CrossDomainMessenger.sol"; +import { Predeploys } from "@contracts-bedrock/libraries/Predeploys.sol"; import { CrossChainPingPong, diff --git a/contracts/test/predictionmarket/PredictionMarket.t.sol b/contracts/test/predictionmarket/PredictionMarket.t.sol new file mode 100644 index 00000000..364c5148 --- /dev/null +++ b/contracts/test/predictionmarket/PredictionMarket.t.sol @@ -0,0 +1,121 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +import {Test} from "forge-std/Test.sol"; + +import {MintableBurnableERC20} from "../../src/predictionmarket/utils/MintableBurnableERC20.sol"; +import {MarketOutcome} from "../../src/predictionmarket/MarketResolver.sol"; +import {MockResolver} from "../../src/predictionmarket/resolvers/MockResolver.sol"; +import { + Market, + MarketStatus, + NoValue, + ResolverOutcomeDecided, + PredictionMarket +} from "../../src/predictionmarket/PredictionMarket.sol"; + +contract PredictionMarketTest is Test { + PredictionMarket public predictionMarket; + + function setUp() public { + predictionMarket = new PredictionMarket(); + } + + function test_newMarket_succeeds() public { + MockResolver testResolver = new MockResolver(predictionMarket); + predictionMarket.newMarket{ value: 1 ether }(testResolver, address(this)); + + (, , MintableBurnableERC20 yesToken, MintableBurnableERC20 noToken, MintableBurnableERC20 lpToken, uint256 ethBalance, uint256 yesBalance, uint256 noBalance) = + predictionMarket.markets(testResolver); + + // eth balance is tracked + assertEq(ethBalance, 1 ether); + + // lp and pool tokens at fair odds + assertEq(lpToken.balanceOf(address(this)), ethBalance); + assertEq(yesToken.balanceOf(address(predictionMarket)), yesBalance); + assertEq(noToken.balanceOf(address(predictionMarket)), noBalance); + } + + function test_newMarket_decidedOutcome_reverts() public { + MockResolver testResolver = new MockResolver(predictionMarket); + testResolver.setOutcome(MarketOutcome.YES); + + vm.expectRevert(ResolverOutcomeDecided.selector); + predictionMarket.newMarket{ value: 1 ether }(testResolver, address(this)); + } + + function test_buyOutcome_succeeds() public { + // seed some liquidity + MockResolver testResolver = new MockResolver(predictionMarket); + predictionMarket.newMarket{ value: 1 ether }(testResolver, address(this)); + + (, , MintableBurnableERC20 yesToken, , , , uint256 yesBalance, ) = predictionMarket.markets(testResolver); + + // buy YES outcome with half of the available liquidity + uint256 expectedAmountOut = predictionMarket.calcOutcomeOut(testResolver, MarketOutcome.YES, 0.5 ether); + predictionMarket.buyOutcome{ value: 0.5 ether }(testResolver, MarketOutcome.YES); + + assertEq(yesToken.balanceOf(address(this)), expectedAmountOut); + assertEq(yesToken.balanceOf(address(predictionMarket)), yesBalance - expectedAmountOut); + + // Additional ETH is now winnable in this pool + (, , , , , uint256 ethBalance, uint256 newYesBalance, ) = predictionMarket.markets(testResolver); + assertEq(ethBalance, 1.5 ether); + assertEq(yesToken.balanceOf(address(predictionMarket)), newYesBalance); + } + + function test_addLiquidity_succeeds() public { + MockResolver testResolver = new MockResolver(predictionMarket); + predictionMarket.newMarket{ value: 1 ether }(testResolver, address(this)); + + // double the liquidity + predictionMarket.addLiquidity { value: 1 ether }(testResolver, address(this)); + + (, , MintableBurnableERC20 yesToken, MintableBurnableERC20 noToken, MintableBurnableERC20 lpToken, uint256 ethBalance, uint256 yesBalance, uint256 noBalance) = + predictionMarket.markets(testResolver); + + // still at even odds since no swaps have occurred + assertEq(ethBalance, 2 ether); + assertEq(yesBalance, 2 ether); + assertEq(noBalance, 2 ether); + + // lp and pool tokens at fair odds + assertEq(lpToken.balanceOf(address(this)), ethBalance); + assertEq(yesToken.balanceOf(address(predictionMarket)), yesBalance); + assertEq(noToken.balanceOf(address(predictionMarket)), noBalance); + } + + function test_addLiquidity_noValue_reverts() public { + MockResolver testResolver = new MockResolver(predictionMarket); + predictionMarket.newMarket{ value: 1 ether }(testResolver, address(this)); + + vm.expectRevert(NoValue.selector); + predictionMarket.addLiquidity(testResolver, address(this)); + } + + function test_addLiquidity_atFairOdds_succeeds() public { + MockResolver testResolver = new MockResolver(predictionMarket); + predictionMarket.newMarket{ value: 1 ether }(testResolver, address(this)); + + // Perform a swap to skew the odds + predictionMarket.buyOutcome{ value: 0.5 ether }(testResolver, MarketOutcome.YES); + + (, , MintableBurnableERC20 yesToken, MintableBurnableERC20 noToken, MintableBurnableERC20 lpToken, , uint256 yesBalance, uint256 noBalance) = + predictionMarket.markets(testResolver); + + // add an additional ETH of liquidity via a new account + vm.prank(address(1)); vm.deal(address(1), 1 ether); + predictionMarket.addLiquidity{ value: 1 ether }(testResolver, address(1)); + + uint256 newYesBalance = yesToken.balanceOf(address(predictionMarket)); + uint256 newNoBalance = noToken.balanceOf(address(predictionMarket)); + + // pool probabilities remain the same relative to lp supply -- added liquidity. (old 1eth, new 2eth) + assertEq(newYesBalance / 2 ether, yesBalance / 1 ether); + assertEq(newNoBalance / 2 ether, noBalance / 1 ether); + + // LP Supply should be equal between the two providers + assertEq(lpToken.balanceOf(address(this)), lpToken.balanceOf(address(1))); + } +} diff --git a/contracts/test/tictactoe/TicTacToe.t.sol b/contracts/test/tictactoe/TicTacToe.t.sol index d2f1e3b4..4180d826 100644 --- a/contracts/test/tictactoe/TicTacToe.t.sol +++ b/contracts/test/tictactoe/TicTacToe.t.sol @@ -36,6 +36,8 @@ contract TicTacToeTest is Test { } function testFuzz_acceptGame_succeeds(uint256 chainId, uint256 gameId, address opponent) public { + vm.assume(opponent != address(this)); + TicTacToe game = new TicTacToe(); ICrossL2Inbox.Identifier memory newGameId = ICrossL2Inbox.Identifier(address(game), 0, 0, 0, chainId); bytes memory newGameData = abi.encodePacked(TicTacToe.NewGame.selector, abi.encode(chainId, gameId, opponent));