Description:
Multi-signature wallet contract requiring multiple confirmations for transaction execution.
Blockchain: Ethereum
Source Code: View Code On The Blockchain
Solidity Source Code:
{{
"language": "Solidity",
"sources": {
"src/Arena.sol": {
"content": "// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
/// @title Arena
/// @notice Commit-reveal RPS wagering with authorized matching
contract Arena {
enum WeaponType {
Rock,
Paper,
Scissors
}
struct Entry {
uint96 wager;
bytes32 commitHash;
uint64 commitBlock;
address opponent;
uint64 matchBlock;
bool revealed; // AW-C-01: Track reveal status
}
mapping(address => Entry) public queue;
mapping(address => uint256) public credits;
mapping(address => uint256) public lastCreditedAt; // AW-L-01: Track credit timestamps
address public owner;
address public matcher;
address public feeProtocol;
address public feeSeasonCurrent;
address public feeSeasonNext;
uint64 public constant REVEAL_DEADLINE_BLOCKS = 1_800;
uint256 public constant CREDIT_RECOVERY_DELAY = 365 days; // AW-L-01: Stale credit recovery delay
uint256 private locked = 1;
event Entered(address indexed player, uint96 wager, bytes32 commitHash);
event Left(address indexed player);
event Matched(address indexed a, address indexed b, uint64 matchBlock);
event Resolved(address indexed a, address indexed b, address winner, bool gunA, bool gunB, bool tiebreak);
event Forfeited(address indexed winner, address indexed loser);
event Credited(address indexed to, uint256 amount);
event CreditsRecovered(address indexed from, address indexed to, uint256 amount); // AW-L-01: Credit recovery event
event IndividualReveal(address indexed player, address indexed opponent);
event FeeRecipientsSet(address protocol, address seasonCurrent, address seasonNext);
event OwnershipTransferred(address indexed previousOwner, address indexed newOwner);
event MatcherSet(address indexed previousMatcher, address indexed newMatcher);
error ActiveEntry();
error InactiveEntry();
error ValueMismatch();
error TierInvalid();
error OpponentRequired();
error NotAfterCommit();
error PastDeadline();
error BadCommit();
error NotMatched();
error WagerMismatch();
error NoCredits();
error TransferFailed();
error Unauthorized();
error InvalidAddress();
error ReentrancyGuard();
error InvalidWeapon();
error AlreadyMatched();
error NotPastDeadline();
error LoserAlreadyRevealed(); // AW-C-01: Prevent forfeit of revealed player
error RecoveryTooEarly(); // AW-L-01: Recovery delay not met
modifier onlyOwner() {
if (msg.sender != owner) revert Unauthorized();
_;
}
modifier onlyMatcher() {
if (msg.sender != matcher) revert Unauthorized();
_;
}
modifier nonReentrant() {
if (locked != 1) revert ReentrancyGuard();
locked = 2;
_;
locked = 1;
}
constructor() {
owner = msg.sender;
matcher = msg.sender;
feeProtocol = msg.sender;
feeSeasonCurrent = msg.sender;
feeSeasonNext = msg.sender;
emit OwnershipTransferred(address(0), msg.sender);
}
function enterQueue(uint8 tier, bytes32 commitHash) external payable nonReentrant {
if (tier > 3) revert TierInvalid();
if (queue[msg.sender].wager != 0) revert ActiveEntry();
uint96 requiredWager = wagerForTier(tier);
if (msg.value != requiredWager) revert ValueMismatch();
queue[msg.sender] = Entry({
wager: requiredWager,
commitHash: commitHash,
commitBlock: uint64(block.number),
opponent: address(0),
matchBlock: 0,
revealed: false
});
emit Entered(msg.sender, requiredWager, commitHash);
}
function leaveQueue() external nonReentrant {
Entry storage entry = queue[msg.sender];
if (entry.wager == 0) revert InactiveEntry();
if (entry.opponent != address(0)) revert AlreadyMatched();
uint96 refundAmount = entry.wager;
delete queue[msg.sender];
_safeSend(msg.sender, refundAmount);
emit Left(msg.sender);
}
/// @notice Allow individual reveal to protect against griefing
/// @dev AW-C-01: Players can independently reveal their commits onchain
/// @param weapons The player's weapon choices
/// @param salt The player's salt used in commitment
function revealIndividual(WeaponType[3] calldata weapons, bytes32 salt) external nonReentrant {
Entry storage entry = queue[msg.sender];
if (entry.opponent == address(0)) revert NotMatched();
if (entry.wager == 0) revert InactiveEntry();
uint64 matchBlock = entry.matchBlock;
if (block.number <= matchBlock + 1) revert NotAfterCommit();
if (block.number > matchBlock + REVEAL_DEADLINE_BLOCKS) revert PastDeadline();
// Validate the player's commitment
_validateWeapons(weapons);
_validateCommit(msg.sender, weapons, salt, entry.commitHash);
// Mark as revealed
entry.revealed = true;
emit IndividualReveal(msg.sender, entry.opponent);
}
function matchPlayers(address a, address b) external onlyMatcher nonReentrant {
if (a == b) revert OpponentRequired();
Entry storage entryA = queue[a];
Entry storage entryB = queue[b];
if (entryA.wager == 0 || entryB.wager == 0) revert InactiveEntry();
if (entryA.wager != entryB.wager) revert WagerMismatch();
if (entryA.opponent != address(0) || entryB.opponent != address(0)) revert AlreadyMatched();
uint64 currentBlock = uint64(block.number);
entryA.opponent = b;
entryA.matchBlock = currentBlock;
entryB.opponent = a;
entryB.matchBlock = currentBlock;
emit Matched(a, b, currentBlock);
}
function revealMatchResult(
address a,
address b,
WeaponType[3] calldata aW,
bytes32 aSalt,
WeaponType[3] calldata bW,
bytes32 bSalt
) external nonReentrant {
if (a == b) revert OpponentRequired();
Entry storage entryA = queue[a];
Entry storage entryB = queue[b];
if (entryA.opponent != b || entryB.opponent != a) revert NotMatched();
if (entryA.wager != entryB.wager) revert WagerMismatch();
uint64 matchBlock = entryA.matchBlock;
if (block.number <= matchBlock + 1) revert NotAfterCommit();
if (block.number > matchBlock + REVEAL_DEADLINE_BLOCKS) revert PastDeadline();
_validateWeapons(aW);
_validateWeapons(bW);
_validateCommit(a, aW, aSalt, entryA.commitHash);
_validateCommit(b, bW, bSalt, entryB.commitHash);
// AW-C-01: Mark both players as revealed
entryA.revealed = true;
entryB.revealed = true;
bytes32 seedBlock = blockhash(matchBlock + 1);
if (seedBlock == 0 && block.number > matchBlock + 257) {
seedBlock = keccak256(abi.encodePacked(matchBlock));
}
address lo = a < b ? a : b;
address hi = a < b ? b : a;
bytes32 seed = keccak256(
abi.encodePacked(
lo,
hi,
lo == a ? entryA.commitHash : entryB.commitHash,
lo == a ? entryB.commitHash : entryA.commitHash,
lo == a ? aSalt : bSalt,
lo == a ? bSalt : aSalt,
seedBlock
)
);
address winner = _determineWinner(a, b, aW, bW, seed);
uint256 pot = uint256(entryA.wager) + uint256(entryB.wager);
delete queue[a];
delete queue[b];
_distributePayout(winner, pot);
bool gunA = uint256(keccak256(abi.encodePacked(seed, "A"))) % 100 == 0;
bool gunB = uint256(keccak256(abi.encodePacked(seed, "B"))) % 100 == 0;
bool tiebreak = !gunA && !gunB && _scoreBestOfThree(aW, bW) == 0;
emit Resolved(a, b, winner, gunA, gunB, tiebreak);
}
function forfeitUnrevealed(address winner, address loser, WeaponType[3] calldata winnerWeapons, bytes32 winnerSalt)
external
nonReentrant
{
if (winner == loser) revert OpponentRequired();
Entry storage entryW = queue[winner];
Entry storage entryL = queue[loser];
if (entryW.opponent != loser || entryL.opponent != winner) revert NotMatched();
if (entryW.wager != entryL.wager) revert WagerMismatch();
uint64 matchBlock = entryW.matchBlock;
if (block.number <= matchBlock + REVEAL_DEADLINE_BLOCKS) revert NotPastDeadline();
// AW-C-01: Prevent forfeit if loser has already revealed
if (entryL.revealed) revert LoserAlreadyRevealed();
_validateWeapons(winnerWeapons);
_validateCommit(winner, winnerWeapons, winnerSalt, entryW.commitHash);
uint256 pot = uint256(entryW.wager) + uint256(entryL.wager);
delete queue[winner];
delete queue[loser];
_distributePayout(winner, pot);
emit Forfeited(winner, loser);
}
function claim() external nonReentrant {
uint256 amount = credits[msg.sender];
if (amount == 0) revert NoCredits();
credits[msg.sender] = 0;
(bool success,) = msg.sender.call{ value: amount }("");
if (!success) revert TransferFailed();
}
function setFeeRecipients(address protocol, address seasonCurr, address seasonNext) external onlyOwner {
if (protocol == address(0) || seasonCurr == address(0) || seasonNext == address(0)) {
revert InvalidAddress();
}
if (protocol == address(this) || seasonCurr == address(this) || seasonNext == address(this)) {
revert InvalidAddress();
}
feeProtocol = protocol;
feeSeasonCurrent = seasonCurr;
feeSeasonNext = seasonNext;
emit FeeRecipientsSet(protocol, seasonCurr, seasonNext);
}
function setMatcher(address newMatcher) external onlyOwner {
if (newMatcher == address(0)) revert InvalidAddress();
address oldMatcher = matcher;
matcher = newMatcher;
emit MatcherSet(oldMatcher, newMatcher);
}
function transferOwnership(address newOwner) external onlyOwner {
if (newOwner == address(0)) revert InvalidAddress();
address oldOwner = owner;
owner = newOwner;
emit OwnershipTransferred(oldOwner, newOwner);
}
function wagerForTier(uint8 tier) public pure returns (uint96) {
if (tier == 0) return 0.005 ether;
if (tier == 1) return 0.05 ether;
if (tier == 2) return 0.5 ether;
if (tier == 3) return 2.5 ether;
revert TierInvalid();
}
function _validateWeapons(WeaponType[3] calldata weapons) private pure {
for (uint256 i = 0; i < 3; i++) {
if (uint8(weapons[i]) > 2) revert InvalidWeapon();
}
}
function _validateCommit(address player, WeaponType[3] calldata weapons, bytes32 salt, bytes32 expectedHash)
private
view
{
bytes32 actualHash = keccak256(
abi.encodePacked(
uint8(weapons[0]), uint8(weapons[1]), uint8(weapons[2]), salt, player, address(this), block.chainid
)
);
if (actualHash != expectedHash) revert BadCommit();
}
function _determineWinner(address a, address b, WeaponType[3] calldata aW, WeaponType[3] calldata bW, bytes32 seed)
private
pure
returns (address)
{
bool gunA = uint256(keccak256(abi.encodePacked(seed, "A"))) % 100 == 0;
bool gunB = uint256(keccak256(abi.encodePacked(seed, "B"))) % 100 == 0;
if (gunA && !gunB) return a;
if (!gunA && gunB) return b;
int8 score = _scoreBestOfThree(aW, bW);
if (score > 0) return a;
if (score < 0) return b;
return (uint256(keccak256(abi.encodePacked(seed, "T"))) & 1) == 0 ? a : b;
}
function _scoreBestOfThree(WeaponType[3] calldata aW, WeaponType[3] calldata bW) private pure returns (int8) {
int8 score = 0;
for (uint256 i = 0; i < 3; i++) {
score += _playRPS(aW[i], bW[i]);
}
return score;
}
function _playRPS(WeaponType a, WeaponType b) private pure returns (int8) {
if (a == b) return 0;
if (a == WeaponType.Rock && b == WeaponType.Scissors) return 1;
if (a == WeaponType.Scissors && b == WeaponType.Paper) return 1;
if (a == WeaponType.Paper && b == WeaponType.Rock) return 1;
return -1;
}
function _distributePayout(address winner, uint256 pot) private {
uint256 feeP = pot * 200 / 10_000;
uint256 feeSC = pot * 100 / 10_000;
uint256 feeSN = pot * 100 / 10_000;
uint256 winnerAmount = pot - feeP - feeSC - feeSN;
_safeSend(feeProtocol, feeP);
_safeSend(feeSeasonCurrent, feeSC);
_safeSend(feeSeasonNext, feeSN);
_safeSend(winner, winnerAmount);
}
function _safeSend(address to, uint256 amount) private {
if (amount == 0) return;
(bool success,) = to.call{ value: amount, gas: 100000 }(""); // AW-L-02: Increased gas for smart wallets
if (!success) {
credits[to] += amount;
lastCreditedAt[to] = block.timestamp; // AW-L-01: Track credit timestamp
emit Credited(to, amount);
}
}
/// @notice Recover stale credits that cannot be claimed
/// @dev AW-L-01: Owner-only recovery after conservative delay
/// @param from Address with unclaimed credits
/// @param to Recovery recipient address
function recoverStaleCredits(address from, address to) external onlyOwner nonReentrant {
uint256 amount = credits[from];
if (amount == 0) revert NoCredits();
// Enforce recovery delay since last credit
if (block.timestamp < lastCreditedAt[from] + CREDIT_RECOVERY_DELAY) {
revert RecoveryTooEarly();
}
credits[from] = 0;
emit CreditsRecovered(from, to, amount);
(bool success,) = to.call{ value: amount }("");
if (!success) revert TransferFailed();
}
receive() external payable { }
}
"
}
},
"settings": {
"remappings": [
"forge-std/=lib/forge-std/src/",
"@openzeppelin/=lib/openzeppelin-contracts/"
],
"optimizer": {
"enabled": true,
"runs": 200
},
"metadata": {
"useLiteralContent": false,
"bytecodeHash": "ipfs",
"appendCBOR": true
},
"outputSelection": {
"*": {
"*": [
"evm.bytecode",
"evm.deployedBytecode",
"devdoc",
"userdoc",
"metadata",
"abi"
]
}
},
"evmVersion": "cancun",
"viaIR": true
}
}}
Submitted on: 2025-10-24 09:30:24
Comments
Log in to comment.
No comments yet.