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
50 changes: 37 additions & 13 deletions apps/web/e2e/fixtures/mockWallet.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import type { Page } from "@playwright/test";
import { Keypair, Networks } from "@stellar/stellar-sdk";

export const MOCK_WALLET_ADDRESS = Keypair.fromRawEd25519Seed(
const MOCK_WALLET_KEYPAIR = Keypair.fromRawEd25519Seed(
Buffer.alloc(32, 7),
).publicKey();
);
export const MOCK_WALLET_ADDRESS = MOCK_WALLET_KEYPAIR.publicKey();

export type MockWalletSetup = {
network?: string;
Expand All @@ -15,19 +16,42 @@ export async function configureMockWallet(
page: Page,
setup: MockWalletSetup = {},
) {
await page.addInitScript((controls) => {
window.__stollaMockWallet = controls;
}, {
network: "TESTNET",
networkPassphrase: Networks.TESTNET,
rejectSignature: false,
...setup,
});
await page.addInitScript(
({ controls, address, secretKey }) => {
window.__stollaMockWallet = controls;
window.__STOLLA_E2E__ = {
wallet: {
address,
networkPassphrase: controls.networkPassphrase,
rejected: controls.rejectSignature,
secretKey,
signedNetworkPassphrases: [],
},
communities: [],
proposals: {},
diagnostics: { submissions: 0, invocations: [] },
};
},
{
controls: {
network: "TESTNET",
networkPassphrase: Networks.TESTNET,
rejectSignature: false,
...setup,
},
address: MOCK_WALLET_ADDRESS,
secretKey: MOCK_WALLET_KEYPAIR.secret(),
},
);
}

/** The passphrases the application actually handed to the wallet for signing. */
export function signedNetworkPassphrases(page: Page): Promise<string[]> {
return page.evaluate(
() => window.__stollaMockWalletRecord?.signedNetworkPassphrases ?? [],
);
return page.evaluate(() => {
const bridgeRecord =
window.__STOLLA_E2E__?.wallet?.signedNetworkPassphrases;
return bridgeRecord?.length
? bridgeRecord
: window.__stollaMockWalletRecord?.signedNetworkPassphrases ?? [];
});
}
9 changes: 7 additions & 2 deletions apps/web/next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,18 @@ import { fileURLToPath } from "node:url";
const appDirectory = path.dirname(fileURLToPath(import.meta.url));
const repositoryRoot = path.resolve(appDirectory, "../..");

if (process.env.NODE_ENV === "production" && process.env.NEXT_PUBLIC_E2E_WALLET) {
if (
process.env.NODE_ENV === "production" &&
(process.env.NEXT_PUBLIC_E2E_WALLET ||
process.env.NEXT_PUBLIC_E2E_MOCKS === "true")
) {
throw new Error(
"NEXT_PUBLIC_E2E_WALLET is set. The mocked wallet must never be bundled into a production build.",
"An E2E browser fixture flag is set. Test fixtures must never be bundled into a production build.",
);
}

const nextConfig: NextConfig = {
allowedDevOrigins: ["127.0.0.1"],
turbopack: {
root: repositoryRoot,
},
Expand Down
13 changes: 13 additions & 0 deletions apps/web/src/__tests__/api.health.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,18 @@ const healthEnvKeys = [
"NEXT_PUBLIC_STELLAR_MAINNET_RPC_URL",
"NEXT_PUBLIC_NFT_CONTRACT_ID",
"NEXT_PUBLIC_GOVERNOR_CONTRACT_ID",
"NEXT_PUBLIC_COMMUNITY_FACTORY_CONTRACT_ID",
"NEXT_PUBLIC_GOVERNOR_START_LEDGER",
] as const;

const contractIds = {
NEXT_PUBLIC_NFT_CONTRACT_ID:
"CCV3ODX5QNB6XH2XZ2XZ2XZ2XZ2XZ2XZ2XZ2XZ2XZ2XZ2XZ2XZ2XZ2",
NEXT_PUBLIC_GOVERNOR_CONTRACT_ID:
"CCV3ODX5QNB6XH2XZ2XZ2XZ2XZ2XZ2XZ2XZ2XZ2XZ2XZ2XZ2XZ2XZ2",
NEXT_PUBLIC_COMMUNITY_FACTORY_CONTRACT_ID:
"CCV3ODX5QNB6XH2XZ2XZ2XZ2XZ2XZ2XZ2XZ2XZ2XZ2XZ2XZ2XZ2XZ2",
NEXT_PUBLIC_GOVERNOR_START_LEDGER: "12345",
};

async function requestHealth(env: Record<string, string>) {
Expand Down Expand Up @@ -55,6 +60,14 @@ describe("GET /api/health", () => {
governorConfigured: true,
allConfigured: true,
},
capabilities: {
rpc: true,
explorer: true,
communityFactory: true,
legacyContracts: true,
proposalDiscovery: true,
},
unavailableCapabilities: [],
});
});

Expand Down
99 changes: 40 additions & 59 deletions apps/web/src/app/api/health/route.ts
Original file line number Diff line number Diff line change
@@ -1,81 +1,62 @@
import { NextResponse } from "next/server";
import { config, contractIds, stellarConfig } from "@/lib/stellar";
import { activeCapabilities } from "@/lib/stellar";
import {
listUnavailableCapabilities,
type NetworkCapabilityName,
} from "@/lib/network";

export const dynamic = "force-dynamic";

type HealthStatus = "ok" | "degraded";

type HealthResponse = {
status: HealthStatus;
network: {
selected: "testnet" | "mainnet";
passphraseConfigured: boolean;
};
rpc: {
configured: boolean;
};
contracts: {
nftConfigured: boolean;
governorConfigured: boolean;
allConfigured: boolean;
};
status: "ok" | "degraded";
network: { selected: "testnet" | "mainnet"; passphraseConfigured: boolean };
rpc: { configured: boolean };
contracts: { nftConfigured: boolean; governorConfigured: boolean; allConfigured: boolean };
capabilities: Record<NetworkCapabilityName, boolean>;
unavailableCapabilities: NetworkCapabilityName[];
};

function buildResponse(): { response: HealthResponse; statusCode: number } {
const selected =
process.env.NEXT_PUBLIC_STELLAR_NETWORK === "mainnet"
? "mainnet"
: "testnet";

const rpcConfigured = Boolean(config.rpcUrl && config.rpcUrl.trim() !== "");

const nftConfigured = Boolean(
contractIds.nft && contractIds.nft.trim() !== "",
);
const governorConfigured = Boolean(
contractIds.governor && contractIds.governor.trim() !== "",
);
const allContractsConfigured = nftConfigured && governorConfigured;

const passphraseConfigured = Boolean(config.networkPassphrase);

const rpcOk =
selected === "mainnet"
? rpcConfigured
: rpcConfigured || Boolean(stellarConfig.testnet.rpcUrl);

const isReady = rpcOk && allContractsConfigured && passphraseConfigured;

const response: HealthResponse = {
status: isReady ? "ok" : "degraded",
network: {
selected,
passphraseConfigured,
},
rpc: {
configured: rpcConfigured,
},
contracts: {
nftConfigured,
governorConfigured,
allConfigured: allContractsConfigured,
},
};
const capabilities = activeCapabilities;
const nftConfigured = Boolean(capabilities.contracts.legacyNft);
const governorConfigured = Boolean(capabilities.contracts.legacyGovernor);
const legacyConfigured = capabilities.legacyContracts.available;
const factoryConfigured = capabilities.communityFactory.available;
const isReady = capabilities.rpc.available && (legacyConfigured || factoryConfigured);
const unavailableCapabilities = listUnavailableCapabilities(capabilities);

return {
response,
response: {
status: isReady ? "ok" : "degraded",
network: {
selected: capabilities.network.id,
passphraseConfigured: Boolean(capabilities.network.networkPassphrase),
},
rpc: { configured: capabilities.rpc.available },
contracts: {
nftConfigured,
governorConfigured,
allConfigured: legacyConfigured,
},
capabilities: {
rpc: capabilities.rpc.available,
explorer: capabilities.explorer.available,
communityFactory: factoryConfigured,
legacyContracts: legacyConfigured,
proposalDiscovery: capabilities.proposalDiscovery.available,
},
unavailableCapabilities,
},
statusCode: isReady ? 200 : 503,
};
}

export async function GET() {
const { response, statusCode } = buildResponse();

return NextResponse.json(response, {
status: statusCode,
headers: {
"Cache-Control":
"no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0",
"Cache-Control": "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0",
Pragma: "no-cache",
Expires: "0",
},
Expand Down
78 changes: 51 additions & 27 deletions apps/web/src/context/WalletProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ import { Networks, KitEventType } from "@creit.tech/stellar-wallets-kit/types";
import { FreighterModule } from "@creit.tech/stellar-wallets-kit/modules/freighter";
import type { SignTransaction } from "@stellar/stellar-sdk/contract";
import { getE2EBridge } from "@/lib/e2eMock";
import { config } from "@/lib/stellar";
import { activeNetwork } from "@/lib/stellar";
import { describeNetwork } from "@/lib/network";

export type WalletConnectionError = {
code: "request-rejected" | "wallet-unavailable" | "connection-failed";
Expand Down Expand Up @@ -112,9 +113,7 @@ function ensureKit() {
StellarWalletsKit.init({
modules: [new FreighterModule()],
network:
config.networkPassphrase === Networks.PUBLIC
? Networks.PUBLIC
: Networks.TESTNET,
activeNetwork.id === "mainnet" ? Networks.PUBLIC : Networks.TESTNET,
});
kitInitialized = true;
}
Expand All @@ -137,20 +136,14 @@ export function WalletProvider({ children }: { children: ReactNode }) {
const initialize = window.setTimeout(() => {
setAddress(mockedWallet.address);
setWalletNetworkPassphrase(mockedWallet.networkPassphrase);
setWalletNetwork(
mockedWallet.networkPassphrase === Networks.PUBLIC
? "mainnet"
: "testnet",
);
setWalletNetwork(describeNetwork(mockedWallet.networkPassphrase).id);
}, 0);
const interval = window.setInterval(() => {
const current = getE2EBridge()?.wallet;
if (!current) return;
setAddress(current.address);
setWalletNetworkPassphrase(current.networkPassphrase);
setWalletNetwork(
current.networkPassphrase === Networks.PUBLIC ? "mainnet" : "testnet",
);
setWalletNetwork(describeNetwork(current.networkPassphrase).id);
}, 100);
return () => {
window.clearTimeout(initialize);
Expand All @@ -165,11 +158,9 @@ export function WalletProvider({ children }: { children: ReactNode }) {
setAddress(updatedAddress);
setWalletNetworkPassphrase(event.payload.networkPassphrase || null);
setWalletNetwork(
event.payload.networkPassphrase === Networks.PUBLIC
? "mainnet"
: event.payload.networkPassphrase === Networks.TESTNET
? "testnet"
: "custom",
event.payload.networkPassphrase
? describeNetwork(event.payload.networkPassphrase).id ?? "custom"
: null,
);
if (updatedAddress) {
setConnectionError(null);
Expand All @@ -185,13 +176,28 @@ export function WalletProvider({ children }: { children: ReactNode }) {
setConnectionError(null);
setIsConnecting(true);
try {
if (
process.env.NEXT_PUBLIC_E2E_WALLET === "mock" &&
window.__stollaMockWallet
) {
const { MockWalletModule } = await import(
"@/testing/mockWalletModule"
);
const wallet = new MockWalletModule();
const [{ address: walletAddress }, selectedNetwork] = await Promise.all([
wallet.getAddress(),
wallet.getNetwork(),
]);
setAddress(walletAddress);
setWalletNetwork(selectedNetwork.network);
setWalletNetworkPassphrase(selectedNetwork.networkPassphrase);
return;
}
const mockedWallet = getE2EBridge()?.wallet;
if (mockedWallet) {
setAddress(mockedWallet.address);
setWalletNetworkPassphrase(mockedWallet.networkPassphrase);
setWalletNetwork(
mockedWallet.networkPassphrase === Networks.PUBLIC ? "mainnet" : "testnet",
);
setWalletNetwork(describeNetwork(mockedWallet.networkPassphrase).id);
return;
}
ensureKit();
Expand All @@ -200,11 +206,8 @@ export function WalletProvider({ children }: { children: ReactNode }) {
typeof StellarWalletsKit.getNetwork === "function"
? await StellarWalletsKit.getNetwork()
: {
network:
config.networkPassphrase === Networks.PUBLIC
? "mainnet"
: "testnet",
networkPassphrase: config.networkPassphrase,
network: activeNetwork.id,
networkPassphrase: activeNetwork.networkPassphrase,
};
setAddress(walletAddress);
setWalletNetwork(selectedNetwork.network);
Expand Down Expand Up @@ -235,6 +238,13 @@ export function WalletProvider({ children }: { children: ReactNode }) {
}, []);

const signTransaction = useCallback<SignTransaction>(async (xdr, options) => {
if (
process.env.NEXT_PUBLIC_E2E_WALLET === "mock" &&
window.__stollaMockWallet
) {
const { MockWalletModule } = await import("@/testing/mockWalletModule");
return new MockWalletModule().signTransaction(xdr, options);
}
const mockedWallet = getE2EBridge()?.wallet;
if (mockedWallet) {
if (mockedWallet.rejected) throw new Error("User rejected the request.");
Expand All @@ -244,18 +254,32 @@ export function WalletProvider({ children }: { children: ReactNode }) {
) {
throw new Error("Wallet network does not match the transaction network.");
}
if (mockedWallet.secretKey) {
const networkPassphrase =
options?.networkPassphrase ?? mockedWallet.networkPassphrase;
const { Keypair, TransactionBuilder } = await import(
"@stellar/stellar-sdk"
);
const transaction = TransactionBuilder.fromXDR(xdr, networkPassphrase);
transaction.sign(Keypair.fromSecret(mockedWallet.secretKey));
(mockedWallet.signedNetworkPassphrases ??= []).push(networkPassphrase);
return {
signedTxXdr: transaction.toXDR(),
signerAddress: mockedWallet.address,
};
}
return { signedTxXdr: xdr, signerAddress: mockedWallet.address };
}
if (
walletNetworkPassphrase &&
walletNetworkPassphrase !== config.networkPassphrase
walletNetworkPassphrase !== activeNetwork.networkPassphrase
) {
throw new Error("Wallet network does not match the application network.");
}
ensureKit();
return StellarWalletsKit.signTransaction(xdr, {
...options,
networkPassphrase: config.networkPassphrase,
networkPassphrase: activeNetwork.networkPassphrase,
});
}, [walletNetworkPassphrase]);

Expand Down
Loading
Loading