diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 48800f0a..f5563185 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,6 +40,7 @@ jobs: scan-ref: "." format: "sarif" output: "trivy-results.sarif" + trivyignores: .trivyignore - name: Upload Trivy results to GitHub Security tab uses: github/codeql-action/upload-sarif@v4 @@ -52,6 +53,9 @@ jobs: uses: actions/dependency-review-action@v4 with: fail-on-severity: high + # Temporary: axios high vuln in @ledgerhq optional deps (via @aastar/airaccount). + # No non-breaking fix available until ledgerhq updates their axios dependency. + allow-ghsas: GHSA-43fc-jf86-j433 # Job 2: Code quality checks quality: @@ -59,7 +63,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - project: [aastar, aastar-frontend, sdk] + project: [aastar, aastar-frontend] steps: - uses: actions/checkout@v5 @@ -100,8 +104,6 @@ jobs: build-command: npm run build - project: aastar-frontend build-command: npm run build - - project: sdk - build-command: npm run build steps: - uses: actions/checkout@v5 @@ -113,11 +115,6 @@ jobs: - name: Install dependencies run: npm ci - # Build SDK first if this is not the SDK build itself - - name: Build SDK (dependency) - if: matrix.project != 'sdk' - run: npm run build --workspace=sdk - - name: Build project run: npm run build --workspace=${{ matrix.project }} env: @@ -154,7 +151,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - project: [aastar, aastar-frontend, sdk] + project: [aastar, aastar-frontend] steps: - uses: actions/checkout@v5 @@ -166,11 +163,6 @@ jobs: - name: Install dependencies run: npm ci - # Build SDK first if this is not the SDK type check itself - - name: Build SDK (dependency) - if: matrix.project != 'sdk' - run: npm run build --workspace=sdk - - name: Run TypeScript compiler run: | if [ -f ${{ matrix.project }}/package.json ] && jq -e '.scripts["type-check"]' ${{ matrix.project }}/package.json > /dev/null; then diff --git a/.trivyignore b/.trivyignore new file mode 100644 index 00000000..a45d73d0 --- /dev/null +++ b/.trivyignore @@ -0,0 +1,4 @@ +# Axios DoS via __proto__ in mergeConfig (CVE-2026-25639) +# Pinned to axios@1.13.2 by @ledgerhq/domain-service (exact version, not a range). +# No non-breaking fix available until ledgerhq updates their internal dependency. +CVE-2026-25639 diff --git a/aastar-frontend/app/auth/login/page.tsx b/aastar-frontend/app/auth/login/page.tsx index 204b5999..b845f9e5 100644 --- a/aastar-frontend/app/auth/login/page.tsx +++ b/aastar-frontend/app/auth/login/page.tsx @@ -49,7 +49,7 @@ export default function LoginPage() { }); // Step 3: Browser WebAuthn authentication ceremony - const credential = await startAuthentication(authResponse.Options as any); + const credential = await startAuthentication({ optionsJSON: authResponse.Options as any }); // Step 4: Complete login via backend (backend calls KMS SignHash to verify) toast.dismiss(loadingToast); diff --git a/aastar-frontend/app/guardian-sign/page.tsx b/aastar-frontend/app/guardian-sign/page.tsx new file mode 100644 index 00000000..35d3b1a9 --- /dev/null +++ b/aastar-frontend/app/guardian-sign/page.tsx @@ -0,0 +1,466 @@ +"use client"; + +/** + * Guardian Sign Page + * + * Mobile-optimized page for guardian devices to sign an acceptance hash. + * Accessed via QR code scan. URL params: + * - acceptanceHash: the raw keccak256 hash to sign + * - factory: factory contract address + * - chainId: numeric chain ID + * - owner: future account owner address + * - salt: numeric salt + * + * Signing flow (Passkey): + * 1. Guardian enters their wallet address (KMS key address) + * 2. KMS BeginAuthentication → browser WebAuthn ceremony + * 3. KMS SignHash (EIP-191 prefixed hash) → returns Signature + * 4. Page displays guardian address + signature for user to copy/paste + * + * Signing flow (MetaMask): + * 1. Guardian clicks "Connect MetaMask" → wallet address auto-filled + * 2. Guardian clicks "Sign" → MetaMask personal_sign (EIP-191 applied automatically) + * 3. Page displays guardian address + signature for user to copy/paste + */ + +import { Suspense, useState } from "react"; +import { useSearchParams } from "next/navigation"; +import { startAuthentication } from "@simplewebauthn/browser"; +import { kmsClient } from "@/lib/yaaa"; +import { ethers } from "ethers"; + +type SignMethod = "passkey" | "metamask"; + +// ── Helper: apply EIP-191 prefix ────────────────────────────────────────── +// Replicates: ethers.hashMessage(ethers.getBytes(hash)) +// Signs the EIP-191 prefixed version of the 32-byte acceptance hash. +function applyEip191(rawHash: string): string { + return ethers.hashMessage(ethers.getBytes(rawHash)); +} + +// ── Copy to clipboard helper ── +async function copyToClipboard(text: string): Promise { + try { + await navigator.clipboard.writeText(text); + return true; + } catch { + return false; + } +} + +// ── Inner component (uses useSearchParams, must be inside Suspense) ── +function GuardianSignInner() { + const searchParams = useSearchParams(); + + const acceptanceHash = searchParams.get("acceptanceHash") || ""; + const factory = searchParams.get("factory") || ""; + const chainId = searchParams.get("chainId") || ""; + const owner = searchParams.get("owner") || ""; + const salt = searchParams.get("salt") || ""; + + const [signMethod, setSignMethod] = useState("passkey"); + const [guardianAddress, setGuardianAddress] = useState(""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + const [result, setResult] = useState<{ address: string; signature: string } | null>(null); + const [copied, setCopied] = useState<"address" | "sig" | "both" | null>(null); + + const isValidParams = acceptanceHash && factory && chainId && owner && salt; + + const handleSignWithPasskey = async () => { + setError(""); + + if (!guardianAddress) { + setError("Please enter your guardian wallet address"); + return; + } + if (!/^0x[0-9a-fA-F]{40}$/.test(guardianAddress)) { + setError("Not a valid Ethereum address"); + return; + } + + setLoading(true); + try { + const authResponse = await kmsClient.beginAuthentication({ + Address: guardianAddress, + }); + const credential = await startAuthentication({ optionsJSON: authResponse.Options as any }); + const hashToSign = applyEip191(acceptanceHash); + const signResponse = await kmsClient.signHashWithWebAuthn( + hashToSign, + authResponse.ChallengeId, + credential, + { Address: guardianAddress } + ); + + setResult({ + address: guardianAddress, + signature: signResponse.Signature?.startsWith("0x") + ? signResponse.Signature + : "0x" + signResponse.Signature, + }); + } catch (err: unknown) { + if (err instanceof Error) { + if (err.name === "NotAllowedError") { + setError("Authentication was cancelled or not allowed. Please try again."); + } else if (err.name === "NotSupportedError") { + setError("Passkeys are not supported on this device."); + } else { + setError(err.message || "Signing failed. Please try again."); + } + } else { + setError("Signing failed. Please try again."); + } + } finally { + setLoading(false); + } + }; + + const handleSignWithMetaMask = async () => { + setError(""); + + if (!("ethereum" in window) || !window.ethereum) { + setError("MetaMask not detected. Please install MetaMask and try again."); + return; + } + + setLoading(true); + try { + const provider = new ethers.BrowserProvider(window.ethereum as ethers.Eip1193Provider); + await provider.send("eth_requestAccounts", []); + const signer = await provider.getSigner(); + const address = await signer.getAddress(); + + // personal_sign automatically applies EIP-191 prefix to the raw bytes + const signature = await signer.signMessage(ethers.getBytes(acceptanceHash)); + + setResult({ address, signature }); + } catch (err: unknown) { + if (err instanceof Error) { + if (err.message.includes("user rejected") || err.message.includes("User denied")) { + setError("Signature request was rejected."); + } else { + setError(err.message || "Signing failed. Please try again."); + } + } else { + setError("Signing failed. Please try again."); + } + } finally { + setLoading(false); + } + }; + + const handleSign = signMethod === "metamask" ? handleSignWithMetaMask : handleSignWithPasskey; + + const handleCopy = async (field: "address" | "sig" | "both") => { + if (!result) return; + let text = ""; + if (field === "address") text = result.address; + else if (field === "sig") text = result.signature; + else text = `Address: ${result.address}\nSignature: ${result.signature}`; + + const ok = await copyToClipboard(text); + if (ok) { + setCopied(field); + setTimeout(() => setCopied(null), 2000); + } + }; + + if (!isValidParams) { + return ( +
+
+
+
+ + + +
+

+ Invalid QR Code +

+

+ This page must be opened by scanning a valid Guardian QR code. Please ask the account + owner to regenerate the QR code. +

+
+
+
+ ); + } + + return ( +
+
+ {/* Header */} +
+
+ + + +
+

Guardian Sign

+

+ Sign as a guardian for an AirAccount +

+
+ + {/* Account details */} +
+
+ Chain ID + {chainId} +
+
+ Owner + + {owner} + +
+
+ Factory + + {factory} + +
+
+ Salt + {salt} +
+
+

Acceptance Hash

+

+ {acceptanceHash} +

+
+
+ + {!result ? ( + <> + {/* Signing method selector */} +
+ +

+ Choose either method — both guardians can use the same method or different ones. +

+
+ + +
+
+ + {/* Address input — only for passkey mode */} + {signMethod === "passkey" && ( +
+ + setGuardianAddress(e.target.value.trim())} + placeholder="0x..." + disabled={loading} + className="block w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-4 py-3 text-sm text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-transparent disabled:opacity-50" + /> +

+ Enter the Ethereum address associated with your passkey on this device. +

+
+ )} + + {/* MetaMask info */} + {signMethod === "metamask" && ( +
+

+ Your wallet address will be detected automatically when you click Sign. +

+
+ )} + + {/* Error */} + {error && ( +
+

{error}

+
+ )} + + {/* Sign button */} + + + ) : ( + /* Signature result */ +
+
+
+ + + +
+
+

+ Signature complete! Copy the values below and paste them into the desktop app. +

+ + {/* Address */} +
+
+ + Your Address + + +
+

+ {result.address} +

+
+ + {/* Signature */} +
+
+ + Signature + + +
+

+ {result.signature} +

+
+ + {/* Copy all button */} + +
+ )} + + {/* Info footer */} +
+

+ {signMethod === "metamask" + ? "Signing with EIP-191 via MetaMask." + : "Signing with EIP-191. Your passkey never leaves this device."} +

+
+
+
+ ); +} + +// ── Page export wrapped in Suspense (required for useSearchParams) ── +export default function GuardianSignPage() { + return ( + +
+
+ } + > + +
+ ); +} diff --git a/aastar-frontend/app/layout.tsx b/aastar-frontend/app/layout.tsx index 1f5b1d7b..b75bb083 100644 --- a/aastar-frontend/app/layout.tsx +++ b/aastar-frontend/app/layout.tsx @@ -4,6 +4,7 @@ import "./globals.css"; import { Toaster } from "react-hot-toast"; import { ThemeProvider } from "@/lib/theme"; import { DashboardProvider } from "@/contexts/DashboardContext"; +import { TaskProvider } from "@/contexts/TaskContext"; const inter = Inter({ subsets: ["latin"] }); @@ -47,8 +48,10 @@ export default function RootLayout({ - {children} - + + {children} + + diff --git a/aastar-frontend/app/paymaster/page.tsx b/aastar-frontend/app/paymaster/page.tsx index 3c193415..a39f8346 100644 --- a/aastar-frontend/app/paymaster/page.tsx +++ b/aastar-frontend/app/paymaster/page.tsx @@ -6,7 +6,6 @@ import { paymasterAPI } from "@/lib/api"; import SwipeableListItem from "@/components/SwipeableListItem"; import toast from "react-hot-toast"; import { PlusIcon, CheckCircleIcon, ExclamationCircleIcon } from "@heroicons/react/24/outline"; - interface Paymaster { name: string; address: string; @@ -338,7 +337,7 @@ export default function PaymasterPage() {

-
+
{paymaster.configured ? ( API Configured diff --git a/aastar-frontend/app/recovery/page.tsx b/aastar-frontend/app/recovery/page.tsx new file mode 100644 index 00000000..b802704a --- /dev/null +++ b/aastar-frontend/app/recovery/page.tsx @@ -0,0 +1,376 @@ +"use client"; + +import { useState } from "react"; +import Layout from "@/components/Layout"; +import { guardianAPI } from "@/lib/api"; +import toast from "react-hot-toast"; + +type Step = "setup" | "initiate" | "support" | "execute" | "done"; + +interface RecoveryState { + accountAddress: string; + newSignerAddress: string; + guardian1Address: string; + guardian2Address: string; +} + +const ZERO: RecoveryState = { + accountAddress: "", + newSignerAddress: "", + guardian1Address: "", + guardian2Address: "", +}; + +function isAddress(v: string) { + return /^0x[0-9a-fA-F]{40}$/.test(v); +} + +export default function RecoveryPage() { + const [step, setStep] = useState("setup"); + const [form, setForm] = useState(ZERO); + const [loading, setLoading] = useState(false); + const [pendingRecovery, setPendingRecovery] = useState(null); + + const set = (field: keyof RecoveryState) => (e: React.ChangeEvent) => + setForm(prev => ({ ...prev, [field]: e.target.value.trim() })); + + // ── Step 1: register guardians + move to initiate ────────────────────── + const handleSetup = async () => { + if (!isAddress(form.accountAddress)) return toast.error("Invalid account address"); + if (!isAddress(form.newSignerAddress)) return toast.error("Invalid new signer address"); + if (!isAddress(form.guardian1Address)) return toast.error("Invalid guardian 1 address"); + if (!isAddress(form.guardian2Address)) return toast.error("Invalid guardian 2 address"); + if (form.guardian1Address.toLowerCase() === form.guardian2Address.toLowerCase()) + return toast.error("Guardian 1 and Guardian 2 must be different addresses"); + + setLoading(true); + try { + // Register both guardians in the database (idempotent — duplicate calls are safe) + await guardianAPI.addGuardian({ guardianAddress: form.guardian1Address }); + await guardianAPI.addGuardian({ guardianAddress: form.guardian2Address }); + toast.success("Guardians registered"); + setStep("initiate"); + } catch (err: unknown) { + const msg = + (err as any)?.response?.data?.message || (err as Error).message || "Failed to register guardians"; + toast.error(msg); + } finally { + setLoading(false); + } + }; + + // ── Step 2: guardian 1 initiates recovery ───────────────────────────── + const handleInitiate = async () => { + setLoading(true); + try { + const res = await guardianAPI.initiateRecovery({ + accountAddress: form.accountAddress, + newSignerAddress: form.newSignerAddress, + }); + setPendingRecovery(res.data); + toast.success("Recovery initiated"); + setStep("support"); + } catch (err: unknown) { + const msg = + (err as any)?.response?.data?.message || (err as Error).message || "Failed to initiate recovery"; + toast.error(msg); + } finally { + setLoading(false); + } + }; + + // ── Step 3: guardian 2 supports recovery ────────────────────────────── + const handleSupport = async () => { + setLoading(true); + try { + const res = await guardianAPI.supportRecovery({ accountAddress: form.accountAddress }); + setPendingRecovery(res.data); + toast.success("Recovery supported"); + setStep("execute"); + } catch (err: unknown) { + const msg = + (err as any)?.response?.data?.message || (err as Error).message || "Failed to support recovery"; + toast.error(msg); + } finally { + setLoading(false); + } + }; + + // ── Step 4: execute recovery (after timelock) ────────────────────────── + const handleExecute = async () => { + setLoading(true); + try { + await guardianAPI.executeRecovery({ accountAddress: form.accountAddress }); + toast.success("Account recovered successfully!"); + setStep("done"); + } catch (err: unknown) { + const msg = + (err as any)?.response?.data?.message || (err as Error).message || "Failed to execute recovery"; + toast.error(msg); + } finally { + setLoading(false); + } + }; + + const handleCancel = async () => { + if (!confirm("Cancel this recovery request?")) return; + setLoading(true); + try { + await guardianAPI.cancelRecovery({ accountAddress: form.accountAddress }); + toast.success("Recovery cancelled"); + setStep("setup"); + setPendingRecovery(null); + } catch (err: unknown) { + const msg = + (err as any)?.response?.data?.message || (err as Error).message || "Failed to cancel recovery"; + toast.error(msg); + } finally { + setLoading(false); + } + }; + + const stepLabels: Record = { + setup: "1. Setup", + initiate: "2. Initiate", + support: "3. Support", + execute: "4. Execute", + done: "Done", + }; + + const stepKeys: Step[] = ["setup", "initiate", "support", "execute"]; + const currentIdx = stepKeys.indexOf(step); + + return ( + +
+

Social Recovery

+

+ Recover an AirAccount by collecting 2-of-3 guardian approvals. +

+ + {/* Step progress */} + {step !== "done" && ( +
+ {stepKeys.map((s, idx) => ( +
+
+ {idx < currentIdx ? "✓" : idx + 1} +
+ {idx < stepKeys.length - 1 && ( +
+ )} +
+ ))} + + {stepLabels[step]} + +
+ )} + + {/* ── Step 1: Setup ── */} + {step === "setup" && ( +
+
+ Enter the account to recover, the new owner address, and the two guardian addresses. + The guardians will each need to approve the recovery. +
+ + {[ + { label: "Account Address (to recover)", field: "accountAddress" as const, placeholder: "0x... (the AirAccount)" }, + { label: "New Signer Address", field: "newSignerAddress" as const, placeholder: "0x... (new owner)" }, + { label: "Guardian 1 Address", field: "guardian1Address" as const, placeholder: "0x..." }, + { label: "Guardian 2 Address", field: "guardian2Address" as const, placeholder: "0x..." }, + ].map(({ label, field, placeholder }) => ( +
+ + +
+ ))} + + +
+ )} + + {/* ── Step 2: Initiate (Guardian 1) ── */} + {step === "initiate" && ( +
+
+

Guardian 1 — Initiate Recovery

+

+ Log in as {form.guardian1Address} and click + Initiate. This records the recovery request with a 48-hour time lock. +

+
+ +
+
+ Account + {form.accountAddress} +
+
+ New Signer + {form.newSignerAddress} +
+
+ + + + +
+ )} + + {/* ── Step 3: Support (Guardian 2) ── */} + {step === "support" && ( +
+
+

Guardian 2 — Support Recovery

+

+ Log in as {form.guardian2Address} and click + Support. Once both guardians have approved and the 48-hour lock expires, recovery + can be executed. +

+
+ + {pendingRecovery && ( +
+
+ Execute After + {new Date(Number(pendingRecovery.executeAfter)).toLocaleString()} +
+
+ Supporters + {pendingRecovery.supportCount} / {pendingRecovery.quorumRequired} +
+
+ )} + + + + +
+ )} + + {/* ── Step 4: Execute ── */} + {step === "execute" && ( +
+
+

Quorum Reached

+

+ Both guardians have approved. After the 48-hour time lock expires, click Execute + to complete the recovery on-chain. +

+
+ + {pendingRecovery && ( +
+
+ Execute After + {new Date(Number(pendingRecovery.executeAfter)).toLocaleString()} +
+
+ Time Lock + + {Date.now() >= Number(pendingRecovery.executeAfter) ? ( + Expired — ready + ) : ( + Not yet expired + )} + +
+
+ )} + + + + +
+ )} + + {/* ── Done ── */} + {step === "done" && ( +
+
+ + + +
+

+ Account Recovered +

+

+ The account signer has been updated to{" "} + {form.newSignerAddress}. +

+ +
+ )} +
+ + ); +} diff --git a/aastar-frontend/app/tasks/[taskId]/page.tsx b/aastar-frontend/app/tasks/[taskId]/page.tsx new file mode 100644 index 00000000..07aeb8fd --- /dev/null +++ b/aastar-frontend/app/tasks/[taskId]/page.tsx @@ -0,0 +1,401 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useRouter, useParams } from "next/navigation"; +import Layout from "@/components/Layout"; +import { useTask } from "@/contexts/TaskContext"; +import { useDashboard } from "@/contexts/DashboardContext"; +import { getStoredAuth } from "@/lib/auth"; +import { + type ParsedTask, + TaskStatus, + TASK_STATUS_COLORS, +} from "@/lib/task-types"; +import { DEFAULT_REWARD_TOKEN_SYMBOL } from "@/lib/contracts/task-config"; +import { + ArrowLeftIcon, + UserIcon, + CurrencyDollarIcon, + ClockIcon, + CheckCircleIcon, + ExclamationTriangleIcon, +} from "@heroicons/react/24/outline"; +import { formatDate, formatDateTime } from "@/lib/date-utils"; +import toast from "react-hot-toast"; +import type { WalletClient } from "viem"; + +function AddressRow({ label, addr }: { label: string; addr: string }) { + if (!addr || addr === "0x0000000000000000000000000000000000000000") return null; + return ( +
+ {label} + + {addr.slice(0, 8)}…{addr.slice(-6)} + +
+ ); +} + +function Section({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+

+ {title} +

+ {children} +
+ ); +} + +export default function TaskDetailPage() { + const router = useRouter(); + const { taskId } = useParams<{ taskId: string }>(); + const { getTask, acceptTask, submitWork, approveWork, finalizeTask, cancelTask } = useTask(); + const { data } = useDashboard(); + + const [task, setTask] = useState(null); + const [loading, setLoading] = useState(true); + const [walletClient, setWalletClient] = useState(null); + const [actionLoading, setActionLoading] = useState(false); + const [evidenceUri, setEvidenceUri] = useState(""); + const [showEvidenceForm, setShowEvidenceForm] = useState(false); + + const myAddress = data.account?.address?.toLowerCase() ?? ""; + const isCommunity = task?.community.toLowerCase() === myAddress; + const isTaskor = task?.taskor.toLowerCase() === myAddress; + const isZeroAddress = (addr: string) => + addr === "0x0000000000000000000000000000000000000000"; + + useEffect(() => { + const { token } = getStoredAuth(); + if (!token) router.push("/auth/login"); + }, [router]); + + useEffect(() => { + async function loadWallet() { + if (typeof window === "undefined") return; + const { createWalletClient, custom } = await import("viem"); + const { SUPPORTED_CHAIN } = await import("@/lib/contracts/task-config"); + const provider = (window as Window & { ethereum?: unknown }).ethereum; + if (!provider) return; + setWalletClient( + createWalletClient({ + chain: SUPPORTED_CHAIN, + transport: custom(provider as Parameters[0]), + }) + ); + } + loadWallet(); + }, []); + + useEffect(() => { + if (!taskId) return; + setLoading(true); + getTask(taskId) + .then((t) => setTask(t)) + .finally(() => setLoading(false)); + }, [taskId, getTask]); + + const refresh = async () => { + if (!taskId) return; + const t = await getTask(taskId); + setTask(t); + }; + + async function runAction(fn: () => Promise, successMsg: string) { + if (!walletClient) { + toast.error("No wallet connected"); + return; + } + setActionLoading(true); + const toastId = toast.loading("Sending transaction..."); + try { + const ok = await fn(); + toast.dismiss(toastId); + if (ok) { + toast.success(successMsg); + await refresh(); + } else { + toast.error("Transaction failed"); + } + } catch (err) { + toast.dismiss(toastId); + toast.error(err instanceof Error ? err.message : "Error"); + } finally { + setActionLoading(false); + } + } + + function getTitle(uri: string): string { + try { + return JSON.parse(uri).title ?? "Untitled Task"; + } catch { + return uri.slice(0, 60) || "Untitled Task"; + } + } + + function getDescription(uri: string): string { + try { + return JSON.parse(uri).description ?? ""; + } catch { + return uri; + } + } + + if (loading) { + return ( + +
+
+
+ + ); + } + + if (!task) { + return ( + +
+

Task not found

+ +
+
+ ); + } + + const isOpen = task.status === TaskStatus.Open; + const isAccepted = task.status === TaskStatus.Accepted || task.status === TaskStatus.InProgress; + const isSubmitted = task.status === TaskStatus.Submitted; + const isFinalized = task.status === TaskStatus.Finalized; + const isRefunded = task.status === TaskStatus.Refunded; + + return ( + +
+ {/* Header */} +
+ +
+
+

+ {getTitle(task.metadataUri)} +

+ + {task.statusLabel} + +
+

+ {task.taskTypeLabel} · Posted {formatDate(task.createdAt)} +

+
+
+ + {/* Description */} +
+

+ {getDescription(task.metadataUri) || "No description provided."} +

+
+ + {/* Reward & Deadline */} +
+
+
+
+ + Reward +
+ + {task.rewardFormatted} {DEFAULT_REWARD_TOKEN_SYMBOL} + +
+
+
+ + Deadline +
+ + {formatDateTime(task.deadline)} + {task.isExpired && " (expired)"} + +
+ {task.challengeDeadline && ( +
+
+ + Challenge period ends +
+ + {formatDateTime(task.challengeDeadline)} + +
+ )} +
+
+ + {/* Participants */} +
+ + {!isZeroAddress(task.taskor) && ( + + )} + {!isZeroAddress(task.supplier) && ( + + )} +
+ + {/* Evidence (if submitted) */} + {task.evidenceUri && ( +
+

+ {task.evidenceUri} +

+
+ )} + + {/* Actions */} +
+ {/* Claim task (Open → Accepted) */} + {isOpen && !isCommunity && !task.isExpired && ( + + )} + + {/* Submit evidence (Accepted → Submitted) */} + {isAccepted && isTaskor && ( + <> + {!showEvidenceForm ? ( + + ) : ( +
+

+ Submit your evidence +

+