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 && ( +
+
+ +

+ {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" + /> + +