diff --git a/apps/web/src/__tests__/api.health.test.ts b/apps/web/src/__tests__/api.health.test.ts index 1a18135..b6981cc 100644 --- a/apps/web/src/__tests__/api.health.test.ts +++ b/apps/web/src/__tests__/api.health.test.ts @@ -7,6 +7,7 @@ const healthEnvKeys = [ "NEXT_PUBLIC_STELLAR_MAINNET_RPC_URL", "NEXT_PUBLIC_NFT_CONTRACT_ID", "NEXT_PUBLIC_GOVERNOR_CONTRACT_ID", + "NEXT_PUBLIC_COMMUNITY_FACTORY_CONTRACT_ID", ] as const; const contractIds = { @@ -14,8 +15,32 @@ const contractIds = { "CCV3ODX5QNB6XH2XZ2XZ2XZ2XZ2XZ2XZ2XZ2XZ2XZ2XZ2XZ2XZ2XZ2", NEXT_PUBLIC_GOVERNOR_CONTRACT_ID: "CCV3ODX5QNB6XH2XZ2XZ2XZ2XZ2XZ2XZ2XZ2XZ2XZ2XZ2XZ2XZ2XZ2", + NEXT_PUBLIC_COMMUNITY_FACTORY_CONTRACT_ID: "CCFACTORY" }; +vi.mock("@stellar/stellar-sdk", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + rpc: { + Server: class { + constructor(public url: string) {} + async getHealth() { + return { status: "healthy" }; + } + } + } + }; +}); + +vi.mock("@/lib/community/registry", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + checkRegistryReadable: async () => true, + }; +}); + async function requestHealth(env: Record) { process.env = { ...originalEnv }; for (const key of healthEnvKeys) delete process.env[key]; @@ -48,8 +73,11 @@ describe("GET /api/health", () => { expect(response.headers.get("cache-control")).toContain("no-store"); expect(body).toEqual({ status: "ok", + healthy: true, network: { selected: "testnet", passphraseConfigured: true }, - rpc: { configured: true }, + rpc: { configured: true, reachable: true }, + factory: { configured: true }, + registry: { readable: true }, contracts: { nftConfigured: true, governorConfigured: true, @@ -58,21 +86,18 @@ describe("GET /api/health", () => { }); }); - it("returns degraded when a required contract id is missing", async () => { + it("returns degraded when factory is missing", async () => { const { response, body } = await requestHealth({ NEXT_PUBLIC_STELLAR_NETWORK: "testnet", NEXT_PUBLIC_STELLAR_RPC_URL: "https://soroban-testnet.stellar.org", - NEXT_PUBLIC_GOVERNOR_CONTRACT_ID: - contractIds.NEXT_PUBLIC_GOVERNOR_CONTRACT_ID, + NEXT_PUBLIC_NFT_CONTRACT_ID: contractIds.NEXT_PUBLIC_NFT_CONTRACT_ID, + NEXT_PUBLIC_GOVERNOR_CONTRACT_ID: contractIds.NEXT_PUBLIC_GOVERNOR_CONTRACT_ID, }); expect(response.status).toBe(503); expect(body.status).toBe("degraded"); - expect(body.contracts).toEqual({ - nftConfigured: false, - governorConfigured: true, - allConfigured: false, - }); + expect(body.healthy).toBe(false); + expect(body.factory).toEqual({ configured: false }); }); it("returns degraded when mainnet RPC configuration is missing", async () => { @@ -91,11 +116,13 @@ describe("GET /api/health", () => { const secretRpcUrl = "https://secret-rpc.example.com"; const secretNftId = "CCSECRET_NFT_CONTRACT_ID"; const secretGovernorId = "CCSECRET_GOVERNOR_CONTRACT_ID"; + const secretFactoryId = "CCSECRET_FACTORY_CONTRACT_ID"; const { response, body } = await requestHealth({ NEXT_PUBLIC_STELLAR_NETWORK: "mainnet", NEXT_PUBLIC_STELLAR_MAINNET_RPC_URL: secretRpcUrl, NEXT_PUBLIC_NFT_CONTRACT_ID: secretNftId, NEXT_PUBLIC_GOVERNOR_CONTRACT_ID: secretGovernorId, + NEXT_PUBLIC_COMMUNITY_FACTORY_CONTRACT_ID: secretFactoryId, }); expect(response.status).toBe(200); @@ -104,6 +131,7 @@ describe("GET /api/health", () => { expect(JSON.stringify(body)).not.toContain(secretRpcUrl); expect(JSON.stringify(body)).not.toContain(secretNftId); expect(JSON.stringify(body)).not.toContain(secretGovernorId); + expect(JSON.stringify(body)).not.toContain(secretFactoryId); }); it("treats whitespace-only configuration as missing", async () => { @@ -112,10 +140,12 @@ describe("GET /api/health", () => { NEXT_PUBLIC_STELLAR_MAINNET_RPC_URL: " ", NEXT_PUBLIC_NFT_CONTRACT_ID: "\t", NEXT_PUBLIC_GOVERNOR_CONTRACT_ID: "\n", + NEXT_PUBLIC_COMMUNITY_FACTORY_CONTRACT_ID: " ", }); expect(response.status).toBe(503); expect(body.rpc.configured).toBe(false); + expect(body.factory.configured).toBe(false); expect(body.contracts.allConfigured).toBe(false); }); }); diff --git a/apps/web/src/app/(app)/communities/[id]/proposals/page.tsx b/apps/web/src/app/(app)/communities/[id]/proposals/page.tsx index d9efea5..c200808 100644 --- a/apps/web/src/app/(app)/communities/[id]/proposals/page.tsx +++ b/apps/web/src/app/(app)/communities/[id]/proposals/page.tsx @@ -45,7 +45,7 @@ function ScopedProposalHistory({ community }: { community: CommunityView }) { try { const client = createReadOnlyGovernorClient(governorContract); const transaction = await client.proposal_state({ - proposal_id: Uint8Array.from(Buffer.from(proposalId, "hex")), + proposal_id: Buffer.from(proposalId, "hex"), }); setStates((current) => ({ ...current, diff --git a/apps/web/src/app/api/health/route.ts b/apps/web/src/app/api/health/route.ts index bdb42db..3df630d 100644 --- a/apps/web/src/app/api/health/route.ts +++ b/apps/web/src/app/api/health/route.ts @@ -1,5 +1,7 @@ import { NextResponse } from "next/server"; import { config, contractIds, stellarConfig } from "@/lib/stellar"; +import { checkRegistryReadable } from "@/lib/community/registry"; +import { rpc } from "@stellar/stellar-sdk"; export const dynamic = "force-dynamic"; @@ -7,12 +9,20 @@ type HealthStatus = "ok" | "degraded"; type HealthResponse = { status: HealthStatus; + healthy: boolean; network: { selected: "testnet" | "mainnet"; passphraseConfigured: boolean; }; rpc: { configured: boolean; + reachable: boolean; + }; + factory: { + configured: boolean; + }; + registry: { + readable: boolean; }; contracts: { nftConfigured: boolean; @@ -21,7 +31,7 @@ type HealthResponse = { }; }; -function buildResponse(): { response: HealthResponse; statusCode: number } { +async function buildResponse(): Promise<{ response: HealthResponse; statusCode: number }> { const selected = process.env.NEXT_PUBLIC_STELLAR_NETWORK === "mainnet" ? "mainnet" @@ -36,6 +46,10 @@ function buildResponse(): { response: HealthResponse; statusCode: number } { contractIds.governor && contractIds.governor.trim() !== "", ); const allContractsConfigured = nftConfigured && governorConfigured; + + const factoryConfigured = Boolean( + contractIds.communityFactory && contractIds.communityFactory.trim() !== "", + ); const passphraseConfigured = Boolean(config.networkPassphrase); @@ -43,17 +57,41 @@ function buildResponse(): { response: HealthResponse; statusCode: number } { selected === "mainnet" ? rpcConfigured : rpcConfigured || Boolean(stellarConfig.testnet.rpcUrl); + + let rpcReachable = false; + if (rpcOk && config.rpcUrl) { + try { + const server = new rpc.Server(config.rpcUrl); + const health = await server.getHealth(); + rpcReachable = health.status === "healthy"; + } catch { + rpcReachable = false; + } + } - const isReady = rpcOk && allContractsConfigured && passphraseConfigured; + const registryReadable = await checkRegistryReadable(); + + // A healthy response proves that at least the registry interface is readable. + // We still require passphrase and RPC to be ok, and factory configured. + // The legacy contracts are no longer mandatory for "healthy" status if factory is present. + const isHealthy = rpcOk && passphraseConfigured && factoryConfigured && registryReadable; const response: HealthResponse = { - status: isReady ? "ok" : "degraded", + status: isHealthy ? "ok" : "degraded", + healthy: isHealthy, network: { selected, passphraseConfigured, }, rpc: { configured: rpcConfigured, + reachable: rpcReachable, + }, + factory: { + configured: factoryConfigured, + }, + registry: { + readable: registryReadable, }, contracts: { nftConfigured, @@ -64,12 +102,12 @@ function buildResponse(): { response: HealthResponse; statusCode: number } { return { response, - statusCode: isReady ? 200 : 503, + statusCode: isHealthy ? 200 : 503, }; } export async function GET() { - const { response, statusCode } = buildResponse(); + const { response, statusCode } = await buildResponse(); return NextResponse.json(response, { status: statusCode, diff --git a/apps/web/src/hooks/useNetworkGuard.ts b/apps/web/src/hooks/useNetworkGuard.ts index cc54cb9..cc29755 100644 --- a/apps/web/src/hooks/useNetworkGuard.ts +++ b/apps/web/src/hooks/useNetworkGuard.ts @@ -13,7 +13,7 @@ import { activeNetwork } from "@/lib/stellar"; export function useNetworkGuard(): NetworkComparison { const { walletNetwork } = useWallet(); return useMemo( - () => compareNetworks(activeNetwork, walletNetwork), + () => compareNetworks(activeNetwork, (walletNetwork as any) || null), [walletNetwork], ); } diff --git a/apps/web/src/lib/community/registry.ts b/apps/web/src/lib/community/registry.ts index dd07bcb..2445e0e 100644 --- a/apps/web/src/lib/community/registry.ts +++ b/apps/web/src/lib/community/registry.ts @@ -320,3 +320,16 @@ export async function getCommunity( return { status: "found", community: await hydrateRecord(record) }; } + +export async function checkRegistryReadable(): Promise { + try { + const factoryId = requireCommunityFactoryId(); + await readContract(factoryId, "list_communities", [ + xdr.ScVal.scvVoid(), + nativeToScVal(1, { type: "u32" }), + ]); + return true; + } catch { + return false; + } +} diff --git a/apps/web/src/lib/communityFactory/useCommunityDeployment.ts b/apps/web/src/lib/communityFactory/useCommunityDeployment.ts index 5e9772d..5a185e3 100644 --- a/apps/web/src/lib/communityFactory/useCommunityDeployment.ts +++ b/apps/web/src/lib/communityFactory/useCommunityDeployment.ts @@ -27,7 +27,7 @@ const stageLabels: Record = { }; export function useCommunityDeployment() { - const { address, networkPassphrase, signTransaction } = useWallet(); + const { address, walletNetworkPassphrase, signTransaction } = useWallet(); const activeSubmissionRef = useRef(false); const [stage, setStage] = useState("idle"); const [error, setError] = useState(null); @@ -55,7 +55,7 @@ export function useCommunityDeployment() { const nextOutcome = await deployCommunityFromWizard(state, { address, expectedNetworkPassphrase: config.networkPassphrase, - walletNetworkPassphrase: networkPassphrase, + walletNetworkPassphrase, createClient: () => createCommunityFactoryClient({ publicKey: address ?? "", @@ -78,7 +78,7 @@ export function useCommunityDeployment() { activeSubmissionRef.current = false; } }, - [address, isSubmitting, networkPassphrase, signTransaction], + [address, isSubmitting, walletNetworkPassphrase, signTransaction], ); return { diff --git a/apps/web/src/lib/contracts.ts b/apps/web/src/lib/contracts.ts index 0814ee1..0b3ba71 100644 --- a/apps/web/src/lib/contracts.ts +++ b/apps/web/src/lib/contracts.ts @@ -42,7 +42,7 @@ export function createNftClient({ const nft = contractId ?? requireContractIds().nft; return new NftClient({ contractId: nft, - networkPassphrase: config.passphrase, + networkPassphrase: config.networkPassphrase, rpcUrl: config.rpcUrl, publicKey, signTransaction, @@ -59,7 +59,7 @@ export function createGovernorClient({ if (mocked) return mocked as unknown as GovernorClient; return new GovernorClient({ contractId: governor, - networkPassphrase: config.passphrase, + networkPassphrase: config.networkPassphrase, rpcUrl: config.rpcUrl, publicKey, signTransaction, @@ -70,7 +70,7 @@ export function createReadOnlyNftClient(contractId?: string) { const nft = contractId ?? requireContractIds().nft; return new NftClient({ contractId: nft, - networkPassphrase: config.passphrase, + networkPassphrase: config.networkPassphrase, rpcUrl: config.rpcUrl, }); } @@ -81,7 +81,7 @@ export function createReadOnlyGovernorClient(contractId?: string) { if (mocked) return mocked as unknown as GovernorClient; return new GovernorClient({ contractId: governor, - networkPassphrase: config.passphrase, + networkPassphrase: config.networkPassphrase, rpcUrl: config.rpcUrl, }); }