Skip to content
Open
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
54 changes: 53 additions & 1 deletion src/app/(sidebar)/transaction/build/page.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -39,6 +39,7 @@ export default function BuildTransaction() {
setActiveStep,
goToNextStep,
markStepCompleted,
resetDownstreamState,
resetAll,
} = useBuildFlowStore();

Expand Down Expand Up @@ -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<string | null>(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 (
Expand Down
142 changes: 142 additions & 0 deletions tests/e2e/buildFlowResetOnEdit.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading