From cd23ff9c6092d87fe2477fe60fad6c1c51774d11 Mon Sep 17 00:00:00 2001 From: jhfnetboy Date: Tue, 14 Jul 2026 15:07:01 +0700 Subject: [PATCH 1/2] =?UTF-8?q?feat(mytask):=20MT-11=20challenge/=E4=BB=B2?= =?UTF-8?q?=E8=A3=81=E5=89=8D=E7=AB=AF=20=E2=80=94=20challengeWork=20ERC-2?= =?UTF-8?q?0=20=E8=B4=A8=E6=8A=BC=20+=20Jury=20=E9=9D=A2=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit B 线 MT-11(docs/MODULES_INTEGRATION_PLAN.md §3):对齐 Sepolia 重部署的 TaskEscrowV2(challengeWork 改 ERC-20 质押,challengeStakeToken=xPNT, transferFrom 收取)与 JuryContract(pull 模式奖励池),全部写路径走 useCos72Session().send 免 gas UserOp,零 window.ethereum。 - lib/contracts/jury-abi.ts:新增 JuryContract ABI(registerJuror/vote/ finalizeTask/claimRewards/pendingRewards/isActiveJuror/getTask/getVotes 等) - lib/contracts/task-escrow-abi.ts:challengeWork payable→nonpayable(ERC-20 质押);补 challengeStakeToken/challengeStakeAmount/validationsSatisfied/ getTaskRequiredValidationTags/getTaskValidationRequirement 视图 + TaskChallenged/ChallengeResolved 事件 - lib/task-types.ts:ParsedTask 补 challengeStake/juryTaskHash/canChallenge; 新增 JuryTask/JuryVote/ValidationRequirementView/质押配置类型 - contexts/TaskContext.tsx:沿用 (args, send) 签名新增 17 个函数——挑战侧 getChallengeStakeConfig/checkStakeAllowance/approveStakeToken/challengeWork/ linkJuryValidation,验证要求只读 getValidationRequirements/ getValidationsSatisfied,Jury 侧 getJuryStakingInfo/getJurorStatus/ approveJuryStake/registerJuror/getJuryTask/getJuryVotes/voteOnJuryTask/ finalizeJuryTask/getPendingJuryRewards/claimJuryRewards + juryConfigured - app/tasks/[taskId]:Submitted+挑战期内(社区)→「挑战」按钮(approve xPNT + challengeWork 两个 gasless op,复刻 approve+create 两步模式);Challenged → 挑战信息卡 + linkJuryValidation 入口(bytes32 校验);per-tag 验证要求只读 展示 + satisfied 徽章 - app/tasks/jury(新子页,YAA Layout):陪审员注册(minStake=0 也走 approve+register 两 op)、按哈希加载 jury task、0-100 打分投票、 finalizeTask、pendingRewards 展示 + claimRewards(奖励币+质押币双行) - 任务市场页头部加陪审团面板入口;i18n 新增 taskChallenge/juryPage EN/ZH 双语 key(947 key 平检绿) 门槛:format/type-check/build/lint:check/i18n:check -w aastar-frontend 全绿。 Claude-Session: https://claude.ai/code/session_01MCxVCnKkpzi5sCGf1FZ4CH --- aastar-frontend/app/tasks/[taskId]/page.tsx | 236 ++++++- aastar-frontend/app/tasks/jury/page.tsx | 585 ++++++++++++++++++ aastar-frontend/app/tasks/page.tsx | 27 +- aastar-frontend/contexts/TaskContext.tsx | 395 ++++++++++++ aastar-frontend/lib/contracts/jury-abi.ts | 172 +++++ aastar-frontend/lib/contracts/task-config.ts | 7 + .../lib/contracts/task-escrow-abi.ts | 64 +- aastar-frontend/lib/i18n/locales/en.json | 89 +++ aastar-frontend/lib/i18n/locales/zh.json | 89 +++ aastar-frontend/lib/task-types.ts | 65 ++ 10 files changed, 1720 insertions(+), 9 deletions(-) create mode 100644 aastar-frontend/app/tasks/jury/page.tsx create mode 100644 aastar-frontend/lib/contracts/jury-abi.ts diff --git a/aastar-frontend/app/tasks/[taskId]/page.tsx b/aastar-frontend/app/tasks/[taskId]/page.tsx index a673f1e..fc39659 100644 --- a/aastar-frontend/app/tasks/[taskId]/page.tsx +++ b/aastar-frontend/app/tasks/[taskId]/page.tsx @@ -1,12 +1,21 @@ "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, @@ -20,10 +29,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 +63,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 +73,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 +100,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 +147,71 @@ 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(err instanceof Error ? err.message : "Error"); + } 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(""); + } + async function runAction(fn: () => Promise, successMsg: string) { if (!isConnected) { toast.error("Passkey login required."); @@ -188,8 +281,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 +393,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 +710,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" + /> + +