@@ -97,7 +231,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 +268,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 +290,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..d926254 100644
--- a/apps/web/src/features/hire/hire-cta.tsx
+++ b/apps/web/src/features/hire/hire-cta.tsx
@@ -3,76 +3,121 @@ 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";
+type UiPhase =
+ | "idle"
+ | "preparing"
+ | HirePhase
+ | "funded"
+ | "failed"
+ | "rejected";
-function readWebEnv(name: string): string | undefined {
- return (import.meta.env as Record
)[name];
+function commerceContract(): Address {
+ try {
+ return getContractAddress(undefined, HIRE_CHAIN_ID);
+ } catch {
+ return COMMERCE_CONTRACTS[HIRE_CHAIN_ID];
+ }
}
-function hireChain(): string {
- return readWebEnv("VITE_CHAIN") ?? "local";
+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 contractAddress(): Address | undefined {
- const raw = readWebEnv("VITE_CONTRACT_ADDRESS");
- return raw && /^0x[a-fA-F0-9]{40}$/.test(raw) ? (raw as Address) : undefined;
+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",
+ };
}
-function storageKey(agentId: string): string {
- return `pulse:hire:${agentId}`;
+function isCancelled(err: unknown): boolean {
+ const message = mapHireError(err).message.toLowerCase();
+ return message.includes("cancelled") || message.includes("canceled");
}
-function readStoredJobId(agentId: string): string | null {
- try {
- const raw = sessionStorage.getItem(storageKey(agentId));
- if (!raw) return null;
- const parsed = JSON.parse(raw) as { jobId?: string };
- return parsed.jobId ?? null;
- } catch {
- return null;
+function explainHireError(err: unknown): string {
+ if (isCancelled(err)) {
+ return "Transaction cancelled. You cancelled the transaction in your wallet.";
}
-}
-
-function storeJobId(agentId: string, jobId: string): void {
- sessionStorage.setItem(storageKey(agentId), JSON.stringify({ jobId, agentId }));
-}
-
-/**
- * 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";
+ 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({
@@ -93,43 +138,49 @@ 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 wrongNetwork = isConnected && chainId !== HIRE_CHAIN_ID;
+ const connecting = walletStatus === "connecting" || walletStatus === "reconnecting";
useEffect(() => {
if (gate === "wallet" && isConnected) setGate(null);
@@ -137,41 +188,77 @@ 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;
+ setJob(null);
+ setResumeJobId(null);
+ setSuccessOpen(false);
+ setError(null);
+ if (!address || !publicClient) {
+ setPhase("idle");
+ return;
+ }
+ const stored = readHireRecord(agent.id, address);
+ if (!stored?.jobId) {
+ setPhase("idle");
+ 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);
+ 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;
- 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));
+ writeHireRecord({ ...stored, status: onChain.status });
+ 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) || "0.001");
+ setPhase("idle");
})
- .catch((err: unknown) => {
+ .catch(() => {
if (cancelled) return;
- setError(mapHireError(err).message);
- setPhase("failed");
+ setResumeJobId(stored.jobId ?? null);
+ setPhase("idle");
+ })
+ .finally(() => {
+ window.clearTimeout(failSafe);
});
return () => {
cancelled = true;
+ window.clearTimeout(failSafe);
};
- }, [agent.id, chain, local, publicClient]);
+ }, [address, agent.id, publicClient]);
function validateFields(): { task?: string; budget?: string } {
const next: { task?: string; budget?: string } = {};
@@ -185,10 +272,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 +302,181 @@ 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;
+ let alreadyBudgeted = false;
+ 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;
+ }
+ 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, status: "Open" });
+ setLastTx(created.txHash);
+ hireLog("transaction submitted", { step: "createJob", txHash: created.txHash, jobId });
}
- if (!walletClient || !publicClient) {
- throw new Error("Connect your wallet to hire.");
+
+ 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 });
}
- 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("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 });
+ }
+
+ 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 = explainHireError(err);
+ hireLog("hire status", { error: mapped, 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);
+ setPhase("failed");
setGate("error");
pushNotice({
kind: "hire_failed",
title: "Hire failed",
- description: mapHireError(err).message,
+ description: mapped,
href: `/agents/${agent.id}`,
});
- toast.push("Hire failed.", "danger");
- } finally {
- processingRef.current = false;
- }
- }
-
- 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;
+ toast.push(mapped, "danger");
}
}
- 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, status: created.status });
recordHire({
jobId: created.jobId,
agentId: agent.id,
@@ -314,7 +485,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 +496,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 && busy && 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 ? (
-
setRevokeOpen(true)}>
- Stop access
-
- ) : null}
-
+
{agent.name} is now active.
+
BNB Smart Chain Testnet
+ {job.txHashes.at(-1) ? (
+
+ Transaction {job.txHashes.at(-1)}
+
) : null}
+
+ {explorer ? (
+
+ View transaction
+
+ ) : null}
+
document.getElementById("main")?.scrollTo({ top: 0 })}
+ >
+ View agent
+
+
) : (
)}
- {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)}
/>
);
@@ -477,20 +731,26 @@ export function HireStickyBar({
agent: AgentListing;
visible: boolean;
}) {
+ const { address } = useAccount();
+ const stored = address ? readHireRecord(agent.id, address) : null;
+ const hired =
+ stored?.status === "Funded" ||
+ stored?.status === "Submitted" ||
+ stored?.status === "Completed";
if (!visible) return null;
return (
{agent.name}
-
Hire with $U
+
{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-log.ts b/apps/web/src/features/hire/hire-log.ts
new file mode 100644
index 0000000..5b1a5bd
--- /dev/null
+++ b/apps/web/src/features/hire/hire-log.ts
@@ -0,0 +1,16 @@
+export function hireLog(event: string, data?: Record): void {
+ if (!import.meta.env.DEV) return;
+ if (data) {
+ console.info(`[HIRE] ${event}`, data);
+ return;
+ }
+ console.info(`[HIRE] ${event}`);
+}
+
+export function testnetExplorerTx(hash: string): string | null {
+ if (!/^0x[a-fA-F0-9]{64}$/.test(hash)) return null;
+ return `https://testnet.bscscan.com/tx/${hash}`;
+}
+
+export const HIRE_CHAIN = "bsc-testnet" as const;
+export const HIRE_CHAIN_ID = 97;
diff --git a/apps/web/src/features/hire/hire-provider.ts b/apps/web/src/features/hire/hire-provider.ts
new file mode 100644
index 0000000..3264c14
--- /dev/null
+++ b/apps/web/src/features/hire/hire-provider.ts
@@ -0,0 +1,35 @@
+import type { Address } from "viem";
+
+const PLACEHOLDER = /^(0x0{40}|0x([0-9a-f])\2{39}|0x(aa|bb|cc|dd|11|22|33|44|55|66|77|88|99|00)+)$/i;
+
+export function isHexAddress(value: unknown): value is Address {
+ return typeof value === "string" && /^0x[a-fA-F0-9]{40}$/.test(value);
+}
+
+export function isPlaceholderAddress(value: string): boolean {
+ return PLACEHOLDER.test(value);
+}
+
+/** Provider for ERC-8183 `createJob`. Never invents an address. */
+export function resolveListingProvider(agent: {
+ owner?: unknown;
+ commerce?: { erc8183Provider?: unknown };
+}): Address | null {
+ const candidates = [agent.commerce?.erc8183Provider, agent.owner];
+ for (const candidate of candidates) {
+ if (!isHexAddress(candidate)) continue;
+ if (isPlaceholderAddress(candidate)) continue;
+ return candidate;
+ }
+ return null;
+}
+
+export function isLocalAgentEndpoint(url: string | null | undefined): boolean {
+ if (!url) return true;
+ try {
+ const host = new URL(url).hostname;
+ return host === "localhost" || host === "127.0.0.1" || host.endsWith(".local");
+ } catch {
+ return true;
+ }
+}
diff --git a/apps/web/src/features/hire/hire-storage.ts b/apps/web/src/features/hire/hire-storage.ts
new file mode 100644
index 0000000..8a57d33
--- /dev/null
+++ b/apps/web/src/features/hire/hire-storage.ts
@@ -0,0 +1,56 @@
+const PREFIX = "pulse:hire:v2:";
+
+export type StoredHireTx = {
+ agentId: string;
+ buyer: string;
+ jobId?: string;
+ task: string;
+ budgetWei: string;
+ txHashes: string[];
+ lastTxHash?: string;
+ createdAt: string;
+ status?: string;
+};
+
+function key(agentId: string, buyer: string): string {
+ return `${PREFIX}${agentId}:${buyer.toLowerCase()}`;
+}
+
+export function readHireRecord(agentId: string, buyer: string | undefined): StoredHireTx | null {
+ if (!buyer) return null;
+ try {
+ const raw = localStorage.getItem(key(agentId, buyer));
+ if (!raw) return null;
+ const parsed = JSON.parse(raw) as StoredHireTx;
+ if (parsed.agentId !== agentId) return null;
+ if (parsed.buyer.toLowerCase() !== buyer.toLowerCase()) return null;
+ return parsed;
+ } catch {
+ return null;
+ }
+}
+
+export function writeHireRecord(record: StoredHireTx): void {
+ try {
+ localStorage.setItem(key(record.agentId, record.buyer), JSON.stringify(record));
+ } catch {
+ /* private mode */
+ }
+}
+
+export function clearHireRecord(agentId: string, buyer: string | undefined): void {
+ if (!buyer) return;
+ try {
+ localStorage.removeItem(key(agentId, buyer));
+ } catch {
+ /* private mode */
+ }
+}
+
+export function appendTxHash(record: StoredHireTx, hash: string | undefined): StoredHireTx {
+ if (!hash || !/^0x[a-fA-F0-9]{64}$/.test(hash)) return record;
+ const txHashes = record.txHashes.includes(hash) ? record.txHashes : [...record.txHashes, hash];
+ const next = { ...record, txHashes, lastTxHash: hash };
+ writeHireRecord(next);
+ return next;
+}
diff --git a/apps/web/src/features/signals/signal-chart.tsx b/apps/web/src/features/signals/signal-chart.tsx
index 76124d6..f5e182e 100644
--- a/apps/web/src/features/signals/signal-chart.tsx
+++ b/apps/web/src/features/signals/signal-chart.tsx
@@ -1,4 +1,3 @@
-import { useId, useMemo, useState, type PointerEvent } from "react";
import { RefreshCw } from "lucide-react";
import type { AgentSignal } from "@era/domain";
import { Button, ChartSkeleton, EmptyState } from "@/components/ui";
@@ -21,7 +20,7 @@ export function SignalChart({
if (!signal) {
return (
@@ -38,16 +37,16 @@ export function SignalChart({
const points = pointsFor(signal);
if (points.length === 0) {
return (
-
+
This signal has no numeric snapshot to plot.
);
}
- return ;
+ return ;
}
-function BinancePanel({
+function SnapshotBars({
points,
refreshing,
onRefresh,
@@ -56,30 +55,12 @@ function BinancePanel({
refreshing: boolean;
onRefresh?: () => void;
}) {
- const gid = useId().replaceAll(":", "");
- const [hover, setHover] = useState(null);
- const primary = points[0];
- const layout = useMemo(() => layoutPoints(points), [points]);
- const active = (hover !== null ? points[hover] : primary) ?? primary;
- if (!primary || !active) return null;
-
- function onMove(event: PointerEvent) {
- const svg = event.currentTarget;
- const box = svg.getBoundingClientRect();
- const x = ((event.clientX - box.left) / box.width) * 360;
- const index = nearestIndex(layout.xs, x);
- setHover(index);
- }
-
return (
Live snapshot
-
- {active.display}
-
-
{active.label}
+
Current values from the catalog. No historical series is stored.
{onRefresh ? (
-
-
setHover(null)}
- >
-
-
-
-
-
-
- {[0, 0.25, 0.5, 0.75, 1].map((t) => {
- const y = 16 + t * 120;
- return (
-
-
-
- {axisLabel(primary.max, 1 - t)}
-
-
- );
- })}
-
-
- {layout.xs.map((x, index) => {
- const point = points[index];
- const y = layout.ys[index];
- if (!point || y === undefined) return null;
- return (
-
- );
- })}
- {hover !== null && layout.xs[hover] !== undefined ? (
-
- ) : null}
- {points.map((point, index) => (
-
- {point.label}
-
- ))}
-
-
-
-