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
13 changes: 13 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,6 +1,19 @@
NEXT_PUBLIC_STELLAR_NETWORK=testnet
NEXT_PUBLIC_CONTRACT_ID=YOUR_CONTRACT_ID_HERE
NEXT_PUBLIC_RPC_URL=https://soroban-testnet.stellar.org

# Optional: per-network settings for the in-app network selector (#889).
# The un-prefixed vars above apply to NEXT_PUBLIC_STELLAR_NETWORK only; set
# these to let users switch to the other network. A network with no contract
# ID is shown as "not configured" and cannot be selected.
# NEXT_PUBLIC_TESTNET_CONTRACT_ID=
# NEXT_PUBLIC_TESTNET_RPC_URL=
# NEXT_PUBLIC_TESTNET_HORIZON_URL=
# NEXT_PUBLIC_TESTNET_USDC_ADDRESS=
# NEXT_PUBLIC_MAINNET_CONTRACT_ID=
# NEXT_PUBLIC_MAINNET_RPC_URL=
# NEXT_PUBLIC_MAINNET_HORIZON_URL=
# NEXT_PUBLIC_MAINNET_USDC_ADDRESS=
API_KEY_SIGNING_SECRET=replace-with-a-long-random-server-secret
CSRF_SECRET=replace-with-a-long-random-server-secret

Expand Down
133 changes: 133 additions & 0 deletions src/__tests__/networks.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { StrKey } from "@stellar/stellar-sdk";
import {
DEFAULT_NETWORK_ID,
MAINNET_PASSPHRASE,
NETWORK_STORAGE_KEY,
TESTNET_PASSPHRASE,
buildCustomNetwork,
checkRpcNetwork,
explorerAccountUrl,
explorerTxUrl,
getActiveNetwork,
getPresetNetwork,
readNetworkPreference,
resolveNetwork,
validateCustomNetwork,
writeNetworkPreference,
type CustomNetworkInput,
} from "@/lib/networks";

const CONTRACT = StrKey.encodeContract(Buffer.alloc(32, 7));

const goodCustom: CustomNetworkInput = {
label: "Local",
passphrase: "Standalone Network ; February 2017",
rpcUrl: "http://localhost:8000/soroban/rpc",
horizonUrl: "http://localhost:8000",
contractId: CONTRACT,
};

function jsonResponse(body: unknown, status = 200) {
return { ok: status >= 200 && status < 300, status, json: async () => body } as Response;
}

describe("network selection (#889)", () => {
beforeEach(() => localStorage.clear());

it("presets use the right passphrase and explorer per network", () => {
const testnet = getPresetNetwork("testnet");
const mainnet = getPresetNetwork("mainnet");
expect(testnet.passphrase).toBe(TESTNET_PASSPHRASE);
expect(mainnet.passphrase).toBe(MAINNET_PASSPHRASE);
expect(testnet.explorerTxBase).toContain("/testnet/");
expect(mainnet.explorerTxBase).toContain("/public/");
expect(testnet.horizonUrl).not.toBe(mainnet.horizonUrl);
});

it("uses the build default when nothing is stored", () => {
expect(readNetworkPreference()).toBeNull();
expect(getActiveNetwork().id).toBe(DEFAULT_NETWORK_ID);
});

it("remembers the chosen network across reads", () => {
writeNetworkPreference({ id: "mainnet" });
expect(readNetworkPreference()).toEqual({ id: "mainnet" });
expect(getActiveNetwork().passphrase).toBe(MAINNET_PASSPHRASE);

writeNetworkPreference(null);
expect(localStorage.getItem(NETWORK_STORAGE_KEY)).toBeNull();
});

it("ignores corrupt or unknown stored values", () => {
localStorage.setItem(NETWORK_STORAGE_KEY, "{not json");
expect(getActiveNetwork().id).toBe(DEFAULT_NETWORK_ID);
localStorage.setItem(NETWORK_STORAGE_KEY, JSON.stringify({ id: "futurenet" }));
expect(getActiveNetwork().id).toBe(DEFAULT_NETWORK_ID);
});

it("resolves a valid custom network and falls back when it is invalid", () => {
const custom = resolveNetwork({ id: "custom", custom: goodCustom });
expect(custom.id).toBe("custom");
expect(custom.rpcUrl).toBe(goodCustom.rpcUrl);
expect(custom.contractId).toBe(CONTRACT);

const broken = resolveNetwork({ id: "custom", custom: { ...goodCustom, rpcUrl: "not a url" } });
expect(broken.id).toBe(DEFAULT_NETWORK_ID);
});

it("never reuses the default network's contract ID on the other network", () => {
const other = DEFAULT_NETWORK_ID === "testnet" ? "mainnet" : "testnet";
const shared = process.env.NEXT_PUBLIC_CONTRACT_ID;
if (shared) expect(getPresetNetwork(other).contractId).not.toBe(shared);
});

describe("validateCustomNetwork", () => {
it("accepts a well-formed network", () => {
expect(validateCustomNetwork(goodCustom)).toEqual({});
});

it("rejects plain http for remote hosts but allows localhost", () => {
const errors = validateCustomNetwork({ ...goodCustom, rpcUrl: "http://rpc.example.org" });
expect(errors.rpcUrl).toBeDefined();
expect(validateCustomNetwork({ ...goodCustom, rpcUrl: "http://127.0.0.1:8000" }).rpcUrl).toBeUndefined();
});

it("requires a passphrase and valid contract addresses", () => {
const errors = validateCustomNetwork({ ...goodCustom, passphrase: " ", contractId: "Cbad", usdcAddress: "nope" });
expect(errors.passphrase).toBeDefined();
expect(errors.contractId).toBeDefined();
expect(errors.usdcAddress).toBeDefined();
});
});

describe("checkRpcNetwork", () => {
it("passes when the RPC reports the expected passphrase", async () => {
const fetchImpl = vi.fn().mockResolvedValue(jsonResponse({ result: { passphrase: goodCustom.passphrase } }));
await expect(checkRpcNetwork(goodCustom.rpcUrl, goodCustom.passphrase, fetchImpl)).resolves.toEqual({ ok: true });
const body = JSON.parse(fetchImpl.mock.calls[0][1].body);
expect(body.method).toBe("getNetwork");
});

it("fails when the RPC is on a different network", async () => {
const fetchImpl = vi.fn().mockResolvedValue(jsonResponse({ result: { passphrase: TESTNET_PASSPHRASE } }));
const result = await checkRpcNetwork(goodCustom.rpcUrl, MAINNET_PASSPHRASE, fetchImpl);
expect(result.ok).toBe(false);
});

it("fails on HTTP errors and unreachable hosts", async () => {
expect((await checkRpcNetwork("https://x", "p", vi.fn().mockResolvedValue(jsonResponse({}, 503)))).ok).toBe(false);
expect((await checkRpcNetwork("https://x", "p", vi.fn().mockRejectedValue(new Error("boom"))))).toEqual({
ok: false,
error: "Could not reach RPC: boom",
});
});
});

it("builds explorer links for the given network", () => {
const mainnet = getPresetNetwork("mainnet");
expect(explorerTxUrl("abc", mainnet)).toBe("https://stellar.expert/explorer/public/tx/abc");
expect(explorerAccountUrl("GABC", mainnet)).toBe("https://stellar.expert/explorer/public/account/GABC");
expect(buildCustomNetwork({ ...goodCustom, passphrase: MAINNET_PASSPHRASE }).explorerTxBase).toContain("/public/");
});
});
3 changes: 2 additions & 1 deletion src/app/dev/debugger/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { useState } from "react";
import { splitClient } from "@/lib/stellar";
import ThemeToggle from "@/components/ThemeToggle";
import { getActiveNetwork } from "@/lib/networks";

const DEV_MODE = process.env.NEXT_PUBLIC_DEV_MODE === "true";

Expand Down Expand Up @@ -61,7 +62,7 @@ function DebuggerContent() {
} else {
// Generic fallback: build a minimal transaction envelope and simulate it
const { rpc } = await import("@stellar/stellar-sdk");
const rpcUrl = process.env.NEXT_PUBLIC_RPC_URL ?? "https://soroban-testnet.stellar.org";
const rpcUrl = getActiveNetwork().rpcUrl;
const server = new rpc.Server(rpcUrl);
// as any: simulateTransaction overload with plain object args is not in @stellar/stellar-sdk RPC types
const raw = await (server as any).simulateTransaction({
Expand Down
5 changes: 3 additions & 2 deletions src/app/dev/inspect/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useEffect, useState, useCallback } from "react";
import { useParams } from "next/navigation";
import { splitClient } from "@/lib/stellar";
import type { Invoice } from "@stellar-split/sdk";
import { getActiveNetwork } from "@/lib/networks";

const DEV_MODE = process.env.NEXT_PUBLIC_DEV_MODE === "true";

Expand Down Expand Up @@ -104,8 +105,8 @@ export default function InspectPage() {
setError(null);
try {
const invoice = await splitClient.getInvoice(id);
const rpcUrl = process.env.NEXT_PUBLIC_RPC_URL ?? "https://soroban-testnet.stellar.org";
const contractId = process.env.NEXT_PUBLIC_CONTRACT_ID ?? "";
const rpcUrl = getActiveNetwork().rpcUrl;
const contractId = getActiveNetwork().contractId;
const wasmHash = await fetchWasmHash(contractId, rpcUrl);
const storageKey = `Invoice:${id}`;
setData({ invoice, wasmHash, storageKey });
Expand Down
5 changes: 2 additions & 3 deletions src/app/pay/[invoiceId]/[amount]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,14 @@ import { splitClient, payWithNonce } from "@/lib/stellar";
import { getFreighterPublicKey } from "@/lib/freighter";
import { formatAmount, parseAmount } from "@stellar-split/sdk";
import type { Invoice } from "@stellar-split/sdk";
import { getActiveNetwork } from "@/lib/networks";

interface Props {
params: { invoiceId: string; amount: string };
}

const STELLAR_EXPERT_BASE =
process.env.NEXT_PUBLIC_STELLAR_NETWORK === "mainnet"
? "https://stellar.expert/explorer/public/tx"
: "https://stellar.expert/explorer/testnet/tx";
getActiveNetwork().explorerTxBase;

export default function DeepLinkPayPage({ params }: Props) {
const { invoiceId, amount: rawAmount } = params;
Expand Down
5 changes: 2 additions & 3 deletions src/app/pay/batch/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,15 @@ import { getFreighterPublicKey } from "@/lib/freighter";
import { parseAmount } from "@stellar-split/sdk";
import BatchPayQueue from "@/components/BatchPayQueue";
import type { Invoice } from "@stellar-split/sdk";
import { getActiveNetwork } from "@/lib/networks";

interface QueueEntry {
invoice: Invoice;
amount: string;
}

const STELLAR_EXPERT_BASE =
process.env.NEXT_PUBLIC_STELLAR_NETWORK === "mainnet"
? "https://stellar.expert/explorer/public/tx"
: "https://stellar.expert/explorer/testnet/tx";
getActiveNetwork().explorerTxBase;

export default function BatchPayPage() {
const [search, setSearch] = useState("");
Expand Down
3 changes: 2 additions & 1 deletion src/app/payments/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@ import { formatAmount, truncateAddress } from "@stellar-split/sdk";
import type { Invoice, Payment } from "@stellar-split/sdk";
import { SkeletonPaymentRow } from "@/components/Skeleton";
import PaymentScheduler from "@/components/PaymentScheduler";
import { getActiveNetwork } from "@/lib/networks";

const STELLAR_EXPERT_BASE = "https://stellar.expert/explorer/testnet/tx";
const STELLAR_EXPERT_BASE = getActiveNetwork().explorerTxBase;
const ITEMS_PER_PAGE = 20;
const MAX_INVOICES = 100;

Expand Down
4 changes: 1 addition & 3 deletions src/app/settings/verify/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,9 +83,7 @@ export default function VerifyIdentityPage() {
const signedMessage = await signMessage(challenge, {
address,
networkPassphrase:
process.env.NEXT_PUBLIC_STELLAR_NETWORK === 'mainnet'
? 'Public Global Stellar Network ; September 2015'
: 'Test SDF Network ; September 2015',
getActiveNetwork().passphrase,
});

// Store attestation
Expand Down
5 changes: 2 additions & 3 deletions src/components/AchievementCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { useRef, useState, useEffect } from "react";
import confetti from "canvas-confetti";
import { getActiveNetwork } from "@/lib/networks";

export interface NFTDetails {
tokenId?: string;
Expand All @@ -19,9 +20,7 @@ interface Props {
}

const STELLAR_EXPERT_BASE =
process.env.NEXT_PUBLIC_STELLAR_NETWORK === "mainnet"
? "https://stellar.expert/explorer/public/tx"
: "https://stellar.expert/explorer/testnet/tx";
getActiveNetwork().explorerTxBase;

export default function AchievementCard({
invoiceId,
Expand Down
5 changes: 2 additions & 3 deletions src/components/InvoiceTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useEffect, useState, useCallback, useRef } from 'react';
import WalletAddress from './WalletAddress';
import RelativeTime from '@/components/ui/RelativeTime';
import { splitClient as _splitClient } from '@/lib/stellar';
import { getActiveNetwork } from "@/lib/networks";

export interface InvoiceEvent {
type:
Expand All @@ -27,9 +28,7 @@ interface Props {
}

const STELLAR_EXPERT_BASE =
process.env.NEXT_PUBLIC_STELLAR_NETWORK === 'mainnet'
? 'https://stellar.expert/explorer/public/tx'
: 'https://stellar.expert/explorer/testnet/tx';
getActiveNetwork().explorerTxBase;

const EVENT_META: Record<
InvoiceEvent['type'],
Expand Down
3 changes: 3 additions & 0 deletions src/components/Navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import SimulationModeToggle from "@/components/SimulationModeToggle";
import NotificationCenter from "@/components/NotificationCenter";
import HeaderShortcutsButton from "@/components/HeaderShortcutsButton";
import NetworkStatus from "@/components/NetworkStatus";
import NetworkSelector from "@/components/NetworkSelector";
import GlobalSearch from "@/components/GlobalSearch";
import MobileSidebar from "@/components/layout/MobileSidebar";

Expand Down Expand Up @@ -106,6 +107,8 @@ export default function Navbar() {

{/* Right-side actions */}
<div className="ml-auto flex items-center gap-1">
{/* Network selector stays visible at every breakpoint */}
<NetworkSelector />
<div className="hidden sm:flex items-center gap-2">
<NetworkStatus />
<SimulationModeToggle />
Expand Down
Loading
Loading