Description:
Decentralized Finance (DeFi) protocol contract providing Swap, Staking, Yield, Factory functionality.
Blockchain: Ethereum
Source Code: View Code On The Blockchain
Solidity Source Code:
{{
"language": "Solidity",
"sources": {
"src/CoinflipResolver.sol": {
"content": "// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;
/* ───────────────────────────── PM minimal interface ───────────────────────────── */
interface IPM {
function createMarket(
string calldata description,
address resolver,
uint72 close,
bool canClose
) external returns (uint256 marketId, uint256 noId);
function resolve(uint256 marketId, bool outcome) external;
function setResolverFeeBps(uint16 bps) external;
function getMarket(uint256 marketId)
external
view
returns (
uint256 yesSupply,
uint256 noSupply,
address resolver,
bool resolved,
bool outcome,
uint256 pot,
uint256 payoutPerShare,
string memory desc
);
function getNoId(uint256 marketId) external pure returns (uint256);
function balanceOf(address owner, uint256 id) external view returns (uint256);
}
/* ───────────────────────────── CoinflipResolver ───────────────────────────── */
/**
* @title CoinflipResolver
* @notice Spawns perpetual YES/NO pari-mutuel markets on PM and resolves each epoch
* from a *pre-committed* pair of future blocks. Outcome is:
*
* YES iff LSB( keccak256(blockhash(tgt), blockhash(tgt+1), marketId) ) == 1
*
* where `tgt` is computed at market creation to occur strictly *after* trading
* closes, using a conservative seconds→blocks conversion plus a post-close buffer.
*
* Core behavior
* - Trading window: `closeDelay` seconds from creation (default ~55m).
* - Target selection: `tgt = currentBlock + ceil(closeDelay/10s) + resolveDelayBlocks`
* so the target is *after* close; `resolveDelayBlocks` is the post-close buffer.
* - Resolve window: callable only after close and strictly after block `tgt+1`
* (both hashes available), and no later than `tgt + maxResolveLagBlocks`
* (blockhash availability window ≤ 256).
* - Emergency fallback: if the normal window is missed, owner may resolve after an
* additional `emergencyDelayBlocks` using parity of `keccak256(seedAtCreation, marketId)`,
* where `seedAtCreation` is `block.prevrandao` captured at creation.
* - Incentives: a fixed ETH tip (`tipPerResolve`) is paid to the successful caller.
* - Auto-roll: every terminal path (resolved, skipped, or emergency) immediately starts
* the next market.
*
* Security notes
* - The randomness is bound at creation; callers cannot cherry-pick blocks.
* - Mixing two consecutive block hashes meaningfully reduces single-proposer bias,
* but does not eliminate builder/proposer influence entirely; suitable for
* low-stakes / PoC settings.
* - The tip is paid via a call to the caller; if the call fails, resolution reverts.
* - Uses a lightweight reentrancy guard.
*/
contract CoinflipResolver {
/* ───────────────────────────── config/constants ───────────────────────────── */
IPM public constant PM = IPM(0x0000000000F8d9F51f0765a9dAd6a9487ba85f1e);
// Convert seconds -> blocks conservatively so target is strictly AFTER close.
// Using 10s per block slightly overestimates (Ethereum ≈12s) and biases later.
uint256 constant SECONDS_PER_BLOCK_BIAS = 10;
address constant WSTETH = 0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0;
address constant ZSTETH = 0x000000000077B216105413Dc45Dc6F6256577c7B;
// Keeper incentive
uint256 public tipPerResolve = 0.001 ether;
// Timing knobs
uint256 public closeDelay = 55 minutes; // seconds from creation to trading close
uint256 public resolveDelayBlocks = 25; // EXTRA blocks AFTER close before the target block (post-close buffer)
uint256 public maxResolveLagBlocks = 200; // must resolve within this many blocks after target (<= 255)
uint256 public emergencyDelayBlocks = 600; // additional blocks after miss before owner fallback
// Ownership
event OwnershipTransferred(address indexed from, address indexed to);
address public owner;
error Unauthorized();
modifier onlyOwner() {
if (msg.sender != owner) revert Unauthorized();
_;
}
function transferOwnership(address _owner) public payable onlyOwner {
emit OwnershipTransferred(msg.sender, owner = _owner);
}
/// @dev Staking function into Lido wstETH for ETH inside this contract.
function stakeETH(uint256 amount) public payable onlyOwner {
assembly ("memory-safe") {
pop(call(gas(), WSTETH, amount, codesize(), 0x00, codesize(), 0x00))
}
}
/// @dev Governed exact-in swap to redeem yield from Lido staking.
function swapExactWSTETHtoETH(address to, uint256 amount, uint256 minOut)
public
payable
onlyOwner
{
CoinflipResolver(payable(ZSTETH)).swapExactWSTETHtoETH(to, amount, minOut);
}
/// @dev Governed exact-out swap to redeem yield from Lido staking.
function swapWSTETHtoExactETH(address to, uint256 exactOut, uint256 maxIn)
public
payable
onlyOwner
{
CoinflipResolver(payable(ZSTETH)).swapWSTETHtoExactETH(to, exactOut, maxIn);
}
// Soledge reentrancy guard:
// (https://github.com/Vectorized/soledge/blob/main/src/utils/ReentrancyGuard.sol)
error Reentrancy();
uint256 constant REENTRANCY_GUARD_SLOT = 0x929eee149b4bd21268;
modifier nonReentrant() {
assembly ("memory-safe") {
if tload(REENTRANCY_GUARD_SLOT) {
mstore(0x00, 0xab143c06) // `Reentrancy()`
revert(0x1c, 0x04)
}
tstore(REENTRANCY_GUARD_SLOT, address())
}
_;
assembly ("memory-safe") {
tstore(REENTRANCY_GUARD_SLOT, 0)
}
}
/* ───────────────────────────── market state ───────────────────────────── */
struct Epoch {
uint72 closeAt; // market closes for trading at this timestamp
uint64 targetBlock; // block number committed at creation for randomness
uint256 seed; // precommitted seed (block.prevrandao at creation) for emergency fallback
bool resolved; // local marker to prevent double work
}
uint256 public currentMarketId;
mapping(uint256 => Epoch) public epochs;
/* ───────────────────────────── events ───────────────────────────── */
event MarketStarted(
uint256 indexed marketId, uint72 closeAt, uint64 targetBlock, string description
);
event MarketResolved(
uint256 indexed marketId,
bool outcome,
uint64 targetBlock,
bytes32 usedHash,
uint256 tipPaid
);
event DeadMarketSkipped(uint256 indexed marketId, uint256 tipPaid);
event Rolled(uint256 indexed fromMarket, uint256 indexed toMarket);
event TipPerResolveSet(uint256 newTip);
event TimeParamsSet(
uint256 closeDelay, uint256 resolveDelayBlocks, uint256 maxLag, uint256 emergencyLag
);
event EmergencyResolved(uint256 indexed marketId, bool outcome, uint256 when);
event PMResolverFeeSet(uint16 bps);
/* ───────────────────────────── errors ───────────────────────────── */
error ActiveMarketLive();
error NoActiveMarket();
error AlreadyResolved();
error MarketTooSoon(); // block.timestamp < closeAt
error TooEarlyForBlock(); // block.number <= targetBlock
error ResolveWindowExceeded(); // block.number > targetBlock + maxResolveLagBlocks
error TipPaymentFailed();
error BadParams();
error InvalidHash();
/* ───────────────────────────── ctor/owner ───────────────────────────── */
constructor() payable {
emit OwnershipTransferred(address(0), owner = msg.sender);
}
/* ───────────────────────────── admin knobs ───────────────────────────── */
function setTipPerResolve(uint256 newTip) public payable onlyOwner {
tipPerResolve = newTip;
emit TipPerResolveSet(newTip);
}
function setPMResolverFeeBps(uint16 bps) public payable onlyOwner {
// PM enforces bps <= 1000 (10%) internally; we can be lenient here:
PM.setResolverFeeBps(bps);
emit PMResolverFeeSet(bps);
}
function setTimeParams(
uint256 _closeDelay,
uint256 _resolveDelayBlocks,
uint256 _maxResolveLagBlocks,
uint256 _emergencyDelayBlocks
) public payable onlyOwner {
if (_closeDelay < 5 minutes) revert BadParams();
if (_resolveDelayBlocks == 0) revert BadParams();
if (_maxResolveLagBlocks < 2 || _maxResolveLagBlocks > 255) revert BadParams();
if (_emergencyDelayBlocks < 60 || _emergencyDelayBlocks > 100_000) revert BadParams();
// prevent uint72 overflow at _startNextMarket()
unchecked {
if (_closeDelay > type(uint72).max - block.timestamp) revert BadParams();
}
closeDelay = _closeDelay;
resolveDelayBlocks = _resolveDelayBlocks;
maxResolveLagBlocks = _maxResolveLagBlocks;
emergencyDelayBlocks = _emergencyDelayBlocks;
emit TimeParamsSet(
_closeDelay, _resolveDelayBlocks, _maxResolveLagBlocks, _emergencyDelayBlocks
);
}
/* ───────────────────────────── funding (tips) ───────────────────────────── */
event Funded(address indexed from, uint256 amount);
receive() external payable {
emit Funded(msg.sender, msg.value);
}
function fundTips() public payable {
emit Funded(msg.sender, msg.value);
}
/* ───────────────────────────── lifecycle ───────────────────────────── */
/// @notice Start the first (or next) market. Reverts if a live one exists.
function startNewMarket() public payable onlyOwner {
uint256 _current = currentMarketId;
if (_current != 0 && !epochs[_current].resolved) revert ActiveMarketLive();
_startNextMarket();
}
/// @notice Resolve current market using the pre-committed target block; then roll.
function resolve() public payable nonReentrant {
uint256 mId = currentMarketId;
if (mId == 0) revert NoActiveMarket();
Epoch storage e = epochs[mId];
if (e.resolved) revert AlreadyResolved();
if (block.timestamp < e.closeAt) revert MarketTooSoon();
(uint256 y, uint256 n,, bool pmResolved,,,,) = PM.getMarket(mId);
// If PM already resolved (defensive), roll forward. No tip:
if (pmResolved) {
e.resolved = true;
emit DeadMarketSkipped(mId, 0);
_roll(mId);
return;
}
// Both sides empty → skip post-close, no tip:
if (y == 0 && n == 0) {
e.resolved = true;
emit DeadMarketSkipped(mId, 0);
_roll(mId);
return;
}
// One-sided market → immediate PM resolve (refund path), no hashes, no tip:
if (y == 0 || n == 0) {
PM.resolve(mId, false); // PM ignores outcome & refunds when one side is zero
e.resolved = true;
emit DeadMarketSkipped(mId, 0);
_roll(mId);
return;
}
// Normal hash-based resolution (both sides > 0):
uint64 tgt = e.targetBlock;
if (block.number <= uint256(tgt) + 1) revert TooEarlyForBlock();
uint256 maxBlock = uint256(tgt) + maxResolveLagBlocks;
if (block.number > maxBlock) revert ResolveWindowExceeded();
bytes32 h1 = blockhash(uint256(tgt));
bytes32 h2 = blockhash(uint256(tgt + 1));
require(h1 != 0 && h2 != 0, InvalidHash());
bool outcome = (uint256(keccak256(abi.encodePacked(h1, h2, mId))) & 1) == 1;
PM.resolve(mId, outcome);
e.resolved = true;
uint256 tipPaid = _payTip(); // tip only for real, contested resolution
bytes32 used = keccak256(abi.encodePacked(h1, h2, mId));
emit MarketResolved(mId, outcome, tgt, used, tipPaid);
_roll(mId);
}
/// @notice Owner-only emergency resolve after the window has been missed.
/// Uses deterministic fallback: parity(keccak256(seed, marketId)).
function emergencyResolve() public payable onlyOwner nonReentrant {
uint256 mId = currentMarketId;
if (mId == 0) revert NoActiveMarket();
Epoch storage e = epochs[mId];
if (e.resolved) revert AlreadyResolved();
uint256 unlockBlk = uint256(e.targetBlock) + maxResolveLagBlocks + emergencyDelayBlocks;
if (block.number <= unlockBlk) revert ResolveWindowExceeded();
(uint256 y, uint256 n,, bool pmResolved,,,,) = PM.getMarket(mId);
if (pmResolved) {
e.resolved = true;
_roll(mId);
return;
}
if (y == 0 && n == 0) {
e.resolved = true;
emit DeadMarketSkipped(mId, _payTip());
_roll(mId);
return;
}
// Deterministic fallback independent of the caller
bool outcome = (uint256(keccak256(abi.encodePacked(e.seed, mId))) & 1) == 1;
// Keep a small safety auto-flip so winners exist if one side is empty (PM would refund anyway):
if (outcome && y == 0 && n > 0) outcome = false;
if (!outcome && n == 0 && y > 0) outcome = true;
PM.resolve(mId, outcome);
e.resolved = true;
emit EmergencyResolved(mId, outcome, block.timestamp);
_roll(mId);
}
/* ───────────────────────────── views ───────────────────────────── */
function currentInfo()
public
view
returns (uint256 marketId, uint72 closeAt, uint64 targetBlock, bool isResolved)
{
marketId = currentMarketId;
if (marketId != 0) {
Epoch storage e = epochs[marketId];
closeAt = e.closeAt;
targetBlock = e.targetBlock;
isResolved = e.resolved;
}
}
/// Helper for bots/keepers.
function canResolveNow() public view returns (bool ready, bool shouldSkip) {
uint256 mId = currentMarketId;
if (mId == 0) return (false, false);
Epoch storage e = epochs[mId];
if (e.resolved) return (false, false);
(uint256 y, uint256 n,, bool pmResolved,,,,) = PM.getMarket(mId);
if (pmResolved) return (true, true); // already done in PM → roll
if (block.timestamp < e.closeAt) return (false, false);
// After close: dead or one-sided can be handled immediately with no tip.
if (y == 0 && n == 0) return (true, true); // dead → skip
if (y == 0 || n == 0) return (true, true); // one-sided → refund path
// Contested market: need both tgt and tgt+1 available, within window.
if (block.number <= uint256(e.targetBlock) + 1) return (false, false);
if (block.number > uint256(e.targetBlock) + maxResolveLagBlocks) return (false, false);
if (blockhash(uint256(e.targetBlock)) == bytes32(0)) return (false, false);
if (blockhash(uint256(e.targetBlock) + 1) == bytes32(0)) return (false, false);
return (true, false);
}
/* ───────────────────────────── internals ───────────────────────────── */
function _startNextMarket() internal returns (uint256 newMarketId) {
// 1) Compute close time
uint72 closeAt = uint72(block.timestamp + closeDelay);
// 2) Convert the close delay (seconds) into blocks conservatively (ceil),
// then add a post-close buffer (resolveDelayBlocks).
// This guarantees targetBlock is strictly AFTER close.
uint64 blocksUntilClose =
uint64((closeDelay + (SECONDS_PER_BLOCK_BIAS - 1)) / SECONDS_PER_BLOCK_BIAS);
uint64 targetBlock = uint64(block.number) + blocksUntilClose + uint64(resolveDelayBlocks);
// 3) Capture a creation-time seed for emergency fallback
uint256 seed = uint256(block.prevrandao);
// 4) Human-friendly description. Since you now mix two blocks in resolve(),
// reflect that so UIs/keepers can sanity-check:
string memory desc = string(
abi.encodePacked(
"Coinflip: YES if parity(keccak(blockhash(",
_u2s(targetBlock),
"), blockhash(",
_u2s(uint256(targetBlock) + 1),
"), marketId)) == 1. Trading closes at unix=",
_u2s(closeAt),
"."
)
);
// 5) Create PM market
(newMarketId,) = PM.createMarket(desc, address(this), closeAt, /*canClose=*/ false);
// 6) Persist epoch state
epochs[newMarketId] =
Epoch({closeAt: closeAt, targetBlock: targetBlock, seed: seed, resolved: false});
currentMarketId = newMarketId;
emit MarketStarted(newMarketId, closeAt, targetBlock, desc);
}
function _roll(uint256 fromMarket) internal {
uint256 nextId = _startNextMarket();
emit Rolled(fromMarket, nextId);
}
function _payTip() internal returns (uint256 paid) {
uint256 tip = tipPerResolve;
if (tip == 0) return 0;
paid = address(this).balance < tip ? address(this).balance : tip;
if (paid == 0) return 0;
(bool ok,) = msg.sender.call{value: paid}("");
if (!ok) revert TipPaymentFailed();
}
/* ───────────────────────────── utils ───────────────────────────── */
function _u2s(uint256 x) internal pure returns (string memory s) {
if (x == 0) return "0";
uint256 j = x;
uint256 len;
while (j != 0) {
len++;
j /= 10;
}
bytes memory b = new bytes(len);
uint256 k = len;
while (x != 0) {
k--;
b[k] = bytes1(uint8(48 + x % 10));
x /= 10;
}
s = string(b);
}
}
"
}
},
"settings": {
"remappings": [
"@solady/=lib/solady/",
"@soledge/=lib/soledge/",
"@forge/=lib/forge-std/src/",
"forge-std/=lib/forge-std/src/",
"solady/=lib/solady/src/"
],
"optimizer": {
"enabled": true,
"runs": 9999999
},
"metadata": {
"useLiteralContent": false,
"bytecodeHash": "ipfs",
"appendCBOR": true
},
"outputSelection": {
"*": {
"*": [
"evm.bytecode",
"evm.deployedBytecode",
"devdoc",
"userdoc",
"metadata",
"abi"
]
}
},
"evmVersion": "prague",
"viaIR": true
}
}}
Submitted on: 2025-10-12 21:06:47
Comments
Log in to comment.
No comments yet.