Description:
Governance contract for decentralized decision-making.
Blockchain: Ethereum
Source Code: View Code On The Blockchain
Solidity Source Code:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;
/// @notice Minimal ERC20 interface
interface IERC20 {
function transfer(address to, uint256 value) external returns (bool);
function transferFrom(address from, address to, uint256 value) external returns (bool);
}
/// @title Simple claimable two-party wager with asymmetric odds and third-party judge
/// @notice Anyone can claim PartyA and PartyB. One side can go first; the other must match
/// according to a fixed multiplier (e.g., 10:1). Funds are escrowed until the judge resolves
/// the wager (A wins, B wins, split) or the bet cancels. Unilateral “bow out” is allowed
/// before the counterparty joins; otherwise cancellation requires mutual consent or a timeout.
contract SimpleWager {
// ====== Events ======
event ClaimedPartyA(address indexed who, uint256 deposit);
event ClaimedPartyB(address indexed who, uint256 deposit);
event Deposited(address indexed who, uint256 amount);
event Resolved(Outcome outcome, address winner, uint256 paidA, uint256 paidB);
event Refunded(address indexed to, uint256 amount);
event Cancelled();
event JudgeChanged(address indexed oldJudge, address indexed newJudge);
// ====== Types ======
enum Outcome { A_Wins, B_Wins, Split }
enum AssetType { ETH, ERC20 }
// ====== Storage ======
address public judge; // third-party arbitrator
address public partyA; // bigger side (deposits multiplier * baseStake)
address public partyB; // smaller side (deposits baseStake)
// Human-readable claim statements (optional convenience)
string public claimA; // e.g., "Claim #1 and #2 are false"
string public claimB; // e.g., "Claim #1 and/or #2 are true"
// Odds: partyA must deposit multiplier * baseStake
uint256 public immutable multiplier; // e.g., 10 for 10:1
// Escrowed amounts (actual funds held)
uint256 public stakeA; // escrowed from partyA
uint256 public stakeB; // escrowed from partyB
// Stake coordination
uint256 public baseStake; // agreed base stake once both joined
uint256 public proposedBaseStake; // A's proposed base if A goes first (no B funds yet)
// Asset configuration
AssetType public immutable assetType;
address public immutable asset; // token address for ERC20; ignored for ETH (set to address(0))
// Timers
uint256 public immutable joinDeadline; // last timestamp the second party can join (0 = no deadline)
uint256 public immutable resolveWindow; // seconds after full funding before anyone can auto-split
uint256 public fundedAt; // timestamp when both sides fully funded
// State flags
bool public resolved;
bool private _locked; // reentrancy guard
// ====== Modifiers ======
modifier onlyJudge() { require(msg.sender == judge, "not judge"); _; }
modifier nonReentrant() {
require(!_locked, "reentrancy");
_locked = true;
_;
_locked = false;
}
// ====== Constructor ======
/// @param _asset address(0) for ETH; ERC20 token address otherwise
/// @param _isERC20 true if using ERC20, false for ETH
/// @param _multiplier partyA must deposit multiplier * stakeB (e.g., 10)
/// @param _judge arbitrator address
/// @param _joinDeadline UNIX timestamp after which unfilled wagers can be cancelled (0 = none)
/// @param _resolveWindow Seconds after funding where anyone can auto-split if judge is idle (0 = disabled)
/// @param _claimA Optional text for A's position
/// @param _claimB Optional text for B's position
constructor(
address _asset,
bool _isERC20,
uint256 _multiplier,
address _judge,
uint256 _joinDeadline,
uint256 _resolveWindow,
string memory _claimA,
string memory _claimB
) {
require(_multiplier > 0, "multiplier=0");
require(_judge != address(0), "judge=0");
if (_isERC20) {
require(_asset != address(0), "token=0");
assetType = AssetType.ERC20;
} else {
require(_asset == address(0), "asset must be 0 for ETH");
assetType = AssetType.ETH;
}
asset = _asset;
judge = _judge;
multiplier = _multiplier;
joinDeadline = _joinDeadline;
resolveWindow = _resolveWindow; // can be 0 to disable auto-split
claimA = _claimA;
claimB = _claimB;
}
// ====== Role claiming & deposit ======
/// @notice Claim the small side (PartyB) and deposit the base stake.
/// For ETH, send value with the call. For ERC20, approve first then pass amount.
function claimPartyB(uint256 amount) external payable nonReentrant {
require(!resolved, "resolved");
require(partyB == address(0), "B taken");
require(amount > 0, "amount=0");
if (partyA != address(0) && proposedBaseStake > 0) {
// B is the second joiner → must be before deadline and not the same address as A
require(joinDeadline == 0 || block.timestamp <= joinDeadline, "deadline");
require(msg.sender != partyA, "same addr");
require(amount == proposedBaseStake, "must match proposal");
_takeFunds(msg.sender, amount);
partyB = msg.sender;
stakeB = amount;
baseStake = amount;
proposedBaseStake = 0;
emit ClaimedPartyB(msg.sender, amount);
emit Deposited(msg.sender, amount);
_maybeMarkFunded();
} else {
// B goes first: establish baseStake
_takeFunds(msg.sender, amount);
partyB = msg.sender;
stakeB = amount;
baseStake = amount;
emit ClaimedPartyB(msg.sender, amount);
emit Deposited(msg.sender, amount);
}
}
/// @notice Claim the big side (PartyA). Deposit must equal multiplier * current stakeB
/// If PartyB hasn't joined yet, you may propose a base stake via `proposedBaseStake`.
function claimPartyA(uint256 _proposedBaseStake) external payable nonReentrant {
require(!resolved, "resolved");
require(partyA == address(0), "A taken");
if (partyB == address(0)) {
// A goes first
require(_proposedBaseStake > 0, "proposed base=0");
uint256 required = _proposedBaseStake * multiplier;
_takeFunds(msg.sender, required);
partyA = msg.sender;
stakeA = required;
proposedBaseStake = _proposedBaseStake;
emit ClaimedPartyA(msg.sender, required);
emit Deposited(msg.sender, required);
} else {
// A is the second joiner → must be before deadline and not the same address as B
require(joinDeadline == 0 || block.timestamp <= joinDeadline, "deadline");
require(msg.sender != partyB, "same addr");
require(baseStake > 0, "no base");
uint256 required = baseStake * multiplier;
_takeFunds(msg.sender, required);
partyA = msg.sender;
stakeA = required;
emit ClaimedPartyA(msg.sender, required);
emit Deposited(msg.sender, required);
_maybeMarkFunded();
}
}
/// @notice Unilateral bow-out BEFORE a counterparty joins. Your funds are fully refunded.
function bowOutSolo() external nonReentrant {
require(!resolved, "resolved");
require(joinDeadline == 0 || block.timestamp <= joinDeadline, "past deadline");
if (msg.sender == partyA) {
require(partyB == address(0), "B already joined");
_payout(partyA, stakeA);
emit Refunded(partyA, stakeA);
stakeA = 0; partyA = address(0); proposedBaseStake = 0;
} else if (msg.sender == partyB) {
require(partyA == address(0), "A already joined");
_payout(partyB, stakeB);
emit Refunded(partyB, stakeB);
stakeB = 0; partyB = address(0); baseStake = 0;
} else {
revert("not a party");
}
}
/// @notice Mutual cancel before funding completion: both parties must call once.
bool private aCancel;
bool private bCancel;
function requestCancel() external nonReentrant {
require(!resolved, "resolved");
require(!fullyFunded(), "funded");
if (msg.sender == partyA) aCancel = true;
else if (msg.sender == partyB) bCancel = true;
else revert("not a party");
if (aCancel && bCancel) {
_refundAll();
resolved = true;
emit Cancelled();
}
}
/// @notice Cancel & refund if second party didn't join in time.
function cancelIfUnfilled() external nonReentrant {
require(!resolved, "resolved");
require(joinDeadline != 0 && block.timestamp > joinDeadline, "no timeout");
require(!_bothFunded(), "funded");
_refundAll();
resolved = true;
emit Cancelled();
}
// ====== Resolution ======
function resolve(Outcome outcome) external onlyJudge nonReentrant {
require(!resolved, "resolved");
require(_bothFunded(), "not funded");
resolved = true;
uint256 pot = stakeA + stakeB;
if (outcome == Outcome.A_Wins) {
_payout(partyA, pot);
emit Resolved(outcome, partyA, pot, 0);
} else if (outcome == Outcome.B_Wins) {
_payout(partyB, pot);
emit Resolved(outcome, partyB, 0, pot);
} else {
uint256 half = pot / 2;
_payout(partyA, half);
_payout(partyB, pot - half);
emit Resolved(outcome, address(0), half, pot - half);
}
_zeroOut();
}
/// @notice Either party may concede once fully funded; pot goes to the opponent.
function concede() external nonReentrant {
require(!resolved, "resolved");
require(_bothFunded(), "not funded");
resolved = true;
uint256 pot = stakeA + stakeB;
if (msg.sender == partyA) {
_payout(partyB, pot);
emit Resolved(Outcome.B_Wins, partyB, 0, pot);
} else if (msg.sender == partyB) {
_payout(partyA, pot);
emit Resolved(Outcome.A_Wins, partyA, pot, 0);
} else {
revert("not a party");
}
_zeroOut();
}
/// @notice Anyone can auto-split if judge is unresponsive past resolveWindow.
function autoSplitIfExpired() external nonReentrant {
require(!resolved, "resolved");
require(resolveWindow > 0, "disabled");
require(_bothFunded(), "not funded");
require(fundedAt != 0 && block.timestamp >= fundedAt + resolveWindow, "not expired");
resolved = true;
uint256 pot = stakeA + stakeB;
uint256 half = pot / 2;
_payout(partyA, half);
_payout(partyB, pot - half);
emit Resolved(Outcome.Split, address(0), half, pot - half);
_zeroOut();
}
// ====== Admin ======
function changeJudge(address newJudge) external onlyJudge {
require(newJudge != address(0), "judge=0");
emit JudgeChanged(judge, newJudge);
judge = newJudge;
}
// ====== Views ======
function fullyFunded() public view returns (bool) {
return _bothFunded();
}
function potSize() external view returns (uint256) {
return stakeA + stakeB;
}
function _bothFunded() internal view returns (bool) {
return baseStake > 0 && stakeA == baseStake * multiplier && stakeB == baseStake;
}
function _maybeMarkFunded() internal {
if (fundedAt == 0 && _bothFunded()) {
fundedAt = block.timestamp;
}
}
// ====== Internal money movement ======
function _takeFunds(address from, uint256 amount) internal {
if (assetType == AssetType.ETH) {
require(msg.value == amount, "bad msg.value");
} else {
require(msg.value == 0, "no ETH");
_safeTransferFromWithCheck(asset, from, address(this), amount);
}
}
function _payout(address to, uint256 amount) internal {
if (amount == 0) return;
if (assetType == AssetType.ETH) {
(bool ok, ) = to.call{ value: amount }("");
require(ok, "ETH payout fail");
} else {
_safeTransferWithCheck(asset, to, amount);
}
}
function _refundAll() internal {
if (stakeA > 0 && partyA != address(0)) {
_payout(partyA, stakeA);
emit Refunded(partyA, stakeA);
stakeA = 0; partyA = address(0);
proposedBaseStake = 0;
}
if (stakeB > 0 && partyB != address(0)) {
_payout(partyB, stakeB);
emit Refunded(partyB, stakeB);
stakeB = 0; partyB = address(0);
baseStake = 0;
}
fundedAt = 0;
}
function _zeroOut() internal {
stakeA = 0; stakeB = 0; baseStake = 0; proposedBaseStake = 0; fundedAt = 0;
}
// ====== Low-level ERC20 helpers (supports non-standard tokens that don't return bool)
// Added balance-delta checks to protect against fee-on-transfer / rebasing tokens
function _safeTransferWithCheck(address token, address to, uint256 value) internal {
uint256 beforeBal = _balanceOf(token, to);
(bool ok, bytes memory data) = token.call(abi.encodeWithSelector(IERC20.transfer.selector, to, value));
require(ok && (data.length == 0 || abi.decode(data, (bool))), "ERC20 transfer fail");
uint256 afterBal = _balanceOf(token, to);
require(afterBal - beforeBal == value, "ERC20 fee-on-transfer not supported");
}
function _safeTransferFromWithCheck(address token, address from, address to, uint256 value) internal {
uint256 beforeBal = _balanceOf(token, to);
(bool ok, bytes memory data) = token.call(abi.encodeWithSelector(IERC20.transferFrom.selector, from, to, value));
require(ok && (data.length == 0 || abi.decode(data, (bool))), "ERC20 transferFrom fail");
uint256 afterBal = _balanceOf(token, to);
require(afterBal - beforeBal == value, "ERC20 fee-on-transfer not supported");
}
function _balanceOf(address token, address who) private view returns (uint256 bal) {
// bytes4(keccak256("balanceOf(address)")) == 0x70a08231
(bool ok, bytes memory data) = token.staticcall(abi.encodeWithSelector(0x70a08231, who));
require(ok && data.length >= 32, "balanceOf fail");
bal = abi.decode(data, (uint256));
}
// ====== Receive ETH ======
receive() external payable { revert("direct ETH not allowed"); }
}
Submitted on: 2025-09-20 17:55:21
Comments
Log in to comment.
No comments yet.