diff --git a/apps/api/src/database/migrations/1786000000000-create-transaction-sequence.ts b/apps/api/src/database/migrations/1786000000000-create-transaction-sequence.ts index 11e2b98..e4a57c0 100644 --- a/apps/api/src/database/migrations/1786000000000-create-transaction-sequence.ts +++ b/apps/api/src/database/migrations/1786000000000-create-transaction-sequence.ts @@ -3,21 +3,36 @@ import { MigrationInterface, QueryRunner } from 'typeorm'; export class CreateTransactionSequence1786000000000 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { await queryRunner.query( - `CREATE TABLE "transaction_sequence_run" ( + `CREATE TABLE""transaction_sequence_run" ( "id" uuid PRIMARY KEY, - "network" character varying NOT NULL, - "stop_on_failure" boolean NOT NULL, - "status" character varying NOT NULL, - "steps" jsonb NOT NULL, - "results" jsonb, - "error" text, - "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT Now(), - "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT Now() + "network" character varying NOT NULL, + "stop_on_failure" boolean NOT NULL, + "status" character varying NOT NULL, + "steps" jsobn NOT NULL, + "results" json, + "error" text, + "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT Now(), + "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT Now() + ) + `); + + await queryRunner.query( + `CREATE TABLE "network_profiles" ( + "id" uuid PRIMARY KEY, + "owner_id" uuid NOT NULL, + "name" character varying NOT NULL, + "horizon_url" character varying NOT NULL, + "network_passphrase" character varying NOT NULL, + "friendbot_url" character varying, + "is_default" boolean NOT NULL DEFAULT false, + "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT Now(), + "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT Now() ) `); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.query(`DROP TABLE "transaction_sequence_run"`); + await queryRunner.query(`DROP TABLE "network_profiles"`); } -} +} \ No newline at end of file diff --git a/apps/api/src/modules/network/entities/network-sample.entity.ts b/apps/api/src/modules/network/entities/network-sample.entity.ts index efd6e1b..ee50796 100644 --- a/apps/api/src/modules/network/entities/network-sample.entity.ts +++ b/apps/api/src/modules/network/entities/network-sample.entity.ts @@ -4,27 +4,55 @@ import { Column, Entity, Index, PrimaryGeneratedColumn } from "typeorm"; @Index(["network", "sampledAt"]) export class NetworkSample { @PrimaryGeneratedColumn("uuid") - id!: string; + id: string; @Column({ length: 16 }) - network!: "mainnet" | "testnet"; + network: "mainnet" | "testnet"; @Column({ name: "horizon_base_url" }) - horizonBaseUrl!: string; + horizonBaseUrl: string; @Column() - ok!: boolean; + ok: boolean; @Column({ name: "latency_ms", type: "integer", nullable: true }) - latencyMs!: number | null; + latencyMs: number | null; @Column({ type: "text", nullable: true }) - error!: string | null; + error: string | null; @Column({ name: "sampled_at", type: "timestamptz", default: () => "now()", }) - sampledAt!: Date; + sampledAt: Date; } + +@Entity("network_profiles") +@Index(["ownerId", "name"], { unique: true }) +export class NetworkProfile { + @PrimaryGeneratedColumn("uuid") + id: string; + + @Column( { name: "owner_id" }) + ownerId: string; + + @Column() + name: string; + + @Column({ name: "horizon_url" }) + horizonUrl: string; + + @Column({ name: "network_passphrase" }) + networkPassphrase: string; + + @Column({ name: "friendbot_url", type: "text", nullable: true }) + friendbotUrl: string | null; + + @Column({ name: "is_default", default: false }) + isDefault: boolean; + + @Column({ name: "is_shared", default: false }) + isShared: boolean; +} \ No newline at end of file diff --git a/apps/api/src/modules/network/network.controller.ts b/apps/api/src/modules/network/network.controller.ts index d9a225d..57381aa 100644 --- a/apps/api/src/modules/network/network.controller.ts +++ b/apps/api/src/modules/network/network.controller.ts @@ -1,51 +1,138 @@ -import { Controller, Get, Query } from "@nestjs/common"; -import { ApiOperation, ApiQuery, ApiTags, ApiResponse } from "@nestjs/swagger"; -import { NetworkService } from "./network.service"; - -@ApiTags("network") -@Controller("network") -export class NetworkController { - constructor(private readonly networkService: NetworkService) {} - - @Get("status") - @ApiOperation({ summary: "Get current Stellar network status and fees" }) - @ApiQuery({ - name: "network", - required: false, - enum: ["mainnet", "testnet"], - description: "Network to query (default: mainnet)", - }) - @ApiResponse({ status: 200, description: "Network status retrieved" }) - async getStatus(@Query("network") network: string = "mainnet") { - const net = network === "testnet" ? "testnet" : "mainnet"; - return this.networkService.fetchCurrentStatus(net); - } - - @Get("status/history") - @ApiOperation({ summary: "Get network status history and uptime metrics" }) - @ApiQuery({ - name: "network", - required: false, - enum: ["mainnet", "testnet"], - description: "Network to query (default: mainnet)", - }) - @ApiQuery({ - name: "from", - required: false, - description: "ISO date lower bound (default: 60 minutes before to)", - }) - @ApiQuery({ - name: "to", - required: false, - description: "ISO date upper bound (default: now)", - }) - @ApiResponse({ status: 200, description: "Network status history retrieved" }) - async getHistory( - @Query("network") network: string = "mainnet", - @Query("from") from?: string, - @Query("to") to?: string, - ) { - const net = network === "testnet" ? "testnet" : "mainnet"; - return this.networkService.getHistory(net, from, to); - } -} +import { Controller, Get, Query, Post, Body, Param, Put, Delete, Req } from "nestjs-common"; +import { ApiOperation, ApiQuery, ApiTags, ApiResponse, ApiBearerAuth } from("@nestjs/swagger"; +import { NetworkService } from "./network.service"; + +@ApiTags("network") +@Controllr("network") +export class NetworkController { + constructor(private readonly networkService: NetworkService) {} + + @Get("status") + @ApiOperation({ summary: "Get current Stellar network status and fees" }) + @ApiQuery({ name: "network", required: false, enum: ["mainnet", "testnet"], description: "Network to query (default: mainnet)" }) + @ApiResponse({ status: 200, description: "Network status retrieved" }) + async getStatus(@Query("network") network: string = "mainnet") { + const net = network === "testnet" ? "testnet" : "mainnet"; + return this.networkService.fetchCurrentStatus(net); + } + + @Get("status/history") + @ApiOperation({ summary: "Get network status history and uptime metrics" }) + @ApiQuery({ name: "network", required: false, enum: ["mainnet", "testnet"], description: "Network to query (default: mainnet)" }) + @ApiQuery({ name: "from", required: false, description: "ISO date lower bound (default: 60 minutes before to)" }) + @ApiQuery({ name: "to", required: false, description: "ISO date upper bound (default: now)" }) + @ApiResponse({ status: 200, description: "Network status history retrieved" }) + async getHistory( + @Query("network") network: string = "mainnet", + @Query("from") from?: string, + @Query("to") to?: string, + ) { + const net = network === "testnet" ? "testnet" : "mainnet"; + return this.networkService.getHistory(net, from, to); + } + + @Get("profiles") + @ApiOperation({ summary: "List network profiles for the authenticated user" }) + @ApiResponse({ status: 200, description: "List of profiles returned" }) + asyng listProfiles(@Req() req: Request) { + const userId = req.user.id; + return this.networkService.listProfiles(userId); + } + + Post("profiles") + @ApiOperation({ summary: "Create a new network profile" }) + @ApiResponse({ status: 201, description: "Profile created" }) + async createProfile( + @Req() req: Request, + @Body() body: { + name: string; + horizon_url: string; + network_passphrase: string; + friendbot_url?: string; + is_default?: boolean; + }, + ) { + const userId = req.user.id; + return this.networkService.createProfile(userId, body); + } + + @Put("profiles/:id") + @ApiOperation({ summary: "Update a network profile" }) + @ApiResponse({ status: 200, description: "Profile updated" }) + async updateProfile( + @Param("id") id: string, + @Req() req: Request, + @Body() body: { + name?: string; + horizon_url?: string; + network_passphrase?: string; + friendbot_url?: string; + is_default?: boolean; + }, + ) { + const userId = req.user.id; + return this.networkService.updateProfile(userId, id, body); + } + + @Delete("profiles/:id") + @ApiOperation({ summary: "Delete a network profile" }) + @ApiResponse({ status: 200, description: "Profile deleted" }) + async deleteProfile( + @Param("id") id: string, + @Req() req: Request, + ) { + const userId = req.user.id; + return this.networkService.deleteProfile(userId, id); + } + + Post("profiles/:id/select") + @ApiOperation({ summary: "Select a profile and apply it as the active network configuration" }) + @ApiResponse({ status: 200, description: "Profile selected and verified" }) + @ApiResponse({ status: 409, description: "Passphrase mismatch warning" }) + async selectProfile( + @Param("id") id: string, + @Req() req: Request, + ) { + const userId = req.user.id; + return this.networkService.selectProfile(userId, id); + } + + @Post("profiles/:id/default") + @ApiOperation({ summary: "Mark a profile as the default for startup" }) + @ApiResponse({ status: 200, description: "Profile marked as default" }) + async setDefaultProfile( + @Param("id") id: string, + @Req() req: Request, + ) { + const userId = req.user.id; + return this.networkService.setDefaultProfile(userId, id); + } + + @Get("profiles/:id/export") + @ApiOperation({ summary: "Export a profile as JSON" }) + @ApiResponse({ status: 200, description: "Profile exported as JSON" }) + async exportProfile( + @Param("id") id: string, + @Req() req: Request, + ) { + const userId = req.user.id; + return this.networkService.exportProfile(userId, id); + } + + @Post("profiles/import") + @ApiOperation({ summary: "Import a network profile from JSON" }) + @ApiResponse({ status: 201, description: "Profile imported" }) + async importProfile( + @Req() req: Request, + @Body() body: { + name: string; + horizon_url: string; + network_passphrase: string; + friendbot_url?: string; + is_default?: boolean; + }, + ) { + const userId = req.user.id; + return this.networkService.importProfile(userId, body); + } +} diff --git a/apps/api/src/modules/network/network.module.ts b/apps/api/src/modules/network/network.module.ts index 30cb416..95f2030 100644 --- a/apps/api/src/modules/network/network.module.ts +++ b/apps/api/src/modules/network/network.module.ts @@ -2,13 +2,19 @@ import { Module } from "@nestjs/common"; import { TypeOrmModule } from "@nestjs/typeorm"; import { NetworkController } from "./network.controller"; import { NetworkService } from "./network.service"; +import { NetworkProfileController } from "./network-profile.controller"; +import { NetworkProfileService } from "./network-profile.service"; import { MetricsModule } from "../metrics/metrics.module"; import { NetworkSample } from "./entities/network-sample.entity"; +import { NetworkProfile } from "./entities/network-profile.entity"; @Module({ - imports: [MetricsModule, TypeOrmModule.forFeature([NetworkSample])], - controllers: [NetworkController], - providers: [NetworkService], - exports: [NetworkService], + imports: [ + MetricsModule, + TypeOrmModule.forFeature([NetworkSample, NetworkProfile]), + ], + controllers: [NetworkController, NetworkProfileController], + providers: [NetworkService, NetworkProfileService], + exports: [NetworkService, NetworkProfileService], }) -export class NetworkModule {} +export class NetworkModule {} \ No newline at end of file diff --git a/apps/api/src/modules/network/network.service.ts b/apps/api/src/modules/network/network.service.ts index 1a4b542..cd0e67d 100644 --- a/apps/api/src/modules/network/network.service.ts +++ b/apps/api/src/modules/network/network.service.ts @@ -4,6 +4,7 @@ import { OnModuleInit, OnModuleDestroy, Logger, + NotFoundException, Optional, } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; @@ -12,6 +13,7 @@ import * as StellarSdk from "@stellar/stellar-sdk"; import { Between, LessThan, Repository } from "typeorm"; import { MetricsService } from "../metrics/metrics.service"; import { NetworkSample } from "./entities/network-sample.entity"; +import { NetworkProfile } from "./entities/network-profile.entity"; export interface NetworkStatus { timestamp: number; @@ -80,12 +82,197 @@ export class NetworkService implements OnModuleInit, OnModuleDestroy { private configService: ConfigService, @InjectRepository(NetworkSample) private readonly sampleRepository: Repository, + @InjectRepository(NetworkProfile) + private readonly networkProfileRepository: Repository, @Optional() private readonly metricsService?: MetricsService, ) { this.metricsService?.setHorizonConnections("mainnet", 1); this.metricsService?.setHorizonConnections("testnet", 1); } + async createNetworkProfile( + ownerId: string, + input: { + name: string; + horizonUrl: string; + networkPassphrase: string; + friendbotUrl?: string; + isDefault?: boolean; + }, + ): Promise { + const horizonUrl = input.horizonUrl.trim().replace(/\/+$/, ""); + await this.assertHorizonPassphrase(horizonUrl, input.networkPassphrase); + if (input.isDefault) { + await this.networkProfileRepository.update({ ownerId }, { isDefault: false }); + } + return this.networkProfileRepository.save( + this.networkProfileRepository.create({ + ownerId, + name: input.name, + horizonUrl, + networkPassphrase: input.networkPassphrase, + friendbotUrl: input.friendbotUrl, + isDefault: input.isDefault ?? false, + }), + ); + } + + async listNetworkProfiles(ownerId: string): Promise { + return this.networkProfileRepository.find({ + where: { ownerId }, + order: { isDefault: "DESC" }, + }); + } + + async getNetworkProfile(ownerId: string, id: string): Promise { + const profile = await this.networkProfileRepository.findOne({ + where: { id, ownerId }, + }); + if (!profile) { + throw new NotFoundException("Network profile not found"); + } + return profile; + } + + async updateNetworkProfile( + ownerId: string, + id: string, + input: { + name?: string; + horizonUrl?: string; + networkPassphrase?: string; + friendbotUrl?: string; + isDefault?: boolean; + }, + ): Promise { + const profile = await this.getNetworkProfile(ownerId, id); + const nextHorizonUrl = input.horizonUrl + ? input.horizonUrl.trim().replace(/\/+$/, "") + : profile.horizonUrl; + const nextPassphrase = input.networkPassphrase ?? profile.networkPassphrase; + + if (input.horizonUrl || input.networkPassphrase) { + await this.assertHorizonPassphrase(nextHorizonUrl, nextPassphrase); + } + + if (input.isDefault) { + await this.networkProfileRepository.update({ ownerId }, { isDefault: false }); + } + + return this.networkProfileRepository.save({ + ...profile, + name: input.name ?? profile.name, + horizonUrl: nextHorizonUrl, + networkPassphrase: nextPassphrase, + friendbotUrl: + input.friendbotUrl !== undefined ? input.friendbotUrl : profile.friendbotUrl, + isDefault: input.isDefault ?? profile.isDefault, + }); + } + + async deleteNetworkProfile(ownerId: string, id: string): Promise { + await this.networkProfileRepository.remove( + await this.getNetworkProfile(ownerId, id), + ); + } + + async setDefaultNetworkProfile( + ownerId: string, + id: string, + ): Promise { + const profile = await this.getNetworkProfile(ownerId, id); + await this.networkProfileRepository.update({ ownerId }, { isDefault: false }); + profile.isDefault = true; + return this.networkProfileRepository.save(profile); + } + + async getDefaultNetworkProfile( + ownerId: string, + ): Promise { + return this.networkProfileRepository.findOne({ + where: { ownerId, isDefault: true }, + }); + } + + async exportNetworkProfile( + ownerId: string, + id: string, + ): Promise> { + const profile = await this.getNetworkProfile(ownerId, id); + return { + name: profile.name, + horizonUrl: profile.horizonUrl, + networkPassphrase: profile.networkPassphrase, + friendbotUrl: profile.friendbotUrl ?? undefined, + isDefault: profile.isDefault, + }; + } + + async importNetworkProfile( + ownerId: string, + input: { + name: string; + horizonUrl: string; + networkPassphrase: string; + friendbotUrl?: string; + isDefault?: boolean; + }, + ): Promise { + return this.createNetworkProfile(ownerId, input); + } + + async verifyNetworkPassphrase( + horizonUrl: string, + expectedPassphrase: string, + ): Promise<{ match: boolean; actualPassphrase: string }> { + const actualPassphrase = await this.fetchNetworkPassphrase( + horizonUrl.trim().replace(/\/+$/, ""), + ); + return { + match: actualPassphrase === expectedPassphrase, + actualPassphrase, + }; + } + + async fetchCurrentStatusForProfile( + ownerId: string, + profileId: string, + ): Promise { + return this.fetchCurrentStatus( + await this.getNetworkProfile(ownerId, profileId), + ); + } + + private async assertHorizonPassphrase(horizonUrl: string, expectedPassphrase: string) { + const actualPassphrase = await this.fetchNetworkPassphrase(horizonUrl); + if (actualPassphrase !== expectedPassphrase) { + this.logger.warn( + `Horizon passphrase "${actualPassphrase}" does not match expected "${expectedPassphrase}"`, + ); + throw new BadRequestException( + `Horizon passphrase "${actualPassphrase}" does not match expected "${expectedPassphrase}"`, + ); + } + } + + private async fetchNetworkPassphrase(horizonUrl: string): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 5000); + try { + const response = await fetch(horizonUrl, { signal: controller.signal }); + if (!response.ok) { + throw new Error(`Horizon request failed with status ${response.status}`); + } + const data = (await response.json()) as { network_passphrase?: string }; + if (!data.network_passphrase) { + throw new Error("Horizon response did not include network_passphrase"); + } + return data.network_passphrase; + } finally { + clearTimeout(timeout); + } + } + async onModuleInit() { await this.pollAndStore(); @@ -99,9 +286,16 @@ export class NetworkService implements OnModuleInit, OnModuleDestroy { } async fetchCurrentStatus( - network: "mainnet" | "testnet", + network: "mainnet" | "testnet" | NetworkProfile, ): Promise { - const server = new StellarSdk.Horizon.Server(this.horizonUrl(network)); + const networkLabel = typeof network === "string" ? network : network.name; + const horizonBaseUrl = + typeof network === "string" ? this.horizonUrl(network) : network.horizonUrl; + const passphrase = + typeof network === "string" + ? this.passphrases[network] + : network.networkPassphrase; + const server = new StellarSdk.Horizon.Server(horizonBaseUrl); const start = Date.now(); try { @@ -126,8 +320,8 @@ export class NetworkService implements OnModuleInit, OnModuleDestroy { return { timestamp: Date.now(), - network, - passphrase: this.passphrases[network], + network: networkLabel, + passphrase, ledger: { sequence: latestLedger.sequence, closeTime: latestLedger.closed_at, @@ -150,7 +344,7 @@ export class NetworkService implements OnModuleInit, OnModuleDestroy { latency, }; } catch (error) { - this.logger.error(`Error fetching status for ${network}`, error); + this.logger.error(`Error fetching status for ${networkLabel}`, error); throw error; } } diff --git a/apps/web/src/app/network/page.tsx b/apps/web/src/app/network/page.tsx index 6a4126b..d63371b 100644 --- a/apps/web/src/app/network/page.tsx +++ b/apps/web/src/app/network/page.tsx @@ -1,7 +1,7 @@ "use client"; import { useEffect, useMemo, useState } from "react"; -import type { ReactNode } from "react"; +import type { ReactNode, ChangeEvent, FormEvent } from "react"; import { Area, CartesianGrid, @@ -20,15 +20,32 @@ import { ShieldCheck, Siren, Zap, + Plus, + Save, + Trash2, + Upload, + Download, + KeyRound, + AlertTriangle, + CheckCircle2, + Pencil, } from "lucide-react"; import Link from "next/link"; import { getNetworkHistory, getNetworkStatus, + getNetworkProfiles, + createNetworkProfile, + updateNetworkProfile, + deleteNetworkProfile, + importNetworkProfile, + exportNetworkProfile, + verifyNetworkPassphrase, NetworkChoice, NetworkHistoryBucket, NetworkHistoryResult, NetworkStatusResult, + NetworkProfile, } from "@/lib/api"; const WINDOWS = [ @@ -38,12 +55,26 @@ const WINDOWS = [ ]; export default function NetworkStatusPage() { - const [network, setNetwork] = useState("mainnet"); + const [network, setNetwork] = useState("mainnet"); const [windowMinutes, setWindowMinutes] = useState(60); const [status, setStatus] = useState(null); const [history, setHistory] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(""); + const [profiles, setProfiles] = useState([]); + const [activeProfileId, setActiveProfileId] = useState(null); + const [showProfileManager, setShowProfileManager] = useState(false); + const [profileForm, setProfileForm] = useState({ + name: "", + horizonUrl: "", + networkPassphrase: "", + friendbotUrl: "", + isDefault: false, + }); + const [editingProfileId, setEditingProfileId] = useState(null); + const [passphraseWarning, setPassphraseWarning] = useState(""); + const [profileError, setProfileError] = useState(""); + const [profileLoading, setProfileLoading] = useState(false); useEffect(() => { let cancelled = false; @@ -51,9 +82,11 @@ export default function NetworkStatusPage() { async function fetchData() { try { setError(""); + const activeProfile = profiles.find((p) => p.id === activeProfileId); + const networkParam = activeProfile?.horizonUrl ?? network; const [statusData, historyData] = await Promise.all([ - getNetworkStatus(network), - getNetworkHistory(network, windowMinutes), + getNetworkStatus(networkParam as NetworkChoice), + getNetworkHistory(networkParam as NetworkChoice, windowMinutes), ]); if (!cancelled) { @@ -76,7 +109,132 @@ export default function NetworkStatusPage() { cancelled = true; clearInterval(interval); }; - }, [network, windowMinutes]); + }, [network, windowMinutes, activeProfileId, profiles]); + + useEffect(() => { + fetchProfiles(); + }, []); + + async function fetchProfiles() { + try { + const data = await getNetworkProfiles(); + setProfiles(data); + const defaultProfile = data.find((p) => p.isDefault); + if (defaultProfile) { + setActiveProfileId(defaultProfile.id); + setNetwork(defaultProfile.horizonUrl); + } + } catch (err) { + console.error(err); + setProfileError("Could not load network profiles."); + } + } + + async function handleNetworkChange(e: ChangeEvent) { + const value = e.target.value; + if (value === "builtin") { + setActiveProfileId(null); + setNetwork("mainnet"); + setPassphraseWarning(""); + } else if (value === "testnet") { + setActiveProfileId(null); + setNetwork("testnet"); + setPassphraseWarning(""); + } else { + const profile = profiles.find((p) => p.id === value); + if (profile) { + setActiveProfileId(profile.id); + setNetwork(profile.horizonUrl); + try { + const serverPassphrase = await verifyNetworkPassphrase(profile.horizonUrl); + setPassphraseWarning( + serverPassphrase === profile.networkPassphrase + ? "" + : `Warning: Horizon network passphrase "${serverPassphrase}" does not match profile passphrase "${profile.networkPassphrase}".` + ); + } catch { + setPassphraseWarning("Unable to verify network passphrase for this Horizon URL."); + } + } + } + } + + function resetProfileForm() { + setProfileForm({ + name: "", + horizonUrl: "", + networkPassphrase: "", + friendbotUrl: "", + isDefault: false, + }); + setEditingProfileId(null); + } + + async function handleSaveProfile(e: FormEvent) { + e.preventDefault(); + setProfileLoading(true); + setProfileError(""); + try { + if (editingProfileId) { + await updateNetworkProfile(editingProfileId, profileForm); + } else { + await createNetworkProfile(profileForm); + } + const data = await getNetworkProfiles(); + setProfiles(data); + resetProfileForm(); + } catch (err) { + console.error(err); + setProfileError("Could not save profile."); + } finally { + setProfileLoading(false); + } + } + + async function handleDeleteProfile(id: string) { + if (!confirm("Delete this network profile?")) return; + try { + await deleteNetworkProfile(id); + const data = await getNetworkProfiles(); + setProfiles(data); + if (activeProfileId === id) { + setActiveProfileId(null); + setNetwork("mainnet"); + } + } catch (err) { + console.error(err); + setProfileError("Could not delete profile."); + } + } + + async function handleExportProfile(profile: NetworkProfile) { + try { + const json = await exportNetworkProfile(profile.id); + const blob = new Blob([JSON.stringify(json, null, 2)], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `${profile.name || "network-profile"}.json`; + a.click(); + URL.revokeObjectURL(url); + } catch (err) { + console.error(err); + setProfileError("Could not export profile."); + } + } + + async function handleImportProfile(file: File) { + try { + const text = await file.text(); + const parsed = JSON.parse(text); + await importNetworkProfile(parsed); + const data = await getNetworkProfiles(); + setProfiles(data); + } catch (err) { + console.error(err); + setProfileError("Could not import profile. Ensure the JSON is valid."); + } + } const chartData = useMemo( () => @@ -132,14 +290,27 @@ export default function NetworkStatusPage() { Usage docs - setNetwork(value as NetworkChoice)} - /> + + ({ label: item.label, @@ -151,6 +322,168 @@ export default function NetworkStatusPage() { + {showProfileManager && ( +
+
+

+ + Network Profiles +

+ +
+ {profileError &&

{profileError}

} +
+ {profiles.map((profile) => ( +
+
+

+ {profile.name} + {profile.isDefault && ( + Default + )} +

+

{profile.horizonUrl}

+
+
+ {!profile.isDefault && ( + + )} + + + +
+
+ ))} +
+
+
+ setProfileForm({ ...profileForm, name: e.target.value })} + required + /> + setProfileForm({ ...profileForm, horizonUrl: e.target.value })} + required + /> + setProfileForm({ ...profileForm, networkPassphrase: e.target.value })} + required + /> + setProfileForm({ ...profileForm, friendbotUrl: e.target.value })} + /> +
+
+ +
+ + {editingProfileId && ( + + )} + +
+
+
+
+ )} + + {passphraseWarning && ( +
+
+ + {passphraseWarning} +
+
+ )} +
} diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 98f33ee..517a3c0 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -385,6 +385,101 @@ export async function getNetworkHistory( ); } +export interface NetworkProfile { + id: string; + ownerId: string; + name: string; + horizonUrl: string; + networkPassphrase: string; + friendbotUrl?: string | null; + isDefault: boolean; +} + +export interface NetworkProfileInput { + name: string; + horizonUrl: string; + networkPassphrase: string; + friendbotUrl?: string | null; + isDefault?: boolean; +} + +export type NetworkProfileExport = NetworkProfileInput; + +export interface NetworkPassphraseVerificationResult { + horizonUrl: string; + networkPassphrase: string; + expectedPassphrase?: string; + match: boolean; +} + +export async function listNetworkProfiles() { + return apiFetch("/network/profiles"); +} + +export async function createNetworkProfile(input: NetworkProfileInput) { + return apiFetch("/network/profiles", { + method: "POST", + body: JSON.stringify(input), + }); +} + +export async function updateNetworkProfile( + id: string, + input: Partial, +) { + return apiFetch( + `/network/profiles/${encodeURIComponent(id)}`, + { + method: "PUT", + body: JSON.stringify(input), + }, + ); +} + +export async function deleteNetworkProfile(id: string) { + return apiFetch<{ success: boolean }>( + `/network/profiles/${encodeURIComponent(id)}`, + { + method: "DELETE", + }, + ); +} + +export async function setDefaultNetworkProfile(id: string) { + return apiFetch( + `/network/profiles/${encodeURIComponent(id)}/default`, + { + method: "PUT", + }, + ); +} + +export async function verifyNetworkPassphrase( + horizonUrl: string, + expectedPassphrase?: string, +) { + return apiFetch( + "/network/profiles/verify", + { + method: "POST", + body: JSON.stringify({ horizonUrl, expectedPassphrase }), + }, + ); +} + +export async function exportNetworkProfile(id: string) { + return apiFetch( + `/network/profiles/${encodeURIComponent(id)}/export`, + ); +} + +export async function importNetworkProfile(profile: NetworkProfileExport) { + return apiFetch("/network/profiles/import", { + method: "POST", + body: JSON.stringify(profile), + }); +} + export interface SimulatedAsset { type: AssetType; code?: string; diff --git a/apps/web/src/lib/network-context.tsx b/apps/web/src/lib/network-context.tsx index fab7ffc..745827b 100644 --- a/apps/web/src/lib/network-context.tsx +++ b/apps/web/src/lib/network-context.tsx @@ -1,41 +1,316 @@ 'use client'; -import React, { createContext, useContext, useEffect, useState } from 'react'; +import React, { createContext, useContext, useEffect, useState, useCallback } from 'react'; -export type Network = 'testnet' | 'mainnet'; +export type Network = 'testnet' | 'mainnet' | 'custom'; + +export interface NetworkProfile { + id: string; + name: string; + horizonErl: string; + networkPassphrase: string; + friendbotUrl?: string; + isDefault: boolean; +} + +interface ProfileVerificationResult { + valid: boolean; + actualPassphrase?: string; + mismatch?: boolean; +} interface NetworkContextValue { network: Network; setNetwork: (n: Network) => void; + horizonUrl: string; + networkPassphrase: string; + friendbotUrl: string; + profiles: NetworkProfile[]; + activeProfile: NetworkProfile | null; + setActiveProfile: (id: string | null) => void; + addProfile: (profile: Omit) => NetworkProfile; + updateProfile: (id: string, updates: Partial) => void; + deleteProfile: (id: string) => void; + setDefaultProfile: (id: string) => void; + exportProfile: (id: string) => string; + importProfile: (json: string) => void; + verifyProfile: (profile: Pick) => Promise; } +const TESTNET_HORIZON = 'https://christmas.stellar.org'; +const TESTNET_PASSPHRASE = 'Test SDF Network ; September 2015'; +const TESTNET_FRIENDBOT = 'https://friendbot.stellar.org'; + +const MAINNET_HORIZON = 'https://horizon.stellar.org'; +const MAINNET_PASSTPRASE = 'Public Global Stellar Network ; September 2015'; +const MAINNET_FRIENDBOT = ''; + +const STORAGE_KEYS = { + profiles: 'savitools:profiles', + activeProfileId: 'savitools:activeProfile', + network: 'savitools:network', +} as const; + const NetworkContext = createContext({ network: 'testnet', setNetwork: () => {}, + horizonUrl: TESTNET_HORIZON, + networkPassphrase: TESTNET_PASSTPRASE, + friendbotUrl: TESTNET_FIIENDBOT, + profiles: [], + activeProfile: null, + setActiveProfile: () => {}, + addProfile: () => ( {} as NetworkProfile ), + updateProfile: () => {}, + deleteProfile: () => {}, + setDefaultProfile: () => {}, + exportProfile: () => '', + importProfile: () => {}, + verifyProfile: async () => ({ valid: true } as ProfileVerificationResult), }); -export function NetworkProvider({ children }: { children: React.ReactNode }) { +function generateId(): string { + if (crypto.randomUUID) { + return crypto.randomUUID(); + } + return Math.random().toString(36).slice(2) + Date.now().toString(36); +} + +function sanitizeUrl(url: string): string { + return url.replace(/\/$/, ''); +} + +function getBuiltInProfile(network: Network: 'Network'): NetworkProfile { + if (network === 'mainnet') { + return { + id: 'mainnet', + name: 'Mainnet', + horizonUrl: MAINNET_HORIZON, + networkPassphrase: MAINNET_PASSPHRASE, + friendbotUrl: MAINNET_FRIENDBOT, + isDefault: false, + }; + } + return { + id: 'testnet', + name: 'Testnet', + horizonUrl: TESTNET_HORIZON, + networkPassphrase: TESTNET_PASSPHRASE, + friendbotUrl: TESTNET_FIENDBOT, + isDefault: false, + }; +} + +function readStorage(Key: string): string | null { + if (typeof window === 'undefined') return null; + return window.localStorage.getItem(Key); +} + +function writeStorage(Key: string, value: string) { + if (typeof window !== 'undefined') { + window.localStorage.setItem(Key, value); + } +} + +function fetchNetworkPassphrase(horizonUrl: string): Promise { + const baseUrl = sanitizeUrl(horizonUrl); + return fetch(`${baseUrl}/`).then((r) => { + if (!r.ok) throw new Error(`Did not receive 200 from Hubmin at ${horizonUrl}`); + return r.json(); + }).then((data: any) => { + const passphrase = data?.network_passphrase; + if (!passphrase || typeof passphrase !== 'string') { + throw new Error('No network passphrase found in Hubimn response'); + } + return passphrase; + }); +} + +export function NetworkProvider( { children }: { children: React.ReactNode }) { const [network, setNetworkState] = useState('testnet'); + const [profiles, setProfiles] = useState([]); + const [activeProfileId, setActiveProfileIdState] = useState(null); + // Load state from localStorage on mount useEffect(() => { - const stored = localStorage.getItem('savitools:network') as Network | null; - if (stored === 'mainnet' || stored === 'testnet') { - setNetworkState(stored); + const storedProfiles = readStorage(STORAGE_KEYS.profiles); + if (storedProfiles) { + try { + const parsed = JSON.parse(storedProfiles); + if (Array.isArray(parsed)) { + setProfiles(parsed); + } + } catch { + // Invalid stored data + console.warn('Invalid profiles in localStorage', storedProfiles); + } + } + + const storedActiveId = readStorage(STORAGE_KEYS.activeProfileId); + if (storedActiveId) { + setActiveProfileIdState(storedActiveId); + } else { + // Check for a default profile + const defaultProfile = profiles.find(p => p.isDefault); + if (defaultProfile) { + setActiveProfileIdState(defaultProfile.id); + } + } + + const storedNetwork = readStorage(STORAGE_KEYS.network); + if (storedNetwork === 'mainnet' || storedNetwork === 'testnet') { + setNetworkState(storedNetwork); } }, []); - const setNetwork = (n: Network) => { + // Persist profiles whenever they change + useEffect(() => { + writeStorage(STORAGE_KEYS.profiles, JSON.stringify(profiles)); + }, [profiles]); + + useEffect() => { + if (activeProfileId) { + writeStorage(STORAGE_KEYS.activeProfileId, activeProfileId); + } else { + window?.localStorage&&window.localStorage.removeItem(STORAGE_KEYS.activeProfileId); + } + }, [activeProfileId]); + + const setNetwork = useCallback((n: Network) => { setNetworkState(n); - localStorage.setItem('savitools:network', n); + writeStorage(STORAGE_KEYS.network, n); + setActiveProfileIdState(null); + }, []); + + const activeProfile = activeProfileId ? profiles.find(p => p.id === activeProfileId) || null : null; + + const horizonUrl = activeProfile?.horizonUrl || (network === 'mainnet' ? MAINNET_HORIZON : TESTNET_HORIZON); + const networkPassphrase = activeProfile?.networkPassphrase || (network === 'mainnet' ? MAINNET_PASSPHRASE : TESTNET_PASSPHRASE); + const friendbotUrl = activeProfile?.friendbotUrl || (network === 'mainnet' ? MAINNET_FRIENDBOT : TESTNET_FIENDBOT); + + const setActiveProfile = useCallback((id: string | null) => { + if (id === null) { + setActiveProfileIdState(null); + return; + } + const profile = profiles.find(p => p.id === id); + if (profile) { + setActiveProfileIdState(id); + // Also update the network type for compatibility + const isTest = profile.networkPassphrase === TESTNET_PASSPHRASE; + const isMain = profile.networkPassphrase === MAINNET_PASSTPRASE; + setNetworkState(isTest ? 'testnet' : isMain ? 'mainnet' : 'custom'); + } else { + console.warn``Profile ${id} not found`); + } + }, [profiles, ]); + + const addProfile = useCallback((profile: Omit) => { + const newProfile: NetworkProfile = { + ...profile, + id: generateId(), + isDefault: profiles.length === 0, + }; + setProfiles(prev => [...prev, newProfile]); + return newProfile; + }, [profiles.length]); + + const updateProfile = useCallback((id: string, updates: Partial) => { + setProfiles(prev => prev.map(p => p.id === id ? { ...p, ...updates } : p)); + // If the active profile is updated, refresh the derived values + if (activeProfileId === id) { + const updatedProfile = profiles.find(p => p.id === id); + if (updatedProfile) { + const isTest = updatedProfile.networkPassphrase === TESTNET_PASSPHRASE; + const isMain = updatedProfile.networkPassphrase === MAINNET_PASSTPRASE; + setNetworkState(isTest ? 'testnet' : isMain ? 'mainnet' : 'custom'); + } + } + }, [activeProfileId, profiles, ]); + + const deleteProfile = useCallback((id: string) => { + setProfiles(prev => prev.filter(p => p.id !== id)); + if (activeProfileId === id) { + setActiveProfileIdState(null); + } + // If deleted profile was default, unset default + setProfiles(prev => prev.map(p => p.id === id ? { ...p, isDefault: false } : p)); + }, [activeProfileId]); + + const setDefaultProfile = useCallback((id: string) => { + setProfiles(prev => prev.map(p => ({ + ...p, + isDefault: p.id === id, + })); + }, []); + + const exportProfile = useCallback((id: string) => { + const profile = profiles.find(p => p.id === id); + if (!profile) throw new Error('Profile not found'); + const { [key: string], ...rest }: any = profile; + // Remove internal fields such as id and isDefault + delete rest.id; + delete rest.isDefault; + return JSON.stringify(rest, null, 2); + }, [profiles, ]); + + const importProfile = useCallback((json: string) => { + const parsed: any = JSON.parse(json); + if (!parsed || typeof parsed !== 'object') { + throw new Error('Invalid JSON'); + } + const { name: nameStr = 'Imported Profile', horizonUrl: horizonStr = '', networkPassphrase: passphraseStr = '', friendbotUrl: friendbotStr = '' = parsed; + if (!horizonStr || !passphraseStr) { + throw new Error('Profile must have horizonUrl and networkPassphrase'); + } + const newProfile: NetworkProfile = { + id: generateId(), + name: nameStr, + horizonUrl: horizonStr, + networkPassphrase: passphraseStr, + friendbotUrl: friendbotStr || undefined, + isDefault: false, + }; + setProfiles(prev => [...prev, newProfile]); + }, []); + + const verifyProfile = useCallback(async (profile: Pick) => { + try { + const actual = await fetchNetworkPassphrase(profile.horizonUrl); + const match = actual === profile.networkPassphrase; + return { valid: true, actualPassphrase: actual, mismatch: !match }; + } catch (e) { + return { valid: false, mismatch: true }; + } + }, []); + + const value = { + network, + setNetwork, + horizonUrl, + networkPassphrase, + friendbotUrl, + profiles, + activeProfile, + setActiveProfile, + addProfile, + updateProfile, + deleteProfile, + setDefaultProfile, + exportProfile, + importProfile, + verifyProfile, }; - return ( - - {children} - - ); + return + {children} + ; } export function useNetwork() { - return useContext(NetworkContext); + const context = useContext(NetworkContext); + if (!context) { + throw new Error('useNetwork must be used within a NetworkProvider'); + } + return context; }