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
5 changes: 3 additions & 2 deletions src/app/(sidebar)/transaction/build/components/Operations.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -287,9 +287,10 @@ export const Operations = () => {

setOperationsError([...errors]);
}
// Re-run when operations are externally reset (e.g. "Clear all")
// Re-run when operations are externally reset (e.g. "Clear all") or when
// the first operation type changes (e.g. legacy URL migration seeding data).
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [txnOperations.length, soroban.operation.operation_type]);
}, [txnOperations.length, txnOperations[0]?.operation_type, soroban.operation.operation_type]);

// Update operations error when operations change
useEffect(() => {
Expand Down
20 changes: 17 additions & 3 deletions src/app/(sidebar)/transaction/build/page.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
"use client";

import { Card } from "@stellar/design-system";
import { Alert, Card } from "@stellar/design-system";

import { useBuildFlowStore } from "@/store/createTransactionFlowStore";

import { useTransactionFlow } from "@/hooks/useTransactionFlow";
import { useLegacyUrlMigration } from "@/hooks/useLegacyUrlMigration";

import { Box } from "@/components/layout/Box";
import { ValidationResponseCard } from "@/components/ValidationResponseCard";
Expand Down Expand Up @@ -39,6 +39,9 @@ export default function BuildTransaction() {
resetAll,
} = useBuildFlowStore();

// Bridge legacy querystring params into the flow store (one-time migration)
const { isLegacyUrl, dismissLegacyAlert } = useLegacyUrlMigration();

// For Classic
const { params: paramsError, operations: operationsError } = build.error;

Expand Down Expand Up @@ -144,10 +147,21 @@ export default function BuildTransaction() {
<Box gap="md">
<BuildStepHeader
heading="Build transaction"
onClearAll={resetAll}
onClearAll={() => {
resetAll();
dismissLegacyAlert();
}}
clearAllLinkClassName="resetButton"
/>

{isLegacyUrl ? (
<Alert variant="warning" placement="inline" title="">
This transaction was loaded from a legacy URL format that will be
removed in a future update. Please save your transaction to preserve
it.
</Alert>
) : null}

<Card>
<Params />
</Card>
Expand Down
57 changes: 28 additions & 29 deletions src/app/(sidebar)/transaction/saved/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,19 +14,17 @@ import { PageCard } from "@/components/layout/PageCard";
import { SaveToLocalStorageModal } from "@/components/SaveToLocalStorageModal";

import { useStore } from "@/store/useStore";
import { useBuildFlowStore } from "@/store/createTransactionFlowStore";
import { localStorageSavedTransactions } from "@/helpers/localStorageSavedTransactions";
import { arrayItem } from "@/helpers/arrayItem";
import { isSorobanOperationType } from "@/helpers/sorobanUtils";

import {
TRANSACTION_OPERATIONS,
INITIAL_OPERATION,
} from "@/constants/transactionOperations";
import { TRANSACTION_OPERATIONS } from "@/constants/transactionOperations";
import { SavedTransaction, SavedTransactionPage } from "@/types/types";
import { trackEvent, TrackingEvent } from "@/metrics/tracking";

export default function SavedTransactions() {
const { network, transaction, xdr } = useStore();
const { network } = useStore();
const router = useRouter();

const [savedTxns, setSavedTxns] = useState<SavedTransaction[]>([]);
Expand Down Expand Up @@ -55,59 +53,60 @@ export default function SavedTransactions() {
const found = findLocalStorageTx(timestamp);

if (found) {
let isSorobanTx = false;
const flowStore = useBuildFlowStore.getState();

// reset both the classic and soroban related states
transaction.updateBuildOperations([INITIAL_OPERATION]);
transaction.updateBuildXdr("");
transaction.updateSorobanBuildOperation(INITIAL_OPERATION);
transaction.updateSorobanBuildXdr("");
// Reset the flow store to a clean state before populating
flowStore.resetAll();

trackEvent(TrackingEvent.TRANSACTION_SAVED_VIEW_BUILDER);

router.push(Routes.BUILD_TRANSACTION);

if (found.params) {
transaction.setBuildParams(found.params);
flowStore.setBuildParams(found.params);
}

if (found.operations) {
isSorobanTx = isSorobanOperationType(
const isSorobanTx = isSorobanOperationType(
found?.operations?.[0]?.operation_type,
);

if (isSorobanTx) {
// reset the classic operation
transaction.updateBuildOperations([INITIAL_OPERATION]);
transaction.updateSorobanBuildOperation(found.operations[0]);
flowStore.setBuildSorobanOperation(found.operations[0]);
} else {
// reset the soroban operation
transaction.updateSorobanBuildOperation(INITIAL_OPERATION);
transaction.updateBuildOperations(found.operations);
flowStore.setBuildClassicOperations(found.operations);
}
}

if (found.xdr) {
if (isSorobanTx) {
transaction.updateSorobanBuildXdr(found.xdr);
} else {
transaction.updateBuildXdr(found.xdr);
if (found.xdr) {
if (isSorobanTx) {
flowStore.setBuildSorobanXdr(found.xdr);
} else {
flowStore.setBuildClassicXdr(found.xdr);
}
}
}

router.push(Routes.BUILD_TRANSACTION);
}
};

const handleViewInSubmitter = (timestamp: number) => {
const found = findLocalStorageTx(timestamp);

if (found) {
const flowStore = useBuildFlowStore.getState();

trackEvent(TrackingEvent.TRANSACTION_SAVED_VIEW_SUBMITTER);

router.push(Routes.SUBMIT_TRANSACTION);
// Reset and seed the flow store at the submit step with the signed XDR
flowStore.resetAll();

if (found.xdr) {
xdr.updateXdrBlob(found.xdr);
flowStore.setSignedXdr(found.xdr);
}

flowStore.setActiveStep("submit");
useBuildFlowStore.setState({ highestCompletedStep: "sign" });

router.push(Routes.BUILD_TRANSACTION);
}
};

Expand Down
79 changes: 79 additions & 0 deletions src/hooks/useLegacyUrlMigration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { useEffect, useState } from "react";

import { useStore } from "@/store/useStore";
import { useBuildFlowStore } from "@/store/createTransactionFlowStore";

/**
* One-time migration hook that bridges legacy querystring-persisted transaction
* params into the sessionStorage-based flow store.
*
* Old bookmarked URLs encode build params in the querystring via
* zustand-querystring. The build page now reads from the flow store
* (sessionStorage). This hook detects legacy params and seeds the flow store
* so old URLs continue to work.
*
* @returns `isLegacyUrl` — true when data was migrated from a legacy URL.
*/
export const useLegacyUrlMigration = () => {
const [isLegacyUrl, setIsLegacyUrl] = useState(false);
const { transaction } = useStore();

useEffect(() => {
const legacyParams = transaction.build.params;
const legacyClassicOps = transaction.build.classic.operations;
const legacySorobanOp = transaction.build.soroban.operation;

// Check if the legacy store has non-default params from a URL
const hasLegacyParams = Boolean(legacyParams.source_account);
const hasLegacyClassicOps =
legacyClassicOps.length > 0 &&
legacyClassicOps.some((op) => Boolean(op.operation_type));
const hasLegacySorobanOp = Boolean(legacySorobanOp.operation_type);

if (!hasLegacyParams && !hasLegacyClassicOps && !hasLegacySorobanOp) {
return;
}

const flowStore = useBuildFlowStore.getState();

let didMigrate = false;

// Seed params if the flow store doesn't have them yet
if (hasLegacyParams && !flowStore.build.params.source_account) {
flowStore.setBuildParams(legacyParams);
didMigrate = true;
}

// Seed operations if the flow store doesn't have them yet
const flowHasOps = flowStore.build.classic.operations.some((op) =>
Boolean(op.operation_type),
);
const flowHasSorobanOp = Boolean(
flowStore.build.soroban.operation.operation_type,
);

if (hasLegacySorobanOp && !flowHasSorobanOp) {
flowStore.setBuildSorobanOperation(legacySorobanOp);

if (transaction.build.soroban.xdr) {
flowStore.setBuildSorobanXdr(transaction.build.soroban.xdr);
}
didMigrate = true;
} else if (hasLegacyClassicOps && !flowHasOps) {
flowStore.setBuildClassicOperations(legacyClassicOps);

if (transaction.build.classic.xdr) {
flowStore.setBuildClassicXdr(transaction.build.classic.xdr);
}
didMigrate = true;
}

if (didMigrate) {
setIsLegacyUrl(true);
}
}, [transaction]);

const dismissLegacyAlert = () => setIsLegacyUrl(false);

return { isLegacyUrl, dismissLegacyAlert };
};
29 changes: 8 additions & 21 deletions tests/e2e/savedTransactions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ test.describe("Saved Transactions Page", () => {
await expect(txItems).toHaveCount(4);
});

test.skip("[Classic] Submit item", async () => {
test("[Classic] Submit item", async () => {
const submitItem = pageContext
.getByTestId("saved-transactions-item")
.nth(0);
Expand All @@ -72,22 +72,17 @@ test.describe("Saved Transactions Page", () => {

await expect(nameInput).toHaveValue(newName);

// View in submitter
// View in submitter — navigates to build flow at submit step
await submitItem.getByText("View in submitter").click();
await pageContext.waitForURL("**/transaction/submit");
await pageContext.waitForURL("**/transaction/build");

await expect(pageContext.locator("h1")).toHaveText("Submit transaction");
await expect(
pageContext.getByLabel("Input a Base64 encoded TransactionEnvelope:"),
).toHaveValue(
"AAAAAgAAAAA55ZjOXdOOulfzeLPXjLDLdplq/5HGjapWAXjGSkdAkwAAAGQADQioAAAAAQAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAPhdN05mkM4xHi+T01SNgtsaiwQnrlbvzEup1jqlpjEnAAAAAlQL5AAAAAAAAAAAAUpHQJMAAABA689dl2/R1R1w0BjbrBWdUBSPIZF3zKvEN5Q7apo/1cX1azapoKcCd92gLOnqGTkIfAD8SID5okbCcwzFSOjFDA==",
);
await expect(pageContext.getByLabel("Transaction hash")).toHaveValue(
"510b76e2cb10e73b05eae02628421d832d340bde5c6c013b11f1a598806b4a3a",
);
});

test.skip("Build item", async () => {
test("Build item", async () => {
const buildItem = pageContext
.getByTestId("saved-transactions-item")
.nth(1);
Expand Down Expand Up @@ -138,7 +133,7 @@ test.describe("Saved Transactions Page", () => {
);
});

test.skip("[Soroban] Submit item", async () => {
test("[Soroban] Submit item", async () => {
await pageContext.waitForSelector(
'[data-testid="saved-transactions-item"]',
);
Expand Down Expand Up @@ -169,22 +164,17 @@ test.describe("Saved Transactions Page", () => {

await expect(nameInput).toHaveValue(newName);

// View in submitter
// View in submitter — navigates to build flow at submit step
await submitItem.getByText("View in submitter").click();
await pageContext.waitForURL("**/transaction/submit");
await pageContext.waitForURL("**/transaction/build");

await expect(pageContext.locator("h1")).toHaveText("Submit transaction");
await expect(
pageContext.getByLabel("Input a Base64 encoded TransactionEnvelope:"),
).toHaveValue(
"AAAAAgAAAAB+TL0HLiAjanMRnyeqyhb8Iu+4d1g2dl1cwPi1UZAigwAAtwUABiLjAAAAGQAAAAAAAAAAAAAAAQAAAAAAAAAZAAAAAAAAdTAAAAABAAAAAAAAAAEAAAAGAAAAASD+7zozM+tVyk2uwVRrzhPzYqyzwMsAT1kzEuqEzbYfAAAAEAAAAAEAAAACAAAADwAAAAdDb3VudGVyAAAAABIAAAAAAAAAAH5MvQcuICNqcxGfJ6rKFvwi77h3WDZ2XVzA+LVRkCKDAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAC2oQAAAAFRkCKDAAAAQANhue2LOei1g9uW8lDfkOIYSkxNBT2MJgA3FXXxVUZXrMMVKMH6or750Xrno+vigxOnIFW8TXlhKjohPFHkOgQ=",
);
await expect(pageContext.getByLabel("Transaction hash")).toHaveValue(
"77bba537ae20e1011737604224f70215445c23ee5851b42824711c8bc80f527f",
);
});

test.skip("[Soroban] Build item", async () => {
test("[Soroban] Build item", async () => {
const buildItem = pageContext
.getByTestId("saved-transactions-item")
.nth(2);
Expand Down Expand Up @@ -232,9 +222,6 @@ test.describe("Saved Transactions Page", () => {
"AAAABgAAAAEg/u86MzPrVcpNrsFUa84T82Kss8DLAE9ZMxLqhM22HwAAABAAAAABAAAAAgAAAA8AAAAHQ291bnRlcgAAAAASAAAAAAAAAAB+TL0HLiAjanMRnyeqyhb8Iu+4d1g2dl1cwPi1UZAigwAAAAE=",
);
await expect(pageContext.getByLabel("Extend To")).toHaveValue("30000");
await expect(
pageContext.getByLabel("Resource Fee (in stroops)"),
).toHaveValue("46753");
});

test("Delete transaction", async () => {
Expand Down
3 changes: 3 additions & 0 deletions tests/e2e/signStepContent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,9 @@ test.describe("Sign Step in Build Flow", () => {
test("Next button is disabled before signing", async ({ page }) => {
await seedSessionStorageAndNavigate(page);

// Wait for the sign step to hydrate from sessionStorage
await expect(page.locator("h1")).toHaveText("Sign transaction");

const nextButton = page.getByRole("button", {
name: "Next: Submit transaction",
});
Expand Down
Loading
Loading