diff --git a/apps/web/e2e/fixtures.ts b/apps/web/e2e/fixtures.ts index 567c34c..cb85317 100644 --- a/apps/web/e2e/fixtures.ts +++ b/apps/web/e2e/fixtures.ts @@ -135,6 +135,9 @@ export async function installCreationFixtures( async verifyRegistry() { return "verified"; }, + async readFactoryOwner() { + return wallet; + }, }, }; }, diff --git a/apps/web/src/components/CommunityDeploymentPanel.test.tsx b/apps/web/src/components/CommunityDeploymentPanel.test.tsx index 015022e..87758ea 100644 --- a/apps/web/src/components/CommunityDeploymentPanel.test.tsx +++ b/apps/web/src/components/CommunityDeploymentPanel.test.tsx @@ -1,6 +1,5 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { createWalletMock } from "@/test-support/stellar"; const mocks = vi.hoisted(() => ({ getE2EBridge: vi.fn(), @@ -74,17 +73,33 @@ function adapter() { }), transactionStatus: vi.fn().mockResolvedValue("success"), verifyRegistry: vi.fn().mockResolvedValue("verified"), + readFactoryOwner: vi.fn().mockResolvedValue(address), }; } +const simulateButtons = () => + screen.queryAllByRole("button", { name: "Simulate deployment" }); +const approveButton = () => + screen.queryByRole("button", { name: "Approve and deploy" }); + +/** The owner preflight resolves asynchronously to "ready" before any action. */ +async function awaitReady() { + await waitFor(async () => { + expect( + simulateButtons().some((button) => !(button as HTMLButtonElement).disabled), + ).toBe(true); + }); +} + describe("CommunityDeploymentPanel", () => { beforeEach(() => { sessionStorage.clear(); - mocks.useWallet.mockReturnValue(createWalletMock({ + mocks.useWallet.mockReturnValue({ address, + signTransaction: vi.fn(), walletNetwork: "testnet", walletNetworkPassphrase: "Test SDF Network ; September 2015", - })); + }); }); it("shows the exact simulated fee and declares success only after registry verification", async () => { @@ -92,6 +107,7 @@ describe("CommunityDeploymentPanel", () => { mocks.getE2EBridge.mockReturnValue({ deployment }); render(); + await awaitReady(); fireEvent.click(screen.getByRole("button", { name: "Simulate deployment" })); expect(await screen.findByText(/12345678 stroops/)).toHaveTextContent( "1.2345678 XLM", @@ -111,11 +127,12 @@ describe("CommunityDeploymentPanel", () => { it("blocks a mismatched wallet network and preserves draft inputs", () => { const deployment = adapter(); mocks.getE2EBridge.mockReturnValue({ deployment }); - mocks.useWallet.mockReturnValue(createWalletMock({ + mocks.useWallet.mockReturnValue({ address, + signTransaction: vi.fn(), walletNetwork: "mainnet", walletNetworkPassphrase: "Public Global Stellar Network ; September 2015", - })); + }); render(); expect(screen.getByText(/Expected testnet/)).toHaveTextContent( @@ -133,6 +150,7 @@ describe("CommunityDeploymentPanel", () => { mocks.getE2EBridge.mockReturnValue({ deployment }); render(); + await awaitReady(); fireEvent.click(screen.getByRole("button", { name: "Simulate deployment" })); await screen.findByText(/12345678 stroops/); fireEvent.click(screen.getByRole("button", { name: "Approve and deploy" })); @@ -195,147 +213,82 @@ describe("CommunityDeploymentPanel", () => { expect(deployment.signAndSubmit).not.toHaveBeenCalled(); }); - it("invalidates a completed simulation when the wallet network changes mid-flow", async () => { + it("blocks the deploy approval action for a non-owner wallet", async () => { const deployment = adapter(); + const other = `G${"B".repeat(55)}`; + mocks.useWallet.mockReturnValue({ + address: other, + signTransaction: vi.fn(), + walletNetwork: "testnet", + walletNetworkPassphrase: "Test SDF Network ; September 2015", + }); mocks.getE2EBridge.mockReturnValue({ deployment }); - const { rerender } = render(); + render(); + await screen.findByText(/Only the CommunityFactory owner can create communities/); + expect( + screen.getByRole("button", { name: "Simulate deployment" }), + ).toBeDisabled(); fireEvent.click(screen.getByRole("button", { name: "Simulate deployment" })); - expect(await screen.findByText(/12345678 stroops/)).toBeInTheDocument(); + expect(deployment.simulate).not.toHaveBeenCalled(); + expect(approveButton()).not.toBeInTheDocument(); + }); + it("reports a disconnected wallet as disconnected and holds actions", async () => { mocks.useWallet.mockReturnValue({ - address, + address: null, signTransaction: vi.fn(), - walletNetwork: "mainnet", - walletNetworkPassphrase: "Public Global Stellar Network ; September 2015", + walletNetwork: null, + walletNetworkPassphrase: null, }); - rerender(); + render(); - await waitFor(() => - expect(screen.queryByText(/12345678 stroops/)).not.toBeInTheDocument(), - ); expect( - screen.getByText(/Network changed. The previous simulation was invalidated/), + await screen.findByText(/Connect your wallet to check whether this account can create a community/), ).toBeInTheDocument(); expect( screen.getByRole("button", { name: "Simulate deployment" }), ).toBeDisabled(); - expect(deployment.signAndSubmit).not.toHaveBeenCalled(); }); - it("requires a fresh simulation after restoring the expected network", async () => { + it("is network-aware and never reports a mismatched wallet as unauthorized", async () => { const deployment = adapter(); - mocks.getE2EBridge.mockReturnValue({ deployment }); - const { rerender } = render(); - - fireEvent.click(screen.getByRole("button", { name: "Simulate deployment" })); - await screen.findByText(/12345678 stroops/); - mocks.useWallet.mockReturnValue({ address, signTransaction: vi.fn(), walletNetwork: "mainnet", walletNetworkPassphrase: "Public Global Stellar Network ; September 2015", }); - rerender(); - await waitFor(() => - expect(screen.queryByText(/12345678 stroops/)).not.toBeInTheDocument(), - ); - - mocks.useWallet.mockReturnValue({ - address, - signTransaction: vi.fn(), - walletNetwork: "testnet", - walletNetworkPassphrase: "Test SDF Network ; September 2015", - }); - rerender(); - - await waitFor(() => - expect( - screen.getByRole("button", { name: "Simulate deployment" }), - ).toBeEnabled(), - ); - expect(screen.queryByRole("button", { name: "Approve and deploy" })).not.toBeInTheDocument(); - - fireEvent.click(screen.getByRole("button", { name: "Simulate deployment" })); - await waitFor(() => expect(deployment.simulate).toHaveBeenCalledTimes(2)); - expect(await screen.findByRole("button", { name: "Approve and deploy" })).toBeEnabled(); - }); - - it("drops a simulation that resolves after the wallet network moved", async () => { - const deployment = adapter(); - let resolveSimulation: (() => void) | undefined; - deployment.simulate.mockImplementation( - () => - new Promise((resolve) => { - resolveSimulation = () => - resolve({ - invocation: { - contractId: props.factoryId, - method: "create_community", - sourceAccount: address, - networkPassphrase: "Test SDF Network ; September 2015", - metadataHash: "12".repeat(32), - externalKey: "12".repeat(32), - args: [], - }, - feeStroops: "12345678", - expectedRecord, - sequence: "2", - expiresAt: 999, - prepared: {}, - }); - }), - ); mocks.getE2EBridge.mockReturnValue({ deployment }); - const { rerender } = render(); - - fireEvent.click(screen.getByRole("button", { name: "Simulate deployment" })); - mocks.useWallet.mockReturnValue({ - address, - signTransaction: vi.fn(), - walletNetwork: "mainnet", - walletNetworkPassphrase: "Public Global Stellar Network ; September 2015", - }); - rerender(); - resolveSimulation?.(); + render(); - await waitFor(() => - expect( - screen.getByRole("button", { name: "Simulate deployment" }), - ).toBeDisabled(), + expect(await screen.findByText(/Expected testnet/)).toHaveTextContent( + "Detected mainnet", ); - expect(screen.queryByText(/12345678 stroops/)).not.toBeInTheDocument(); - expect(screen.queryByRole("button", { name: "Approve and deploy" })).not.toBeInTheDocument(); + expect(deployment.readFactoryOwner).not.toHaveBeenCalled(); + expect( + screen.queryByText(/cannot create/i), + ).not.toBeInTheDocument(); + expect( + screen.queryByText(/only the communityfactory owner/i), + ).not.toBeInTheDocument(); }); - it("keeps the submitted transaction explorer link on the network it was submitted to", async () => { + it("treats a factory owner read failure as retryable, not unauthorized", async () => { const deployment = adapter(); + deployment.readFactoryOwner.mockRejectedValueOnce(new Error("RPC unavailable")); mocks.getE2EBridge.mockReturnValue({ deployment }); - const { rerender } = render(); + render(); - fireEvent.click(screen.getByRole("button", { name: "Simulate deployment" })); - await screen.findByText(/12345678 stroops/); - fireEvent.click(screen.getByRole("button", { name: "Approve and deploy" })); + const retry = await screen.findByRole("button", { name: "Retry owner check" }); expect( - await screen.findByRole("heading", { - name: "Community verified in the registry", - }), - ).toBeInTheDocument(); - - mocks.useWallet.mockReturnValue({ - address, - signTransaction: vi.fn(), - walletNetwork: "mainnet", - walletNetworkPassphrase: "Public Global Stellar Network ; September 2015", - }); - rerender(); + screen.queryByText(/only the communityfactory owner|cannot create/i), + ).not.toBeInTheDocument(); - expect( - screen.getByRole("heading", { - name: "Community verified in the registry", - }), - ).toBeInTheDocument(); - expect(deployment.signAndSubmit).toHaveBeenCalledTimes(1); + deployment.readFactoryOwner.mockResolvedValueOnce(address); + fireEvent.click(retry); + await awaitReady(); + expect(deployment.readFactoryOwner).toHaveBeenCalledTimes(2); + expect(screen.queryByText("Retry owner check")).not.toBeInTheDocument(); }); }); diff --git a/apps/web/src/components/CommunityDeploymentPanel.tsx b/apps/web/src/components/CommunityDeploymentPanel.tsx index f86edf7..650415e 100644 --- a/apps/web/src/components/CommunityDeploymentPanel.tsx +++ b/apps/web/src/components/CommunityDeploymentPanel.tsx @@ -33,6 +33,36 @@ type Props = { confirmed: boolean; }; +export type FactoryAuthorizationStatus = + | "checking" + | "ready" + | "disconnected" + | "network-unknown" + | "wrong-network" + | "unauthorized" + | "read-failed"; + +function authorizationMessage(status: FactoryAuthorizationStatus): string | null { + switch (status) { + case "ready": + return null; + case "checking": + return "Checking CommunityFactory owner authorization."; + case "disconnected": + return "Connect your wallet to check whether this account can create a community."; + case "network-unknown": + return "Reading the wallet network. Deploy stays locked until it is confirmed."; + case "wrong-network": + return "Your wallet is on a different Stellar network. Switch it to the configured network to check creation rights."; + case "unauthorized": + return "Only the CommunityFactory owner can create communities during this pilot. This wallet cannot deploy one."; + case "read-failed": + return "Could not read the CommunityFactory owner. Retry to re-check before simulating."; + default: + return null; + } +} + function friendlyError(error: unknown): string { const message = error instanceof Error ? error.message : String(error); if (/reject|declin|denied/i.test(message)) { @@ -72,6 +102,13 @@ export function CommunityDeploymentPanel({ const [knownTransactionStatus, setKnownTransactionStatus] = useState(null); const [busy, setBusy] = useState(false); + const [ownerRead, setOwnerRead] = useState< + | { status: "ok"; owner: string } + | { status: "error" } + | null + >(null); + const [authorizationCheck, setAuthorizationCheck] = useState(0); + const preflightInFlight = useRef(false); const inFlight = useRef(false); const previousInput = useRef(""); const simulationGeneration = useRef(0); @@ -125,6 +162,65 @@ export function CommunityDeploymentPanel({ } }, [inputSignature, networkMismatch, transactionHash]); + /** + * Authorization preflight: resolve the authorization state before any + * simulation or signature. The deterministic states (disconnected, + * network-unknown, wrong-network, unconfigured factory) are derived during + * render. Only when the wallet and the network are in a comparable state do + * we read the factory owner from chain and compare it with the connected + * address. The read is network-aware - it only runs when the wallet network + * matches the application network, so a wrong-network wallet is reported as + * such rather than as unauthorized. A failed read surfaces a retryable + * "read-failed" state, never an authorization verdict. + */ + const staticAuthorization: FactoryAuthorizationStatus | null = useMemo(() => { + if (transactionHash) return null; + if (!address) return "disconnected"; + if (walletNetworkUnknown) return "network-unknown"; + if (networkMismatch) return "wrong-network"; + if (!factoryId) return "read-failed"; + return null; + }, [address, factoryId, networkMismatch, transactionHash, walletNetworkUnknown]); + + const authorization: FactoryAuthorizationStatus = useMemo(() => { + if (staticAuthorization !== null) return staticAuthorization; + if (ownerRead === null) return "checking"; + if (ownerRead.status === "ok") { + return ownerRead.owner === address ? "ready" : "unauthorized"; + } + return "read-failed"; + }, [address, ownerRead, staticAuthorization]); + + useEffect(() => { + if (transactionHash || staticAuthorization !== null) return; + let cancelled = false; + if (preflightInFlight.current) return; + preflightInFlight.current = true; + void adapter + .readFactoryOwner(factoryId, address ?? "") + .then((owner) => { + if (cancelled) return; + setOwnerRead({ status: "ok", owner }); + }) + .catch(() => { + if (cancelled) return; + setOwnerRead({ status: "error" }); + }) + .finally(() => { + preflightInFlight.current = false; + }); + return () => { + cancelled = true; + }; + }, [ + adapter, + address, + authorizationCheck, + factoryId, + staticAuthorization, + transactionHash, + ]); + const verifyExpectedRecord = useCallback( async (expected: CommunityDeploymentRecovery["expectedRecord"]) => { setRegistryState("checking"); @@ -221,6 +317,7 @@ export function CommunityDeploymentPanel({ !factoryId || networkMismatch || walletNetworkUnknown || + authorization !== "ready" || transactionHash ) { return; @@ -270,6 +367,7 @@ export function CommunityDeploymentPanel({ !confirmed || networkMismatch || walletNetworkUnknown || + authorization !== "ready" || transactionHash ) { return; @@ -354,6 +452,39 @@ export function CommunityDeploymentPanel({ )} + {(authorization === "disconnected" || + authorization === "network-unknown" || + authorization === "unauthorized" || + authorization === "read-failed") && ( + + {authorizationMessage(authorization)} + {authorization === "read-failed" && ( + + )} + + )} + + {authorization === "checking" && ( + + {authorizationMessage("checking")} + + )} + {simulation && !transactionHash && (
@@ -406,6 +537,7 @@ export function CommunityDeploymentPanel({ !factoryId || networkMismatch || walletNetworkUnknown || + authorization !== "ready" || stage === "simulating" } className="min-h-11 rounded-lg border border-indigo-500 px-4 py-2 text-sm font-medium text-indigo-200 hover:bg-indigo-950/50 disabled:cursor-not-allowed disabled:opacity-50" @@ -421,6 +553,7 @@ export function CommunityDeploymentPanel({ !confirmed || networkMismatch || walletNetworkUnknown || + authorization !== "ready" || stage === "awaiting_approval" } className="min-h-11 rounded-lg bg-indigo-500 px-5 py-2 text-sm font-medium text-white hover:bg-indigo-400 disabled:cursor-not-allowed disabled:opacity-50" diff --git a/apps/web/src/lib/community/deployment.ts b/apps/web/src/lib/community/deployment.ts index 9818891..97ebeea 100644 --- a/apps/web/src/lib/community/deployment.ts +++ b/apps/web/src/lib/community/deployment.ts @@ -74,6 +74,13 @@ export type CommunityDeploymentAdapter = { verifyRegistry( expected: CommunityRegistryRecord, ): Promise<"verified" | "missing" | "mismatch" | "rpc-error">; + /** + * Reads the CommunityFactory owner so the UI can gate deployment before any + * simulation or signature. Resolves to the owner address string on success; + * throws on a transient read failure so callers can retry rather than treat + * the result as an authorization verdict. + */ + readFactoryOwner(factoryId: string, publicKey: string): Promise; }; function bytesToHex(bytes: Uint8Array): string { @@ -355,6 +362,29 @@ export const defaultCommunityDeploymentAdapter: CommunityDeploymentAdapter = { return "rpc-error"; } }, + + /** + * Reads `owner()` off the factory as a read-only simulation. Any throw is a + * transient read failure that the UI reports as retryable, never as + * "unauthorized". + */ + async readFactoryOwner(factoryId, publicKey) { + const transaction = await AssembledTransaction.build({ + contractId: factoryId, + method: "owner", + args: [], + networkPassphrase: config.networkPassphrase, + rpcUrl: config.rpcUrl, + publicKey, + timeoutInSeconds: COMMUNITY_DEPLOYMENT_TIMEOUT_SECONDS, + parseResultXdr: (value) => scValToNative(value) as string, + }); + const owner = transaction.result; + if (typeof owner !== "string" || owner.trim() === "") { + throw new Error("The CommunityFactory owner read did not return an address."); + } + return owner; + }, }; export function metadataHashBytes(invocation: CommunityFactoryInvocation) { diff --git a/apps/web/src/lib/communityFactory/useCommunityDeployment.ts b/apps/web/src/lib/communityFactory/useCommunityDeployment.ts index 11b4cf8..5a185e3 100644 --- a/apps/web/src/lib/communityFactory/useCommunityDeployment.ts +++ b/apps/web/src/lib/communityFactory/useCommunityDeployment.ts @@ -7,7 +7,7 @@ import { getStoredCommunityDeploymentHash, storeCommunityDeploymentHash, } from "@/lib/contracts"; -import { activeCapabilities } from "@/lib/stellar"; +import { config } from "@/lib/stellar"; import { deployCommunityFromWizard, type DeploymentStage, @@ -54,8 +54,7 @@ export function useCommunityDeployment() { try { const nextOutcome = await deployCommunityFromWizard(state, { address, - expectedNetworkPassphrase: - activeCapabilities.network.networkPassphrase, + expectedNetworkPassphrase: config.networkPassphrase, walletNetworkPassphrase, createClient: () => createCommunityFactoryClient({