Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions aastar-frontend/app/dashboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@ import CopyButton from "@/components/CopyButton";
import CreateAccountDialog from "@/components/CreateAccountDialog";
import ReceiveModal from "@/components/ReceiveModal";
import { useDashboard } from "@/contexts/DashboardContext";
import { useTask } from "@/contexts/TaskContext";
import { User } from "@/lib/types";
import { getStoredAuth } from "@/lib/auth";
import { DEFAULT_REWARD_TOKEN_SYMBOL, isContractsConfigured } from "@/lib/contracts/task-config";
import toast from "react-hot-toast";
import {
WalletIcon,
Expand All @@ -30,6 +32,7 @@ function DashboardContent() {
refreshBalance: contextRefreshBalance,
} = useDashboard();
const { account, transfers, paymasters, tokenBalances, lastUpdated } = data;
const { taskTokenBalance, taskTokenBalanceFormatted, loadTaskTokenBalance } = useTask();

const [user, setUser] = useState<User | null>(null);
const [showCreateDialog, setShowCreateDialog] = useState(false);
Expand All @@ -52,6 +55,13 @@ function DashboardContent() {
}
}, [loadDashboardData]);

// T01: load task token balance when account is available
useEffect(() => {
if (account?.address && isContractsConfigured()) {
loadTaskTokenBalance(account.address);
}
}, [account?.address, loadTaskTokenBalance]);

const handleAccountCreated = () => {
// Reload data to get updated balance
setTimeout(() => loadDashboardData(true), 2000);
Expand Down Expand Up @@ -553,6 +563,37 @@ function DashboardContent() {
</div>
)}

{/* T01: Task Reward Token Balance */}
{isContractsConfigured() && taskTokenBalance !== null && (
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-xl border border-emerald-200 dark:border-emerald-800 mb-6">
<div className="p-5">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-500 dark:text-gray-400">Task Reward Balance</p>
<div className="flex items-baseline gap-2 mt-1">
<span className="text-2xl font-bold text-emerald-600 dark:text-emerald-400">
{parseFloat(taskTokenBalanceFormatted ?? "0").toFixed(4)}
</span>
<span className="text-sm font-medium text-gray-600 dark:text-gray-400">
{DEFAULT_REWARD_TOKEN_SYMBOL}
</span>
</div>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-0.5">
ERC-20 balance of your smart account
</p>
</div>
<button
onClick={() => account?.address && loadTaskTokenBalance(account.address)}
className="p-2 rounded-lg text-gray-400 hover:text-emerald-600 dark:hover:text-emerald-400 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
title="Refresh task balance"
>
<ArrowPathIcon className="w-4 h-4" />
</button>
</div>
</div>
</div>
)}

{/* Paymaster Status */}
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-xl border border-gray-200 dark:border-gray-700 mb-6">
<div className="p-6">
Expand Down
136 changes: 127 additions & 9 deletions aastar-frontend/app/tasks/[taskId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,17 +50,23 @@ function Section({ title, children }: { title: string; children: React.ReactNode
export default function TaskDetailPage() {
const router = useRouter();
const { taskId } = useParams<{ taskId: string }>();
const { getTask, acceptTask, submitWork, approveWork, finalizeTask, cancelTask } = useTask();
const { getTask, acceptTask, submitWork, approveWork, finalizeTask, cancelTask, getTaskReceipts, linkReceipt } = useTask();
const { data } = useDashboard();

const [task, setTask] = useState<ParsedTask | null>(null);
const [loading, setLoading] = useState(true);
const [walletClient, setWalletClient] = useState<WalletClient | null>(null);
const [eoaAddress, setEoaAddress] = useState<string>("");
const [actionLoading, setActionLoading] = useState(false);
const [evidenceUri, setEvidenceUri] = useState("");
const [showEvidenceForm, setShowEvidenceForm] = useState(false);
// T06: receipts
const [receipts, setReceipts] = useState<`0x${string}`[]>([]);
const [showLinkReceiptForm, setShowLinkReceiptForm] = useState(false);
const [receiptInput, setReceiptInput] = useState("");

const myAddress = data.account?.address?.toLowerCase() ?? "";
// Use MetaMask EOA for role checks — contract stores EOA, not YAA smart account
const myAddress = (eoaAddress || (data.account?.address ?? "")).toLowerCase();
const isCommunity = task?.community.toLowerCase() === myAddress;
const isTaskor = task?.taskor.toLowerCase() === myAddress;
const isZeroAddress = (addr: string) =>
Expand All @@ -78,12 +84,18 @@ export default function TaskDetailPage() {
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<typeof custom>[0]),
})
);
const client = createWalletClient({
chain: SUPPORTED_CHAIN,
transport: custom(provider as Parameters<typeof custom>[0]),
});
setWalletClient(client);
// Fetch EOA address for role comparison (contract stores EOA, not YAA smart account)
try {
const addrs = await client.getAddresses();
if (addrs[0]) setEoaAddress(addrs[0].toLowerCase());
} catch {
// not connected yet — will be resolved on first requestAddresses()
}
}
loadWallet();
}, []);
Expand All @@ -98,8 +110,26 @@ export default function TaskDetailPage() {

const refresh = async () => {
if (!taskId) return;
const t = await getTask(taskId);
const [t, r] = await Promise.all([getTask(taskId), getTaskReceipts(taskId)]);
setTask(t);
setReceipts(r);
};

// T06: load receipts on mount
useEffect(() => {
if (taskId) {
getTaskReceipts(taskId).then(setReceipts);
}
}, [taskId, getTaskReceipts]);

const switchWallet = async () => {
if (!walletClient) return;
try {
const addrs = await walletClient.requestAddresses();
if (addrs[0]) setEoaAddress(addrs[0].toLowerCase());
} catch {
toast.error("Failed to switch wallet");
}
};

async function runAction(fn: () => Promise<boolean>, successMsg: string) {
Expand Down Expand Up @@ -202,6 +232,23 @@ export default function TaskDetailPage() {
</div>
</div>

{/* Wallet indicator */}
<div className="flex items-center justify-between px-1 text-xs text-gray-500 dark:text-gray-400">
<span>
Wallet:{" "}
{eoaAddress
? <span className="font-mono">{eoaAddress.slice(0, 6)}…{eoaAddress.slice(-4)}</span>
: <span className="italic">not connected</span>
}
</span>
<button
onClick={switchWallet}
className="text-emerald-600 dark:text-emerald-400 hover:underline"
>
{eoaAddress ? "Switch wallet" : "Connect wallet"}
</button>
</div>

{/* Description */}
<Section title="Description">
<p className="text-sm text-gray-700 dark:text-gray-300 whitespace-pre-wrap leading-relaxed">
Expand Down Expand Up @@ -271,6 +318,77 @@ export default function TaskDetailPage() {
</Section>
)}

{/* T06: x402 Receipts */}
{(receipts.length > 0 || (eoaAddress && (isCommunity || isTaskor))) && (
<Section title="x402 Receipts">
{receipts.length > 0 ? (
<div className="space-y-2">
{receipts.map((rid) => (
<div key={rid} className="flex items-center gap-2 py-1.5 border-b border-gray-100 dark:border-gray-700 last:border-0">
<span className="text-xs font-mono text-gray-500 dark:text-gray-400 break-all">
{rid.slice(0, 14)}…{rid.slice(-10)}
</span>
</div>
))}
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
{receipts.length} receipt{receipts.length > 1 ? "s" : ""} linked
</p>
</div>
) : (
<p className="text-sm text-gray-500 dark:text-gray-400">No receipts linked yet.</p>
)}

{eoaAddress && (isCommunity || isTaskor) && (
<div className="mt-3">
{!showLinkReceiptForm ? (
<button
onClick={() => setShowLinkReceiptForm(true)}
className="text-xs text-emerald-600 dark:text-emerald-400 hover:underline"
>
+ Link receipt
</button>
) : (
<div className="space-y-2">
<input
type="text"
value={receiptInput}
onChange={(e) => setReceiptInput(e.target.value)}
placeholder="Receipt ID (0x…) or receipt URI"
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"
/>
<div className="flex gap-2">
<button
onClick={() => { setShowLinkReceiptForm(false); setReceiptInput(""); }}
className="flex-1 py-1.5 rounded-lg border border-gray-200 dark:border-gray-700 text-xs text-gray-600 dark:text-gray-400"
>
Cancel
</button>
<button
onClick={async () => {
if (!receiptInput.trim() || !walletClient) return;
const ok = await linkReceipt(task!.taskId, receiptInput.trim(), receiptInput.trim(), walletClient);
if (ok) {
toast.success("Receipt linked!");
setShowLinkReceiptForm(false);
setReceiptInput("");
await refresh();
} else {
toast.error("Failed to link receipt");
}
}}
disabled={!receiptInput.trim() || actionLoading}
className="flex-1 py-1.5 rounded-lg bg-emerald-600 hover:bg-emerald-700 disabled:opacity-60 text-white text-xs font-medium"
>
Link
</button>
</div>
</div>
)}
</div>
)}
</Section>
)}

{/* Actions */}
<div className="space-y-3">
{/* Claim task (Open → Accepted) */}
Expand Down
129 changes: 107 additions & 22 deletions aastar-frontend/app/tasks/create/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,32 +79,117 @@ export default function CreateTaskPage() {

setSubmitting(true);
try {
// Step 1: Check and approve token allowance
const { parseUnits } = await import("viem");
const { DEFAULT_REWARD_TOKEN_DECIMALS } = await import("@/lib/contracts/task-config");
const rewardWei = parseUnits(form.rewardAmount, DEFAULT_REWARD_TOKEN_DECIMALS);
const { parseUnits, createPublicClient, http } = await import("viem");
const {
DEFAULT_REWARD_TOKEN_DECIMALS,
DEFAULT_REWARD_TOKEN,
TASK_ESCROW_ADDRESS,
SUPPORTED_CHAIN,
RPC_URL,
} = await import("@/lib/contracts/task-config");
const { ERC20_ABI, TASK_ESCROW_ABI } = await import("@/lib/contracts/task-escrow-abi");

const addresses = await walletClient.getAddresses();
const rewardWei = parseUnits(form.rewardAmount, DEFAULT_REWARD_TOKEN_DECIMALS);
const addresses = await walletClient.requestAddresses();
const ownerAddress = addresses[0];
const currentAllowance = await checkAllowance(ownerAddress);

if (currentAllowance < rewardWei) {
setStep("approve");
toast.loading("Approving token spend...", { id: "approve" });
const approved = await approveToken(rewardWei, walletClient);
toast.dismiss("approve");
if (!approved) {
toast.error("Token approval failed");
setStep("form");
return;

const metadata = JSON.stringify({
title: form.title,
description: form.description,
createdAt: Math.floor(Date.now() / 1000),
});
const deadlineBig = BigInt(Math.floor(Date.now() / 1000) + form.deadlineDays * 86400);

const publicClient = createPublicClient({ chain: SUPPORTED_CHAIN, transport: http(RPC_URL) });

// T02: Try EIP-2612 permit first (single tx, no pre-approve needed)
let taskId: `0x${string}` | null = null;
let usedPermit = false;

try {
const [tokenName, nonce] = await Promise.all([
publicClient.readContract({ address: DEFAULT_REWARD_TOKEN, abi: ERC20_ABI, functionName: "name" }),
publicClient.readContract({ address: DEFAULT_REWARD_TOKEN, abi: ERC20_ABI, functionName: "nonces", args: [ownerAddress] }),
]);

const permitDeadline = BigInt(Math.floor(Date.now() / 1000) + 3600); // 1h
const chainId = await walletClient.getChainId();

const signature = await walletClient.signTypedData({
account: ownerAddress,
domain: {
name: tokenName as string,
version: "2",
chainId,
verifyingContract: DEFAULT_REWARD_TOKEN,
},
types: {
Permit: [
{ name: "owner", type: "address" },
{ name: "spender", type: "address" },
{ name: "value", type: "uint256" },
{ name: "nonce", type: "uint256" },
{ name: "deadline", type: "uint256" },
],
},
primaryType: "Permit",
message: {
owner: ownerAddress,
spender: TASK_ESCROW_ADDRESS,
value: rewardWei,
nonce: nonce as bigint,
deadline: permitDeadline,
},
});

// Parse v, r, s from signature
const r = `0x${signature.slice(2, 66)}` as `0x${string}`;
const s = `0x${signature.slice(66, 130)}` as `0x${string}`;
const v = parseInt(signature.slice(130, 132), 16);

setStep("submit");
toast.loading("Creating task with permit (single tx)...", { id: "create" });

const hash = await walletClient.writeContract({
address: TASK_ESCROW_ADDRESS,
abi: TASK_ESCROW_ABI,
functionName: "createTaskWithPermit",
args: [DEFAULT_REWARD_TOKEN, rewardWei, deadlineBig, metadata, form.taskType, permitDeadline, v, r, s],
account: ownerAddress,
chain: SUPPORTED_CHAIN,
});

const receipt = await publicClient.waitForTransactionReceipt({ hash });
const log = receipt.logs.find((l) => l.address.toLowerCase() === TASK_ESCROW_ADDRESS.toLowerCase());
if (log?.topics[1]) {
taskId = log.topics[1] as `0x${string}`;
usedPermit = true;
}
toast.success("Token approved");
} catch {
// Permit not supported or signing cancelled — fall back to approve + createTask
}

if (!usedPermit) {
// Fallback: approve + createTask (original flow)
const currentAllowance = await checkAllowance(ownerAddress);
if (currentAllowance < rewardWei) {
setStep("approve");
toast.loading("Approving token spend...", { id: "approve" });
const approved = await approveToken(rewardWei, walletClient);
toast.dismiss("approve");
if (!approved) {
toast.error("Token approval failed");
setStep("form");
return;
}
toast.success("Token approved");
}

setStep("submit");
toast.loading("Creating task on-chain...", { id: "create" });
taskId = await createTask(form, walletClient);
}

// Step 2: Create task
setStep("submit");
toast.loading("Creating task on-chain...", { id: "create" });
const taskId = await createTask(form, walletClient);
toast.dismiss("create");

if (taskId) {
Expand Down Expand Up @@ -288,7 +373,7 @@ export default function CreateTaskPage() {
: "Creating task..."
: !walletClient
? "Connect wallet to post"
: "Post Task"}
: "Post Task (EIP-2612 Permit)"}
</button>
</div>
</div>
Expand Down
Loading