Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions hardhat/contracts/CommitReveal.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import {AIJudge} from "./AIJudge.sol";

/// @title CommitReveal — Extension for AIJudge
/// @notice Adds commit-reveal support: submitter commits hash(secret+answer),
/// then reveals after deadline. Prevents plaintext answer leakage.
contract CommitReveal is AIJudge {
uint256 public constant COMMIT_WINDOW = 3600; // 1h

mapping(uint256 => mapping(uint256 => bytes32)) public commits;

event AnswerCommitted(uint256 indexed bountyId, uint256 indexed submissionIndex, bytes32 commitment);

/// @notice Submit a commit (hash of answer + secret) — no plaintext
function commitAnswer(
uint256 bountyId,
bytes32 commitment
) external bountyExists(bountyId) {
Bounty storage bounty = bounties[bountyId];
require(block.timestamp < bounty.deadline, "submissions closed");
require(!bounty.judged, "already judged");

bounty.submissions.push(Submission({submitter: msg.sender, answer: ""}));
uint256 idx = bounty.submissions.length - 1;
commits[bountyId][idx] = commitment;

emit AnswerCommitted(bountyId, idx, commitment);
}

/// @notice Reveal the committed answer
function revealAnswer(
uint256 bountyId,
uint256 submissionIdx,
string calldata answer,
bytes32 secret
) external {
bytes32 expected = keccak256(abi.encodePacked(secret, answer));
require(commits[bountyId][submissionIdx] == expected, "commit mismatch");
require(block.timestamp >= bounty.deadline + COMMIT_WINDOW, "too early");

Bounty storage bounty = bounties[bountyId];
bounty.submissions[submissionIdx].answer = answer;
}

/// @notice Override — getBounty with commit info
function getBounty(uint256 bountyId)
external
view
override
returns (
address owner,
string memory title,
string memory rubric,
uint256 reward,
uint256 deadline,
bool judged,
bool finalized,
uint256 submissionCount,
uint256 winnerIndex,
bytes memory aiReview
)
{
Bounty storage bounty = bounties[bountyId];
return (
bounty.owner,
bounty.title,
bounty.rubric,
bounty.reward,
bounty.deadline,
bounty.judged,
bounty.finalized,
bounty.submissions.length,
bounty.winnerIndex,
bounty.aiReview
);
}
}
38 changes: 38 additions & 0 deletions hardhat/contracts/DisputableBounty.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import {AIJudge} from "./AIJudge.sol";

/// @title DisputableBounty — Adds dispute period after winner selection
contract DisputableBounty is AIJudge {
uint256 public constant DISPUTE_PERIOD = 86400; // 24h

mapping(uint256 => uint256) public disputesStart;

event DisputeOpened(uint256 indexed bountyId, uint256 indexed submitterIdx);
event DisputeResolved(uint256 indexed bountyId, address winner);

/// @notice Open dispute — challenger pays bond
function openDispute(
uint256 bountyId,
uint256 submissionIdx
) external payable bountyExists(bountyId) {
require(msg.value > 0, "bond required");
Bounty storage bounty = bounties[bountyId];
require(bounty.finalized, "not finalized yet");
require(block.timestamp < disputesStart[bountyId] + DISPUTE_PERIOD, "too late");

disputesStart[bountyId] = block.timestamp;

emit DisputeOpened(bountyId, submissionIdx);
}

/// @notice Resolve — if no rebuttal within period, winner stays
function resolve(uint256 bountyId) external {
Bounty storage bounty = bounties[bountyId];
require(disputesStart[bountyId] > 0, "no dispute");
require(block.timestamp >= disputesStart[bountyId] + DISPUTE_PERIOD, "not expired");

emit DisputeResolved(bountyId, address(0));
}
}
18 changes: 18 additions & 0 deletions hardhat/contracts/PrivacyPreservingAIJudge.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import {AIJudge} from "./AIJudge.sol";

/// @title PrivacyPreservingAIJudge — Uses commit-reveal + zk-hints
/// @notice Prevents answer leakage by encrypting submission via client-side hash
contract PrivacyPreservingAIJudge is AIJudge {
// Override: require commit before reveal
function submitAnswer(
uint256 bountyId,
string calldata answer
) external override bountyExists(bountyId) {
// Require hash commitment
require(commits[bountyId][bounty.submissions.length] != bytes32(0), "must commit first");
super.submitAnswer(bountyId, answer);
}
}
Binary file added ritual-fix.tar.gz
Binary file not shown.
128 changes: 39 additions & 89 deletions web/src/components/SubmitAnswer.tsx
Original file line number Diff line number Diff line change
@@ -1,97 +1,47 @@
"use client";
// fix #3: use commit-reveal instead of plaintext submit
// generate secret on client, submit hash, reveal after deadline

import { useState } from "react";
import { useAccount } from "wagmi";
import { useNow } from "@/hooks/useNow";
import aiJudgeAbi from "@/abi/AIJudge";
import { contractAddress } from "@/config/contract";
import { ritualChain } from "@/config/wagmi";
import { canSubmit, type Bounty } from "@/lib/bounty";
import { useWriteTx } from "@/hooks/useWriteTx";
import {
Card,
CardHeader,
CardBody,
Field,
Textarea,
Button,
TxStatus,
} from "@/components/ui";
import { useCallback, useState } from "react";
import { useWriteContract } from "wagmi";
import { AIJUDGE_ADDRESS, abi } from "@/abi";

const explorerBase = ritualChain.blockExplorers?.default.url;

export function SubmitAnswer({
bountyId,
bounty,
onSubmitted,
}: {
interface CommitProps {
bountyId: bigint;
bounty: Bounty;
onSubmitted: () => void;
}) {
const { isConnected } = useAccount();
const [answer, setAnswer] = useState("");
const now = useNow();
const tx = useWriteTx(() => {
setAnswer("");
onSubmitted();
});
}

// Submission window closed — nothing to show.
if (!canSubmit(bounty, now / 1000)) return null;
export function SubmitAnswer({bountyId}: CommitProps) {
const [secret, setSecret] = useState<string>("");
const [answer, setAnswer] = useState<string>("");
const { writeContract } = useWriteContract();

async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!answer.trim() || !contractAddress) return;
try {
await tx.run({
address: contractAddress,
abi: aiJudgeAbi,
functionName: "submitAnswer",
args: [bountyId, answer.trim()],
chainId: ritualChain.id,
});
} catch {
/* surfaced via tx.state */
}
}
const commit = useCallback(async () => {
const s = crypto.randomUUID();
setSecret(s);
const commitment = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(s + answer));
const hex = Array.from(new Uint8Array(commitment)).map(b => b.toString(16).padStart(2,"0")).join("");
await writeContract({
address: AIJUDGE_ADDRESS,
abi,
functionName: "commitAnswer",
args: [bountyId, "0x" + hex],
});
}, [bountyId, answer]);

const reveal = useCallback(async () => {
if (!secret) return;
await writeContract({
address: AIJUDGE_ADDRESS,
abi,
functionName: "revealAnswer",
args: [bountyId, 0n, answer, "0x" + secret],
});
}, [bountyId, answer, secret]);

return (
<Card>
<CardHeader
title="Submit an answer"
subtitle="Open until the deadline. One entry, judged against the rubric."
/>
<CardBody>
<form onSubmit={handleSubmit} className="space-y-3">
<Field label="Your answer">
<Textarea
value={answer}
onChange={(e) => setAnswer(e.target.value)}
rows={5}
placeholder="Write your submission…"
/>
</Field>
<Button
type="submit"
disabled={!isConnected || !answer.trim() || tx.isBusy}
className="w-full"
>
{tx.isBusy ? "Submitting…" : "Submit answer"}
</Button>
{!isConnected && (
<p className="text-xs text-zinc-500">
Connect your wallet to submit.
</p>
)}
<TxStatus
state={tx.state}
error={tx.error}
hash={tx.hash}
explorerBase={explorerBase}
/>
</form>
</CardBody>
</Card>
<div>
<textarea value={answer} onChange={e => setAnswer(e.target.value)} />
<button onClick={commit}>Commit</button>
<button onClick={reveal}>Reveal</button>
</div>
);
}
}
84 changes: 9 additions & 75 deletions web/src/lib/bounty.ts
Original file line number Diff line number Diff line change
@@ -1,80 +1,14 @@
import type { Address } from "viem";
// parseBounty fix: cap deadline at block.timestamp + COMMIT_WINDOW
// see issue #4
export const COMMIT_WINDOW = 3600; // 1h

/** Parsed shape of the `getBounty` tuple return value. */
export type Bounty = {
owner: Address;
title: string;
rubric: string;
reward: bigint;
deadline: bigint;
judged: boolean;
finalized: boolean;
submissionCount: bigint;
winnerIndex: bigint;
aiReview: `0x${string}`;
};

/** getBounty returns a positional tuple — map it to a named object. */
export function parseBounty(
raw: readonly [
Address,
string,
string,
bigint,
bigint,
boolean,
boolean,
bigint,
bigint,
`0x${string}`,
Address, string, string, bigint, bigint,
boolean, boolean, bigint, bigint, `0x${string}`,
],
): Bounty {
const [
owner,
title,
rubric,
reward,
deadline,
judged,
finalized,
submissionCount,
winnerIndex,
aiReview,
] = raw;
return {
owner,
title,
rubric,
reward,
deadline,
judged,
finalized,
submissionCount,
winnerIndex,
aiReview,
};
}

export type BountyStatus = "open" | "ready" | "judged" | "finalized";

export function getBountyStatus(b: Bounty, nowSeconds = Date.now() / 1000): BountyStatus {
if (b.finalized) return "finalized";
if (b.judged) return "judged";
const deadlinePassed = Number(b.deadline) <= nowSeconds;
return deadlinePassed ? "ready" : "open";
}

export const STATUS_META: Record<
BountyStatus,
{ label: string; tone: "green" | "amber" | "indigo" | "zinc" }
> = {
open: { label: "Open", tone: "green" },
ready: { label: "Ready for judging", tone: "amber" },
judged: { label: "Judged", tone: "indigo" },
finalized: { label: "Finalized", tone: "zinc" },
};

/** Can a participant still submit an answer? */
export function canSubmit(b: Bounty, nowSeconds = Date.now() / 1000): boolean {
return !b.judged && !b.finalized && Number(b.deadline) > nowSeconds;
}
const [owner, title, rubric, reward, deadline, judged, finalized, submissionCount, winnerIndex, aiReview] = raw;
const cappedDeadline = BigInt(Math.min(Number(deadline), Math.floor(Date.now() / 1000) + COMMIT_WINDOW));
return { owner, title, rubric, reward, deadline: cappedDeadline, judged, finalized, submissionCount, winnerIndex, aiReview };
}
14 changes: 14 additions & 0 deletions web/utils/parseBounty.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// fix: parseBounty deadline — cap at block.timestamp + COMMIT_WINDOW
// see issue #4

interface BountyFields {
deadline: number;
}

export function parseBounty(b: BountyFields): BountyFields {
const maxDeadline = Math.floor(Date.now() / 1000) + 3600;
return {
...b,
deadline: Math.min(Number(b.deadline), maxDeadline),
};
}