, successMsg: string) {
if (!isConnected) {
toast.error("Passkey login required.");
@@ -230,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);
}
@@ -719,7 +728,7 @@ export default function TaskDetailPage() {
className="w-full py-3 rounded-xl border border-orange-300 dark:border-orange-700 text-orange-600 dark:text-orange-400 hover:bg-orange-50 dark:hover:bg-orange-900/20 disabled:opacity-60 font-semibold text-sm transition-colors flex items-center justify-center gap-2"
>
- {actionLoading ? "Processing..." : t("taskChallenge.challengeButton")}
+ {actionLoading ? t("taskChallenge.processing") : t("taskChallenge.challengeButton")}
{stakeConfig
diff --git a/aastar-frontend/app/tasks/jury/page.tsx b/aastar-frontend/app/tasks/jury/page.tsx
index cdfeefe..de4188e 100644
--- a/aastar-frontend/app/tasks/jury/page.tsx
+++ b/aastar-frontend/app/tasks/jury/page.tsx
@@ -186,7 +186,7 @@ export default function JuryPanelPage() {
} catch (err) {
toast.dismiss("jury-approve");
toast.dismiss("jury-register");
- toast.error(err instanceof Error ? err.message : "Error");
+ toast.error(err instanceof Error && err.message ? err.message : t("juryPage.genericError"));
} finally {
setActionLoading(false);
}
@@ -207,7 +207,7 @@ export default function JuryPanelPage() {
await refreshJurorState();
} catch (err) {
toast.dismiss(toastId);
- toast.error(err instanceof Error ? err.message : "Error");
+ toast.error(err instanceof Error && err.message ? err.message : t("juryPage.genericError"));
} finally {
setActionLoading(false);
}
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/task-escrow-abi.ts b/aastar-frontend/lib/contracts/task-escrow-abi.ts
index 9728d64..71fabcd 100644
--- a/aastar-frontend/lib/contracts/task-escrow-abi.ts
+++ b/aastar-frontend/lib/contracts/task-escrow-abi.ts
@@ -190,23 +190,29 @@ export const TASK_ESCROW_ABI = [
inputs: [{ name: "taskId", type: "bytes32" }],
outputs: [{ name: "", type: "bool" }],
},
- {
- name: "createTaskWithPermit",
- type: "function",
- stateMutability: "nonpayable",
- 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" },
- ],
- outputs: [{ name: "taskId", type: "bytes32" }],
- },
+ // ====== 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",
@@ -344,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 996a7a8..4284553 100644
--- a/aastar-frontend/lib/i18n/locales/en.json
+++ b/aastar-frontend/lib/i18n/locales/en.json
@@ -1129,7 +1129,21 @@
"reqMinCount": "Min validations",
"reqMinAvg": "Min avg score",
"reqMinUnique": "Min unique validators",
- "loginRequired": "Passkey login required."
+ "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",
@@ -1190,6 +1204,7 @@
"claimed": "Rewards claimed!",
"nothingToClaim": "Nothing to claim yet.",
"refresh": "Refresh",
- "loginRequired": "Passkey login required."
+ "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 2642976..a2af539 100644
--- a/aastar-frontend/lib/i18n/locales/zh.json
+++ b/aastar-frontend/lib/i18n/locales/zh.json
@@ -1129,7 +1129,21 @@
"reqMinCount": "最少验证次数",
"reqMinAvg": "最低平均分",
"reqMinUnique": "最少独立验证者",
- "loginRequired": "需要 Passkey 登录。"
+ "loginRequired": "需要 Passkey 登录。",
+ "processing": "处理中…",
+ "errors": {
+ "InvalidTaskState": "任务当前状态不允许此操作。",
+ "NotCommunity": "仅任务社区(发布者)可执行此操作。",
+ "TaskExpired": "任务已过截止时间。",
+ "ChallengePeriodNotOver": "挑战期尚未结束。",
+ "ChallengePeriodExpired": "挑战期已过,无法再发起挑战。",
+ "AlreadyChallenged": "该任务已被挑战。",
+ "TransferFailed": "代币转账失败 — 请检查 xPNT 余额与授权额度。",
+ "ZeroAmount": "数量必须大于 0。",
+ "ValidationsNotSatisfied": "任务的验证要求尚未满足。",
+ "PausedError": "合约当前已暂停。",
+ "generic": "交易失败。"
+ }
},
"juryPage": {
"title": "陪审团面板",
@@ -1190,6 +1204,7 @@
"claimed": "奖励已领取!",
"nothingToClaim": "暂无可领取奖励。",
"refresh": "刷新",
- "loginRequired": "需要 Passkey 登录。"
+ "loginRequired": "需要 Passkey 登录。",
+ "genericError": "交易失败。"
}
}
diff --git a/aastar-frontend/lib/task-types.ts b/aastar-frontend/lib/task-types.ts
index 87405b1..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