diff --git a/src/app/(sidebar)/transaction/build/page.tsx b/src/app/(sidebar)/transaction/build/page.tsx index 1c7ba314c..db100bec8 100644 --- a/src/app/(sidebar)/transaction/build/page.tsx +++ b/src/app/(sidebar)/transaction/build/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect } from "react"; +import { useEffect, useRef } from "react"; import { Notification, Card } from "@stellar/design-system"; import { useBuildFlowStore } from "@/store/createTransactionFlowStore"; @@ -39,6 +39,7 @@ export default function BuildTransaction() { setActiveStep, goToNextStep, markStepCompleted, + resetDownstreamState, resetAll, } = useBuildFlowStore(); @@ -103,6 +104,57 @@ export default function BuildTransaction() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [isNextDisabled, activeStep]); + const isBuildFormValid = build.isValid.params && build.isValid.operations; + + // When the user edits the transaction on the build step after having already + // progressed past it, the rebuilt XDR no longer matches what was simulated, + // signed, or validated downstream — those results are now stale. Reset them + // so a signature produced against the previous transaction can't be carried + // through to submit; the user must re-run the later steps against the edited + // transaction. + const prevBuiltXdrRef = useRef(null); + useEffect(() => { + // An empty XDR happens for two different reasons, and the form tells them + // apart: + // - form invalid: the user edited the transaction and broke it, so + // anything built downstream is now stale. + // - form valid: the XDR encoder is still starting up (useIsXdrInit). + // This happens every time the build step remounts. + // Only the first one is an edit. Ignoring the second is what stops simple + // step navigation from throwing away a signature. + if (!currentXdr && isBuildFormValid) { + return; + } + + const prevBuiltXdr = prevBuiltXdrRef.current; + prevBuiltXdrRef.current = currentXdr; + + // On the first run there is no earlier XDR to compare against, so nothing + // has been edited yet. That is true even when the XDR is empty, because + // this page can also be opened from a previously saved transaction: + // "View in submitter" on the Saved transactions page + // (transaction/saved/page.tsx) passes in only the saved signed XDR and + // opens the submit step, so the build form is empty while the signature + // is real and must be kept. + if (prevBuiltXdr === null) { + return; + } + if (prevBuiltXdr === currentXdr) { + return; + } + + const buildIndex = steps.indexOf("build"); + const highestIndex = highestCompletedStep + ? steps.indexOf(highestCompletedStep) + : -1; + + // Only reset when there is downstream progress to invalidate. + if (highestIndex > buildIndex) { + resetDownstreamState(steps[buildIndex + 1], steps); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [currentXdr, isBuildFormValid]); + const renderError = () => { if (paramsError.length > 0 || operationsError.length > 0) { return ( diff --git a/tests/e2e/buildFlowResetOnEdit.test.ts b/tests/e2e/buildFlowResetOnEdit.test.ts new file mode 100644 index 000000000..41b2de394 --- /dev/null +++ b/tests/e2e/buildFlowResetOnEdit.test.ts @@ -0,0 +1,142 @@ +import { baseURL } from "../../playwright.config"; +import { test, expect, Page } from "@playwright/test"; + +/** + * Regression tests for resetting downstream steps when the transaction is + * edited on the build step after the user has already progressed past it. + * + * A signature is produced against a specific transaction; if the build inputs + * change afterwards, the signed envelope is stale and must not be carried + * through to submit. Editing build inputs should clear the sign (and any + * simulate/validate) results so the user re-runs those steps. + */ +test.describe("Build flow — reset downstream on build edit", () => { + const SOURCE_ACCOUNT = + "GAGSY6UVUIINHRHSSDO7NMCM7ADWUF5UJ4ZGGJSFSXKWNJDRW6AA4H3Q"; + const SEQUENCE_NUMBER = "4466559829409793"; + const DESTINATION = "GC3N3GAECL3PJOWIKAAKPOB677WHCUNWZRULANQMHEIL4WDX5FICMF3I"; + const MOCK_SECRET_KEY = + "SCAM6CZNCLJFQOGSC7LLE2KMBYCBD7S5IYV447MZX5NHPGCHRHPYITCF"; + + const SIGNED_MESSAGE = "Transaction signed and ready to submit."; + + const buildValidClassicTx = async (page: Page, startingBalance: string) => { + await page.getByLabel("Source account").fill(SOURCE_ACCOUNT); + await page.getByLabel("Transaction sequence number").fill(SEQUENCE_NUMBER); + + const operation_0 = page.getByTestId("build-transaction-operation-0"); + await operation_0 + .getByLabel("Operation type") + .selectOption({ value: "create_account" }); + await operation_0.getByLabel("Destination").fill(DESTINATION); + await operation_0.getByLabel("Starting balance").fill(startingBalance); + + // XDR is built once params + operation are valid. + await expect( + page.getByTestId("build-transaction-envelope-xdr"), + ).toBeVisible(); + }; + + const signWithSecretKey = async (page: Page) => { + const signComponent = page.getByTestId("sign-tx-xdr-sign-step"); + await signComponent + .getByPlaceholder( + "Secret key (starting with S) or hash preimage (in hex)", + ) + .first() + .fill(MOCK_SECRET_KEY); + await signComponent.getByRole("button", { name: "Sign" }).click(); + await expect(page.getByText(SIGNED_MESSAGE)).toBeVisible(); + }; + + const nextButton = (page: Page) => page.locator('[data-position="right"]'); + const backButton = (page: Page) => page.locator('[data-position="left"]'); + + const stepperStep = (page: Page, label: string) => + page.locator(".TransactionStepper__step").filter({ + has: page.locator(".TransactionStepper__labelFull", { hasText: label }), + }); + + test("Editing a build input after signing clears the signature", async ({ + page, + }) => { + await page.goto(`${baseURL}/transaction/build`); + + // Build a valid classic tx, advance to sign, and sign it. + await buildValidClassicTx(page, "1"); + await nextButton(page).click(); + await expect(page.locator("h1")).toHaveText("Sign transaction"); + await signWithSecretKey(page); + + // Navigating back and forth alone must NOT drop the signature. + await backButton(page).click(); + await expect(page.locator("h1")).toHaveText("Build transaction"); + await nextButton(page).click(); + await expect(page.locator("h1")).toHaveText("Sign transaction"); + await expect(page.getByText(SIGNED_MESSAGE)).toBeVisible(); + + // Now edit the transaction on the build step — this must reset the signature. + await backButton(page).click(); + await expect(page.locator("h1")).toHaveText("Build transaction"); + await page + .getByTestId("build-transaction-operation-0") + .getByLabel("Starting balance") + .fill("2"); + // Let the rebuilt XDR settle. + await expect( + page.getByTestId("build-transaction-envelope-xdr"), + ).toBeVisible(); + + // Back on the sign step the previous signature is gone and Next is blocked. + await nextButton(page).click(); + await expect(page.locator("h1")).toHaveText("Sign transaction"); + await expect(page.getByText(SIGNED_MESSAGE)).toBeHidden(); + await expect(nextButton(page)).toBeDisabled(); + }); + + test("Invalidating a build input after signing clears the signature", async ({ + page, + }) => { + await page.goto(`${baseURL}/transaction/build`); + + // Build a valid classic tx, advance to sign, and sign it. + await buildValidClassicTx(page, "1"); + await nextButton(page).click(); + await expect(page.locator("h1")).toHaveText("Sign transaction"); + await signWithSecretKey(page); + await expect(stepperStep(page, "Sign transaction")).toHaveAttribute( + "data-is-completed", + "true", + ); + + // Back on the build step, clear a required field. The transaction is now + // invalid, so the builder clears the XDR to "" instead of rebuilding it — + // that must still invalidate the signature produced against the previous + // transaction. + await backButton(page).click(); + await expect(page.locator("h1")).toHaveText("Build transaction"); + await page + .getByTestId("build-transaction-operation-0") + .getByLabel("Starting balance") + .fill(""); + await expect( + page.getByTestId("build-transaction-envelope-xdr"), + ).toBeHidden(); + + // Sign is no longer complete and submit is no longer reachable. + await expect(stepperStep(page, "Sign transaction")).not.toHaveAttribute( + "data-is-completed", + "true", + ); + await expect(stepperStep(page, "Submit transaction")).not.toHaveAttribute( + "data-is-clickable", + "true", + ); + + // The previous signature is gone from the sign step. + await stepperStep(page, "Sign transaction").click(); + await expect(page.locator("h1")).toHaveText("Sign transaction"); + await expect(page.getByText(SIGNED_MESSAGE)).toBeHidden(); + await expect(nextButton(page)).toBeDisabled(); + }); +});