From 17de40f1c1b602a136a6e2147ae00ff226430b15 Mon Sep 17 00:00:00 2001 From: Pericena Date: Wed, 9 Sep 2026 12:46:23 -0400 Subject: [PATCH 1/2] feat(web): connect API over LAN and update featured agents --- apps/web/.env.example | 8 + .../src/features/account/activity-list.tsx | 10 + apps/web/src/features/account/notice-list.tsx | 39 +- apps/web/src/features/catalog/marketplace.tsx | 4 +- apps/web/src/features/catalog/use-agent.ts | 51 ++ apps/web/src/features/catalog/use-catalog.ts | 87 +-- apps/web/src/features/hire/hire-confirm.tsx | 176 ++++- apps/web/src/features/hire/hire-cta.tsx | 670 ++++++++++++------ apps/web/src/features/hire/hire-log.ts | 16 + apps/web/src/features/hire/hire-provider.ts | 35 + apps/web/src/features/hire/hire-storage.ts | 55 ++ .../web/src/features/signals/signal-chart.tsx | 192 +---- apps/web/src/lib/api.ts | 205 +++++- apps/web/src/lib/format.ts | 4 +- apps/web/src/providers/network.ts | 21 +- apps/web/src/providers/wallet.tsx | 2 +- .../src/providers/wrong-network-banner.tsx | 2 +- apps/web/src/routes/agents/$agentId.tsx | 110 +-- apps/web/src/routes/browse/$category.tsx | 1 + apps/web/src/routes/index.tsx | 1 + apps/web/src/vite-env.d.ts | 7 +- apps/web/vite.config.ts | 40 +- packages/indexer/fixtures/featured.json | 70 +- 23 files changed, 1173 insertions(+), 633 deletions(-) create mode 100644 apps/web/src/features/catalog/use-agent.ts create mode 100644 apps/web/src/features/hire/hire-log.ts create mode 100644 apps/web/src/features/hire/hire-provider.ts create mode 100644 apps/web/src/features/hire/hire-storage.ts diff --git a/apps/web/.env.example b/apps/web/.env.example index b9ccf21..24f90cc 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -1,5 +1,13 @@ +# Catalog API on the PC. Desktop (`localhost:5173`) calls this URL directly. +# A phone on the LAN (`http://PC-IP:5173`) uses the Vite `/api` proxy instead. VITE_API_URL=http://localhost:3001 + +# WalletConnect Cloud project id. The placeholder is fine for injected +# wallets (MetaMask). WalletConnect QR / mobile linking needs a real id. VITE_WC_PROJECT_ID=era-marketplace-dev + +# local = mock hire via POST /jobs (no wallet). +# bsc-testnet | bsc-mainnet = wagmi + ERC-8183 on that chain. VITE_CHAIN=local # Optional. Both default to the canonical BNB Agent Studio deployment for the diff --git a/apps/web/src/features/account/activity-list.tsx b/apps/web/src/features/account/activity-list.tsx index 1c3d2bb..715bc9c 100644 --- a/apps/web/src/features/account/activity-list.tsx +++ b/apps/web/src/features/account/activity-list.tsx @@ -1,3 +1,4 @@ +import { Link } from "@tanstack/react-router"; import { Bell, Copy, History, Unplug, Wallet, Zap } from "lucide-react"; import { EmptyState } from "@/components/ui"; import { Icon } from "@/components/ui/icon"; @@ -39,6 +40,15 @@ export function ActivityList() { {item.status ? `${item.status} · ` : ""} {formatDateTime(item.createdAt)}

+ {item.href?.startsWith("/agents/") ? ( + + Open agent + + ) : null} ))} diff --git a/apps/web/src/features/account/notice-list.tsx b/apps/web/src/features/account/notice-list.tsx index da480e4..05744be 100644 --- a/apps/web/src/features/account/notice-list.tsx +++ b/apps/web/src/features/account/notice-list.tsx @@ -1,3 +1,5 @@ +import { type ReactNode } from "react"; +import { Link } from "@tanstack/react-router"; import { Bell, CircleAlert, Info, Wallet, TriangleAlert, Zap } from "lucide-react"; import { Badge, Button, EmptyState } from "@/components/ui"; import { Icon } from "@/components/ui/icon"; @@ -94,9 +96,9 @@ function NoticeRow({ item, onRead }: { item: Notice; onRead: () => void }) { return (
{item.href ? ( - + {body} - + ) : ( body )} @@ -108,3 +110,36 @@ function NoticeRow({ item, onRead }: { item: Notice; onRead: () => void }) {
); } + +function NoticeLink({ + href, + onRead, + children, +}: { + href: string; + onRead: () => void; + children: ReactNode; +}) { + const profile = href.startsWith("/profile"); + if (profile) { + const tab = new URLSearchParams(href.split("?")[1] ?? "").get("tab") ?? "overview"; + return ( + + {children} + + ); + } + const agent = href.match(/^\/agents\/([^/?#]+)/); + if (agent?.[1]) { + return ( + + {children} + + ); + } + return ( + + {children} + + ); +} diff --git a/apps/web/src/features/catalog/marketplace.tsx b/apps/web/src/features/catalog/marketplace.tsx index 3e2184a..0f3f9b8 100644 --- a/apps/web/src/features/catalog/marketplace.tsx +++ b/apps/web/src/features/catalog/marketplace.tsx @@ -32,12 +32,14 @@ export function Marketplace({ status, onRetry, initialSearch = "", + error, }: { agents: AgentListing[]; signals: Record; status: Status; onRetry: () => void; initialSearch?: string; + error?: string | null; }) { const [query, setQuery] = useState({ ...DEFAULT_CATALOG_QUERY, @@ -100,7 +102,7 @@ export function Marketplace({ } > -

{friendlyLoadError()}

+

{friendlyLoadError(error)}

); } diff --git a/apps/web/src/features/catalog/use-agent.ts b/apps/web/src/features/catalog/use-agent.ts new file mode 100644 index 0000000..2f4146f --- /dev/null +++ b/apps/web/src/features/catalog/use-agent.ts @@ -0,0 +1,51 @@ +import { useQuery } from "@tanstack/react-query"; +import { ApiError, getAgent, getSignal, isNotFound } from "@/lib/api"; + +function retryAgent(count: number, err: Error) { + if (err instanceof ApiError && (err.status === 404 || err.status === 400)) return false; + return count < 1; +} + +export function useAgent(agentId: string) { + const agentQuery = useQuery({ + queryKey: ["agent", agentId], + queryFn: ({ signal }) => getAgent(agentId, { signal }), + retry: retryAgent, + }); + + const signalQuery = useQuery({ + queryKey: ["agent-signal", agentId], + queryFn: ({ signal }) => getSignal(agentId, { signal }), + enabled: agentQuery.isSuccess, + retry: false, + staleTime: 30_000, + }); + + const signalMissing = signalQuery.isError && isNotFound(signalQuery.error); + const signalFailed = signalQuery.isError && !signalMissing; + + return { + agent: agentQuery.data?.agent ?? null, + signal: signalQuery.data?.signal ?? null, + loading: agentQuery.isPending, + notFound: agentQuery.isError && isNotFound(agentQuery.error), + error: agentQuery.isError + ? agentQuery.error instanceof Error + ? agentQuery.error.message + : "This agent could not be loaded" + : null, + signalStatus: agentQuery.isPending || (agentQuery.isSuccess && signalQuery.isPending) + ? ("loading" as const) + : signalQuery.isSuccess + ? ("ready" as const) + : ("empty" as const), + signalRefreshing: signalQuery.isFetching && !signalQuery.isPending, + signalFailed, + reload: () => { + void agentQuery.refetch(); + }, + reloadSignal: () => { + void signalQuery.refetch(); + }, + }; +} diff --git a/apps/web/src/features/catalog/use-catalog.ts b/apps/web/src/features/catalog/use-catalog.ts index 31a7db3..a337d39 100644 --- a/apps/web/src/features/catalog/use-catalog.ts +++ b/apps/web/src/features/catalog/use-catalog.ts @@ -1,65 +1,48 @@ -import { useEffect, useState } from "react"; -import type { AgentListing, AgentSignal, Category } from "@era/domain"; -import { getSignal, listAgents } from "@/lib/api"; +import { useQuery } from "@tanstack/react-query"; +import type { Category } from "@era/domain"; +import { ApiError, fetchSignals, listAgents } from "@/lib/api"; type Status = "loading" | "ready" | "error"; -export function useCatalog(category?: Category) { - const [agents, setAgents] = useState([]); - const [signals, setSignals] = useState>({}); - const [status, setStatus] = useState("loading"); - const [error, setError] = useState(null); - const [attempt, setAttempt] = useState(0); +function retryCatalog(count: number, err: Error) { + if (err instanceof ApiError && (err.status === 404 || err.status === 400)) return false; + return count < 1; +} - useEffect(() => { - let cancelled = false; - setStatus("loading"); - setError(null); - setAgents([]); - setSignals({}); +export function useCatalog(category?: Category) { + const agentsQuery = useQuery({ + queryKey: ["catalog-agents", category ?? "all"], + queryFn: ({ signal }) => listAgents(category, { signal }), + retry: retryCatalog, + }); - listAgents(category) - .then(async ({ agents: rows }) => { - if (cancelled) return; - setAgents(rows); - setStatus("ready"); + const agents = agentsQuery.data?.agents ?? []; - const settled = await Promise.allSettled( - rows.map(async (agent) => { - const { signal } = await getSignal(agent.id); - return [agent.id, signal] as const; - }), - ); - if (cancelled) return; - setSignals( - Object.fromEntries( - settled - .filter( - ( - entry, - ): entry is PromiseFulfilledResult => - entry.status === "fulfilled", - ) - .map((entry) => entry.value), - ), - ); - }) - .catch((err: unknown) => { - if (cancelled) return; - setStatus("error"); - setError(err instanceof Error ? err.message : "Failed to load agents"); - }); + const signalsQuery = useQuery({ + queryKey: ["catalog-signals", category ?? "all", agents.map((agent) => agent.id)], + queryFn: ({ signal }) => fetchSignals( + agents.map((agent) => agent.id), + signal, + ), + enabled: agentsQuery.isSuccess && agents.length > 0, + retry: false, + staleTime: 30_000, + }); - return () => { - cancelled = true; - }; - }, [category, attempt]); + const status: Status = agentsQuery.isPending + ? "loading" + : agentsQuery.isError + ? "error" + : "ready"; return { agents, - signals, + signals: signalsQuery.data ?? {}, status, - error, - retry: () => setAttempt((n) => n + 1), + error: agentsQuery.error instanceof Error ? agentsQuery.error.message : null, + retry: () => { + void agentsQuery.refetch(); + void signalsQuery.refetch(); + }, }; } diff --git a/apps/web/src/features/hire/hire-confirm.tsx b/apps/web/src/features/hire/hire-confirm.tsx index 34f6ba6..61e1506 100644 --- a/apps/web/src/features/hire/hire-confirm.tsx +++ b/apps/web/src/features/hire/hire-confirm.tsx @@ -1,6 +1,14 @@ import { CATEGORY_LABELS, type AgentListing } from "@era/domain"; import { Button, Dialog } from "@/components/ui"; import { formatAddress, weiToU } from "@/lib/format"; +import { testnetExplorerTx } from "./hire-log"; + +export type HireProgress = { + created: boolean; + submitted: boolean; + confirming: boolean; + activating: boolean; +}; export function HireConfirmDialog({ open, @@ -9,8 +17,11 @@ export function HireConfirmDialog({ agent, task, budgetWei, - local, + contractAddress, + provider, busy, + canPay, + blockedReason, }: { open: boolean; onClose: () => void; @@ -18,23 +29,26 @@ export function HireConfirmDialog({ agent: AgentListing; task: string; budgetWei: string; - local: boolean; + contractAddress: string; + provider: string; busy: boolean; + canPay: boolean; + blockedReason?: string; }) { return ( undefined : onClose} title="Confirm hire" - description="Review the job before you fund it. This opens a session the agent can spend against." + description="Review the job before you pay. Your wallet will ask you to confirm each ERC-8183 step." placement="bottom" footer={
-
} @@ -43,13 +57,121 @@ export function HireConfirmDialog({ - - - - + + + + + + + {blockedReason ? ( +

+ {blockedReason} +

+ ) : ( +

+ One hire. Several wallet confirmations (create, budget, policy, approve $U, fund). Not a + second payment of the same step. +

+ )} +
+ ); +} + +export function HireProgressDialog({ + open, + agentName, + phaseLabel, + progress, + txHash, +}: { + open: boolean; + agentName: string; + phaseLabel: string; + progress: HireProgress; + txHash?: string; +}) { + const explorer = txHash ? testnetExplorerTx(txHash) : null; + return ( + undefined} + title={`Hiring ${agentName}`} + description={phaseLabel} + placement="bottom" + > +
    + + + + +
+ {txHash ? ( +

+ Transaction: {txHash} + {explorer ? ( + <> + {" "} + + View on BscScan + + + ) : null} +

+ ) : null} +
+ ); +} + +export function HireSuccessDialog({ + open, + onClose, + agentName, + txHash, +}: { + open: boolean; + onClose: () => void; + agentName: string; + txHash?: string; +}) { + const explorer = txHash ? testnetExplorerTx(txHash) : null; + return ( + + {explorer ? ( + + + + ) : null} + + + } + > +
+ + + + {txHash ? : null}
); @@ -71,7 +193,7 @@ export function RevokeConfirmDialog({ open={open} onClose={busy ? () => undefined : onClose} title="Stop access" - description="This ends the agent’s spend session. It cannot keep working against this hire." + description="On-chain session revoke is not available in this app." placement="center" footer={
@@ -97,7 +219,7 @@ export function HireGateDialog({ details, }: { open: boolean; - kind: "wallet" | "network" | "fields" | "error" | null; + kind: "wallet" | "network" | "fields" | "error" | "rejected" | null; onClose: () => void; onConnect?: () => void; onSwitch?: () => void; @@ -134,17 +256,20 @@ export function HireGateDialog({ ); } -function gateCopy(kind: "wallet" | "network" | "fields" | "error", details?: string) { +function gateCopy( + kind: "wallet" | "network" | "fields" | "error" | "rejected", + details?: string, +) { if (kind === "wallet") { return { title: "Wallet not connected", - description: "Connect a buyer wallet before you hire on a live chain.", + description: "Connect your wallet to continue.", }; } if (kind === "network") { return { title: "Wrong network", - description: "Switch to the network this demo uses, then try again.", + description: "Please switch to BNB Smart Chain Testnet.", }; } if (kind === "fields") { @@ -153,12 +278,29 @@ function gateCopy(kind: "wallet" | "network" | "fields" | "error", details?: str description: details || "Check the required fields and try again.", }; } + if (kind === "rejected") { + return { + title: "Transaction cancelled", + description: "You cancelled the transaction in your wallet.", + }; + } return { title: "Hire failed", - description: details || "The hire did not complete. You can retry when you are ready.", + description: details || "The hire did not complete. Your payment was not started again.", }; } +function Step({ done, active, label }: { done: boolean; active: boolean; label: string }) { + return ( +
  • + + {done ? "✓" : active ? "●" : "○"} + + {label} +
  • + ); +} + function Row({ label, value, diff --git a/apps/web/src/features/hire/hire-cta.tsx b/apps/web/src/features/hire/hire-cta.tsx index 13f63c8..75f1076 100644 --- a/apps/web/src/features/hire/hire-cta.tsx +++ b/apps/web/src/features/hire/hire-cta.tsx @@ -3,76 +3,103 @@ import { Link } from "@tanstack/react-router"; import { ConnectButton, useConnectModal } from "@rainbow-me/rainbowkit"; import { useAccount, usePublicClient, useSwitchChain, useWalletClient } from "wagmi"; import { Handshake, CircleCheck } from "lucide-react"; -import type { Address } from "viem"; +import type { Address, Hex } from "viem"; import type { AgentListing, JobView } from "@era/domain"; import { - createJob as createCommerceJob, - getJob as getCommerceJob, + COMMERCE_CONTRACTS, + TESTNET_DEFAULT_AMOUNT_WEI, + assertAffordable, + createRealJob, + ensureAllowance, + fundRealJob, + getContractAddress, + getJobStatus, mapHireError, + registerJobPolicy, + resolveTestnetAmountWei, + setJobBudget, + toFundedJobView, validateHireReady, + waitForFundedStatus, type HirePhase, } from "@era/commerce"; import { Button, Card, Lede, Meta, TextAreaField, TextField, useToast } from "@/components/ui"; -import { - createJob as createMockJobViaApi, - getJob as getMockJobViaApi, - isLocalChain, - revokeJobSession, -} from "@/lib/api"; -import { formatDateTime, uToWei, weiToU } from "@/lib/format"; -import { HireConfirmDialog, HireGateDialog, RevokeConfirmDialog } from "./hire-confirm"; -import { WalletGlyph } from "@/components/layout/wallet-glyph"; -import { targetChain } from "@/providers/network"; import { Icon } from "@/components/ui/icon"; +import { WalletGlyph } from "@/components/layout/wallet-glyph"; import { useInbox } from "@/features/account/inbox"; +import { formatAddress, uToWei, weiToU } from "@/lib/format"; +import { targetChain } from "@/providers/network"; +import { + HireConfirmDialog, + HireGateDialog, + HireProgressDialog, + HireSuccessDialog, +} from "./hire-confirm"; +import { HIRE_CHAIN, HIRE_CHAIN_ID, hireLog, testnetExplorerTx } from "./hire-log"; +import { resolveListingProvider } from "./hire-provider"; +import { + appendTxHash, + clearHireRecord, + readHireRecord, + writeHireRecord, + type StoredHireTx, +} from "./hire-storage"; -type UiPhase = "idle" | "preparing" | HirePhase | "funded" | "failed"; - -function readWebEnv(name: string): string | undefined { - return (import.meta.env as Record)[name]; -} - -function hireChain(): string { - return readWebEnv("VITE_CHAIN") ?? "local"; -} - -function contractAddress(): Address | undefined { - const raw = readWebEnv("VITE_CONTRACT_ADDRESS"); - return raw && /^0x[a-fA-F0-9]{40}$/.test(raw) ? (raw as Address) : undefined; -} - -function storageKey(agentId: string): string { - return `pulse:hire:${agentId}`; -} +type UiPhase = + | "idle" + | "preparing" + | HirePhase + | "funded" + | "failed" + | "rejected"; -function readStoredJobId(agentId: string): string | null { +function commerceContract(): Address { try { - const raw = sessionStorage.getItem(storageKey(agentId)); - if (!raw) return null; - const parsed = JSON.parse(raw) as { jobId?: string }; - return parsed.jobId ?? null; + return getContractAddress(undefined, HIRE_CHAIN_ID); } catch { - return null; + return COMMERCE_CONTRACTS[HIRE_CHAIN_ID]; } } -function storeJobId(agentId: string, jobId: string): void { - sessionStorage.setItem(storageKey(agentId), JSON.stringify({ jobId, agentId })); +function phaseCopy(phase: UiPhase): string { + if (phase === "preparing") return "Preparing transaction…"; + if (phase === "creating") return "Waiting for wallet confirmation"; + if (phase === "budgeting") return "Waiting for wallet confirmation"; + if (phase === "registering") return "Waiting for wallet confirmation"; + if (phase === "approving") return "Waiting for wallet confirmation"; + if (phase === "funding") return "Waiting for wallet confirmation"; + if (phase === "confirming") return "Confirming transaction…"; + if (phase === "funded") return "Payment confirmed"; + return "Ready to hire"; +} + +function progressFromPhase(phase: UiPhase): { + created: boolean; + submitted: boolean; + confirming: boolean; + activating: boolean; +} { + const order: UiPhase[] = [ + "creating", + "budgeting", + "registering", + "approving", + "funding", + "confirming", + "funded", + ]; + const index = order.indexOf(phase); + return { + created: index >= 0, + submitted: index >= 0 && phase !== "creating", + confirming: index >= 5, + activating: phase === "funded", + }; } -/** - * A live hire is four wallet signatures: open the job, set its budget, - * approve $U, then fund. Each phase says which one is on screen, so the - * buyer is not surprised by a second or third prompt. - */ -function phaseLabel(phase: UiPhase): string { - if (phase === "preparing") return "Preparing…"; - if (phase === "creating") return "1 of 4 · Confirm the job in your wallet…"; - if (phase === "budgeting") return "2 of 4 · Confirm the budget…"; - if (phase === "approving") return "3 of 4 · Approve $U spending…"; - if (phase === "funding") return "4 of 4 · Confirm the payment…"; - if (phase === "confirming") return "Processing… we’re checking the payment."; - return "Hire"; +function isCancelled(err: unknown): boolean { + const message = mapHireError(err).message.toLowerCase(); + return message.includes("cancelled") || message.includes("canceled"); } export function HireCTA({ @@ -93,43 +120,50 @@ export function HireCTA({ } export function HirePanel({ agent }: { agent: AgentListing }) { - const { isConnected, chainId } = useAccount(); + const { address, isConnected, chainId, status: walletStatus } = useAccount(); const publicClient = usePublicClient(); const { data: walletClient } = useWalletClient(); const { openConnectModal } = useConnectModal(); const { switchChain, isPending: switching } = useSwitchChain(); const target = targetChain(); - const local = isLocalChain(); - const chain = hireChain(); const toast = useToast(); const { pushNotice, pushActivity, recordHire } = useInbox(); const processingRef = useRef(false); - const restoredRef = useRef(false); const formId = useId(); + const provider = resolveListingProvider(agent); + const contract = commerceContract(); const [task, setTask] = useState( `Run a ${agent.category.replaceAll("_", " ")} pass and return the current signal plus a recommended action.`, ); - const [budgetU, setBudgetU] = useState("0.1"); + const [budgetU, setBudgetU] = useState(() => weiToU(TESTNET_DEFAULT_AMOUNT_WEI) || "0.001"); const [job, setJob] = useState(null); const [error, setError] = useState(null); const [fieldErrors, setFieldErrors] = useState<{ task?: string; budget?: string }>({}); const [phase, setPhase] = useState("idle"); const [confirmOpen, setConfirmOpen] = useState(false); - const [revokeOpen, setRevokeOpen] = useState(false); - const [gate, setGate] = useState<"wallet" | "network" | "fields" | "error" | null>(null); + const [successOpen, setSuccessOpen] = useState(false); + const [gate, setGate] = useState<"wallet" | "network" | "fields" | "error" | "rejected" | null>( + null, + ); + const [resumeJobId, setResumeJobId] = useState(null); + const [lastTx, setLastTx] = useState(); const budgetWei = uToWei(budgetU); + const amountWei = budgetWei + ? resolveTestnetAmountWei(budgetWei, HIRE_CHAIN_ID) + : TESTNET_DEFAULT_AMOUNT_WEI; const busy = + processingRef.current || phase === "preparing" || phase === "creating" || phase === "budgeting" || + phase === "registering" || phase === "approving" || phase === "funding" || phase === "confirming"; - const wrongNetwork = - !local && isConnected && chainId !== (chain === "bsc-mainnet" ? 56 : 97); - const canHire = - local || (isConnected && Boolean(walletClient) && Boolean(publicClient) && !wrongNetwork); + const inFlight = busy || phase === "creating"; + const wrongNetwork = isConnected && chainId !== HIRE_CHAIN_ID; + const connecting = walletStatus === "connecting" || walletStatus === "reconnecting"; useEffect(() => { if (gate === "wallet" && isConnected) setGate(null); @@ -137,41 +171,56 @@ export function HirePanel({ agent }: { agent: AgentListing }) { }, [gate, isConnected, wrongNetwork]); useEffect(() => { - if (restoredRef.current) return; - const jobId = readStoredJobId(agent.id); - if (!jobId) return; - if (!local && !publicClient) return; - restoredRef.current = true; + if (!address || !publicClient) return; + const stored = readHireRecord(agent.id, address); + if (!stored?.jobId) return; let cancelled = false; + hireLog("restore", { agentId: agent.id, wallet: address, jobId: stored.jobId }); setPhase("confirming"); - const restore = local - ? getMockJobViaApi(jobId).then((res) => res.job) - : publicClient - ? getCommerceJob(jobId, { - chain, - publicClient, - contractAddress: contractAddress(), - }) - : Promise.resolve(undefined); - restore - .then((restored) => { + setLastTx(stored.lastTxHash); + getJobStatus(stored.jobId, publicClient, { expectedChainId: HIRE_CHAIN_ID }) + .then((onChain) => { if (cancelled) return; - if (restored) { - setJob(restored); + if ( + onChain.status === "Funded" || + onChain.status === "Submitted" || + onChain.status === "Completed" + ) { + const hashes = stored.txHashes.filter((hash) => /^0x[a-fA-F0-9]{64}$/.test(hash)); + setJob({ + jobId: stored.jobId!, + agentId: agent.id, + budgetWei: stored.budgetWei, + task: stored.task, + status: onChain.status, + txHashes: hashes, + session: null, + createdAt: stored.createdAt, + }); setPhase("funded"); - } else { + hireLog("hire status", { status: onChain.status, jobId: stored.jobId }); + return; + } + if (onChain.status === "Rejected" || onChain.status === "Expired") { + clearHireRecord(agent.id, address); + setResumeJobId(null); setPhase("idle"); + return; } + setResumeJobId(stored.jobId!); + setTask(stored.task); + setBudgetU(weiToU(stored.budgetWei) || budgetU); + setPhase("idle"); }) - .catch((err: unknown) => { + .catch(() => { if (cancelled) return; - setError(mapHireError(err).message); - setPhase("failed"); + setResumeJobId(stored.jobId ?? null); + setPhase("idle"); }); return () => { cancelled = true; }; - }, [agent.id, chain, local, publicClient]); + }, [address, agent.id, publicClient]); function validateFields(): { task?: string; budget?: string } { const next: { task?: string; budget?: string } = {}; @@ -185,10 +234,20 @@ export function HirePanel({ agent }: { agent: AgentListing }) { return next; } + function blockedReason(): string | undefined { + if (connecting) return "Connecting wallet..."; + if (!isConnected) return "Connect your wallet to continue."; + if (wrongNetwork) return "Please switch to BNB Smart Chain Testnet."; + if (!provider) return "This agent has no real ERC-8183 provider address."; + if (!contract) return "Commerce contract is not configured."; + if (!walletClient || !publicClient) return "Wallet is not ready."; + return undefined; + } + function onReview(event: FormEvent) { event.preventDefault(); - if (processingRef.current || busy) return; - if (!local && !isConnected) { + if (processingRef.current || busy || phase === "funded") return; + if (!isConnected) { setGate("wallet"); return; } @@ -205,107 +264,175 @@ export function HirePanel({ agent }: { agent: AgentListing }) { setConfirmOpen(true); } + function persist( + record: StoredHireTx, + extra?: Partial & { hash?: string }, + ): StoredHireTx { + let next = { ...record, ...extra }; + if (extra?.hash) next = appendTxHash(next, extra.hash); + else writeHireRecord(next); + if (next.lastTxHash) setLastTx(next.lastTxHash); + return next; + } + async function executeHire() { - if (processingRef.current) return; - if (!budgetWei) return; + if (processingRef.current) { + hireLog("blocked duplicate submit"); + return; + } + if (!address || !walletClient || !publicClient || !provider || !budgetWei) return; + const block = blockedReason(); + if (block) { + setError(block); + return; + } + processingRef.current = true; setError(null); setPhase("preparing"); - toast.push("Hire started.", "ok"); + hireLog("agentId", { agentId: agent.id }); + hireLog("wallet", { wallet: address }); + hireLog("chainId", { chainId, expected: HIRE_CHAIN_ID }); + hireLog("contract", { contract }); + hireLog("function", { name: resumeJobId ? "resume" : "createJob" }); + + const clients = { walletClient, publicClient }; + const options = { + expectedChainId: HIRE_CHAIN_ID, + agentAddress: provider, + amount: amountWei, + onPhase: (next: HirePhase) => { + setPhase(next); + if (next === "confirming") hireLog("waiting confirmation"); + }, + }; + + let record: StoredHireTx = + readHireRecord(agent.id, address) ?? { + agentId: agent.id, + buyer: address, + task: task.trim(), + budgetWei: amountWei, + txHashes: [], + createdAt: new Date().toISOString(), + }; + record = persist({ ...record, task: task.trim(), budgetWei: amountWei }); + try { const intent = validateHireReady({ - intent: { agentId: agent.id, budgetWei, task: task.trim() }, - chain, - connected: local || isConnected, + intent: { agentId: agent.id, budgetWei: amountWei, task: task.trim() }, + chain: HIRE_CHAIN, + connected: true, chainId, }); - if (local) { - setPhase("confirming"); - const { job: created } = await createMockJobViaApi(intent); - storeJobId(agent.id, created.jobId); - setJob(created); - setPhase("funded"); - setConfirmOpen(false); - rememberHire(created); - toast.push("Hire successful.", "ok"); - return; + + await assertAffordable(clients, BigInt(amountWei), { chainId: HIRE_CHAIN_ID }); + + let jobId = record.jobId ?? resumeJobId ?? undefined; + if (jobId) { + const existing = await getJobStatus(jobId, publicClient, { + expectedChainId: HIRE_CHAIN_ID, + }); + if ( + existing.status === "Funded" || + existing.status === "Submitted" || + existing.status === "Completed" + ) { + const hashes = record.txHashes.filter((hash) => /^0x[a-fA-F0-9]{64}$/.test(hash)); + finishHire( + { + jobId, + agentId: agent.id, + budgetWei: amountWei, + task: task.trim(), + status: existing.status, + txHashes: hashes, + session: null, + createdAt: record.createdAt, + }, + record, + ); + return; + } + hireLog("resume job", { jobId, status: existing.status }); + } else { + setPhase("creating"); + const created = await createRealJob(intent, clients, options); + jobId = created.jobId; + record = persist(record, { jobId, hash: created.txHash }); + setLastTx(created.txHash); + hireLog("transaction submitted", { step: "createJob", txHash: created.txHash, jobId }); } - if (!walletClient || !publicClient) { - throw new Error("Connect your wallet to hire."); + + setPhase("budgeting"); + const budget = await setJobBudget(jobId, amountWei, clients, options); + record = persist(record, { jobId, hash: budget.txHash }); + hireLog("transaction submitted", { step: "setBudget", txHash: budget.txHash }); + + setPhase("registering"); + const registered = await registerJobPolicy(jobId, clients, options); + record = persist(record, { jobId, hash: registered.txHash }); + hireLog("transaction submitted", { step: "registerJob", txHash: registered.txHash }); + + const approval = await ensureAllowance(amountWei, clients, options); + if (approval.txHash) { + record = persist(record, { jobId, hash: approval.txHash }); + hireLog("transaction submitted", { step: "approve", txHash: approval.txHash }); } - const created = await createCommerceJob(intent, { - chain, - clients: { walletClient, publicClient }, - agentAddress: agent.commerce.erc8183Provider as Address, - contractAddress: contractAddress(), - amountInWei: intent.budgetWei, - onPhase: (next) => { - setPhase(next); - if (next === "confirming") toast.push("Transaction pending.", "muted"); - }, - onCreated: (jobId) => storeJobId(agent.id, jobId), + + setPhase("funding"); + const funded = await fundRealJob(jobId, amountWei, clients, options); + record = persist(record, { jobId, hash: funded.txHash }); + setLastTx(funded.txHash); + hireLog("txHash", { txHash: funded.txHash }); + hireLog("waiting confirmation"); + + const onChain = await waitForFundedStatus(jobId, publicClient, options); + const view = toFundedJobView(intent, { + jobId, + txHash: funded.txHash, + createTxHash: record.txHashes[0] as Hex | undefined, + budgetTxHash: record.txHashes[1] as Hex | undefined, + registerTxHash: record.txHashes[2] as Hex | undefined, + approveTxHash: approval.txHash, }); - storeJobId(agent.id, created.jobId); - setJob(created); - setPhase("funded"); - setConfirmOpen(false); - rememberHire(created); - toast.push("Hire successful.", "ok"); + hireLog("transaction confirmed", { status: onChain.status, jobId }); + finishHire({ ...view, status: onChain.status, txHashes: record.txHashes }, record); } catch (err) { - setError(mapHireError(err).message); - setPhase("failed"); + const mapped = mapHireError(err); + hireLog("hire status", { error: mapped.message, jobId: record.jobId }); + if (record.jobId) setResumeJobId(record.jobId); setConfirmOpen(false); + processingRef.current = false; + if (isCancelled(err)) { + setPhase("rejected"); + setGate("rejected"); + toast.push("Transaction cancelled.", "muted"); + return; + } + setError(mapped.message); + setPhase("failed"); setGate("error"); pushNotice({ kind: "hire_failed", title: "Hire failed", - description: mapHireError(err).message, + description: mapped.message, href: `/agents/${agent.id}`, }); - toast.push("Hire failed.", "danger"); - } finally { - processingRef.current = false; + toast.push(mapped.message, "danger"); } } - async function onRevoke() { - if (!job || processingRef.current) return; - processingRef.current = true; - try { - const { job: next } = await revokeJobSession(job.jobId); - setJob(next); - setRevokeOpen(false); - recordHire({ - jobId: next.jobId, - agentId: agent.id, - agentName: agent.name, - category: agent.category, - status: next.session?.revoked ? "Expired" : next.status, - budgetWei: next.budgetWei, - createdAt: next.createdAt, - txHash: next.txHashes[0], - }); - pushActivity({ - type: "revoke", - title: `Stopped access for ${agent.name}`, - description: next.jobId, - status: "Revoked", - }); - toast.push("Access stopped.", "ok"); - } catch (err) { - setError(mapHireError(err).message); - toast.push("Could not stop access.", "danger"); - } finally { - processingRef.current = false; - } - } - - function onRetry() { - setError(null); - setPhase("idle"); - } - - function rememberHire(created: JobView) { + function finishHire(created: JobView, record: StoredHireTx) { + const hashes = created.txHashes.filter((hash) => /^0x[a-fA-F0-9]{64}$/.test(hash)); + const view = { ...created, txHashes: hashes }; + setJob(view); + setPhase("funded"); + setConfirmOpen(false); + setSuccessOpen(true); + setResumeJobId(null); + processingRef.current = false; + persist(record, { jobId: created.jobId }); recordHire({ jobId: created.jobId, agentId: agent.id, @@ -314,7 +441,7 @@ export function HirePanel({ agent }: { agent: AgentListing }) { status: created.status, budgetWei: created.budgetWei, createdAt: created.createdAt, - txHash: created.txHashes[0], + txHash: hashes.at(-1), }); pushNotice({ kind: "hire_success", @@ -325,68 +452,91 @@ export function HirePanel({ agent }: { agent: AgentListing }) { pushActivity({ type: "hire", title: `Hired ${agent.name}`, - description: created.jobId, + description: hashes.at(-1) ?? created.jobId, status: created.status, href: `/agents/${agent.id}`, }); + toast.push("Hire successful.", "ok"); + hireLog("hire status", { status: created.status, jobId: created.jobId }); + } + + function onRetry() { + if (processingRef.current) return; + setError(null); + setPhase("idle"); + setGate(null); + } + + const block = blockedReason(); + const canPay = !block && Boolean(budgetWei) && Object.keys(validateFieldsPreview()).length === 0; + + function validateFieldsPreview(): { task?: string; budget?: string } { + const next: { task?: string; budget?: string } = {}; + const trimmed = task.trim(); + if (!trimmed || trimmed.length < 8 || trimmed.length > 500) next.task = "invalid"; + if (!budgetWei) next.budget = "invalid"; + return next; } + const explorer = lastTx ? testnetExplorerTx(lastTx) : job?.txHashes.at(-1) + ? testnetExplorerTx(job.txHashes.at(-1) ?? "") + : null; + const progressOpen = confirmOpen === false && inFlight && phase !== "funded" && phase !== "idle"; + return (
    Activate

    Hire {agent.name}

    - - {local - ? "No wallet needed. We’ll mark this hire as complete." - : "Confirm in your wallet. We’ll tell you when it’s done."} - + BNB Smart Chain Testnet · chain ID 97 · ERC-8183 in $U +
    +
    + + + + + +
    - {!local ? ( -
    - - - - - - -
    - ) : null}
    {job && phase === "funded" ? (

    - Hire complete + Hire successful

    - {job.txHashes[0] ? ( -

    Receipt {job.txHashes[0]}

    - ) : null} - {job.session ? ( -
    -

    Access

    -

    Spend cap {weiToU(job.session.spendCapWei) || job.session.spendCapWei} $U

    -

    Expires {formatDateTime(job.session.expiry)}

    -

    - {job.session.revoked ? "Access stopped" : "Access active"} -

    - {!job.session.revoked ? ( - - ) : null} -
    +

    {agent.name} is now active.

    +

    BNB Smart Chain Testnet

    + {job.txHashes.at(-1) ? ( +

    + Transaction {job.txHashes.at(-1)} +

    ) : null} +
    + {explorer ? ( + + + + ) : null} + +
    ) : (
    -
      -
    1. 1. Review
    2. -
    3. 2. Confirm
    4. -
    5. 3. Fund
    6. -
    + {connecting ?

    Connecting wallet...

    : null} + {resumeJobId ? ( +

    + A hire is already open on-chain (job {resumeJobId}). Continue to finish funding. This + will not open a second job. +

    + ) : null} +
    +
    +
    Network
    +
    BNB Smart Chain Testnet
    +
    +
    +
    Chain ID
    +
    97
    +
    +
    +
    Contract
    +
    {contract}
    +
    +
    +
    Provider
    +
    + {provider ? formatAddress(provider) : "Not configured"} +
    +
    +
    {wrongNetwork ? ( -

    Wrong network. Switch before you hire.

    - ) : !canHire ? ( -

    Wallet not connected. You can still review, then connect.

    +
    +

    + Wrong network. Please switch to BNB Smart Chain Testnet. +

    + +
    + ) : !isConnected ? ( +

    Connect your wallet to continue.

    + ) : !provider ? ( +

    + This agent has no real provider address. Hire is disabled. +

    ) : null} - + {phase === "rejected" ? ( +

    Transaction cancelled. You can try again.

    + ) : null} {phase === "failed" && error ? (

    {error}

    - + {resumeJobId ? ( +

    + A job is already on-chain. Try again continues that job and will not create a + second payment from scratch. +

    + ) : null}
    ) : null} )}

    - {busy ? phaseLabel(phase) : ""} + {busy ? phaseCopy(phase) : ""}

    switchChain({ chainId: target.id }) - : undefined - } + onSwitch={switchChain ? () => switchChain({ chainId: target.id }) : undefined} switching={switching} /> setConfirmOpen(false)} onConfirm={() => void executeHire()} agent={agent} task={task.trim()} - budgetWei={budgetWei ?? ""} - local={local} + budgetWei={amountWei} + contractAddress={contract} + provider={provider ?? ""} busy={busy} + canPay={canPay && !wrongNetwork && isConnected} + blockedReason={block} /> - setRevokeOpen(false)} - onConfirm={() => void onRevoke()} - busy={busy} + + setSuccessOpen(false)} + agentName={agent.name} + txHash={job?.txHashes.at(-1)} />
    ); @@ -483,7 +693,7 @@ export function HireStickyBar({

    {agent.name}

    -

    Hire with $U

    +

    Hire on BSC Testnet

    + {notFound ? null : ( + + )}
    } > -

    {friendlyLoadError()}

    +

    {notFound ? "It is not in the catalog. Start from the marketplace." : friendlyLoadError(error)}

    ); } - if (!agent) { + if (loading || !agent) { return ; } @@ -138,19 +100,19 @@ export function AgentDetailPage() {

    {CATEGORY_JOBS[agent.category]}

    - - - - Live signal - - {signalStatus === "ready" && signal ? : null} - - + + + + Live signal + + {signalStatus === "ready" && signal ? : null} + + @@ -158,8 +120,8 @@ export function AgentDetailPage() { Agent details
    - - + + diff --git a/apps/web/src/routes/browse/$category.tsx b/apps/web/src/routes/browse/$category.tsx index bb82b53..e5ecb7e 100644 --- a/apps/web/src/routes/browse/$category.tsx +++ b/apps/web/src/routes/browse/$category.tsx @@ -43,6 +43,7 @@ function BrowseCategory({ category }: { category: Category }) { status={catalog.status} onRetry={catalog.retry} initialSearch={q ?? ""} + error={catalog.error} /> {catalog.status === "ready" && catalog.agents.length === 0 ? (
    diff --git a/apps/web/src/routes/index.tsx b/apps/web/src/routes/index.tsx index ce4eacf..486bcae 100644 --- a/apps/web/src/routes/index.tsx +++ b/apps/web/src/routes/index.tsx @@ -73,6 +73,7 @@ export function HomePage() { status={catalog.status} onRetry={catalog.retry} initialSearch={q ?? ""} + error={catalog.error} />
    diff --git a/apps/web/src/vite-env.d.ts b/apps/web/src/vite-env.d.ts index 3d6be34..cc70210 100644 --- a/apps/web/src/vite-env.d.ts +++ b/apps/web/src/vite-env.d.ts @@ -1,10 +1,11 @@ /// interface ImportMetaEnv { - readonly VITE_API_URL: string; - readonly VITE_CHAIN: string; - readonly VITE_WC_PROJECT_ID: string; + readonly VITE_API_URL?: string; + readonly VITE_CHAIN?: string; + readonly VITE_WC_PROJECT_ID?: string; readonly VITE_CONTRACT_ADDRESS?: string; + readonly VITE_PAYMENT_TOKEN?: string; readonly VITE_RPC_URL?: string; } diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 6b435c2..b34fa98 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -1,16 +1,36 @@ -import { defineConfig } from "vite"; +import { defineConfig, loadEnv } from "vite"; import react from "@vitejs/plugin-react"; import tailwindcss from "@tailwindcss/vite"; import path from "node:path"; -export default defineConfig({ - plugins: [react(), tailwindcss()], - resolve: { - alias: { - "@": path.resolve(__dirname, "src"), +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, process.cwd(), ""); + + return { + plugins: [react(), tailwindcss()], + resolve: { + alias: { + "@": path.resolve(__dirname, "src"), + }, + }, + // Commerce reads these via process.env in the browser. Vite only exposes + // import.meta.env unless we define the process.env keys here. + define: { + "process.env.VITE_CHAIN": JSON.stringify(env.VITE_CHAIN ?? ""), + "process.env.VITE_CONTRACT_ADDRESS": JSON.stringify(env.VITE_CONTRACT_ADDRESS ?? ""), + "process.env.VITE_PAYMENT_TOKEN": JSON.stringify(env.VITE_PAYMENT_TOKEN ?? ""), + }, + server: { + port: 5173, + host: true, + allowedHosts: true, + proxy: { + "/api": { + target: "http://127.0.0.1:3001", + changeOrigin: true, + rewrite: (url) => url.replace(/^\/api/, ""), + }, + }, }, - }, - server: { - port: 5173, - }, + }; }); diff --git a/packages/indexer/fixtures/featured.json b/packages/indexer/fixtures/featured.json index 4ddb8d3..d7e1d49 100644 --- a/packages/indexer/fixtures/featured.json +++ b/packages/indexer/fixtures/featured.json @@ -7,13 +7,13 @@ "category": "rebalancing", "name": "Range Keeper", "description": "Keeps Pancake V3 BNB/USDT liquidity in range and resets when price walks the band.", - "owner": "0x1111111111111111111111111111111111111111", + "owner": "0x638592bfdd792AE59A4B5892e971e1A79F8F517d", "endpoints": { "a2a": "https://agents.local/range-keeper/a2a", "mcp": "https://agents.local/range-keeper/mcp" }, "commerce": { - "erc8183Provider": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "erc8183Provider": "0x638592bfdd792AE59A4B5892e971e1A79F8F517d", "x402": false }, "featured": true, @@ -34,13 +34,13 @@ "category": "rebalancing", "name": "LP Sentinel", "description": "Widens and tightens Cake/BNB ranges from fee/volume, never from a vibe prompt.", - "owner": "0x1111111111111111111111111111111111111112", + "owner": "0x638592bfdd792AE59A4B5892e971e1A79F8F517d", "endpoints": { "a2a": "https://agents.local/lp-sentinel/a2a", "mcp": null }, "commerce": { - "erc8183Provider": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaab", + "erc8183Provider": "0x638592bfdd792AE59A4B5892e971e1A79F8F517d", "x402": false }, "featured": true, @@ -61,13 +61,13 @@ "category": "grid_trading", "name": "Grid Runner", "description": "Places a 12-level grid on BNB/USDT and reports fill rate plus realized PnL for the window.", - "owner": "0x2222222222222222222222222222222222222221", + "owner": "0x638592bfdd792AE59A4B5892e971e1A79F8F517d", "endpoints": { "a2a": "https://agents.local/grid-runner/a2a", "mcp": "https://agents.local/grid-runner/mcp" }, "commerce": { - "erc8183Provider": "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "erc8183Provider": "0x638592bfdd792AE59A4B5892e971e1A79F8F517d", "x402": false }, "featured": true, @@ -89,13 +89,13 @@ "category": "grid_trading", "name": "Band Bot", "description": "Tighter grid on Cake/USDT with a hard loss cap encoded in the session spend limit.", - "owner": "0x2222222222222222222222222222222222222222", + "owner": "0x638592bfdd792AE59A4B5892e971e1A79F8F517d", "endpoints": { "a2a": null, "mcp": "https://agents.local/band-bot/mcp" }, "commerce": { - "erc8183Provider": "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbc", + "erc8183Provider": "0x638592bfdd792AE59A4B5892e971e1A79F8F517d", "x402": false }, "featured": true, @@ -117,13 +117,13 @@ "category": "yield", "name": "Yield Router", "description": "Routes idle $U across Lista, Venus, and Cake staking. Publishes venue, net APR, last hop.", - "owner": "0x3333333333333333333333333333333333333331", + "owner": "0x638592bfdd792AE59A4B5892e971e1A79F8F517d", "endpoints": { "a2a": "https://agents.local/yield-router/a2a", "mcp": "https://agents.local/yield-router/mcp" }, "commerce": { - "erc8183Provider": "0xcccccccccccccccccccccccccccccccccccccccc", + "erc8183Provider": "0x638592bfdd792AE59A4B5892e971e1A79F8F517d", "x402": false }, "featured": true, @@ -144,13 +144,13 @@ "category": "yield", "name": "APR Hopper", "description": "Compares Venus supply APR vs Pancake stable farms and moves only inside a spend cap.", - "owner": "0x3333333333333333333333333333333333333332", + "owner": "0x638592bfdd792AE59A4B5892e971e1A79F8F517d", "endpoints": { "a2a": "https://agents.local/apr-hopper/a2a", "mcp": null }, "commerce": { - "erc8183Provider": "0xcccccccccccccccccccccccccccccccccccccccd", + "erc8183Provider": "0x638592bfdd792AE59A4B5892e971e1A79F8F517d", "x402": true }, "featured": true, @@ -171,13 +171,13 @@ "category": "health_factor", "name": "HF Watch", "description": "Tracks Venus BNB collateral health factor and recommends repay before the cliff.", - "owner": "0x4444444444444444444444444444444444444441", + "owner": "0x638592bfdd792AE59A4B5892e971e1A79F8F517d", "endpoints": { "a2a": "https://agents.local/hf-watch/a2a", "mcp": "https://agents.local/hf-watch/mcp" }, "commerce": { - "erc8183Provider": "0xdddddddddddddddddddddddddddddddddddddddd", + "erc8183Provider": "0x638592bfdd792AE59A4B5892e971e1A79F8F517d", "x402": false }, "featured": true, @@ -198,25 +198,25 @@ "category": "health_factor", "name": "Liq Guard", "description": "Aave-style HF monitor with a repay skill scoped to a session allowlist.", - "owner": "0x4444444444444444444444444444444444444442", + "owner": "0x638592bfdd792AE59A4B5892e971e1A79F8F517d", "endpoints": { - "a2a": "https://agents.local/liq-guard/a2a", - "mcp": null - }, - "commerce": { - "erc8183Provider": "0xddddddddddddddddddddddddddddddddddddddde", - "x402": false - }, - "featured": true, - "live": true, - "createdAt": "2026-08-24T10:00:00.000Z", - "signal": { - "category": "health_factor", - "healthFactor": 1.92, - "liquidationPrice": 288.0, - "protocol": "Lista / Venus", - "lastActionAt": "2026-09-01T08:00:00.000Z" - } - } - ] -} + "a2a": "https://agents.local/liq-guard/a2a", + "mcp": null + }, + "commerce": { + "erc8183Provider": "0x638592bfdd792AE59A4B5892e971e1A79F8F517d", + "x402": false + }, + "featured": true, + "live": true, + "createdAt": "2026-08-24T10:00:00.000Z", + "signal": { + "category": "health_factor", + "healthFactor": 1.92, + "liquidationPrice": 288.0, + "protocol": "Lista / Venus", + "lastActionAt": "2026-09-01T08:00:00.000Z" + } + } + ] +} \ No newline at end of file From c3d4f525f7f68b7e335db5700aed9b61514d742a Mon Sep 17 00:00:00 2001 From: Pericena Date: Wed, 9 Sep 2026 13:19:47 -0400 Subject: [PATCH 2/2] Update hire flow and agent details --- apps/web/src/features/hire/hire-confirm.tsx | 14 +++- apps/web/src/features/hire/hire-cta.tsx | 92 ++++++++++++++++----- apps/web/src/features/hire/hire-storage.ts | 1 + apps/web/src/routes/agents/$agentId.tsx | 26 +++++- 4 files changed, 108 insertions(+), 25 deletions(-) diff --git a/apps/web/src/features/hire/hire-confirm.tsx b/apps/web/src/features/hire/hire-confirm.tsx index 61e1506..614476b 100644 --- a/apps/web/src/features/hire/hire-confirm.tsx +++ b/apps/web/src/features/hire/hire-confirm.tsx @@ -1,3 +1,4 @@ +import { useEffect, useRef } from "react"; import { CATEGORY_LABELS, type AgentListing } from "@era/domain"; import { Button, Dialog } from "@/components/ui"; import { formatAddress, weiToU } from "@/lib/format"; @@ -35,6 +36,17 @@ export function HireConfirmDialog({ canPay: boolean; blockedReason?: string; }) { + const payLock = useRef(false); + useEffect(() => { + if (open && !busy) payLock.current = false; + }, [open, busy]); + + function confirm() { + if (payLock.current || busy || !canPay) return; + payLock.current = true; + onConfirm(); + } + return ( Cancel -
    diff --git a/apps/web/src/features/hire/hire-cta.tsx b/apps/web/src/features/hire/hire-cta.tsx index 75f1076..d926254 100644 --- a/apps/web/src/features/hire/hire-cta.tsx +++ b/apps/web/src/features/hire/hire-cta.tsx @@ -102,6 +102,24 @@ function isCancelled(err: unknown): boolean { return message.includes("cancelled") || message.includes("canceled"); } +function explainHireError(err: unknown): string { + if (isCancelled(err)) { + return "Transaction cancelled. You cancelled the transaction in your wallet."; + } + const message = mapHireError(err).message; + const lower = message.toLowerCase(); + if (lower.includes("tbnb") || lower.includes("for gas")) { + return "Insufficient tBNB. You need enough test BNB to pay network fees."; + } + if (lower.includes("switch to") || lower.includes("wrong network") || lower.includes("bsc testnet")) { + return "Wrong network. Switch to BNB Smart Chain Testnet."; + } + if (lower.includes("reverted") || lower.includes("couldn’t complete") || lower.includes("couldn't complete")) { + return "Transaction failed. The blockchain rejected the transaction."; + } + return message; +} + export function HireCTA({ agent, className, @@ -161,7 +179,6 @@ export function HirePanel({ agent }: { agent: AgentListing }) { phase === "approving" || phase === "funding" || phase === "confirming"; - const inFlight = busy || phase === "creating"; const wrongNetwork = isConnected && chainId !== HIRE_CHAIN_ID; const connecting = walletStatus === "connecting" || walletStatus === "reconnecting"; @@ -171,13 +188,29 @@ export function HirePanel({ agent }: { agent: AgentListing }) { }, [gate, isConnected, wrongNetwork]); useEffect(() => { - if (!address || !publicClient) return; + setJob(null); + setResumeJobId(null); + setSuccessOpen(false); + setError(null); + if (!address || !publicClient) { + setPhase("idle"); + return; + } const stored = readHireRecord(agent.id, address); - if (!stored?.jobId) return; + if (!stored?.jobId) { + setPhase("idle"); + return; + } let cancelled = false; hireLog("restore", { agentId: agent.id, wallet: address, jobId: stored.jobId }); setPhase("confirming"); setLastTx(stored.lastTxHash); + const failSafe = window.setTimeout(() => { + if (cancelled) return; + setResumeJobId(stored.jobId ?? null); + setPhase("idle"); + setError("Checking the hire timed out. You can continue the open job without paying again."); + }, 20_000); getJobStatus(stored.jobId, publicClient, { expectedChainId: HIRE_CHAIN_ID }) .then((onChain) => { if (cancelled) return; @@ -187,6 +220,7 @@ export function HirePanel({ agent }: { agent: AgentListing }) { onChain.status === "Completed" ) { const hashes = stored.txHashes.filter((hash) => /^0x[a-fA-F0-9]{64}$/.test(hash)); + writeHireRecord({ ...stored, status: onChain.status }); setJob({ jobId: stored.jobId!, agentId: agent.id, @@ -209,16 +243,20 @@ export function HirePanel({ agent }: { agent: AgentListing }) { } setResumeJobId(stored.jobId!); setTask(stored.task); - setBudgetU(weiToU(stored.budgetWei) || budgetU); + setBudgetU(weiToU(stored.budgetWei) || "0.001"); setPhase("idle"); }) .catch(() => { if (cancelled) return; setResumeJobId(stored.jobId ?? null); setPhase("idle"); + }) + .finally(() => { + window.clearTimeout(failSafe); }); return () => { cancelled = true; + window.clearTimeout(failSafe); }; }, [address, agent.id, publicClient]); @@ -329,6 +367,7 @@ export function HirePanel({ agent }: { agent: AgentListing }) { await assertAffordable(clients, BigInt(amountWei), { chainId: HIRE_CHAIN_ID }); let jobId = record.jobId ?? resumeJobId ?? undefined; + let alreadyBudgeted = false; if (jobId) { const existing = await getJobStatus(jobId, publicClient, { expectedChainId: HIRE_CHAIN_ID, @@ -354,20 +393,25 @@ export function HirePanel({ agent }: { agent: AgentListing }) { ); return; } - hireLog("resume job", { jobId, status: existing.status }); + alreadyBudgeted = BigInt(existing.amount) > 0n; + hireLog("resume job", { jobId, status: existing.status, alreadyBudgeted }); } else { setPhase("creating"); const created = await createRealJob(intent, clients, options); jobId = created.jobId; - record = persist(record, { jobId, hash: created.txHash }); + record = persist(record, { jobId, hash: created.txHash, status: "Open" }); setLastTx(created.txHash); hireLog("transaction submitted", { step: "createJob", txHash: created.txHash, jobId }); } - setPhase("budgeting"); - const budget = await setJobBudget(jobId, amountWei, clients, options); - record = persist(record, { jobId, hash: budget.txHash }); - hireLog("transaction submitted", { step: "setBudget", txHash: budget.txHash }); + if (!alreadyBudgeted) { + setPhase("budgeting"); + const budget = await setJobBudget(jobId, amountWei, clients, options); + record = persist(record, { jobId, hash: budget.txHash }); + hireLog("transaction submitted", { step: "setBudget", txHash: budget.txHash }); + } else { + hireLog("skip setBudget", { jobId }); + } setPhase("registering"); const registered = await registerJobPolicy(jobId, clients, options); @@ -399,8 +443,8 @@ export function HirePanel({ agent }: { agent: AgentListing }) { hireLog("transaction confirmed", { status: onChain.status, jobId }); finishHire({ ...view, status: onChain.status, txHashes: record.txHashes }, record); } catch (err) { - const mapped = mapHireError(err); - hireLog("hire status", { error: mapped.message, jobId: record.jobId }); + const mapped = explainHireError(err); + hireLog("hire status", { error: mapped, jobId: record.jobId }); if (record.jobId) setResumeJobId(record.jobId); setConfirmOpen(false); processingRef.current = false; @@ -410,16 +454,16 @@ export function HirePanel({ agent }: { agent: AgentListing }) { toast.push("Transaction cancelled.", "muted"); return; } - setError(mapped.message); + setError(mapped); setPhase("failed"); setGate("error"); pushNotice({ kind: "hire_failed", title: "Hire failed", - description: mapped.message, + description: mapped, href: `/agents/${agent.id}`, }); - toast.push(mapped.message, "danger"); + toast.push(mapped, "danger"); } } @@ -432,7 +476,7 @@ export function HirePanel({ agent }: { agent: AgentListing }) { setSuccessOpen(true); setResumeJobId(null); processingRef.current = false; - persist(record, { jobId: created.jobId }); + persist(record, { jobId: created.jobId, status: created.status }); recordHire({ jobId: created.jobId, agentId: agent.id, @@ -481,7 +525,7 @@ export function HirePanel({ agent }: { agent: AgentListing }) { const explorer = lastTx ? testnetExplorerTx(lastTx) : job?.txHashes.at(-1) ? testnetExplorerTx(job.txHashes.at(-1) ?? "") : null; - const progressOpen = confirmOpen === false && inFlight && phase !== "funded" && phase !== "idle"; + const progressOpen = confirmOpen === false && busy && phase !== "funded" && phase !== "idle"; return ( @@ -651,7 +695,7 @@ export function HirePanel({ agent }: { agent: AgentListing }) { switching={switching} /> setConfirmOpen(false)} onConfirm={() => void executeHire()} agent={agent} @@ -664,7 +708,7 @@ export function HirePanel({ agent }: { agent: AgentListing }) { blockedReason={block} />

    {agent.name}

    -

    Hire on BSC Testnet

    +

    {hired ? "Active on BSC Testnet" : "Hire on BSC Testnet"}

    diff --git a/apps/web/src/features/hire/hire-storage.ts b/apps/web/src/features/hire/hire-storage.ts index 11332f0..8a57d33 100644 --- a/apps/web/src/features/hire/hire-storage.ts +++ b/apps/web/src/features/hire/hire-storage.ts @@ -9,6 +9,7 @@ export type StoredHireTx = { txHashes: string[]; lastTxHash?: string; createdAt: string; + status?: string; }; function key(agentId: string, buyer: string): string { diff --git a/apps/web/src/routes/agents/$agentId.tsx b/apps/web/src/routes/agents/$agentId.tsx index 4a8619c..3590637 100644 --- a/apps/web/src/routes/agents/$agentId.tsx +++ b/apps/web/src/routes/agents/$agentId.tsx @@ -10,6 +10,7 @@ import { useAgent } from "@/features/catalog/use-agent"; import { CategorySignal } from "@/features/signals/category-signal"; import { SignalChart } from "@/features/signals/signal-chart"; import { HirePanel, HireStickyBar } from "@/features/hire/hire-cta"; +import { isLocalAgentEndpoint, isPlaceholderAddress } from "@/features/hire/hire-provider"; import { chainLabel, formatAddress, formatDate, friendlyLoadError } from "@/lib/format"; const agentRoute = getRouteApi("/agents/$agentId"); @@ -120,12 +121,16 @@ export function AgentDetailPage() { Agent details
    - - + + - +
    @@ -138,6 +143,21 @@ export function AgentDetailPage() { ); } +function endpointLabel(url: string | null | undefined): string { + if (!url) return "Not configured"; + if (isLocalAgentEndpoint(url)) return "Unavailable"; + return url; +} + +function isPublicEndpoint(url: string | null | undefined): boolean { + return Boolean(url) && !isLocalAgentEndpoint(url); +} + +function providerLabel(value: string | null | undefined): string { + if (!value || isPlaceholderAddress(value)) return "Not configured"; + return formatAddress(value); +} + function Fact({ label, value,