Skip to content
Closed
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
48 changes: 39 additions & 9 deletions apps/web/src/__tests__/api.health.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,40 @@ 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 = {
NEXT_PUBLIC_NFT_CONTRACT_ID:
"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<any>();
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<any>();
return {
...actual,
checkRegistryReadable: async () => true,
};
});

async function requestHealth(env: Record<string, string>) {
process.env = { ...originalEnv };
for (const key of healthEnvKeys) delete process.env[key];
Expand Down Expand Up @@ -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,
Expand All @@ -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 () => {
Expand All @@ -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);
Expand All @@ -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 () => {
Expand All @@ -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);
});
});
2 changes: 1 addition & 1 deletion apps/web/src/app/(app)/communities/[id]/proposals/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
48 changes: 43 additions & 5 deletions apps/web/src/app/api/health/route.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,28 @@
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";

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;
Expand All @@ -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"
Expand All @@ -36,24 +46,52 @@ 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);

const rpcOk =
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,
Expand All @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/hooks/useNetworkGuard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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],
);
}
13 changes: 13 additions & 0 deletions apps/web/src/lib/community/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -320,3 +320,16 @@ export async function getCommunity(

return { status: "found", community: await hydrateRecord(record) };
}

export async function checkRegistryReadable(): Promise<boolean> {
try {
const factoryId = requireCommunityFactoryId();
await readContract(factoryId, "list_communities", [
xdr.ScVal.scvVoid(),
nativeToScVal(1, { type: "u32" }),
]);
return true;
} catch {
return false;
}
}
6 changes: 3 additions & 3 deletions apps/web/src/lib/communityFactory/useCommunityDeployment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ const stageLabels: Record<DeploymentStage, string> = {
};

export function useCommunityDeployment() {
const { address, networkPassphrase, signTransaction } = useWallet();
const { address, walletNetworkPassphrase, signTransaction } = useWallet();
const activeSubmissionRef = useRef(false);
const [stage, setStage] = useState<DeploymentStage>("idle");
const [error, setError] = useState<string | null>(null);
Expand Down Expand Up @@ -55,7 +55,7 @@ export function useCommunityDeployment() {
const nextOutcome = await deployCommunityFromWizard(state, {
address,
expectedNetworkPassphrase: config.networkPassphrase,
walletNetworkPassphrase: networkPassphrase,
walletNetworkPassphrase,
createClient: () =>
createCommunityFactoryClient({
publicKey: address ?? "",
Expand All @@ -78,7 +78,7 @@ export function useCommunityDeployment() {
activeSubmissionRef.current = false;
}
},
[address, isSubmitting, networkPassphrase, signTransaction],
[address, isSubmitting, walletNetworkPassphrase, signTransaction],
);

return {
Expand Down
8 changes: 4 additions & 4 deletions apps/web/src/lib/contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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,
});
}
Expand All @@ -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,
});
}
Expand Down