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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added public/Icon_only_cyan.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
16 changes: 0 additions & 16 deletions server/routes/api/faucet/[...path].ts

This file was deleted.

11 changes: 5 additions & 6 deletions src/components/trade/header/top-nav.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { DownloadSimpleIcon, DropIcon, GearIcon, TerminalIcon, TrophyIcon } from "@phosphor-icons/react";
import { DownloadSimpleIcon, DropIcon, GearIcon, TrophyIcon } from "@phosphor-icons/react";
import { Link } from "@tanstack/react-router";
import { useConnection } from "wagmi";
import { Button } from "@/components/ui/button";
Expand Down Expand Up @@ -55,12 +55,11 @@ export function TopNav() {
>
<div className="flex items-center gap-3 min-w-0">
<div className="flex items-center gap-1.5">
<div className="size-5 rounded bg-primary-default/10 border border-primary-default/30 flex items-center justify-center">
<TerminalIcon className="size-3 text-primary-default" />
</div>
<img src="/hyperodd_icon.png" alt="Hyperodd" className="size-5 dark:hidden" />
<img src="/Icon_only_cyan.png" alt="Hyperodd" className="size-5 hidden dark:block" />
<span className="text-xs font-bold tracking-tight">
<span className="text-primary-default">HyperOdd</span>
<span className="text-text-950">Terminal</span>
<span className="text-primary-default">Hyperodd</span>
<span className="text-text-950"> Terminal</span>
</span>
</div>
<div className="h-4 w-px bg-border-200 hidden md:block" />
Expand Down
102 changes: 12 additions & 90 deletions src/components/trade/tradebox/faucet-modal.tsx
Original file line number Diff line number Diff line change
@@ -1,94 +1,36 @@
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { Turnstile, type TurnstileInstance } from "@marsidev/react-turnstile";
import {
CheckCircleIcon,
ClockIcon,
CurrencyDollarIcon,
DropIcon,
SpinnerGapIcon,
WalletIcon,
WarningCircleIcon,
} from "@phosphor-icons/react";
import { useRef, useState } from "react";
import { usePrivy } from "@privy-io/react-auth";
import { useConnection } from "wagmi";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { InfoRow } from "@/components/ui/info-row";
import { cn } from "@/lib/cn";
import { useFaucetClaim } from "@/lib/faucet/use-faucet-claim";
import { useFaucetModalActions, useFaucetModalOpen } from "@/stores/use-global-modal-store";

const TURNSTILE_SITE_KEY = import.meta.env.VITE_TURNSTILE_SITE_KEY;

interface StepProps {
label: string;
active: boolean;
done: boolean;
}

function Step({ label, active, done }: StepProps) {
return (
<div
className={cn(
"flex items-center gap-2 text-3xs py-1",
done ? "text-market-up-600" : active ? "text-primary-default" : "text-text-500",
)}
>
{done ? (
<CheckCircleIcon className="size-3.5" />
) : active ? (
<SpinnerGapIcon className="size-3.5 animate-spin" />
) : (
<div className="size-3.5 rounded-full border border-current opacity-40" />
)}
<span>{label}</span>
</div>
);
}

function ClaimProgress({ status }: { status: string }) {
const steps = [
{ key: "verifying-captcha", label: t`Verifying captcha` },
{ key: "verifying-balance", label: t`Checking balance` },
{ key: "claiming", label: t`Claiming USDH` },
];
const activeIdx = steps.findIndex((s) => s.key === status);

return (
<div className="space-y-0.5">
{steps.map((step, i) => (
<Step key={step.key} label={step.label} active={i === activeIdx} done={i < activeIdx} />
))}
</div>
);
}

export function FaucetModal() {
const open = useFaucetModalOpen();
const { close } = useFaucetModalActions();
const { address } = useConnection();
const { authenticated, getAccessToken } = usePrivy();
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Destructure the login function from usePrivy to allow users to authenticate directly from the faucet modal if they are not already logged in.

Suggested change
const { authenticated, getAccessToken } = usePrivy();
const { authenticated, getAccessToken, login } = usePrivy();

const { status, error, result, claim, reset } = useFaucetClaim();
const [turnstileToken, setTurnstileToken] = useState<string | null>(null);
const turnstileRef = useRef<TurnstileInstance>(null);

const isProcessing = status === "verifying-captcha" || status === "verifying-balance" || status === "claiming";

function handleClose() {
reset();
setTurnstileToken(null);
close();
}

function handleClaim() {
if (!turnstileToken || !address) return;
claim(turnstileToken, address);
}

function handleRetry() {
reset();
setTurnstileToken(null);
turnstileRef.current?.reset();
if (!address) return;
claim(address, getAccessToken);
}

if (status === "success") {
Expand Down Expand Up @@ -145,7 +87,7 @@ export function FaucetModal() {
<Button variant="outlined" onClick={handleClose} className="flex-1">
<Trans>Cancel</Trans>
</Button>
<Button onClick={handleRetry} className="flex-1">
<Button onClick={reset} className="flex-1">
<Trans>Retry</Trans>
</Button>
</div>
Expand All @@ -155,7 +97,7 @@ export function FaucetModal() {
);
}

if (isProcessing) {
if (status === "claiming") {
return (
<Dialog open onOpenChange={() => {}}>
<DialogContent className="sm:max-w-md" showCloseButton={false}>
Expand All @@ -171,7 +113,9 @@ export function FaucetModal() {
<SpinnerGapIcon className="size-7 animate-spin text-primary-default" />
</div>
</div>
<ClaimProgress status={status} />
<p className="text-3xs text-primary-default">
<Trans>Claiming USDH...</Trans>
</p>
</div>
</DialogContent>
</Dialog>
Expand Down Expand Up @@ -214,20 +158,9 @@ export function FaucetModal() {
<Trans>Amount</Trans>
</>
}
value="1,000 USDH"
value="50 USDH"
valueClassName="font-medium"
/>
<InfoRow
className="p-0"
labelClassName="flex items-center gap-1.5 text-text-950"
label={
<>
<CurrencyDollarIcon className="size-3" />
<Trans>Requirement</Trans>
</>
}
value={t`$5+ USDC balance`}
/>
<InfoRow
className="p-0"
labelClassName="flex items-center gap-1.5 text-text-950"
Expand All @@ -241,20 +174,9 @@ export function FaucetModal() {
/>
</div>

<div className="flex justify-center">
<Turnstile
ref={turnstileRef}
siteKey={TURNSTILE_SITE_KEY}
options={{ theme: "dark", size: "normal" }}
onSuccess={setTurnstileToken}
onExpire={() => setTurnstileToken(null)}
onError={() => setTurnstileToken(null)}
/>
</div>

<Button variant="contained" onClick={handleClaim} disabled={!turnstileToken} className="w-full">
<Button variant="contained" onClick={handleClaim} disabled={!authenticated} className="w-full">
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The 'Claim' button is disabled when the user is not authenticated via Privy (!authenticated), but there is no visual feedback or call-to-action explaining why. A user who has connected their wallet via wagmi but hasn't logged into Privy might find this confusing. Consider updating the button text to 'Login to Claim' or providing a brief message to guide the user when they are not authenticated.

<DropIcon className="size-4" />
<Trans>Claim 1,000 USDH</Trans>
<Trans>Claim 50 USDH</Trans>
</Button>
Comment on lines +177 to 180
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Instead of disabling the button when the user is not authenticated, it's better to provide a 'Login to Claim' action. This improves the user experience by allowing them to complete the authentication flow without leaving the modal.

							<Button
								variant="contained"
								onClick={authenticated ? handleClaim : login}
								className="w-full"
							>
								<DropIcon className="size-4" />
								{authenticated ? <Trans>Claim 50 USDH</Trans> : <Trans>Login to Claim</Trans>}
							</Button>

</>
)}
Expand Down
2 changes: 1 addition & 1 deletion src/domain/market/scope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ export type ExchangeScope = "all" | "perp" | "spot" | "builders-perp";
export const EXCHANGE_SCOPES: ExchangeScope[] = ["all", "perp", "spot", "builders-perp"];

export const DEFAULT_SELECTED_MARKETS: Record<ExchangeScope, string> = {
all: "BTC",
all: "VOLX-USDH",
perp: "BTC",
spot: "@107", // ETH/USDC
"builders-perp": "xyz:SILVER",
Expand Down
82 changes: 29 additions & 53 deletions src/lib/faucet/use-faucet-claim.ts
Original file line number Diff line number Diff line change
@@ -1,82 +1,58 @@
import { useState } from "react";

type FaucetStatus = "idle" | "verifying-captcha" | "verifying-balance" | "claiming" | "success" | "error";
const API_URL = import.meta.env.VITE_HYPERMILES_API_URL;

type FaucetStatus = "idle" | "claiming" | "success" | "error";

interface FaucetResult {
amount: string;
txHash?: string;
amount: number;
walletAddress: string;
}

interface UseFaucetClaimReturn {
status: FaucetStatus;
error: string | null;
result: FaucetResult | null;
claim: (turnstileToken: string, address: string) => Promise<void>;
claim: (address: string, getAccessToken: () => Promise<string | null>) => Promise<void>;
reset: () => void;
}

async function postFaucet<T>(path: string, body: Record<string, string>): Promise<T> {
const res = await fetch(`/api/faucet/${path}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
return res.json();
}

export function useFaucetClaim(): UseFaucetClaimReturn {
const [status, setStatus] = useState<FaucetStatus>("idle");
const [error, setError] = useState<string | null>(null);
const [result, setResult] = useState<FaucetResult | null>(null);

async function claim(turnstileToken: string, address: string) {
setStatus("verifying-captcha");
async function claim(address: string, getAccessToken: () => Promise<string | null>) {
setStatus("claiming");
setError(null);
setResult(null);

try {
const turnstileData = await postFaucet<{ success: boolean; sessionToken?: string; error?: string }>(
"verify-turnstile",
{ token: turnstileToken },
);
if (!turnstileData.success || !turnstileData.sessionToken)
throw new Error(turnstileData.error || "Captcha verification failed");
const sessionToken = turnstileData.sessionToken;
const token = await getAccessToken();
if (!token) throw new Error("Not authenticated");

setStatus("verifying-balance");
const balanceData = await postFaucet<{
success: boolean;
hasMinimumBalance?: boolean;
totalBalance?: string;
required?: string;
error?: string;
}>("verify-balance", { address, sessionToken });
if (!balanceData.success) throw new Error(balanceData.error || "Balance check failed");
if (!balanceData.hasMinimumBalance)
throw new Error(`Insufficient balance: $${balanceData.totalBalance} (need $${balanceData.required})`);

setStatus("claiming");
const claimData = await postFaucet<{
success: boolean;
amount?: string;
txHash?: string;
error?: string;
nextClaimTime?: number;
}>("claim", {
recipientAddress: address,
sessionToken,
authMethod: "wallet",
walletAddress: address,
const res = await fetch(`${API_URL}/faucet`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ walletAddress: address }),
});
if (!claimData.success) {
if (claimData.nextClaimTime) {
const hours = Math.max(1, Math.ceil((claimData.nextClaimTime * 1000 - Date.now()) / (1000 * 60 * 60)));
throw new Error(`Cooldown active. Try again in ~${hours}h`);
}
throw new Error(claimData.error || "Claim failed");

if (!res.ok) {
const text = await res.text();
let message = `Claim failed (${res.status})`;
try {
const data = JSON.parse(text);
if (data.error) message = data.error;
} catch {}
throw new Error(message);
}

setResult({ amount: claimData.amount || "1,000", txHash: claimData.txHash });
const data = await res.json();

setResult({ amount: data.amount, walletAddress: data.walletAddress });
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

It's safer to validate the API response before updating the state. This ensures that the application doesn't end up with an invalid or incomplete result object if the backend returns unexpected data.

			if (!data || typeof data.amount !== "number" || !data.walletAddress) {
				throw new Error("Invalid response from faucet API");
			}
			setResult({ amount: data.amount, walletAddress: data.walletAddress });

setStatus("success");
} catch (err) {
setError(err instanceof Error ? err.message : "Something went wrong");
Expand Down
Loading
Loading