diff --git a/DisputableAIBounty.sol b/DisputableAIBounty.sol new file mode 100644 index 00000000..5f5b1871 --- /dev/null +++ b/DisputableAIBounty.sol @@ -0,0 +1,231 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +contract DisputableAIBounty { + struct Challenge { + address owner; + string prompt; + uint256 prize; + uint256 commitDeadline; + uint256 revealDeadline; + bool judged; + bool finalized; + address winner; + string[] answers; + address[] participants; + mapping(address => bytes32) commitments; + mapping(address => bool) hasRevealed; + mapping(address => uint256) answerIndex; + mapping(address => bool) isParticipant; + bool disputed; + uint256 disputeDeadline; + } + + struct ChallengeInfo { + address owner; + string prompt; + uint256 prize; + uint256 commitDeadline; + uint256 revealDeadline; + bool judged; + bool finalized; + address winner; + uint256 participantCount; + uint256 answerCount; + bool disputed; + uint256 disputeDeadline; + } + + uint256 public challengeCounter; + mapping(uint256 => Challenge) public challenges; + + event ChallengeCreated(uint256 indexed id, address indexed owner, uint256 prize); + event CommitmentSubmitted(uint256 indexed id, address indexed participant); + event AnswerRevealed(uint256 indexed id, address indexed participant, string answer); + event Judged(uint256 indexed id, uint256 answerCount); + event WinnerFinalized(uint256 indexed id, address indexed winner); + event DisputeInitiated(uint256 indexed id, address indexed disputer); + event DisputeResolved(uint256 indexed id, address indexed newWinner); + + modifier challengeExists(uint256 id) { + require(challenges[id].owner != address(0), "Challenge does not exist"); + _; + } + + modifier onlyCommitPhase(uint256 id) { + require(block.timestamp <= challenges[id].commitDeadline, "Commit phase ended"); + _; + } + + modifier onlyRevealPhase(uint256 id) { + require(block.timestamp > challenges[id].commitDeadline, "Not reveal phase"); + require(block.timestamp <= challenges[id].revealDeadline, "Reveal phase ended"); + _; + } + + modifier onlyAfterReveal(uint256 id) { + require(block.timestamp > challenges[id].revealDeadline, "Reveal phase not over"); + _; + } + + modifier onlyChallengeOwner(uint256 id) { + require(msg.sender == challenges[id].owner, "Not challenge owner"); + _; + } + + modifier notJudged(uint256 id) { + require(!challenges[id].judged, "Already judged"); + _; + } + + modifier notFinalized(uint256 id) { + require(!challenges[id].finalized, "Already finalized"); + _; + } + + function createChallenge( + string calldata prompt, + uint256 commitDeadline, + uint256 revealDuration + ) external payable { + require(msg.value > 0, "Prize must be > 0"); + require(commitDeadline > block.timestamp, "Deadline must be in future"); + require(revealDuration > 0, "Reveal duration must be > 0"); + + uint256 id = challengeCounter++; + Challenge storage c = challenges[id]; + c.owner = msg.sender; + c.prompt = prompt; + c.prize = msg.value; + c.commitDeadline = commitDeadline; + c.revealDeadline = commitDeadline + revealDuration; + + emit ChallengeCreated(id, msg.sender, msg.value); + } + + function commitSolution(uint256 id, bytes32 commitment) external + challengeExists(id) + onlyCommitPhase(id) + { + Challenge storage c = challenges[id]; + require(c.commitments[msg.sender] == 0, "Already committed"); + + c.commitments[msg.sender] = commitment; + c.participants.push(msg.sender); + c.isParticipant[msg.sender] = true; + + emit CommitmentSubmitted(id, msg.sender); + } + + function revealSolution( + uint256 id, + string calldata answer, + bytes32 salt + ) external + challengeExists(id) + onlyRevealPhase(id) + { + Challenge storage c = challenges[id]; + bytes32 commitment = c.commitments[msg.sender]; + require(commitment != 0, "No commitment found"); + require(!c.hasRevealed[msg.sender], "Already revealed"); + + bytes32 computed = keccak256(abi.encodePacked(answer, salt, msg.sender, id)); + require(computed == commitment, "Commitment mismatch"); + + c.hasRevealed[msg.sender] = true; + c.answerIndex[msg.sender] = c.answers.length; + c.answers.push(answer); + + emit AnswerRevealed(id, msg.sender, answer); + } + + function judgeAll(uint256 id, bytes calldata _llmInput) external + challengeExists(id) + onlyChallengeOwner(id) + onlyAfterReveal(id) + notJudged(id) + { + Challenge storage c = challenges[id]; + require(c.answers.length > 0, "No revealed answers"); + + c.judged = true; + emit Judged(id, c.answers.length); + } + + function finalizeWinner(uint256 id, uint256 winnerIndex) external + challengeExists(id) + onlyChallengeOwner(id) + onlyAfterReveal(id) + notFinalized(id) + { + Challenge storage c = challenges[id]; + require(c.judged, "Must judge first"); + require(winnerIndex < c.answers.length, "Invalid winner index"); + + c.finalized = true; + c.winner = c.participants[winnerIndex]; + c.disputeDeadline = block.timestamp + 1 days; + + payable(c.winner).transfer(c.prize); + + emit WinnerFinalized(id, c.winner); + } + + function initiateDispute(uint256 id) external + challengeExists(id) + onlyAfterReveal(id) + { + Challenge storage c = challenges[id]; + require(c.finalized, "Not finalized yet"); + require(!c.disputed, "Already disputed"); + require(block.timestamp <= c.disputeDeadline, "Dispute period ended"); + require(c.isParticipant[msg.sender], "Not a participant"); + + c.disputed = true; + emit DisputeInitiated(id, msg.sender); + } + + function resolveDispute(uint256 id, uint256 newWinnerIndex) external + challengeExists(id) + onlyChallengeOwner(id) + { + Challenge storage c = challenges[id]; + require(c.disputed, "No active dispute"); + require(block.timestamp > c.disputeDeadline, "Dispute period still active"); + + c.winner = c.participants[newWinnerIndex]; + emit DisputeResolved(id, c.winner); + } + + function getChallengeInfo(uint256 id) external view returns (ChallengeInfo memory) { + Challenge storage c = challenges[id]; + return ChallengeInfo({ + owner: c.owner, + prompt: c.prompt, + prize: c.prize, + commitDeadline: c.commitDeadline, + revealDeadline: c.revealDeadline, + judged: c.judged, + finalized: c.finalized, + winner: c.winner, + participantCount: c.participants.length, + answerCount: c.answers.length, + disputed: c.disputed, + disputeDeadline: c.disputeDeadline + }); + } + + function getAnswers(uint256 id) external view returns (string[] memory) { + require(msg.sender == challenges[id].owner || challenges[id].finalized, "Not authorized"); + return challenges[id].answers; + } + + function hasCommitted(uint256 id, address participant) external view returns (bool) { + return challenges[id].commitments[participant] != 0; + } + + function hasRevealed(uint256 id, address participant) external view returns (bool) { + return challenges[id].hasRevealed[participant]; + } +} diff --git a/README.md b/README.md index 6025607d..019eab34 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,17 @@ -## Starter for Ritual workshop on 23th June 2026 +# DisputableAIBounty – Commit-Reveal with Dispute Period -/hardhat -> Where we'll write the smart contract +This contract adds a 24‑hour dispute window after the owner finalizes a winner. Participants can challenge the outcome if they believe the AI judgment was incorrect. -/web -> Where the frontend lives. +## How it works +1. Commit phase: participants submit hashed answers. +2. Reveal phase: participants reveal their answers. +3. Owner triggers AI judging (off‑chain simulation). +4. Owner finalizes a winner and prize is sent. +5. Any participant can dispute within 24 hours. +6. Owner resolves the dispute by choosing a new winner. + +## Contract Address (Ritual Testnet) + 0x535F7C44b6990e58140547c45D39094681E33755 + +## Network +Ritual Chain Testnet (ID: 1979) diff --git a/Reflection.md b/Reflection.md new file mode 100644 index 00000000..6dccee02 --- /dev/null +++ b/Reflection.md @@ -0,0 +1,9 @@ +# Reflection: Adding Dispute Resolution + +A dispute period introduces human oversight to AI‑judged bounties. It makes the system more resilient to AI errors, but adds complexity and delays payout. + +**Public vs Hidden:** Answers stay hidden until reveal; disputes are public after they are initiated. + +**AI vs Human:** AI handles the initial ranking; the owner (human) resolves disputes, ensuring accountability. + +**Trade‑off:** Slightly longer finalization time, but higher trust. diff --git a/TestPlan.md b/TestPlan.md new file mode 100644 index 00000000..d83ca0a3 --- /dev/null +++ b/TestPlan.md @@ -0,0 +1,5 @@ +# Test Plan – DisputableAIBounty + +- Happy path (commit → reveal → judge → finalize) +- Dispute path (initiate within window → resolve after window) +- Revert cases: reveal before deadline, dispute after window, non‑owner actions diff --git a/test/DisputableAIBounty.t.sol b/test/DisputableAIBounty.t.sol new file mode 100644 index 00000000..d5a506e9 --- /dev/null +++ b/test/DisputableAIBounty.t.sol @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; +import "forge-std/Test.sol"; +import "../src/DisputableAIBounty.sol"; + +contract DisputableAIBountyTest is Test { + DisputableAIBounty public bounty; + address owner = address(0x1); + address alice = address(0x2); + address bob = address(0x3); + uint256 challengeId; + bytes32 aliceCommitment; + bytes32 bobCommitment; + bytes32 aliceSalt = keccak256("alice_salt"); + bytes32 bobSalt = keccak256("bob_salt"); + string aliceAnswer = "Alice's solution"; + string bobAnswer = "Bob's solution"; + + function setUp() public { + vm.deal(owner, 10 ether); + vm.deal(alice, 1 ether); + vm.deal(bob, 1 ether); + bounty = new DisputableAIBounty(); + vm.startPrank(owner); + uint256 commitDeadline = block.timestamp + 1 days; + bounty.createChallenge{value: 1 ether}("Test", commitDeadline, 2 days); + challengeId = 0; + vm.stopPrank(); + aliceCommitment = keccak256(abi.encodePacked(aliceAnswer, aliceSalt, alice, challengeId)); + bobCommitment = keccak256(abi.encodePacked(bobAnswer, bobSalt, bob, challengeId)); + } + + function testFullFlow() public { + vm.startPrank(alice); + bounty.commitSolution(challengeId, aliceCommitment); + vm.stopPrank(); + vm.startPrank(bob); + bounty.commitSolution(challengeId, bobCommitment); + vm.stopPrank(); + + vm.warp(block.timestamp + 1 days + 1); + vm.startPrank(alice); + bounty.revealSolution(challengeId, aliceAnswer, aliceSalt); + vm.stopPrank(); + vm.startPrank(bob); + bounty.revealSolution(challengeId, bobAnswer, bobSalt); + vm.stopPrank(); + + vm.warp(block.timestamp + 2 days + 1); + vm.startPrank(owner); + bounty.judgeAll(challengeId, bytes("")); + bounty.finalizeWinner(challengeId, 1); + vm.stopPrank(); + + DisputableAIBounty.ChallengeInfo memory info = bounty.getChallengeInfo(challengeId); + assertEq(info.winner, bob); + assertEq(bob.balance, 1 ether + 1 ether); + } + + function testDisputeFlow() public { + testFullFlow(); + vm.warp(block.timestamp + 12 hours); + vm.startPrank(alice); + bounty.initiateDispute(challengeId); + vm.stopPrank(); + DisputableAIBounty.ChallengeInfo memory info = bounty.getChallengeInfo(challengeId); + assertTrue(info.disputed); + vm.warp(block.timestamp + 13 hours); + vm.startPrank(owner); + bounty.resolveDispute(challengeId, 0); + vm.stopPrank(); + info = bounty.getChallengeInfo(challengeId); + assertEq(info.winner, alice); + } + + function testCannotRevealBeforeDeadline() public { + vm.startPrank(alice); + bounty.commitSolution(challengeId, aliceCommitment); + vm.expectRevert("Not reveal phase"); + bounty.revealSolution(challengeId, aliceAnswer, aliceSalt); + vm.stopPrank(); + } +}