- Wrong network. Switch to {target.name} to hire on this demo.
+ Wrong network. Switch to BNB Smart Chain Testnet.
(null);
- const [signal, setSignal] = useState(null);
- const [error, setError] = useState(null);
- const [attempt, setAttempt] = useState(0);
- const [signalStatus, setSignalStatus] = useState<"loading" | "ready" | "empty">("loading");
- const [signalRefreshing, setSignalRefreshing] = useState(false);
-
- useEffect(() => {
- let cancelled = false;
- setAgent(null);
- setSignal(null);
- setError(null);
- setSignalStatus("loading");
-
- getAgent(agentId)
- .then(async ({ agent: row }) => {
- if (cancelled) return;
- setAgent(row);
- try {
- const { signal: value } = await getSignal(agentId);
- if (cancelled) return;
- setSignal(value);
- setSignalStatus("ready");
- } catch {
- if (cancelled) return;
- setSignal(null);
- setSignalStatus("empty");
- }
- })
- .catch(() => {
- if (!cancelled) setError("load");
- });
- return () => {
- cancelled = true;
- };
- }, [agentId, attempt]);
-
- function reloadSignal() {
- setSignalRefreshing(true);
- getSignal(agentId)
- .then(({ signal: value }) => {
- setSignal(value);
- setSignalStatus("ready");
- })
- .catch(() => {
- setSignal(null);
- setSignalStatus("empty");
- })
- .finally(() => setSignalRefreshing(false));
- }
+ const {
+ agent,
+ signal,
+ loading,
+ notFound,
+ error,
+ signalStatus,
+ signalRefreshing,
+ reload,
+ reloadSignal,
+ } = useAgent(agentId);
useEffect(() => {
if (!agent) return;
@@ -77,23 +37,25 @@ export function AgentDetailPage() {
if (error) {
return (
- setAttempt((n) => n + 1)}>
- Retry
-
+ {notFound ? null : (
+ void reload()}>
+ Retry
+
+ )}
Back to the marketplace
}
>
-
{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
-
+
{busy ? "Processing…" : "Confirm and pay"}
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"}
document.getElementById("hire")?.scrollIntoView({ behavior: "smooth", block: "start" })
}
>
- Hire
+ {hired ? "View agent" : "Hire"}
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