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
14 changes: 13 additions & 1 deletion src/app/solve/SolvePageClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -684,17 +684,29 @@ export default function SolvePageClient() {
</p>
)}

{networkMismatch && (
<p role="alert" className="text-xs text-yellow-400">
⚠ Wrong network — switch Freighter to{" "}
<span className="font-semibold">
{process.env["NEXT_PUBLIC_NETWORK"] ?? "testnet"}
</span>{" "}
before registering.
</p>
)}

<button
type="button"
onClick={handleRegisterSubmit}
disabled={(!canSubmit && regStatus !== "success") || isBusy}
disabled={(!canRegister && regStatus !== "success") || isBusy}
aria-busy={isBusy}
className="w-full py-2.5 bg-vx-sage-bg text-vx-sage text-xs font-semibold rounded-lg border border-vx-sage/30 hover:bg-vx-sage/15 disabled:opacity-50 disabled:cursor-not-allowed transition-all"
>
{isRegistering
? t(REGISTRATION_LABEL_KEY[registration.status]!)
: registration.status === "success"
? t("solve.register.button.registered")
: networkMismatch
? t("solve.register.button.wrongNetwork")
: t("solve.register.button.connect")}
</button>
</div>
Expand Down
70 changes: 70 additions & 0 deletions src/components/SwapCard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -289,4 +289,74 @@ describe("SwapCard", () => {
}),
);
});

// ── Issue #248: networkMismatch submission guard ──────────────────────────

it("disables the swap button and shows an inline alert when connected to the wrong network", async () => {
useWalletStore.setState({
isConnected: true,
address: "GABC123",
network: "MAINNET",
networkMismatch: true,
});

const user = userEvent.setup();
renderSwapCard();

const input = screen.getByPlaceholderText("0");
await user.type(input, "500");

// Inline alert must be visible.
await waitFor(() => {
expect(screen.getAllByRole("alert").some((el) =>
el.textContent?.toLowerCase().includes("wrong network")
)).toBe(true);
});

// The swap button must be disabled.
const swapButton = screen.getAllByRole("button").find(
(btn) => btn.textContent?.toLowerCase().includes("wrong network")
);
expect(swapButton).toBeDefined();
expect(swapButton).toBeDisabled();

// Freighter must never be called.
expect(signTransactionMock).not.toHaveBeenCalled();
});

it("does not show the network-mismatch alert when networkMismatch is false", async () => {
useWalletStore.setState({
isConnected: true,
address: "GABC123",
network: "TESTNET",
networkMismatch: false,
});

renderSwapCard();

// No mismatch alert should be present.
const alerts = screen.queryAllByRole("alert");
const mismatchAlert = alerts.find((el) =>
el.textContent?.toLowerCase().includes("wrong network")
);
expect(mismatchAlert).toBeUndefined();
});

it("does not show the network-mismatch alert when wallet is not connected (networkMismatch defaults to false)", () => {
// networkMismatch defaults to false before any connection — the guard
// must not falsely block a pre-connection state.
useWalletStore.setState({
isConnected: false,
address: null,
networkMismatch: false,
});

renderSwapCard();

const alerts = screen.queryAllByRole("alert");
const mismatchAlert = alerts.find((el) =>
el.textContent?.toLowerCase().includes("wrong network")
);
expect(mismatchAlert).toBeUndefined();
});
});
19 changes: 18 additions & 1 deletion src/components/SwapCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { useQuote } from "@/hooks/useQuote";
import { useSwapSubmission } from "@/hooks/useSwapSubmission";
import { useRecentChains } from "@/hooks/useRecentChains";
import { useToastStore } from "@/store/toast";
import { useWalletStore } from "@/store/wallet";
import { CHAINS, DST_TOKENS, SRC_TOKENS } from "@/lib/marketData";
import { isValidStellarPublicKey } from "@/lib/stellarAddress";
import { formatTokenAmount } from "@/lib/format";
Expand Down Expand Up @@ -214,12 +215,14 @@ export function SwapCard({ initialAmount = "", previewQuote, onPreviewSubmit }:
// ── Submission ─────────────────────────────────────────────────────────────
const submission = useSwapSubmission();
const isSubmitting = submission.status in SUBMISSION_LABEL_KEY;
const networkMismatch = useWalletStore((s) => s.networkMismatch);
const canSwap =
Boolean(srcAmount) &&
parseFloat(srcAmount) > 0 &&
!quoting &&
!isSubmitting &&
!dstAddressError;
!dstAddressError &&
!networkMismatch;

function truncateToDecimals(value: string, decimals: number): string {
const dotIndex = value.indexOf(".");
Expand Down Expand Up @@ -673,6 +676,16 @@ export function SwapCard({ initialAmount = "", previewQuote, onPreviewSubmit }:
</p>
)}

{networkMismatch && (
<p role="alert" className="text-center text-xs text-yellow-400 px-1">
⚠ Wrong network — switch Freighter to{" "}
<span className="font-semibold">
{process.env["NEXT_PUBLIC_NETWORK"] ?? "testnet"}
</span>{" "}
before swapping.
</p>
)}

<button
type="button"
className="btn-swap"
Expand Down Expand Up @@ -722,6 +735,8 @@ export function SwapCard({ initialAmount = "", previewQuote, onPreviewSubmit }:
</svg>
{t("swap.submit.findingRoute")}
</span>
) : networkMismatch ? (
t("swap.submit.wrongNetwork")
) : canSwap ? (
t(submission.status === "error" ? "swap.submit.retryCta" : "swap.submit.cta", {
amount: srcAmount,
Expand Down Expand Up @@ -797,6 +812,8 @@ export function SwapCard({ initialAmount = "", previewQuote, onPreviewSubmit }:
</span>
) : quoteIsStale ? (
t("swap.quote.expired")
) : networkMismatch ? (
t("swap.submit.wrongNetwork")
) : canSwap ? (
t(
submission.status === "error"
Expand Down
10 changes: 9 additions & 1 deletion src/hooks/useSolverRegistration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { walletAdapter } from "@/lib/wallet";
import { registerSolver, submitSolverRegistration } from "@/lib/api";
import { ApiError } from "@/lib/api";
import { verifySignedXdrMatches } from "@/lib/xdrReview";
import { useWalletStore } from "@/store/wallet";
import { useWalletStore, EXPECTED_NETWORK } from "@/store/wallet";
import { useToastStore } from "@/store/toast";
import { decodeXdr, validateRegistrationXdr, XdrMismatchError } from "@/lib/xdrReview";

Expand Down Expand Up @@ -86,6 +86,14 @@ export function useSolverRegistration() {
// ──────────────────────────────────────────────────────────────────────

setStatus("awaiting-signature");
// Defense-in-depth (#248): validate network against the expected
// passphrase immediately before calling Freighter — guards against
// stale-closure or UI-bypass scenarios.
if ((wallet.network ?? "").toUpperCase() !== EXPECTED_NETWORK) {
throw new Error(
`Network mismatch: Freighter is on "${wallet.network ?? "unknown"}" but this app requires "${EXPECTED_NETWORK}". Switch networks in Freighter and try again.`,
);
}
const signedXdr = await walletAdapter.signTransaction(unsignedXdr, {
network: wallet.network ?? undefined,
});
Expand Down
12 changes: 10 additions & 2 deletions src/hooks/useSwapSubmission.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { useCallback, useState } from "react";
import { useCallback, useRef, useState } from "react";
import { walletAdapter } from "@/lib/wallet";
import { createIntent, submitIntent } from "@/lib/api";
import { verifySignedXdrMatches } from "@/lib/xdrReview";
import { useWalletStore } from "@/store/wallet";
import { useWalletStore, EXPECTED_NETWORK } from "@/store/wallet";
import { useToastStore } from "@/store/toast";
import { decodeXdr, validateSwapXdr, XdrMismatchError } from "@/lib/xdrReview";
import type { QuoteRequest } from "@/lib/types";
Expand Down Expand Up @@ -137,6 +137,14 @@ export function useSwapSubmission() {
// ──────────────────────────────────────────────────────────────────────

setStatus("awaiting-signature");
// Defense-in-depth (#248): validate network against the expected
// passphrase immediately before calling Freighter — guards against
// stale-closure or UI-bypass scenarios.
if ((wallet.network ?? "").toUpperCase() !== EXPECTED_NETWORK) {
throw new Error(
`Network mismatch: Freighter is on "${wallet.network ?? "unknown"}" but this app requires "${EXPECTED_NETWORK}". Switch networks in Freighter and try again.`,
);
}
const signedXdr = await walletAdapter.signTransaction(unsignedXdr, {
network: wallet.network ?? undefined,
});
Expand Down
2 changes: 2 additions & 0 deletions src/lib/i18n/messages/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ export const en = {
"swap.submit.enterAmount": "Enter an amount",
"swap.submit.cta": "Swap {amount} {srcToken} → {dstToken}",
"swap.submit.retryCta": "Retry: Swap {amount} {srcToken} → {dstToken}",
"swap.submit.wrongNetwork": "Wrong network — switch Freighter first",

"swap.destination.label": "Destination address",
"swap.destination.placeholder": "G...",
Expand Down Expand Up @@ -104,6 +105,7 @@ export const en = {
"solve.register.info.withdraw": "Your solver bond earns you exclusive rights to solve intents.",
"solve.register.button.registered": "Registered ✓",
"solve.register.button.connect": "Connect to Register",
"solve.register.button.wrongNetwork": "Wrong network — switch Freighter first",

"solve.leaderboard.title": "Active Solvers",
"solve.leaderboard.error": "Failed to load leaderboard.",
Expand Down
1 change: 1 addition & 0 deletions src/lib/i18n/messages/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ export const es = {
"swap.submit.cta": "Intercambiar {amount} {srcToken} → {dstToken}",
"swap.submit.retryCta":
"Reintentar: Intercambiar {amount} {srcToken} → {dstToken}",
"swap.submit.wrongNetwork": "Red incorrecta — cambia Freighter primero",

"swap.destination.label": "Dirección de destino",
"swap.destination.placeholder": "G...",
Expand Down
2 changes: 1 addition & 1 deletion src/store/wallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export type PersistedWalletState = {
export const PERSIST_KEY = "vortex-wallet";

/** The network name the app expects, normalised to upper-case for comparison. */
const EXPECTED_NETWORK = (
export const EXPECTED_NETWORK = (
process.env["NEXT_PUBLIC_NETWORK"] ?? "testnet"
).toUpperCase();

Expand Down
Loading