diff --git a/aastar-frontend/app/tasks/[taskId]/page.tsx b/aastar-frontend/app/tasks/[taskId]/page.tsx
index a673f1e..98a501f 100644
--- a/aastar-frontend/app/tasks/[taskId]/page.tsx
+++ b/aastar-frontend/app/tasks/[taskId]/page.tsx
@@ -1,18 +1,28 @@
"use client";
import { useEffect, useState } from "react";
+import Link from "next/link";
import { useRouter, useParams } from "next/navigation";
+import { useTranslation } from "react-i18next";
+import { formatUnits } from "viem";
import Layout from "@/components/Layout";
import { useTask } from "@/contexts/TaskContext";
import { useCos72Session } from "@/contexts/Cos72SessionContext";
import { getStoredAuth } from "@/lib/auth";
-import { type ParsedTask, TaskStatus, TASK_STATUS_COLORS } from "@/lib/task-types";
+import {
+ type ParsedTask,
+ type ChallengeStakeConfig,
+ type ValidationRequirementView,
+ TaskStatus,
+ TASK_STATUS_COLORS,
+} from "@/lib/task-types";
import {
DEFAULT_REWARD_TOKEN_SYMBOL,
X402_API_URL,
isX402Configured,
} from "@/lib/contracts/task-config";
import { fetchReceiptDetails, type X402ReceiptDetails } from "@/lib/x402-client";
+import { decodeTaskErrorKey } from "@/lib/contracts/decode-task-error";
import {
ArrowLeftIcon,
CurrencyDollarIcon,
@@ -20,10 +30,14 @@ import {
CheckCircleIcon,
ExclamationTriangleIcon,
ReceiptRefundIcon,
+ ScaleIcon,
} from "@heroicons/react/24/outline";
import { formatDate, formatDateTime } from "@/lib/date-utils";
import toast from "react-hot-toast";
+const ZERO_HASH = "0x0000000000000000000000000000000000000000000000000000000000000000";
+const isBytes32 = (v: string) => /^0x[0-9a-fA-F]{64}$/.test(v);
+
function AddressRow({ label, addr }: { label: string; addr: string }) {
if (!addr || addr === "0x0000000000000000000000000000000000000000") return null;
return (
@@ -50,6 +64,7 @@ function Section({ title, children }: { title: string; children: React.ReactNode
export default function TaskDetailPage() {
const router = useRouter();
const { taskId } = useParams<{ taskId: string }>();
+ const { t } = useTranslation();
const {
getTask,
acceptTask,
@@ -59,6 +74,14 @@ export default function TaskDetailPage() {
cancelTask,
getTaskReceipts,
linkReceipt,
+ // MT-11: challenge / arbitration
+ getChallengeStakeConfig,
+ checkStakeAllowance,
+ approveStakeToken,
+ challengeWork,
+ linkJuryValidation,
+ getValidationRequirements,
+ getValidationsSatisfied,
} = useTask();
// Cos72 is AirAccount-only: the smart account is the on-chain actor, so the
// contract stores it as community/taskor. Role checks compare against it, and
@@ -78,6 +101,12 @@ export default function TaskDetailPage() {
const [showLinkReceiptForm, setShowLinkReceiptForm] = useState(false);
const [receiptInput, setReceiptInput] = useState("");
const [receiptUriInput, setReceiptUriInput] = useState("");
+ // MT-11: challenge (approve xPNT stake + challengeWork, two gasless ops)
+ const [stakeConfig, setStakeConfig] = useState(null);
+ const [juryHashInput, setJuryHashInput] = useState("");
+ // MT-11: per-tag validation requirements (read-only)
+ const [validationReqs, setValidationReqs] = useState([]);
+ const [validationsOk, setValidationsOk] = useState(null);
const myAddress = (sessionAddress ?? "").toLowerCase();
const isCommunity = task?.community.toLowerCase() === myAddress;
@@ -119,6 +148,79 @@ export default function TaskDetailPage() {
});
}, [taskId, getTaskReceipts]);
+ // MT-11: load the ERC-20 challenge-stake config once the task is in a
+ // challengeable/challenged state (needed for the stake notice + amounts).
+ useEffect(() => {
+ if (!task || (!task.canChallenge && task.status !== TaskStatus.Challenged)) return;
+ getChallengeStakeConfig().then(setStakeConfig);
+ }, [task, getChallengeStakeConfig]);
+
+ // MT-11: read-only per-tag validation requirements + satisfied flag.
+ useEffect(() => {
+ if (!taskId) return;
+ getValidationRequirements(taskId).then(async reqs => {
+ setValidationReqs(reqs);
+ if (reqs.length > 0) setValidationsOk(await getValidationsSatisfied(taskId));
+ });
+ }, [taskId, getValidationRequirements, getValidationsSatisfied]);
+
+ // MT-11: approve stake (op #1, skipped when allowance suffices) + challengeWork (op #2)
+ async function handleChallenge() {
+ if (!task || !isConnected || !sessionAddress) {
+ toast.error(t("taskChallenge.loginRequired"));
+ return;
+ }
+ const config = stakeConfig ?? (await getChallengeStakeConfig());
+ if (!config) {
+ toast.error(t("taskChallenge.stakeLoading"));
+ return;
+ }
+ setActionLoading(true);
+ try {
+ const allowance = await checkStakeAllowance(sessionAddress);
+ if (allowance < config.amount) {
+ toast.loading(t("taskChallenge.approving"), { id: "challenge-approve" });
+ await approveStakeToken(config.amount, send);
+ toast.dismiss("challenge-approve");
+ toast.success(t("taskChallenge.approved"));
+ }
+ toast.loading(t("taskChallenge.challenging"), { id: "challenge-send" });
+ await challengeWork(task.taskId, send);
+ toast.dismiss("challenge-send");
+ toast.success(t("taskChallenge.challenged"));
+ await refresh();
+ } catch (err) {
+ toast.dismiss("challenge-approve");
+ toast.dismiss("challenge-send");
+ toast.error(describeTxError(err));
+ } finally {
+ setActionLoading(false);
+ }
+ }
+
+ // MT-11: resolve a Challenged task against a COMPLETED jury verdict
+ async function handleLinkJury() {
+ if (!task || !isConnected) {
+ toast.error(t("taskChallenge.loginRequired"));
+ return;
+ }
+ const hash = juryHashInput.trim();
+ if (!isBytes32(hash)) {
+ toast.error(t("taskChallenge.invalidHash"));
+ return;
+ }
+ await runAction(() => linkJuryValidation(task.taskId, hash, send), t("taskChallenge.linked"));
+ setJuryHashInput("");
+ }
+
+ /** MT-11 (Codex M1/M2): map escrow custom-error reverts to bilingual copy;
+ * unrecognized failures fall back to the raw message, then a generic key. */
+ function describeTxError(err: unknown): string {
+ const key = decodeTaskErrorKey(err);
+ if (key) return t(key);
+ return err instanceof Error && err.message ? err.message : t("taskChallenge.errors.generic");
+ }
+
async function runAction(fn: () => Promise, successMsg: string) {
if (!isConnected) {
toast.error("Passkey login required.");
@@ -137,7 +239,7 @@ export default function TaskDetailPage() {
}
} catch (err) {
toast.dismiss(toastId);
- toast.error(err instanceof Error ? err.message : "Error");
+ toast.error(describeTxError(err));
} finally {
setActionLoading(false);
}
@@ -188,8 +290,10 @@ export default function TaskDetailPage() {
const isOpen = task.status === TaskStatus.Open;
const isAccepted = task.status === TaskStatus.Accepted || task.status === TaskStatus.InProgress;
const isSubmitted = task.status === TaskStatus.Submitted;
+ const isChallenged = task.status === TaskStatus.Challenged;
const isFinalized = task.status === TaskStatus.Finalized;
const isRefunded = task.status === TaskStatus.Refunded;
+ const hasJuryHash = task.juryTaskHash && task.juryTaskHash !== ZERO_HASH;
return (
@@ -298,6 +402,123 @@ export default function TaskDetailPage() {
)}
+ {/* MT-11: per-tag validation requirements (read-only, escrow getters) */}
+ {validationReqs.length > 0 && (
+
+
+ {validationsOk !== null && (
+
+ {validationsOk
+ ? t("taskChallenge.validationSatisfied")
+ : t("taskChallenge.validationUnsatisfied")}
+
+ )}
+ {validationReqs.map(req => (
+
+
+
+ {t("taskChallenge.reqTag")}
+
+
+ {req.tag.slice(0, 14)}…{req.tag.slice(-8)}
+
+
+
+
+
{t("taskChallenge.reqMinCount")}
+
+ {req.minCount.toString()}
+
+
+
+
{t("taskChallenge.reqMinAvg")}
+
+ {req.minAvgResponse}
+
+
+
+
{t("taskChallenge.reqMinUnique")}
+
+ {req.minUniqueValidators}
+
+
+
+
+ ))}
+
+
+ )}
+
+ {/* MT-11: challenge in progress — stake info + linkJuryValidation entry */}
+ {isChallenged && (
+
+
+
+ {t("taskChallenge.challengedInfo")}
+
+ {task.challengeStake > 0n && stakeConfig && (
+
+
+ {t("taskChallenge.stakeLocked")}
+
+
+ {formatUnits(task.challengeStake, stakeConfig.decimals)} {stakeConfig.symbol}
+
+
+ )}
+ {hasJuryHash ? (
+
+
+ {t("taskChallenge.juryHash")}
+
+
+ {task.juryTaskHash.slice(0, 14)}…{task.juryTaskHash.slice(-10)}
+
+
+ ) : (
+
+
+ {t("taskChallenge.linkJuryTitle")}
+
+
+ {t("taskChallenge.linkJuryHint")}
+
+
setJuryHashInput(e.target.value)}
+ placeholder={t("taskChallenge.juryHashPlaceholder")}
+ className="w-full px-3 py-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-transparent text-sm font-mono text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-orange-500"
+ />
+
+
+ )}
+
+ {t("taskChallenge.goToJury")} →
+
+
+
+ )}
+
{/* T06: x402 Receipts */}
{(receipts.length > 0 || isCommunity || isTaskor) && (
@@ -498,6 +719,28 @@ export default function TaskDetailPage() {
)}
+ {/* MT-11: community challenges the submission (approve xPNT stake + challengeWork) */}
+ {isSubmitted && isCommunity && task.canChallenge && (
+
+
+
+ {stakeConfig
+ ? t("taskChallenge.stakeNotice", {
+ amount: formatUnits(stakeConfig.amount, stakeConfig.decimals),
+ symbol: stakeConfig.symbol,
+ })
+ : t("taskChallenge.stakeLoading")}
+
+
+ )}
+
{/* Anyone: finalize after challenge period */}
{task.canFinalize && (
+ );
+}
+
+export default function JuryPanelPage() {
+ const { t } = useTranslation();
+ const router = useRouter();
+ const {
+ juryConfigured,
+ getJuryStakingInfo,
+ getJurorStatus,
+ approveJuryStake,
+ registerJuror,
+ getJuryTask,
+ getJuryVotes,
+ voteOnJuryTask,
+ finalizeJuryTask,
+ getPendingJuryRewards,
+ claimJuryRewards,
+ } = useTask();
+ const { send, address: sessionAddress, isConnected } = useCos72Session();
+
+ // Juror status / staking config
+ const [stakingInfo, setStakingInfo] = useState(null);
+ const [jurorActive, setJurorActive] = useState(false);
+ const [jurorStake, setJurorStake] = useState(0n);
+ const [stakeInput, setStakeInput] = useState("");
+ // Jury task lookup + voting
+ const [hashInput, setHashInput] = useState("");
+ const [juryTask, setJuryTask] = useState(null);
+ const [juryVotes, setJuryVotes] = useState([]);
+ const [taskLoading, setTaskLoading] = useState(false);
+ const [scoreInput, setScoreInput] = useState("");
+ const [reasoningInput, setReasoningInput] = useState("");
+ // Rewards (pull pattern): reward token + staking token when distinct
+ const [pendingReward, setPendingReward] = useState(0n);
+ const [pendingStakeToken, setPendingStakeToken] = useState(0n);
+ const [actionLoading, setActionLoading] = useState(false);
+
+ const myAddress = (sessionAddress ?? "").toLowerCase();
+
+ useEffect(() => {
+ const { token } = getStoredAuth();
+ if (!token) router.push("/auth/login");
+ }, [router]);
+
+ const refreshJurorState = useCallback(async () => {
+ if (!juryConfigured) return;
+ const info = await getJuryStakingInfo();
+ setStakingInfo(info);
+ if (sessionAddress) {
+ const status = await getJurorStatus(sessionAddress);
+ setJurorActive(status.isActive);
+ setJurorStake(status.stake);
+ if (DEFAULT_REWARD_TOKEN) {
+ setPendingReward(await getPendingJuryRewards(sessionAddress, DEFAULT_REWARD_TOKEN));
+ }
+ if (info && info.token.toLowerCase() !== DEFAULT_REWARD_TOKEN.toLowerCase()) {
+ setPendingStakeToken(await getPendingJuryRewards(sessionAddress, info.token));
+ }
+ }
+ }, [juryConfigured, sessionAddress, getJuryStakingInfo, getJurorStatus, getPendingJuryRewards]);
+
+ useEffect(() => {
+ refreshJurorState();
+ }, [refreshJurorState]);
+
+ const refreshJuryTask = useCallback(
+ async (hash: string) => {
+ setTaskLoading(true);
+ try {
+ const [task, votes] = await Promise.all([getJuryTask(hash), getJuryVotes(hash)]);
+ setJuryTask(task);
+ setJuryVotes(votes);
+ if (!task) toast.error(t("juryPage.notFound"));
+ } finally {
+ setTaskLoading(false);
+ }
+ },
+ [getJuryTask, getJuryVotes, t]
+ );
+
+ async function handleLoadTask() {
+ const hash = hashInput.trim();
+ if (!isBytes32(hash)) {
+ toast.error(t("juryPage.invalidHash"));
+ return;
+ }
+ await refreshJuryTask(hash);
+ }
+
+ // Always the two-op approve + registerJuror flow (even with minStake = 0)
+ async function handleRegister() {
+ if (!isConnected) {
+ toast.error(t("juryPage.loginRequired"));
+ return;
+ }
+ if (!stakingInfo) return;
+ const raw = stakeInput.trim() || "0";
+ let amount: bigint;
+ try {
+ amount = parseUnits(raw, stakingInfo.decimals);
+ } catch {
+ toast.error(t("juryPage.invalidAmount"));
+ return;
+ }
+ if (amount < 0n) {
+ toast.error(t("juryPage.invalidAmount"));
+ return;
+ }
+ if (amount < stakingInfo.minStake) {
+ toast.error(t("juryPage.stakeTooLow"));
+ return;
+ }
+ setActionLoading(true);
+ try {
+ toast.loading(t("juryPage.approvingStake"), { id: "jury-approve" });
+ await approveJuryStake(amount, send);
+ toast.dismiss("jury-approve");
+ toast.loading(t("juryPage.registering"), { id: "jury-register" });
+ await registerJuror(amount, send);
+ toast.dismiss("jury-register");
+ toast.success(t("juryPage.registered"));
+ await refreshJurorState();
+ } catch (err) {
+ toast.dismiss("jury-approve");
+ toast.dismiss("jury-register");
+ toast.error(err instanceof Error && err.message ? err.message : t("juryPage.genericError"));
+ } finally {
+ setActionLoading(false);
+ }
+ }
+
+ async function runJuryAction(fn: () => Promise, pendingMsg: string, successMsg: string) {
+ if (!isConnected) {
+ toast.error(t("juryPage.loginRequired"));
+ return;
+ }
+ setActionLoading(true);
+ const toastId = toast.loading(pendingMsg);
+ try {
+ await fn();
+ toast.dismiss(toastId);
+ toast.success(successMsg);
+ if (juryTask) await refreshJuryTask(juryTask.taskHash);
+ await refreshJurorState();
+ } catch (err) {
+ toast.dismiss(toastId);
+ toast.error(err instanceof Error && err.message ? err.message : t("juryPage.genericError"));
+ } finally {
+ setActionLoading(false);
+ }
+ }
+
+ async function handleVote() {
+ if (!juryTask) return;
+ const score = Number(scoreInput);
+ if (!Number.isInteger(score) || score < 0 || score > 100) {
+ toast.error(t("juryPage.invalidAmount"));
+ return;
+ }
+ await runJuryAction(
+ () => voteOnJuryTask(juryTask.taskHash, score, reasoningInput.trim(), send),
+ t("juryPage.voting"),
+ t("juryPage.voted")
+ );
+ setScoreInput("");
+ setReasoningInput("");
+ }
+
+ const alreadyVoted = juryVotes.some(v => v.juror.toLowerCase() === myAddress);
+ const now = Math.floor(Date.now() / 1000);
+ const taskInProgress = juryTask?.status === JuryTaskStatus.InProgress;
+ const votingOpen = taskInProgress && juryTask !== null && now <= Number(juryTask.deadline);
+ const canFinalizeJury =
+ taskInProgress &&
+ juryTask !== null &&
+ (now > Number(juryTask.deadline) || juryTask.totalVotes >= juryTask.minJurors);
+
+ const rewardRows: { token: `0x${string}`; symbol: string; decimals: number; amount: bigint }[] =
+ [];
+ if (DEFAULT_REWARD_TOKEN) {
+ rewardRows.push({
+ token: DEFAULT_REWARD_TOKEN,
+ symbol: DEFAULT_REWARD_TOKEN_SYMBOL,
+ decimals: DEFAULT_REWARD_TOKEN_DECIMALS,
+ amount: pendingReward,
+ });
+ }
+ if (stakingInfo && stakingInfo.token.toLowerCase() !== DEFAULT_REWARD_TOKEN.toLowerCase()) {
+ rewardRows.push({
+ token: stakingInfo.token,
+ symbol: stakingInfo.symbol,
+ decimals: stakingInfo.decimals,
+ amount: pendingStakeToken,
+ });
+ }
+
+ return (
+
+
+ {/* Header */}
+
+
+
+
+
+
+ {t("juryPage.title")}
+
+
+
+ {t("juryPage.subtitle")}
+
+
+
+
+
+ {!juryConfigured ? (
+
{t("juryPage.notConfigured")}
+ ) : (
+ <>
+ {/* Juror status + registration */}
+
+
+
+
+ {t("juryPage.taskStatus")}
+
+
+ {jurorActive ? t("juryPage.active") : t("juryPage.inactive")}
+
+
+ {stakingInfo && (
+ <>
+
+
+ {t("juryPage.minStake")}
+
+
+ {formatUnits(stakingInfo.minStake, stakingInfo.decimals)}{" "}
+ {stakingInfo.symbol}
+
+
+ {jurorActive && (
+
+
+ {t("juryPage.yourStake")}
+
+
+ {formatUnits(jurorStake, stakingInfo.decimals)} {stakingInfo.symbol}
+
+
+ )}
+ >
+ )}
+ {!jurorActive && stakingInfo && (
+
+
+ setStakeInput(e.target.value)}
+ placeholder={formatUnits(stakingInfo.minStake, stakingInfo.decimals)}
+ className="w-full px-3 py-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-transparent text-sm text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-emerald-500"
+ />
+
+
+ )}
+
+
+
+ {/* Vote on a jury task */}
+
+
+
+ setHashInput(e.target.value)}
+ placeholder={t("juryPage.taskHashPlaceholder")}
+ className="flex-1 px-3 py-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-transparent text-sm font-mono text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-emerald-500"
+ />
+
+
+
+ {juryTask && (
+
+
+
+ {t("juryPage.taskStatus")}
+
+
+ {t(`juryPage.status${juryTask.status}`)}
+
+
+ {juryTask.evidenceUri && (
+
+
+ {t("juryPage.evidence")}:{" "}
+
+
+ {juryTask.evidenceUri}
+
+
+ )}
+
+
+ {t("juryPage.votingDeadline")}
+
+
+ {formatDateTime(new Date(Number(juryTask.deadline) * 1000))}
+
+
+
+
+ {t("juryPage.votes")}
+
+
+ {juryTask.totalVotes.toString()} / {juryTask.minJurors.toString()} (
+ {t("juryPage.consensus")}{" "}
+ {(Number(juryTask.consensusThreshold) / 100).toFixed(0)}
+ %)
+
+
+ {juryTask.status === JuryTaskStatus.Completed ||
+ juryTask.status === JuryTaskStatus.Disputed ? (
+
+
+ {t("juryPage.finalScore")}
+
+
+ {juryTask.finalResponse} / 100
+
+
+ ) : null}
+
+ {/* Cast votes */}
+ {juryVotes.length > 0 && (
+
+
+ {t("juryPage.votesList")}
+
+ {juryVotes.map((v, i) => (
+
+
+ {v.juror.slice(0, 8)}…{v.juror.slice(-6)}
+
+
+ {t("juryPage.score")} {v.response}
+ {v.slashed && (
+ ({t("juryPage.slashed")})
+ )}
+
+
+ ))}
+
+ )}
+
+ {/* Vote form */}
+ {votingOpen && jurorActive && !alreadyVoted && (
+
+
+ setScoreInput(e.target.value)}
+ className="w-full px-3 py-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-transparent text-sm text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-emerald-500"
+ />
+
+
+ )}
+ {taskInProgress && !jurorActive && (
+
+ {t("juryPage.notJuror")}
+
+ )}
+ {taskInProgress && jurorActive && alreadyVoted && (
+
+ {t("juryPage.alreadyVoted")}
+
+ )}
+ {!taskInProgress &&
+ juryTask.status !== JuryTaskStatus.Completed &&
+ juryTask.status !== JuryTaskStatus.Disputed && (
+
+ {t("juryPage.voteNotOpen")}
+
+ )}
+
+ {/* Finalize */}
+ {canFinalizeJury && (
+
+ )}
+ {taskInProgress && !canFinalizeJury && (
+
+ {t("juryPage.finalizeHint")}
+
+ )}
+
+ )}
+
+
+
+ {/* Rewards (pull pattern) */}
+
+ {rewardRows.length === 0 || rewardRows.every(r => r.amount === 0n) ? (
+
+ {t("juryPage.nothingToClaim")}
+
+ ) : (
+
+ {rewardRows
+ .filter(r => r.amount > 0n)
+ .map(r => (
+
+
+
+
+ {t("juryPage.pendingLabel")}
+
+
+ {formatUnits(r.amount, r.decimals)} {r.symbol}
+
+
+
+
+ ))}
+
+ )}
+
+ >
+ )}
+
+
+ );
+}
diff --git a/aastar-frontend/app/tasks/page.tsx b/aastar-frontend/app/tasks/page.tsx
index 2fef121..db93925 100644
--- a/aastar-frontend/app/tasks/page.tsx
+++ b/aastar-frontend/app/tasks/page.tsx
@@ -2,6 +2,7 @@
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
+import { useTranslation } from "react-i18next";
import Layout from "@/components/Layout";
import { useTask } from "@/contexts/TaskContext";
import { useDashboard } from "@/contexts/DashboardContext";
@@ -14,6 +15,7 @@ import {
ClockIcon,
CurrencyDollarIcon,
ArrowPathIcon,
+ ScaleIcon,
} from "@heroicons/react/24/outline";
import { formatDistanceToNow } from "@/lib/date-utils";
@@ -101,6 +103,7 @@ function shortenAddress(addr: string): string {
}
export default function TasksPage() {
+ const { t } = useTranslation();
const router = useRouter();
const {
tasks,
@@ -164,13 +167,23 @@ export default function TasksPage() {
{openCount} open {openCount === 1 ? "task" : "tasks"} available
-
+
+ {/* MT-11: jury panel entry (register / vote / finalize / claim) */}
+
+
+
{/* Contract not configured warning */}
diff --git a/aastar-frontend/contexts/TaskContext.tsx b/aastar-frontend/contexts/TaskContext.tsx
index 55566e9..62c9dcd 100644
--- a/aastar-frontend/contexts/TaskContext.tsx
+++ b/aastar-frontend/contexts/TaskContext.tsx
@@ -3,12 +3,15 @@
import { createContext, useContext, useState, useCallback, useEffect, type ReactNode } from "react";
import { parseUnits, formatUnits, keccak256, toBytes, type PublicClient } from "viem";
import { TASK_ESCROW_ABI, ERC20_ABI } from "@/lib/contracts/task-escrow-abi";
+import { JURY_CONTRACT_ABI } from "@/lib/contracts/jury-abi";
import {
TASK_ESCROW_ADDRESS,
+ JURY_CONTRACT_ADDRESS,
DEFAULT_REWARD_TOKEN,
DEFAULT_REWARD_TOKEN_DECIMALS,
TASK_TYPE_LABELS,
isContractsConfigured,
+ isJuryConfigured,
getPublicClient,
} from "@/lib/contracts/task-config";
import type { ContractCall } from "@/lib/sdk/cosTx";
@@ -16,6 +19,11 @@ import {
type Task,
type ParsedTask,
type CreateTaskForm,
+ type JuryTask,
+ type JuryVote,
+ type ValidationRequirementView,
+ type ChallengeStakeConfig,
+ type JuryStakingInfo,
TaskStatus,
TASK_STATUS_LABELS,
} from "@/lib/task-types";
@@ -65,6 +73,32 @@ interface TaskContextType {
getTask: (taskId: string) => Promise;
approveToken: (amount: bigint, send: SendFn) => Promise;
checkAllowance: (ownerAddress: string) => Promise;
+ // MT-11: challenge (escrow, ERC-20 stake) — approve + challengeWork two gasless ops
+ juryConfigured: boolean;
+ getChallengeStakeConfig: () => Promise;
+ checkStakeAllowance: (ownerAddress: string) => Promise;
+ approveStakeToken: (amount: bigint, send: SendFn) => Promise;
+ challengeWork: (taskId: string, send: SendFn) => Promise;
+ linkJuryValidation: (taskId: string, juryTaskHash: string, send: SendFn) => Promise;
+ // MT-11: validation requirements (read-only display)
+ getValidationRequirements: (taskId: string) => Promise;
+ getValidationsSatisfied: (taskId: string) => Promise;
+ // MT-11: jury panel (JuryContract)
+ getJuryStakingInfo: () => Promise;
+ getJurorStatus: (address: string) => Promise<{ isActive: boolean; stake: bigint }>;
+ approveJuryStake: (amount: bigint, send: SendFn) => Promise;
+ registerJuror: (stakeAmount: bigint, send: SendFn) => Promise;
+ getJuryTask: (taskHash: string) => Promise;
+ getJuryVotes: (taskHash: string) => Promise;
+ voteOnJuryTask: (
+ taskHash: string,
+ response: number,
+ reasoning: string,
+ send: SendFn
+ ) => Promise;
+ finalizeJuryTask: (taskHash: string, send: SendFn) => Promise;
+ getPendingJuryRewards: (address: string, token: `0x${string}`) => Promise;
+ claimJuryRewards: (token: `0x${string}`, send: SendFn) => Promise;
}
const TaskContext = createContext(null);
@@ -86,6 +120,8 @@ function parseTask(raw: Task): ParsedTask {
const canFinalize =
raw.status === TaskStatus.Submitted && challengeDeadline !== null && now > challengeDeadline;
+ const canChallenge =
+ raw.status === TaskStatus.Submitted && challengeDeadline !== null && now <= challengeDeadline;
return {
taskId: raw.taskId,
@@ -99,17 +135,39 @@ function parseTask(raw: Task): ParsedTask {
deadline,
createdAt: new Date(Number(raw.createdAt) * 1000),
challengeDeadline,
+ challengeStake: raw.challengeStake,
status: raw.status,
statusLabel: TASK_STATUS_LABELS[raw.status] ?? "Unknown",
metadataUri: raw.metadataUri,
evidenceUri: raw.evidenceUri,
taskType: raw.taskType,
taskTypeLabel,
+ juryTaskHash: raw.juryTaskHash,
isExpired: now > deadline,
canFinalize,
+ canChallenge,
};
}
+/** Read an ERC-20's symbol/decimals, tolerating non-standard tokens. */
+async function readTokenMeta(
+ token: `0x${string}`,
+ fallbackSymbol: string
+): Promise<{ symbol: string; decimals: number }> {
+ const client = getPublicClient();
+ const [symbol, decimals] = await Promise.all([
+ client
+ .readContract({ address: token, abi: ERC20_ABI, functionName: "symbol" })
+ .then(s => s as string)
+ .catch(() => fallbackSymbol),
+ client
+ .readContract({ address: token, abi: ERC20_ABI, functionName: "decimals" })
+ .then(d => Number(d))
+ .catch(() => 18),
+ ]);
+ return { symbol, decimals };
+}
+
export function TaskProvider({ children }: { children: ReactNode }) {
const [tasks, setTasks] = useState([]);
const [myTasks, setMyTasks] = useState([]);
@@ -390,6 +448,324 @@ export function TaskProvider({ children }: { children: ReactNode }) {
[]
);
+ // ====== MT-11: challenge (escrow side, ERC-20 stake) ======
+
+ const juryConfigured = isJuryConfigured();
+
+ /** Read the escrow's challenge-stake config (token = xPNT, amount = 10e18 by default). */
+ const getChallengeStakeConfig = useCallback(async (): Promise => {
+ if (!contractConfigured) return null;
+ try {
+ const client = getPublicClient();
+ const [token, amount] = await Promise.all([
+ client.readContract({
+ address: TASK_ESCROW_ADDRESS,
+ abi: TASK_ESCROW_ABI,
+ functionName: "challengeStakeToken",
+ }) as Promise<`0x${string}`>,
+ client.readContract({
+ address: TASK_ESCROW_ADDRESS,
+ abi: TASK_ESCROW_ABI,
+ functionName: "challengeStakeAmount",
+ }) as Promise,
+ ]);
+ const meta = await readTokenMeta(token, "xPNT");
+ return { token, amount, ...meta };
+ } catch {
+ return null;
+ }
+ }, [contractConfigured]);
+
+ /** Allowance of the challenge-stake token (xPNT) granted to the escrow. */
+ const checkStakeAllowance = useCallback(
+ async (ownerAddress: string): Promise => {
+ const config = await getChallengeStakeConfig();
+ if (!config) return 0n;
+ const client = getPublicClient();
+ const allowance = await client.readContract({
+ address: config.token,
+ abi: ERC20_ABI,
+ functionName: "allowance",
+ args: [ownerAddress as `0x${string}`, TASK_ESCROW_ADDRESS],
+ });
+ return allowance as bigint;
+ },
+ [getChallengeStakeConfig]
+ );
+
+ /** Gasless op #1 of the challenge flow: approve the escrow to pull the stake. */
+ const approveStakeToken = useCallback(
+ async (amount: bigint, send: SendFn): Promise => {
+ const config = await getChallengeStakeConfig();
+ if (!config) return false;
+ await send({
+ to: config.token,
+ abi: ERC20_ABI,
+ functionName: "approve",
+ args: [TASK_ESCROW_ADDRESS, amount],
+ });
+ return true;
+ },
+ [getChallengeStakeConfig]
+ );
+
+ /** Gasless op #2: challengeWork (community only, Submitted + inside challenge window). */
+ const challengeWork = useCallback(async (taskId: string, send: SendFn): Promise => {
+ await send({
+ to: TASK_ESCROW_ADDRESS,
+ abi: TASK_ESCROW_ABI,
+ functionName: "challengeWork",
+ args: [taskId as `0x${string}`],
+ });
+ return true;
+ }, []);
+
+ /** Resolve a Challenged task against a COMPLETED jury task (anyone can call). */
+ const linkJuryValidation = useCallback(
+ async (taskId: string, juryTaskHash: string, send: SendFn): Promise => {
+ await send({
+ to: TASK_ESCROW_ADDRESS,
+ abi: TASK_ESCROW_ABI,
+ functionName: "linkJuryValidation",
+ args: [taskId as `0x${string}`, juryTaskHash as `0x${string}`],
+ });
+ return true;
+ },
+ []
+ );
+
+ // ====== MT-11: validation requirements (read-only display) ======
+
+ const getValidationRequirements = useCallback(
+ async (taskId: string): Promise => {
+ if (!contractConfigured) return [];
+ try {
+ const client = getPublicClient();
+ const tags = (await client.readContract({
+ address: TASK_ESCROW_ADDRESS,
+ abi: TASK_ESCROW_ABI,
+ functionName: "getTaskRequiredValidationTags",
+ args: [taskId as `0x${string}`],
+ })) as `0x${string}`[];
+ const reqs = await Promise.all(
+ tags.map(async tag => {
+ const [minCount, minAvgResponse, minUniqueValidators, enabled] =
+ (await client.readContract({
+ address: TASK_ESCROW_ADDRESS,
+ abi: TASK_ESCROW_ABI,
+ functionName: "getTaskValidationRequirement",
+ args: [taskId as `0x${string}`, tag],
+ })) as [bigint, number, number, boolean];
+ return { tag, minCount, minAvgResponse, minUniqueValidators, enabled };
+ })
+ );
+ return reqs.filter(r => r.enabled);
+ } catch {
+ return [];
+ }
+ },
+ [contractConfigured]
+ );
+
+ const getValidationsSatisfied = useCallback(
+ async (taskId: string): Promise => {
+ if (!contractConfigured) return null;
+ try {
+ const client = getPublicClient();
+ const ok = await client.readContract({
+ address: TASK_ESCROW_ADDRESS,
+ abi: TASK_ESCROW_ABI,
+ functionName: "validationsSatisfied",
+ args: [taskId as `0x${string}`],
+ });
+ return ok as boolean;
+ } catch {
+ return null;
+ }
+ },
+ [contractConfigured]
+ );
+
+ // ====== MT-11: jury panel (JuryContract) ======
+
+ /** Jury staking config: stakingToken (xPNT) + minStake (0 = role gates all off). */
+ const getJuryStakingInfo = useCallback(async (): Promise => {
+ if (!juryConfigured) return null;
+ try {
+ const client = getPublicClient();
+ const [token, minStake] = await Promise.all([
+ client.readContract({
+ address: JURY_CONTRACT_ADDRESS,
+ abi: JURY_CONTRACT_ABI,
+ functionName: "getStakingToken",
+ }) as Promise<`0x${string}`>,
+ client.readContract({
+ address: JURY_CONTRACT_ADDRESS,
+ abi: JURY_CONTRACT_ABI,
+ functionName: "getMinJurorStake",
+ }) as Promise,
+ ]);
+ const meta = await readTokenMeta(token, "xPNT");
+ return { token, minStake, ...meta };
+ } catch {
+ return null;
+ }
+ }, [juryConfigured]);
+
+ const getJurorStatus = useCallback(
+ async (address: string): Promise<{ isActive: boolean; stake: bigint }> => {
+ if (!juryConfigured || !address) return { isActive: false, stake: 0n };
+ try {
+ const client = getPublicClient();
+ const [isActive, stake] = (await client.readContract({
+ address: JURY_CONTRACT_ADDRESS,
+ abi: JURY_CONTRACT_ABI,
+ functionName: "isActiveJuror",
+ args: [address as `0x${string}`],
+ })) as [boolean, bigint];
+ return { isActive, stake };
+ } catch {
+ return { isActive: false, stake: 0n };
+ }
+ },
+ [juryConfigured]
+ );
+
+ /** Gasless op #1 of registration: approve the jury contract to pull the stake.
+ * Even with minStake = 0 the flow keeps the approve+register two-op shape. */
+ const approveJuryStake = useCallback(
+ async (amount: bigint, send: SendFn): Promise => {
+ const info = await getJuryStakingInfo();
+ if (!info) return false;
+ await send({
+ to: info.token,
+ abi: ERC20_ABI,
+ functionName: "approve",
+ args: [JURY_CONTRACT_ADDRESS, amount],
+ });
+ return true;
+ },
+ [getJuryStakingInfo]
+ );
+
+ /** Gasless op #2: registerJuror(stakeAmount) — ERC-20 transferFrom stake. */
+ const registerJuror = useCallback(async (stakeAmount: bigint, send: SendFn): Promise => {
+ await send({
+ to: JURY_CONTRACT_ADDRESS,
+ abi: JURY_CONTRACT_ABI,
+ functionName: "registerJuror",
+ args: [stakeAmount],
+ });
+ return true;
+ }, []);
+
+ const getJuryTask = useCallback(
+ async (taskHash: string): Promise => {
+ if (!juryConfigured) return null;
+ try {
+ const client = getPublicClient();
+ const raw = (await client.readContract({
+ address: JURY_CONTRACT_ADDRESS,
+ abi: JURY_CONTRACT_ABI,
+ functionName: "getTask",
+ args: [taskHash as `0x${string}`],
+ })) as JuryTask;
+ // Unknown hash returns an empty struct — treat as not found
+ if (
+ !raw.taskHash ||
+ raw.taskHash === "0x0000000000000000000000000000000000000000000000000000000000000000"
+ ) {
+ return null;
+ }
+ return raw;
+ } catch {
+ return null;
+ }
+ },
+ [juryConfigured]
+ );
+
+ const getJuryVotes = useCallback(
+ async (taskHash: string): Promise => {
+ if (!juryConfigured) return [];
+ try {
+ const client = getPublicClient();
+ const votes = await client.readContract({
+ address: JURY_CONTRACT_ADDRESS,
+ abi: JURY_CONTRACT_ABI,
+ functionName: "getVotes",
+ args: [taskHash as `0x${string}`],
+ });
+ return votes as JuryVote[];
+ } catch {
+ return [];
+ }
+ },
+ [juryConfigured]
+ );
+
+ /** Juror vote: response is a 0-100 score; reasoning is a free-text/URI note. */
+ const voteOnJuryTask = useCallback(
+ async (
+ taskHash: string,
+ response: number,
+ reasoning: string,
+ send: SendFn
+ ): Promise => {
+ await send({
+ to: JURY_CONTRACT_ADDRESS,
+ abi: JURY_CONTRACT_ABI,
+ functionName: "vote",
+ args: [taskHash as `0x${string}`, response, reasoning],
+ });
+ return true;
+ },
+ []
+ );
+
+ const finalizeJuryTask = useCallback(async (taskHash: string, send: SendFn): Promise => {
+ await send({
+ to: JURY_CONTRACT_ADDRESS,
+ abi: JURY_CONTRACT_ABI,
+ functionName: "finalizeTask",
+ args: [taskHash as `0x${string}`],
+ });
+ return true;
+ }, []);
+
+ /** Pull-mode reward pool: claimable balance for (juror, token). */
+ const getPendingJuryRewards = useCallback(
+ async (address: string, token: `0x${string}`): Promise => {
+ if (!juryConfigured || !address || !token) return 0n;
+ try {
+ const client = getPublicClient();
+ const amount = await client.readContract({
+ address: JURY_CONTRACT_ADDRESS,
+ abi: JURY_CONTRACT_ABI,
+ functionName: "pendingRewards",
+ args: [address as `0x${string}`, token],
+ });
+ return amount as bigint;
+ } catch {
+ return 0n;
+ }
+ },
+ [juryConfigured]
+ );
+
+ const claimJuryRewards = useCallback(
+ async (token: `0x${string}`, send: SendFn): Promise => {
+ await send({
+ to: JURY_CONTRACT_ADDRESS,
+ abi: JURY_CONTRACT_ABI,
+ functionName: "claimRewards",
+ args: [token],
+ });
+ return true;
+ },
+ []
+ );
+
// Auto-load tasks when context mounts
useEffect(() => {
if (contractConfigured) {
@@ -424,6 +800,25 @@ export function TaskProvider({ children }: { children: ReactNode }) {
getTask,
approveToken,
checkAllowance,
+ // MT-11: challenge / arbitration
+ juryConfigured,
+ getChallengeStakeConfig,
+ checkStakeAllowance,
+ approveStakeToken,
+ challengeWork,
+ linkJuryValidation,
+ getValidationRequirements,
+ getValidationsSatisfied,
+ getJuryStakingInfo,
+ getJurorStatus,
+ approveJuryStake,
+ registerJuror,
+ getJuryTask,
+ getJuryVotes,
+ voteOnJuryTask,
+ finalizeJuryTask,
+ getPendingJuryRewards,
+ claimJuryRewards,
}}
>
{children}
diff --git a/aastar-frontend/lib/contracts/decode-task-error.ts b/aastar-frontend/lib/contracts/decode-task-error.ts
new file mode 100644
index 0000000..2615a59
--- /dev/null
+++ b/aastar-frontend/lib/contracts/decode-task-error.ts
@@ -0,0 +1,89 @@
+/**
+ * MT-11: decode TaskEscrowV2 custom-error reverts into i18n keys.
+ *
+ * The escrow reverts with 4-byte custom errors (`InvalidTaskState()`,
+ * `ChallengePeriodExpired()`, …). Depending on where the failure surfaces the
+ * frontend sees one of three shapes:
+ *
+ * 1. a viem `BaseError` wrapping `ContractFunctionRevertedError` (direct
+ * simulate/read paths) — walk it and take `errorName`;
+ * 2. an `Error` whose message embeds the raw revert data as hex (the backend
+ * `/userop/*` path bubbles bundler/simulation output as text) — extract
+ * every hex blob and try `decodeErrorResult` against the escrow ABI;
+ * 3. an `Error` whose message merely names the error (`"InvalidTaskState"`)
+ * — match known error names as words.
+ *
+ * Returns the mapped i18n key (`taskChallenge.errors.`) or `null` when
+ * the failure isn't a recognized escrow error, so callers can fall back to the
+ * raw message / a generic key.
+ */
+import { BaseError, ContractFunctionRevertedError, decodeErrorResult, type Hex } from "viem";
+import { TASK_ESCROW_ABI } from "./task-escrow-abi";
+
+/** Escrow custom errors with a dedicated EN/ZH message (see `taskChallenge.errors.*`). */
+const MAPPED_ERROR_NAMES = new Set([
+ "InvalidTaskState",
+ "NotCommunity",
+ "TaskExpired",
+ "ChallengePeriodNotOver",
+ "ChallengePeriodExpired",
+ "AlreadyChallenged",
+ "TransferFailed",
+ "ZeroAmount",
+ "ValidationsNotSatisfied",
+ "PausedError",
+]);
+
+/** All error names declared in the ABI (for the plain-text word match). */
+const ALL_ERROR_NAMES: string[] = TASK_ESCROW_ABI.filter(e => e.type === "error").map(e => e.name);
+
+function keyFor(errorName: string | undefined): string | null {
+ return errorName && MAPPED_ERROR_NAMES.has(errorName)
+ ? `taskChallenge.errors.${errorName}`
+ : null;
+}
+
+/** Shape 2: pull hex blobs out of an error message and try ABI decoding. */
+function decodeFromHexInMessage(message: string): string | null {
+ const hexes = message.match(/0x[0-9a-fA-F]{8,}/g) ?? [];
+ for (const hex of hexes) {
+ try {
+ const decoded = decodeErrorResult({ abi: TASK_ESCROW_ABI, data: hex as Hex });
+ const key = keyFor(decoded.errorName);
+ if (key) return key;
+ } catch {
+ // not revert data for this ABI — try the next blob
+ }
+ }
+ return null;
+}
+
+/** Shape 3: the message names the error in plain text (word-boundary match). */
+function matchNameInMessage(message: string): string | null {
+ for (const name of ALL_ERROR_NAMES) {
+ if (new RegExp(`\\b${name}\\b`).test(message)) {
+ const key = keyFor(name);
+ if (key) return key;
+ }
+ }
+ return null;
+}
+
+/**
+ * Map a caught error to a `taskChallenge.errors.*` i18n key, or `null` when it
+ * isn't a recognized TaskEscrowV2 revert (caller falls back to the raw
+ * message or a generic key).
+ */
+export function decodeTaskErrorKey(err: unknown): string | null {
+ // Shape 1: structured viem revert
+ if (err instanceof BaseError) {
+ const revert = err.walk(e => e instanceof ContractFunctionRevertedError);
+ if (revert instanceof ContractFunctionRevertedError) {
+ const key = keyFor(revert.data?.errorName);
+ if (key) return key;
+ }
+ }
+ const message = err instanceof Error ? err.message : typeof err === "string" ? err : "";
+ if (!message) return null;
+ return decodeFromHexInMessage(message) ?? matchNameInMessage(message);
+}
diff --git a/aastar-frontend/lib/contracts/jury-abi.ts b/aastar-frontend/lib/contracts/jury-abi.ts
new file mode 100644
index 0000000..42749bc
--- /dev/null
+++ b/aastar-frontend/lib/contracts/jury-abi.ts
@@ -0,0 +1,172 @@
+/**
+ * JuryContract ABI (MT-11) — hand-copied from
+ * ~/Dev/mycelium/MyTask/contracts/src/JuryContract.sol (Sepolia redeploy).
+ *
+ * Only the surface the frontend uses: juror lifecycle (registerJuror /
+ * unregisterJuror / isActiveJuror), voting (vote / finalizeTask), the pull-mode
+ * reward pool (pendingRewards / claimRewards) and read helpers. Staking is an
+ * ERC-20 `transferFrom` (stakingToken = xPNT), so registration is a two-op
+ * approve + registerJuror flow, same as the reward-escrow approve + createTask.
+ */
+export const JURY_CONTRACT_ABI = [
+ // ====== Juror lifecycle ======
+ {
+ name: "registerJuror",
+ type: "function",
+ stateMutability: "nonpayable",
+ inputs: [{ name: "stakeAmount", type: "uint256" }],
+ outputs: [],
+ },
+ {
+ name: "unregisterJuror",
+ type: "function",
+ stateMutability: "nonpayable",
+ inputs: [],
+ outputs: [],
+ },
+ {
+ name: "isActiveJuror",
+ type: "function",
+ stateMutability: "view",
+ inputs: [{ name: "juror", type: "address" }],
+ outputs: [
+ { name: "isActive", type: "bool" },
+ { name: "stake", type: "uint256" },
+ ],
+ },
+ // ====== Voting ======
+ {
+ name: "vote",
+ type: "function",
+ stateMutability: "nonpayable",
+ inputs: [
+ { name: "taskHash", type: "bytes32" },
+ { name: "response", type: "uint8" },
+ { name: "reasoning", type: "string" },
+ ],
+ outputs: [],
+ },
+ {
+ name: "finalizeTask",
+ type: "function",
+ stateMutability: "nonpayable",
+ inputs: [{ name: "taskHash", type: "bytes32" }],
+ outputs: [],
+ },
+ // ====== Reward pool (pull pattern) ======
+ {
+ name: "pendingRewards",
+ type: "function",
+ stateMutability: "view",
+ inputs: [
+ { name: "juror", type: "address" },
+ { name: "token", type: "address" },
+ ],
+ outputs: [{ name: "", type: "uint256" }],
+ },
+ {
+ name: "claimRewards",
+ type: "function",
+ stateMutability: "nonpayable",
+ inputs: [{ name: "token", type: "address" }],
+ outputs: [],
+ },
+ // ====== Views ======
+ {
+ name: "getTask",
+ type: "function",
+ stateMutability: "view",
+ inputs: [{ name: "taskHash", type: "bytes32" }],
+ outputs: [
+ {
+ name: "task",
+ type: "tuple",
+ components: [
+ { name: "agentId", type: "uint256" },
+ { name: "taskHash", type: "bytes32" },
+ { name: "evidenceUri", type: "string" },
+ { name: "taskType", type: "uint8" },
+ { name: "reward", type: "uint256" },
+ { name: "deadline", type: "uint256" },
+ { name: "status", type: "uint8" },
+ { name: "minJurors", type: "uint256" },
+ { name: "consensusThreshold", type: "uint256" },
+ { name: "totalVotes", type: "uint256" },
+ { name: "positiveVotes", type: "uint256" },
+ { name: "finalResponse", type: "uint8" },
+ ],
+ },
+ ],
+ },
+ {
+ name: "getVotes",
+ type: "function",
+ stateMutability: "view",
+ inputs: [{ name: "taskHash", type: "bytes32" }],
+ outputs: [
+ {
+ name: "votes",
+ type: "tuple[]",
+ components: [
+ { name: "juror", type: "address" },
+ { name: "response", type: "uint8" },
+ { name: "reasoning", type: "string" },
+ { name: "timestamp", type: "uint256" },
+ { name: "slashed", type: "bool" },
+ ],
+ },
+ ],
+ },
+ {
+ name: "getMinJurorStake",
+ type: "function",
+ stateMutability: "view",
+ inputs: [],
+ outputs: [{ name: "minStake", type: "uint256" }],
+ },
+ {
+ name: "getStakingToken",
+ type: "function",
+ stateMutability: "view",
+ inputs: [],
+ outputs: [{ name: "token", type: "address" }],
+ },
+ // ====== Events ======
+ {
+ name: "JurorRegistered",
+ type: "event",
+ inputs: [
+ { name: "juror", type: "address", indexed: true },
+ { name: "stakeAmount", type: "uint256", indexed: false },
+ ],
+ },
+ {
+ name: "JurorVoted",
+ type: "event",
+ inputs: [
+ { name: "taskHash", type: "bytes32", indexed: true },
+ { name: "juror", type: "address", indexed: true },
+ { name: "response", type: "uint8", indexed: false },
+ { name: "timestamp", type: "uint256", indexed: false },
+ ],
+ },
+ {
+ name: "TaskFinalized",
+ type: "event",
+ inputs: [
+ { name: "taskHash", type: "bytes32", indexed: true },
+ { name: "finalResponse", type: "uint8", indexed: false },
+ { name: "totalVotes", type: "uint256", indexed: false },
+ { name: "positiveVotes", type: "uint256", indexed: false },
+ ],
+ },
+ {
+ name: "RewardClaimed",
+ type: "event",
+ inputs: [
+ { name: "juror", type: "address", indexed: true },
+ { name: "token", type: "address", indexed: true },
+ { name: "amount", type: "uint256", indexed: false },
+ ],
+ },
+] as const;
diff --git a/aastar-frontend/lib/contracts/task-config.ts b/aastar-frontend/lib/contracts/task-config.ts
index 30d48a6..59fd390 100644
--- a/aastar-frontend/lib/contracts/task-config.ts
+++ b/aastar-frontend/lib/contracts/task-config.ts
@@ -92,3 +92,10 @@ export function isX402Configured(): boolean {
export function isContractsConfigured(): boolean {
return !!TASK_ESCROW_ADDRESS && TASK_ESCROW_ADDRESS !== "0x" && TASK_ESCROW_ADDRESS.length === 42;
}
+
+/** MT-11: JuryContract deployed & wired via env (challenge/arbitration UI). */
+export function isJuryConfigured(): boolean {
+ return (
+ !!JURY_CONTRACT_ADDRESS && JURY_CONTRACT_ADDRESS !== "0x" && JURY_CONTRACT_ADDRESS.length === 42
+ );
+}
diff --git a/aastar-frontend/lib/contracts/task-escrow-abi.ts b/aastar-frontend/lib/contracts/task-escrow-abi.ts
index 0da6226..71fabcd 100644
--- a/aastar-frontend/lib/contracts/task-escrow-abi.ts
+++ b/aastar-frontend/lib/contracts/task-escrow-abi.ts
@@ -38,9 +38,11 @@ export const TASK_ESCROW_ABI = [
outputs: [],
},
{
+ // V2 redeploy: the challenge stake is an ERC-20 transferFrom
+ // (challengeStakeToken, e.g. xPNT) — no native value, requires prior approve().
name: "challengeWork",
type: "function",
- stateMutability: "payable",
+ stateMutability: "nonpayable",
inputs: [{ name: "taskId", type: "bytes32" }],
outputs: [],
},
@@ -146,22 +148,71 @@ export const TASK_ESCROW_ABI = [
outputs: [{ name: "", type: "uint256" }],
},
{
- name: "createTaskWithPermit",
+ name: "challengeStakeToken",
type: "function",
- stateMutability: "nonpayable",
+ stateMutability: "view",
+ inputs: [],
+ outputs: [{ name: "", type: "address" }],
+ },
+ {
+ name: "challengeStakeAmount",
+ type: "function",
+ stateMutability: "view",
+ inputs: [],
+ outputs: [{ name: "", type: "uint256" }],
+ },
+ {
+ name: "getTaskRequiredValidationTags",
+ type: "function",
+ stateMutability: "view",
+ inputs: [{ name: "taskId", type: "bytes32" }],
+ outputs: [{ name: "tags", type: "bytes32[]" }],
+ },
+ {
+ name: "getTaskValidationRequirement",
+ type: "function",
+ stateMutability: "view",
inputs: [
- { name: "token", type: "address" },
- { name: "reward", type: "uint256" },
- { name: "deadline", type: "uint256" },
- { name: "metadataUri", type: "string" },
- { name: "taskType", type: "bytes32" },
- { name: "permitDeadline", type: "uint256" },
- { name: "v", type: "uint8" },
- { name: "r", type: "bytes32" },
- { name: "s", type: "bytes32" },
+ { name: "taskId", type: "bytes32" },
+ { name: "tag", type: "bytes32" },
+ ],
+ outputs: [
+ { name: "minCount", type: "uint64" },
+ { name: "minAvgResponse", type: "uint8" },
+ { name: "minUniqueValidators", type: "uint8" },
+ { name: "enabled", type: "bool" },
],
- outputs: [{ name: "taskId", type: "bytes32" }],
},
+ {
+ name: "validationsSatisfied",
+ type: "function",
+ stateMutability: "view",
+ inputs: [{ name: "taskId", type: "bytes32" }],
+ outputs: [{ name: "", type: "bool" }],
+ },
+ // ====== Custom Errors (TaskEscrowV2.sol) — used by decodeTaskError ======
+ { name: "InvalidTaskState", type: "error", inputs: [] },
+ { name: "NotCommunity", type: "error", inputs: [] },
+ { name: "NotTaskor", type: "error", inputs: [] },
+ { name: "NotParticipant", type: "error", inputs: [] },
+ { name: "TaskExpired", type: "error", inputs: [] },
+ { name: "ChallengePeriodNotOver", type: "error", inputs: [] },
+ { name: "ChallengePeriodExpired", type: "error", inputs: [] },
+ { name: "AlreadyChallenged", type: "error", inputs: [] },
+ { name: "ReentrancyDetected", type: "error", inputs: [] },
+ { name: "TransferFailed", type: "error", inputs: [] },
+ { name: "NotOwner", type: "error", inputs: [] },
+ { name: "ZeroAddress", type: "error", inputs: [] },
+ { name: "ZeroAmount", type: "error", inputs: [] },
+ { name: "InvalidDeadline", type: "error", inputs: [] },
+ { name: "InvalidReceipt", type: "error", inputs: [] },
+ { name: "InvalidTag", type: "error", inputs: [] },
+ { name: "InvalidRequestHash", type: "error", inputs: [] },
+ { name: "ValidationsNotSatisfied", type: "error", inputs: [] },
+ { name: "PolicyViolation", type: "error", inputs: [] },
+ { name: "PausedError", type: "error", inputs: [] },
+ { name: "InvalidChallengePeriod", type: "error", inputs: [] },
+ { name: "FeeExceedsLimit", type: "error", inputs: [] },
{
name: "linkReceipt",
type: "function",
@@ -231,6 +282,23 @@ export const TASK_ESCROW_ABI = [
{ name: "refundAmount", type: "uint256", indexed: false },
],
},
+ {
+ name: "TaskChallenged",
+ type: "event",
+ inputs: [
+ { name: "taskId", type: "bytes32", indexed: true },
+ { name: "challenger", type: "address", indexed: true },
+ { name: "stake", type: "uint256", indexed: false },
+ ],
+ },
+ {
+ name: "ChallengeResolved",
+ type: "event",
+ inputs: [
+ { name: "taskId", type: "bytes32", indexed: true },
+ { name: "challengeAccepted", type: "bool", indexed: false },
+ ],
+ },
] as const;
export const ERC20_ABI = [
@@ -282,26 +350,4 @@ export const ERC20_ABI = [
inputs: [],
outputs: [{ name: "", type: "string" }],
},
- {
- name: "nonces",
- type: "function",
- stateMutability: "view",
- inputs: [{ name: "owner", type: "address" }],
- outputs: [{ name: "", type: "uint256" }],
- },
- {
- name: "permit",
- type: "function",
- stateMutability: "nonpayable",
- inputs: [
- { name: "owner", type: "address" },
- { name: "spender", type: "address" },
- { name: "value", type: "uint256" },
- { name: "deadline", type: "uint256" },
- { name: "v", type: "uint8" },
- { name: "r", type: "bytes32" },
- { name: "s", type: "bytes32" },
- ],
- outputs: [],
- },
] as const;
diff --git a/aastar-frontend/lib/i18n/locales/en.json b/aastar-frontend/lib/i18n/locales/en.json
index cbaffdb..4284553 100644
--- a/aastar-frontend/lib/i18n/locales/en.json
+++ b/aastar-frontend/lib/i18n/locales/en.json
@@ -1102,5 +1102,109 @@
"network": "Could not reach the cos72 backend. Check your connection and try again.",
"unknown": "The cos72 backend rejected the SSO authorization request."
}
+ },
+ "taskChallenge": {
+ "challengeButton": "Challenge Work",
+ "stakeNotice": "Challenging locks a {{amount}} {{symbol}} stake (returned if the jury upholds the work; forfeited to the taskor if the jury rejects your challenge).",
+ "stakeLoading": "Loading challenge stake config…",
+ "approving": "Approving challenge stake (gasless)…",
+ "approved": "Stake approved",
+ "challenging": "Submitting challenge…",
+ "challenged": "Task challenged — awaiting jury arbitration",
+ "challengedTitle": "Challenge in progress",
+ "challengedInfo": "The community challenged this submission. The dispute is resolved by linking a completed jury verdict.",
+ "stakeLocked": "Challenge stake locked",
+ "juryHash": "Jury task hash",
+ "linkJuryTitle": "Link jury verdict",
+ "linkJuryHint": "Enter the hash of a COMPLETED jury task. Score ≥ 50 pays the taskor and refunds the challenger's stake; < 50 refunds the community and forfeits the stake to the taskor.",
+ "juryHashPlaceholder": "Jury task hash (bytes32, 0x…)",
+ "linkButton": "Resolve with jury verdict",
+ "linked": "Jury verdict linked — task resolved",
+ "invalidHash": "Invalid bytes32 hash (0x + 64 hex chars)",
+ "goToJury": "Open jury panel",
+ "validationTitle": "Validation Requirements",
+ "validationSatisfied": "Satisfied",
+ "validationUnsatisfied": "Not satisfied",
+ "reqTag": "Tag",
+ "reqMinCount": "Min validations",
+ "reqMinAvg": "Min avg score",
+ "reqMinUnique": "Min unique validators",
+ "loginRequired": "Passkey login required.",
+ "processing": "Processing…",
+ "errors": {
+ "InvalidTaskState": "The task is not in a state that allows this action.",
+ "NotCommunity": "Only the task's community (publisher) can do this.",
+ "TaskExpired": "The task deadline has passed.",
+ "ChallengePeriodNotOver": "The challenge period has not ended yet.",
+ "ChallengePeriodExpired": "The challenge period has expired — challenging is no longer possible.",
+ "AlreadyChallenged": "This task has already been challenged.",
+ "TransferFailed": "Token transfer failed — check your xPNT balance and allowance.",
+ "ZeroAmount": "Amount must be greater than zero.",
+ "ValidationsNotSatisfied": "The task's validation requirements are not satisfied yet.",
+ "PausedError": "The contract is currently paused.",
+ "generic": "Transaction failed."
+ }
+ },
+ "juryPage": {
+ "title": "Jury Panel",
+ "subtitle": "Register as a juror, vote on disputes, finalize verdicts and claim rewards",
+ "entry": "Jury Panel",
+ "back": "Back to tasks",
+ "notConfigured": "Jury contract not configured (NEXT_PUBLIC_JURY_CONTRACT_ADDRESS).",
+ "statusTitle": "Juror Status",
+ "active": "Active juror",
+ "inactive": "Not registered",
+ "yourStake": "Your stake",
+ "minStake": "Minimum stake",
+ "stakingToken": "Staking token",
+ "stakeAmountLabel": "Stake amount ({{symbol}})",
+ "register": "Stake & Register",
+ "approvingStake": "Approving stake (gasless)…",
+ "registering": "Registering juror…",
+ "registered": "Registered as juror!",
+ "invalidAmount": "Enter a valid amount",
+ "stakeTooLow": "Stake is below the minimum",
+ "voteTitle": "Vote on a Jury Task",
+ "taskHashPlaceholder": "Jury task hash (bytes32, 0x…)",
+ "load": "Load",
+ "loadingTask": "Loading jury task…",
+ "notFound": "Jury task not found",
+ "invalidHash": "Invalid bytes32 hash (0x + 64 hex chars)",
+ "taskStatus": "Status",
+ "status0": "Pending",
+ "status1": "In Progress",
+ "status2": "Completed",
+ "status3": "Disputed",
+ "status4": "Cancelled",
+ "evidence": "Evidence",
+ "votingDeadline": "Voting deadline",
+ "votes": "Votes",
+ "consensus": "Consensus threshold",
+ "finalScore": "Final score",
+ "votesList": "Cast votes",
+ "score": "Score",
+ "slashed": "slashed",
+ "scoreLabel": "Your score (0-100)",
+ "reasoningLabel": "Reasoning (text or URI)",
+ "reasoningPlaceholder": "Why did you score it this way?",
+ "voteButton": "Submit Vote",
+ "voting": "Submitting vote…",
+ "voted": "Vote submitted!",
+ "alreadyVoted": "You already voted on this task.",
+ "notJuror": "Register as a juror to vote.",
+ "voteNotOpen": "This task is not open for voting (must be In Progress).",
+ "finalizeButton": "Finalize Task",
+ "finalizing": "Finalizing…",
+ "finalized": "Jury task finalized!",
+ "finalizeHint": "Finalize once the deadline has passed or enough jurors have voted.",
+ "rewardsTitle": "Rewards",
+ "pendingLabel": "Claimable",
+ "claim": "Claim",
+ "claiming": "Claiming…",
+ "claimed": "Rewards claimed!",
+ "nothingToClaim": "Nothing to claim yet.",
+ "refresh": "Refresh",
+ "loginRequired": "Passkey login required.",
+ "genericError": "Transaction failed."
}
}
diff --git a/aastar-frontend/lib/i18n/locales/zh.json b/aastar-frontend/lib/i18n/locales/zh.json
index c18271b..a2af539 100644
--- a/aastar-frontend/lib/i18n/locales/zh.json
+++ b/aastar-frontend/lib/i18n/locales/zh.json
@@ -1102,5 +1102,109 @@
"network": "无法连接 cos72 后端。请检查网络后重试。",
"unknown": "cos72 后端拒绝了本次 SSO 授权请求。"
}
+ },
+ "taskChallenge": {
+ "challengeButton": "挑战工作成果",
+ "stakeNotice": "发起挑战需锁定 {{amount}} {{symbol}} 质押(陪审团支持你的挑战则退还;驳回则质押赔付给执行者)。",
+ "stakeLoading": "读取挑战质押配置中…",
+ "approving": "授权挑战质押(免 gas)…",
+ "approved": "质押已授权",
+ "challenging": "提交挑战中…",
+ "challenged": "已发起挑战 — 等待陪审团仲裁",
+ "challengedTitle": "挑战进行中",
+ "challengedInfo": "社区已对该提交发起挑战,争议通过关联已完成的陪审团裁决解决。",
+ "stakeLocked": "已锁定挑战质押",
+ "juryHash": "陪审团任务哈希",
+ "linkJuryTitle": "关联陪审团裁决",
+ "linkJuryHint": "输入状态为「已完成」的陪审团任务哈希。评分 ≥ 50 付款给执行者并退还挑战质押;< 50 退款给社区,质押赔付给执行者。",
+ "juryHashPlaceholder": "陪审团任务哈希(bytes32,0x…)",
+ "linkButton": "按陪审团裁决结算",
+ "linked": "已关联陪审团裁决 — 任务已结算",
+ "invalidHash": "无效的 bytes32 哈希(0x + 64 位十六进制)",
+ "goToJury": "打开陪审团面板",
+ "validationTitle": "验证要求",
+ "validationSatisfied": "已满足",
+ "validationUnsatisfied": "未满足",
+ "reqTag": "标签",
+ "reqMinCount": "最少验证次数",
+ "reqMinAvg": "最低平均分",
+ "reqMinUnique": "最少独立验证者",
+ "loginRequired": "需要 Passkey 登录。",
+ "processing": "处理中…",
+ "errors": {
+ "InvalidTaskState": "任务当前状态不允许此操作。",
+ "NotCommunity": "仅任务社区(发布者)可执行此操作。",
+ "TaskExpired": "任务已过截止时间。",
+ "ChallengePeriodNotOver": "挑战期尚未结束。",
+ "ChallengePeriodExpired": "挑战期已过,无法再发起挑战。",
+ "AlreadyChallenged": "该任务已被挑战。",
+ "TransferFailed": "代币转账失败 — 请检查 xPNT 余额与授权额度。",
+ "ZeroAmount": "数量必须大于 0。",
+ "ValidationsNotSatisfied": "任务的验证要求尚未满足。",
+ "PausedError": "合约当前已暂停。",
+ "generic": "交易失败。"
+ }
+ },
+ "juryPage": {
+ "title": "陪审团面板",
+ "subtitle": "注册成为陪审员,对争议投票、敲定裁决并领取奖励",
+ "entry": "陪审团面板",
+ "back": "返回任务列表",
+ "notConfigured": "陪审团合约未配置(NEXT_PUBLIC_JURY_CONTRACT_ADDRESS)。",
+ "statusTitle": "陪审员状态",
+ "active": "已激活陪审员",
+ "inactive": "未注册",
+ "yourStake": "你的质押",
+ "minStake": "最低质押",
+ "stakingToken": "质押代币",
+ "stakeAmountLabel": "质押数量({{symbol}})",
+ "register": "质押并注册",
+ "approvingStake": "授权质押(免 gas)…",
+ "registering": "注册陪审员中…",
+ "registered": "已注册为陪审员!",
+ "invalidAmount": "请输入有效数量",
+ "stakeTooLow": "质押低于最低要求",
+ "voteTitle": "对陪审团任务投票",
+ "taskHashPlaceholder": "陪审团任务哈希(bytes32,0x…)",
+ "load": "加载",
+ "loadingTask": "加载陪审团任务中…",
+ "notFound": "未找到该陪审团任务",
+ "invalidHash": "无效的 bytes32 哈希(0x + 64 位十六进制)",
+ "taskStatus": "状态",
+ "status0": "待处理",
+ "status1": "进行中",
+ "status2": "已完成",
+ "status3": "有争议",
+ "status4": "已取消",
+ "evidence": "证据",
+ "votingDeadline": "投票截止",
+ "votes": "票数",
+ "consensus": "共识阈值",
+ "finalScore": "最终评分",
+ "votesList": "已投票记录",
+ "score": "评分",
+ "slashed": "已罚没",
+ "scoreLabel": "你的评分(0-100)",
+ "reasoningLabel": "理由(文本或 URI)",
+ "reasoningPlaceholder": "为什么给出这个评分?",
+ "voteButton": "提交投票",
+ "voting": "提交投票中…",
+ "voted": "投票已提交!",
+ "alreadyVoted": "你已对该任务投过票。",
+ "notJuror": "注册成为陪审员后才能投票。",
+ "voteNotOpen": "该任务当前不可投票(需处于「进行中」)。",
+ "finalizeButton": "敲定任务",
+ "finalizing": "敲定中…",
+ "finalized": "陪审团任务已敲定!",
+ "finalizeHint": "截止时间已过或投票人数足够后即可敲定。",
+ "rewardsTitle": "奖励",
+ "pendingLabel": "可领取",
+ "claim": "领取",
+ "claiming": "领取中…",
+ "claimed": "奖励已领取!",
+ "nothingToClaim": "暂无可领取奖励。",
+ "refresh": "刷新",
+ "loginRequired": "需要 Passkey 登录。",
+ "genericError": "交易失败。"
}
}
diff --git a/aastar-frontend/lib/task-types.ts b/aastar-frontend/lib/task-types.ts
index ec0a799..de92261 100644
--- a/aastar-frontend/lib/task-types.ts
+++ b/aastar-frontend/lib/task-types.ts
@@ -7,7 +7,6 @@ export enum TaskStatus {
Challenged = 4,
Finalized = 5,
Refunded = 6,
- Disputed = 7,
}
export const TASK_STATUS_LABELS: Record = {
@@ -18,7 +17,6 @@ export const TASK_STATUS_LABELS: Record = {
[TaskStatus.Challenged]: "Challenged",
[TaskStatus.Finalized]: "Completed",
[TaskStatus.Refunded]: "Refunded",
- [TaskStatus.Disputed]: "Disputed",
};
export const TASK_STATUS_COLORS: Record = {
@@ -32,7 +30,6 @@ export const TASK_STATUS_COLORS: Record = {
"bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-400",
[TaskStatus.Finalized]: "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400",
[TaskStatus.Refunded]: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400",
- [TaskStatus.Disputed]: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400",
};
// Mirrors TaskEscrowV2.sol Task struct
@@ -68,14 +65,18 @@ export interface ParsedTask {
deadline: Date;
createdAt: Date;
challengeDeadline: Date | null;
+ challengeStake: bigint;
status: TaskStatus;
statusLabel: string;
metadataUri: string;
evidenceUri: string;
taskType: string;
taskTypeLabel: string;
+ juryTaskHash: `0x${string}`;
isExpired: boolean;
canFinalize: boolean;
+ /** Submitted and still inside the challenge window (community may challengeWork). */
+ canChallenge: boolean;
}
export interface CreateTaskForm {
@@ -99,3 +100,64 @@ export interface TaskMetadata {
tags?: string[];
createdAt: number;
}
+
+// ====== MT-11: Jury / arbitration types (JuryContract.sol) ======
+
+// Mirrors IJuryContract.TaskStatus
+export enum JuryTaskStatus {
+ Pending = 0,
+ InProgress = 1,
+ Completed = 2,
+ Disputed = 3,
+ Cancelled = 4,
+}
+
+// Mirrors IJuryContract.Task (raw chain shape)
+export interface JuryTask {
+ agentId: bigint;
+ taskHash: `0x${string}`;
+ evidenceUri: string;
+ taskType: number;
+ reward: bigint;
+ deadline: bigint;
+ status: JuryTaskStatus;
+ minJurors: bigint;
+ consensusThreshold: bigint;
+ totalVotes: bigint;
+ positiveVotes: bigint;
+ finalResponse: number;
+}
+
+// Mirrors IJuryContract.Vote
+export interface JuryVote {
+ juror: `0x${string}`;
+ response: number;
+ reasoning: string;
+ timestamp: bigint;
+ slashed: boolean;
+}
+
+/** Per-tag validation requirement on the escrow (read-only display). */
+export interface ValidationRequirementView {
+ tag: `0x${string}`;
+ minCount: bigint;
+ minAvgResponse: number;
+ minUniqueValidators: number;
+ enabled: boolean;
+}
+
+/** Challenge stake config read from the escrow (ERC-20, e.g. xPNT). */
+export interface ChallengeStakeConfig {
+ token: `0x${string}`;
+ amount: bigint;
+ symbol: string;
+ decimals: number;
+}
+
+/** Jury staking config (registerJuror): stakingToken + minStake. */
+export interface JuryStakingInfo {
+ token: `0x${string}`;
+ minStake: bigint;
+ symbol: string;
+ decimals: number;
+}