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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,14 @@

# Base URL of the GuildWorkman API. Defaults to the hosted instance if unset.
NEXT_PUBLIC_API_BASE_URL=http://localhost:8080

# Stellar network the wallet-connection layer expects Freighter to be on.
# Valid values: PUBLIC (mainnet), TESTNET, FUTURENET. Defaults to TESTNET if
# unset, matching the Soroban contracts' current deployment target.
# This is a security-relevant setting, not just a label: it's what
# NetworkGuard (src/components/wallet/NetworkGuard.tsx) and the escrow
# funding wizard check the connected wallet against before allowing funds to
# move. Setting it to PUBLIC before the backend/contract integration is
# actually deployed to mainnet would let a wallet connected to real funds
# pass the guard — only change it once that deployment exists.
NEXT_PUBLIC_STELLAR_NETWORK=TESTNET
49 changes: 40 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,14 +199,43 @@ Java backend that doesn't exist today.
Rather than pretend that integration is further along than it is, the
frontend currently does two honest things:

1. **A real wallet connection.** The navbar's "Connect Wallet" button
(`src/components/WalletButton.tsx`, `src/lib/wallet.ts`) uses
1. **A real, resilient wallet connection.** The navbar's "Connect Wallet"
button (`src/components/WalletButton.tsx`, `src/lib/wallet.ts`) uses
`@stellar/freighter-api` to connect an actual Freighter wallet, showing a
truncated address, network (Testnet/Mainnet), and a disconnect option. If
the Freighter extension isn't installed, it shows an "Install Freighter"
hint instead of failing silently. This makes **no contract calls** —
booking, payment, and review logic are all unchanged and still go through
the backend API/Paystack.
truncated address, network (Testnet/Mainnet/Futurenet), and a disconnect
option. If the Freighter extension isn't installed, it shows an "Install
Freighter" hint instead of failing silently. This makes **no contract
calls** — booking, payment, and review logic are all unchanged and still
go through the backend API/Paystack.

On top of the base connection, `useWallet()` (`src/lib/wallet.ts`) adds:
- **Session restore across reloads** — a localStorage flag is only a fast
hint; the actual restore verifies Freighter's own `isAllowed()` grant
before trusting it, so a session revoked inside the extension (or a
browser profile that never had it) self-heals instead of showing a
stale "connected" UI.
- **A live network-switch guard** — Freighter has no API to switch its
own network on a dApp's behalf (a deliberate security boundary), so
`useWallet` runs Freighter's `WatchWalletChanges` poller for the life
of a session and exposes `isWrongNetwork` / `expectedNetwork`.
`NetworkGuard` (`src/components/wallet/NetworkGuard.tsx`, mounted
app-wide in `src/app/layout.tsx`) shows a banner guiding the user to
switch inside Freighter and clears itself automatically once the
watcher detects the change — no reload or manual recheck required,
though a "check again" button short-circuits the wait.
`EscrowFundingWizard`'s connect-wallet step additionally **blocks
progress** past that step while on the wrong network, since funding
escrow there isn't recoverable after the fact.
- The expected network is configurable via `NEXT_PUBLIC_STELLAR_NETWORK`
— one of `PUBLIC` (mainnet) / `TESTNET` / `FUTURENET`, defaults to
`TESTNET` (see `.env.example`). This is security-relevant, not just a
label: it's what the guard checks a connected wallet against before
letting funds move, so it should only be pointed at `PUBLIC` once the
backend/contract integration is actually deployed there.

No new dependencies were added — `WatchWalletChanges` and `isAllowed()`
are both part of the `@stellar/freighter-api` version already in
`package.json`.
2. **An informational trust layer.** The homepage sections (`Hero`,
`HowItWorks`, `StatsBand`) plus "Escrow protected" badges on worker cards
and the booking flow explain in plain language what the contracts *will* do
Expand All @@ -222,13 +251,15 @@ src/
components/
ui/ # design-system primitives (Button, Input, Select, Card, Badge)
brand/ # the marks as components (Logo, NorthStar, AdinkraPattern)
wallet/ # NetworkGuard — app-wide wrong-network banner
*.tsx # page-level and shared client components
lib/
api.ts # typed API client — every backend call goes through here
config.ts # API_BASE_URL resolution
config.ts # API_BASE_URL / EXPECTED_STELLAR_NETWORK resolution
types.ts # shared request/response types
constants.ts # category list and skill-detail seed data
wallet.ts # useWallet() hook wrapping @stellar/freighter-api
wallet.ts # useWallet() hook — connect/session-restore/network-guard
# on top of @stellar/freighter-api
public/
assets/ # images used across the app
brand/ # exported logo marks (SVG masters + 4x PNGs)
Expand Down
12 changes: 8 additions & 4 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import Footer from "@/components/Footer";
import { ThemeProvider } from "@/components/theme";
import { NotificationProvider } from "@/components/notifications/useNotifications";
import NotificationToast from "@/components/notifications/NotificationToast";
import { NetworkGuard, WalletProvider } from "@/components/wallet";
import { themeScript } from "@/lib/theme";

const inter = Inter({
Expand Down Expand Up @@ -41,10 +42,13 @@ export default function RootLayout({
<body className="min-h-full flex flex-col bg-sand text-ink">
<ThemeProvider>
<NotificationProvider>
<Navbar />
<main className="flex-1">{children}</main>
<Footer />
<NotificationToast />
<WalletProvider>
<Navbar />
<NetworkGuard />
<main className="flex-1">{children}</main>
<Footer />
<NotificationToast />
</WalletProvider>
</NotificationProvider>
</ThemeProvider>
</body>
Expand Down
3 changes: 3 additions & 0 deletions src/components/Navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { HiMenu, HiX } from "react-icons/hi";
import Logo from "./brand/Logo";
import ThemeToggle from "./ThemeToggle";
import NotificationCenter from "./notifications/NotificationCenter";
import WalletButton from "./WalletButton";
import { buttonClasses } from "./ui/Button";

const navLinks = [
Expand Down Expand Up @@ -35,6 +36,7 @@ export default function Navbar() {
<div className="ml-auto hidden items-center gap-2 sm:flex">
<ThemeToggle />
<NotificationCenter />
<WalletButton />
<Link href="/login" className={buttonClasses("outline", "sm")}>
Log in
</Link>
Expand Down Expand Up @@ -72,6 +74,7 @@ export default function Navbar() {
))}
</nav>
<div className="mt-3 flex flex-col gap-2">
<WalletButton variant="mobile" />
<Link href="/login" onClick={() => setOpen(false)} className={buttonClasses("outline", "md")}>
Log in
</Link>
Expand Down
43 changes: 30 additions & 13 deletions src/components/WalletButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,25 @@

import { useEffect, useRef, useState } from "react";
import { HiLink, HiChevronDown, HiLogout } from "react-icons/hi";
import { truncateAddress, useWallet } from "@/lib/wallet";

const NETWORK_LABELS: Record<string, string> = {
PUBLIC: "Mainnet",
TESTNET: "Testnet",
FUTURENET: "Futurenet",
};
import { NETWORK_LABELS, truncateAddress } from "@/lib/wallet";
import { useWallet } from "@/components/wallet";

interface WalletButtonProps {
variant?: "navbar" | "mobile";
}

export default function WalletButton({ variant = "navbar" }: WalletButtonProps) {
const { address, network, connecting, error, freighterMissing, connect, disconnect } = useWallet();
const {
address,
network,
connecting,
error,
freighterMissing,
isWrongNetwork,
expectedNetwork,
connect,
disconnect,
} = useWallet();
const [open, setOpen] = useState(false);
const rootRef = useRef<HTMLDivElement>(null);

Expand All @@ -30,24 +35,36 @@ export default function WalletButton({ variant = "navbar" }: WalletButtonProps)
}, []);

const baseButton =
"inline-flex items-center gap-2 rounded-full border border-white/40 px-4 py-1.5 text-sm text-white hover:bg-white/10 transition-colors";
"inline-flex items-center gap-2 rounded-full border border-line px-4 py-1.5 text-sm text-ink hover:border-navy-2 transition-colors";

if (address) {
return (
<div ref={rootRef} className={`relative ${variant === "mobile" ? "w-full" : ""}`}>
<button
onClick={() => setOpen((o) => !o)}
aria-expanded={open}
aria-label={`Wallet connected: ${truncateAddress(address)}${
isWrongNetwork ? " — wrong network, action needed" : ""
}`}
className={`${baseButton} ${variant === "mobile" ? "w-full justify-between" : ""}`}
>
<span className="h-2 w-2 rounded-full bg-ok shrink-0" />
<span
aria-hidden
className={`h-2 w-2 rounded-full shrink-0 ${isWrongNetwork ? "bg-gold-deep" : "bg-ok"}`}
/>
{truncateAddress(address)}
<HiChevronDown className={`transition-transform ${open ? "rotate-180" : ""}`} />
<HiChevronDown aria-hidden className={`transition-transform ${open ? "rotate-180" : ""}`} />
</button>
{open && (
<div className="absolute right-0 mt-2 w-56 rounded-xl bg-surface text-ink shadow-lg border border-line overflow-hidden z-50">
<div className="px-4 py-3 border-b border-line">
<p className="text-xs text-muted">Connected to</p>
<p className="text-sm font-medium">{network ? NETWORK_LABELS[network] ?? network : "Stellar"}</p>
{isWrongNetwork && (
<p className="text-xs font-semibold text-gold-deep mt-1">
Switch to {NETWORK_LABELS[expectedNetwork] ?? expectedNetwork} in Freighter
</p>
)}
<p className="text-xs text-muted mt-1 break-all">{address}</p>
</div>
<button
Expand All @@ -57,7 +74,7 @@ export default function WalletButton({ variant = "navbar" }: WalletButtonProps)
}}
className="w-full flex items-center gap-2 px-4 py-2.5 text-sm text-err hover:bg-err/5"
>
<HiLogout /> Disconnect
<HiLogout aria-hidden /> Disconnect
</button>
</div>
)}
Expand All @@ -72,7 +89,7 @@ export default function WalletButton({ variant = "navbar" }: WalletButtonProps)
disabled={connecting}
className={`${baseButton} ${variant === "mobile" ? "w-full justify-center" : ""} disabled:opacity-60`}
>
<HiLink />
<HiLink aria-hidden />
{connecting ? "Connecting..." : "Connect Wallet"}
</button>
{freighterMissing && (
Expand Down
12 changes: 9 additions & 3 deletions src/components/escrow/EscrowFundingWizard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import { useEffect, useRef, useState } from "react";
import Button from "@/components/ui/Button";
import Card from "@/components/ui/Card";
import { useWallet } from "@/lib/wallet";
import { useWallet } from "@/components/wallet";
import EscrowStepper from "./EscrowStepper";
import ReviewStep from "./steps/ReviewStep";
import ConnectWalletStep from "./steps/ConnectWalletStep";
Expand Down Expand Up @@ -110,11 +110,14 @@ export default function EscrowFundingWizard({ bookingRef, amount, workerName }:
await wallet.connect();
}

// Only advance past this step once the wallet is both connected *and* on
// the expected network — funding on the wrong network isn't recoverable
// after the fact, so the guard has to block progress here, not just warn.
useEffect(() => {
if (wallet.address && state.name === "connectWallet") {
if (wallet.address && !wallet.isWrongNetwork && state.name === "connectWallet") {
dispatch({ type: "WALLET_CONNECTED", address: wallet.address });
}
}, [wallet.address, state.name]);
}, [wallet.address, wallet.isWrongNetwork, state.name]);

async function handleFund() {
dispatch({ type: "FUND_START" });
Expand Down Expand Up @@ -184,6 +187,9 @@ export default function EscrowFundingWizard({ bookingRef, amount, workerName }:
connecting={wallet.connecting}
freighterMissing={wallet.freighterMissing}
error={wallet.error}
isWrongNetwork={wallet.isWrongNetwork}
network={wallet.network}
expectedNetwork={wallet.expectedNetwork}
onConnect={handleConnectWallet}
/>
)}
Expand Down
21 changes: 19 additions & 2 deletions src/components/escrow/steps/ConnectWalletStep.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import { FaWallet } from "react-icons/fa6";
import Button from "@/components/ui/Button";
import { truncateAddress } from "@/lib/wallet";
import { NETWORK_LABELS, truncateAddress, type StellarNetwork } from "@/lib/wallet";

interface ConnectWalletStepProps {
address: string | null;
connecting: boolean;
freighterMissing: boolean;
error: string | null;
isWrongNetwork: boolean;
network: string | null;
expectedNetwork: StellarNetwork;
onConnect: () => void;
}

Expand All @@ -15,16 +18,30 @@ export default function ConnectWalletStep({
connecting,
freighterMissing,
error,
isWrongNetwork,
network,
expectedNetwork,
onConnect,
}: ConnectWalletStepProps) {
const expectedLabel = NETWORK_LABELS[expectedNetwork] ?? expectedNetwork;

return (
<div>
<h2 className="font-heading text-xl font-semibold">Connect your wallet</h2>
<p className="text-muted mt-1 mb-6">
We use your Stellar wallet to fund the escrow contract directly — no card details needed.
</p>

{address ? (
{address && isWrongNetwork ? (
<div className="rounded-xl border border-gold-deep/30 bg-gold/15 p-4">
<p className="font-mono text-sm font-semibold text-ink">{truncateAddress(address)}</p>
<p role="alert" className="mt-2 text-sm text-ink">
<span className="font-semibold">Wrong network</span> — connected to{" "}
{network ? NETWORK_LABELS[network] ?? network : "an unrecognized network"}, but escrow funding requires{" "}
{expectedLabel}. Switch to {expectedLabel} in the Freighter extension to continue.
</p>
</div>
) : address ? (
<div className="flex items-center gap-3 rounded-xl border border-ok/30 bg-ok/10 p-4">
<FaWallet className="text-ok" aria-hidden />
<span className="font-mono text-sm font-semibold text-ink">{truncateAddress(address)}</span>
Expand Down
75 changes: 75 additions & 0 deletions src/components/wallet/NetworkGuard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
"use client";

import { useEffect, useRef, useState } from "react";
import { HiExclamationCircle } from "react-icons/hi";
import Button from "@/components/ui/Button";
import { NETWORK_LABELS, truncateAddress } from "@/lib/wallet";
import { useWallet } from "./WalletProvider";

/**
* App-wide banner that appears whenever a connected wallet is on the wrong
* Stellar network. Freighter has no API to switch its own network on a
* dApp's behalf, so this guides the user through doing it themselves rather
* than pretending to do it for them — the underlying `useWallet` watcher
* clears the banner automatically once it detects the switch (usually
* within a few seconds); "Check again" just short-circuits that wait.
*/
export default function NetworkGuard() {
const { address, network, isWrongNetwork, expectedNetwork, recheckNetwork } = useWallet();
const [checking, setChecking] = useState(false);
const bannerRef = useRef<HTMLDivElement>(null);

// Move focus to the banner the moment it appears — a sighted keyboard
// user or screen-reader user acting elsewhere on the page otherwise has
// no reason to notice a `role="alert"` region that materialized outside
// their current focus.
useEffect(() => {
if (address && isWrongNetwork) {
bannerRef.current?.focus();
}
}, [address, isWrongNetwork]);

if (!address || !isWrongNetwork) return null;

const currentLabel = network ? NETWORK_LABELS[network] ?? network : "an unrecognized network";
const expectedLabel = NETWORK_LABELS[expectedNetwork] ?? expectedNetwork;

async function handleRecheck() {
setChecking(true);
await recheckNetwork();
setChecking(false);
}

return (
<div
ref={bannerRef}
role="alert"
tabIndex={-1}
className="sticky top-16 z-40 border-b border-gold-deep/30 bg-gold/15 outline-none"
>
<div className="mx-auto flex max-w-6xl flex-col gap-3 px-5 py-3 text-sm text-ink sm:flex-row sm:items-center sm:justify-between md:px-10">
<div className="flex items-start gap-2">
<HiExclamationCircle className="mt-0.5 shrink-0 text-lg text-gold-deep" aria-hidden />
<p>
<span className="font-semibold">Wrong network —</span> your wallet (
{truncateAddress(address)}) is connected to <span className="font-semibold">{currentLabel}</span>, but
GuildWorkman runs on <span className="font-semibold">{expectedLabel}</span>. Open the Freighter
extension and switch its network to {expectedLabel}; this banner clears itself once it detects the
change.
</p>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleRecheck}
disabled={checking}
aria-label={`Recheck the connected wallet's network${checking ? " — checking now" : ""}`}
className="shrink-0"
>
{checking ? "Checking…" : "I've switched — check again"}
</Button>
</div>
</div>
);
}
Loading
Loading