From f9f0f5461bc963e18b9f3d47d54a4c5f0d44641b Mon Sep 17 00:00:00 2001 From: Jeesun Kim Date: Tue, 24 Mar 2026 15:50:37 -0700 Subject: [PATCH 1/7] [New Tx] Add Submit Step Co-Authored-By: Claude Opus 4.6 (1M context) --- .../build/components/SubmitStepContent.tsx | 624 ++++++++++++++++++ src/app/(sidebar)/transaction/build/page.tsx | 2 + 2 files changed, 626 insertions(+) create mode 100644 src/app/(sidebar)/transaction/build/components/SubmitStepContent.tsx diff --git a/src/app/(sidebar)/transaction/build/components/SubmitStepContent.tsx b/src/app/(sidebar)/transaction/build/components/SubmitStepContent.tsx new file mode 100644 index 000000000..96d9472de --- /dev/null +++ b/src/app/(sidebar)/transaction/build/components/SubmitStepContent.tsx @@ -0,0 +1,624 @@ +"use client"; + +import { + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, +} from "react"; +import { Button, Icon, Link, Text } from "@stellar/design-system"; + +import { useBuildFlowStore } from "@/store/createTransactionFlowStore"; +import { useStore } from "@/store/useStore"; + +import { useSubmitRpcTx } from "@/query/useSubmitRpcTx"; +import { useSubmitHorizonTx } from "@/query/useSubmitHorizonTx"; + +import { Box } from "@/components/layout/Box"; +import { PageHeader } from "@/components/layout/PageHeader"; +import { XdrPicker } from "@/components/FormElements/XdrPicker"; +import { TransactionHashReadOnlyField } from "@/components/TransactionHashReadOnlyField"; +import { CodeEditor } from "@/components/CodeEditor"; +import { ValidationResponseCard } from "@/components/ValidationResponseCard"; +import { TxResponse } from "@/components/TxResponse"; +import { XdrLink } from "@/components/XdrLink"; +import { TxHashLink } from "@/components/TxHashLink"; + +import { getNetworkHeaders } from "@/helpers/getNetworkHeaders"; +import { getBlockExplorerLink } from "@/helpers/getBlockExplorerLink"; +import { openUrl } from "@/helpers/openUrl"; +import { delayedAction } from "@/helpers/delayedAction"; +import { localStorageSettings } from "@/helpers/localStorageSettings"; +import * as StellarXdr from "@/helpers/StellarXdr"; +import { + SETTINGS_SUBMIT_METHOD, + XDR_TYPE_TRANSACTION_ENVELOPE, +} from "@/constants/settings"; +import { Routes } from "@/constants/routes"; + +import { useScrollIntoView } from "@/hooks/useScrollIntoView"; + +import { trackEvent, TrackingEvent } from "@/metrics/tracking"; + +import { buildEndpointHref } from "@/helpers/buildEndpointHref"; + +const SUBMIT_OPTIONS = [ + { + id: "rpc", + title: "via RPC", + description: + "Submit the transaction via the Stellar RPC. Supports simulating Soroban invocations and all other transaction types.", + note: "Not selectable because no RPC URL is configured", + }, + { + id: "horizon", + title: "via Horizon", + description: + "Submit the transaction via the Horizon API. Does not support Soroban transactions that need simulating.", + }, +]; + +/** + * Submit step content for the single-page transaction flow. + * + * Reads the signed (or validated) XDR from the flow store, displays it + * alongside the transaction hash and decoded JSON, then submits via RPC + * or Horizon. On success, shows the result with block-explorer links. + * + * @example + * {activeStep === "submit" && } + */ +export const SubmitStepContent = () => { + const { network } = useStore(); + const { build, sign, validate, simulate, setSubmitResult, resetAll } = + useBuildFlowStore(); + + const [submitMethod, setSubmitMethod] = useState<"horizon" | "rpc" | string>( + "", + ); + const [isDropdownActive, setIsDropdownActive] = useState(false); + const [isDropdownVisible, setIsDropdownVisible] = useState(false); + + const dropdownRef = useRef(null); + const responseSuccessEl = useRef(null); + + const { + data: submitRpcResponse, + mutate: submitRpc, + error: submitRpcError, + isPending: isSubmitRpcPending, + isSuccess: isSubmitRpcSuccess, + reset: resetSubmitRpc, + } = useSubmitRpcTx(); + + const { + data: submitHorizonResponse, + mutate: submitHorizon, + error: submitHorizonError, + isPending: isSubmitHorizonPending, + isSuccess: isSubmitHorizonSuccess, + reset: resetSubmitHorizon, + } = useSubmitHorizonTx(); + + // Derive the XDR to submit: validated > assembled > signed + const xdrBlob = + validate?.validatedXdr || simulate?.assembledXdr || sign.signedXdr; + + const isSoroban = Boolean(build.soroban.operation.operation_type); + const isRpcAvailable = Boolean(network.rpcUrl); + const isSubmitInProgress = isSubmitRpcPending || isSubmitHorizonPending; + + const IS_BLOCK_EXPLORER_ENABLED = + network.id === "testnet" || network.id === "mainnet"; + + const isSuccess = Boolean( + (isSubmitRpcSuccess && submitRpcResponse) || + (isSubmitHorizonSuccess && submitHorizonResponse), + ); + + // Decode XDR to JSON for the transaction envelope display + const getXdrJson = useCallback(() => { + if (!xdrBlob) return null; + + try { + const jsonString = StellarXdr.decode( + XDR_TYPE_TRANSACTION_ENVELOPE, + xdrBlob, + ); + return { jsonString, error: "" }; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } catch (e) { + return { jsonString: "", error: "Unable to decode XDR" }; + } + }, [xdrBlob]); + + const [xdrJson, setXdrJson] = useState<{ + jsonString: string; + error: string; + } | null>(null); + + // Initialize XDR decoding + useEffect(() => { + const init = async () => { + await StellarXdr.initialize(); + setXdrJson(getXdrJson()); + }; + init(); + }, [getXdrJson]); + + // Set default submit method + useEffect(() => { + const localStorageMethod = localStorageSettings.get(SETTINGS_SUBMIT_METHOD); + + if (localStorageMethod) { + setSubmitMethod(localStorageMethod); + } else { + setSubmitMethod(isSoroban && isRpcAvailable ? "rpc" : "horizon"); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isRpcAvailable, isSoroban]); + + // Scroll to success response + useScrollIntoView(isSuccess, responseSuccessEl); + + // Track submit events + useEffect(() => { + if (isSubmitRpcSuccess) { + trackEvent(TrackingEvent.TRANSACTION_SUBMIT_SUCCESS, { method: "rpc" }); + } + }, [isSubmitRpcSuccess]); + + useEffect(() => { + if (isSubmitHorizonSuccess) { + trackEvent(TrackingEvent.TRANSACTION_SUBMIT_SUCCESS, { + method: "horizon", + }); + } + }, [isSubmitHorizonSuccess]); + + // Close dropdown when clicked outside + const handleClickOutside = useCallback((event: MouseEvent) => { + if (dropdownRef?.current?.contains(event.target as Node)) { + return; + } + toggleDropdown(false); + }, []); + + useLayoutEffect(() => { + if (isDropdownVisible) { + document.addEventListener("pointerup", handleClickOutside); + } else { + document.removeEventListener("pointerup", handleClickOutside); + } + + return () => { + document.removeEventListener("pointerup", handleClickOutside); + }; + }, [isDropdownVisible, handleClickOutside]); + + const toggleDropdown = (show: boolean) => { + const delay = 100; + + if (show) { + setIsDropdownActive(true); + delayedAction({ + action: () => { + setIsDropdownVisible(true); + }, + delay, + }); + } else { + setIsDropdownVisible(false); + delayedAction({ + action: () => { + setIsDropdownActive(false); + }, + delay, + }); + } + }; + + const resetSubmitState = () => { + if (submitRpcError || submitRpcResponse) { + resetSubmitRpc(); + } + if (submitHorizonError || submitHorizonResponse) { + resetSubmitHorizon(); + } + }; + + const handleSubmit = () => { + resetSubmitState(); + + delayedAction({ + action: () => { + if (submitMethod === "rpc") { + submitRpc({ + rpcUrl: network.rpcUrl, + transactionXdr: xdrBlob, + networkPassphrase: network.passphrase, + headers: getNetworkHeaders(network, submitMethod), + }); + } else if (submitMethod === "horizon") { + submitHorizon({ + horizonUrl: network.horizonUrl, + transactionXdr: xdrBlob, + networkPassphrase: network.passphrase, + headers: getNetworkHeaders(network, submitMethod), + }); + } + }, + delay: 300, + }); + }; + + // Store submit result in flow store when successful + useEffect(() => { + if (isSubmitRpcSuccess && submitRpcResponse) { + setSubmitResult(JSON.stringify(submitRpcResponse)); + } + }, [isSubmitRpcSuccess, submitRpcResponse, setSubmitResult]); + + useEffect(() => { + if (isSubmitHorizonSuccess && submitHorizonResponse) { + setSubmitResult(JSON.stringify(submitHorizonResponse)); + } + }, [isSubmitHorizonSuccess, submitHorizonResponse, setSubmitResult]); + + const getButtonLabel = () => { + return ( + SUBMIT_OPTIONS.find((s) => s.id === submitMethod)?.title || + "Select submit method" + ); + }; + + const isSubmitDisabled = !submitMethod || !xdrBlob; + + const renderSuccess = () => { + if (isSubmitRpcSuccess && submitRpcResponse && network.id) { + return ( +
+ } + footerLeftEl={ + + } + footerRightEl={ + IS_BLOCK_EXPLORER_ENABLED ? ( + <> + + + + ) : null + } + response={ + + } + /> + + + } + /> + + } + /> + + } + /> + + + } + /> +
+ ); + } + + if (isSubmitHorizonSuccess && submitHorizonResponse) { + return ( +
+ } + footerLeftEl={ + + } + footerRightEl={ + IS_BLOCK_EXPLORER_ENABLED ? ( + <> + + + + ) : null + } + response={ + + } + /> + + + } + /> + + } + /> + + } + /> + + + } + /> +
+ ); + } + + return null; + }; + + return ( + + + + + + { + resetAll(); + }} + > + Clear all + + + + + {!isSuccess ? ( + <> + + + + + {xdrJson?.jsonString ? ( + + ) : null} + + + + +
+ +
+
+ {SUBMIT_OPTIONS.map((s) => ( +
{ + if (s.id === "rpc" && !isRpcAvailable) { + return; + } + + setSubmitMethod(s.id); + toggleDropdown(false); + resetSubmitState(); + + localStorageSettings.set({ + key: SETTINGS_SUBMIT_METHOD, + value: s.id, + }); + }} + > +
+ {s.title} + {s.id === submitMethod ? : null} +
+
+ {s.description} +
+ {s.note && s.id === "rpc" && !isRpcAvailable ? ( +
+ {s.note} +
+ ) : null} +
+ ))} +
+
+
+
+ + ) : null} + + {renderSuccess()} +
+ ); +}; diff --git a/src/app/(sidebar)/transaction/build/page.tsx b/src/app/(sidebar)/transaction/build/page.tsx index f35d1668b..001024e4c 100644 --- a/src/app/(sidebar)/transaction/build/page.tsx +++ b/src/app/(sidebar)/transaction/build/page.tsx @@ -21,6 +21,7 @@ import { ClassicTransactionXdr } from "./components/ClassicTransactionXdr"; import { SorobanTransactionXdr } from "./components/SorobanTransactionXdr"; import { SimulateStepContent } from "./components/SimulateStepContent"; import { SignStepContent } from "./components/SignStepContent"; +import { SubmitStepContent } from "./components/SubmitStepContent"; import "./styles.scss"; @@ -185,6 +186,7 @@ export default function BuildTransaction() { {activeStep === "build" && renderBuildStep()} {activeStep === "simulate" && } {activeStep === "sign" && } + {activeStep === "submit" && } Date: Wed, 25 Mar 2026 14:38:36 -0700 Subject: [PATCH 2/7] [New TX] Add 'submit transaction' page --- .../build/components/BuildStepHeader.tsx | 55 ++++++ .../build/components/SignStepContent.tsx | 18 +- .../build/components/SubmitStepContent.tsx | 142 ++++++++------ src/app/(sidebar)/transaction/build/page.tsx | 22 +-- src/components/TxErrorResponse.tsx | 20 +- src/constants/networkLimits.ts | 180 +++++++++--------- src/helpers/scrollElIntoView.ts | 2 +- src/hooks/useScrollIntoView.ts | 2 +- 8 files changed, 247 insertions(+), 194 deletions(-) create mode 100644 src/app/(sidebar)/transaction/build/components/BuildStepHeader.tsx diff --git a/src/app/(sidebar)/transaction/build/components/BuildStepHeader.tsx b/src/app/(sidebar)/transaction/build/components/BuildStepHeader.tsx new file mode 100644 index 000000000..c11d02ccb --- /dev/null +++ b/src/app/(sidebar)/transaction/build/components/BuildStepHeader.tsx @@ -0,0 +1,55 @@ +import { Link, Text } from "@stellar/design-system"; + +import { Box } from "@/components/layout/Box"; +import { PageHeader } from "@/components/layout/PageHeader"; + +interface BuildStepHeaderProps { + /** Step title displayed on the left. */ + heading: string; + /** Semantic heading element for the page header. */ + headingAs?: "h1" | "h2"; + /** Callback for resetting the entire transaction build flow. */ + onClearAll: () => void; + /** Optional class for the clear-all link to support page-specific styles. */ + clearAllLinkClassName?: string; + /** Wrap the clear-all link with xs text sizing. */ + wrapClearAllInText?: boolean; +} + +/** + * Shared header for transaction build steps with a Clear all action. + * + * @example + * + */ +export const BuildStepHeader = ({ + heading, + headingAs, + onClearAll, + clearAllLinkClassName, + wrapClearAllInText = true, +}: BuildStepHeaderProps) => { + const clearAllLink = ( + + Clear all + + ); + + return ( + + + + {wrapClearAllInText ? ( + + {clearAllLink} + + ) : ( + clearAllLink + )} + + ); +}; diff --git a/src/app/(sidebar)/transaction/build/components/SignStepContent.tsx b/src/app/(sidebar)/transaction/build/components/SignStepContent.tsx index e45776cb7..a5a305452 100644 --- a/src/app/(sidebar)/transaction/build/components/SignStepContent.tsx +++ b/src/app/(sidebar)/transaction/build/components/SignStepContent.tsx @@ -7,7 +7,8 @@ import { useBuildFlowStore } from "@/store/createTransactionFlowStore"; import { SignTransactionXdr } from "@/components/SignTransactionXdr"; import { Box } from "@/components/layout/Box"; -import { PageHeader } from "@/components/layout/PageHeader"; + +import { BuildStepHeader } from "./BuildStepHeader"; /** * Sign step content for the single-page transaction flow. @@ -31,20 +32,7 @@ export const SignStepContent = () => { return ( - - - - - { - resetAll(); - }} - > - Clear all - - - + To be included in the ledger, the transaction must be signed and diff --git a/src/app/(sidebar)/transaction/build/components/SubmitStepContent.tsx b/src/app/(sidebar)/transaction/build/components/SubmitStepContent.tsx index 96d9472de..98d5d40cd 100644 --- a/src/app/(sidebar)/transaction/build/components/SubmitStepContent.tsx +++ b/src/app/(sidebar)/transaction/build/components/SubmitStepContent.tsx @@ -7,21 +7,30 @@ import { useRef, useState, } from "react"; -import { Button, Icon, Link, Text } from "@stellar/design-system"; +import { Button, Card, Icon } from "@stellar/design-system"; import { useBuildFlowStore } from "@/store/createTransactionFlowStore"; import { useStore } from "@/store/useStore"; +import { + SETTINGS_SUBMIT_METHOD, + XDR_TYPE_TRANSACTION_ENVELOPE, +} from "@/constants/settings"; +import { Routes } from "@/constants/routes"; + import { useSubmitRpcTx } from "@/query/useSubmitRpcTx"; import { useSubmitHorizonTx } from "@/query/useSubmitHorizonTx"; import { Box } from "@/components/layout/Box"; -import { PageHeader } from "@/components/layout/PageHeader"; import { XdrPicker } from "@/components/FormElements/XdrPicker"; import { TransactionHashReadOnlyField } from "@/components/TransactionHashReadOnlyField"; import { CodeEditor } from "@/components/CodeEditor"; import { ValidationResponseCard } from "@/components/ValidationResponseCard"; import { TxResponse } from "@/components/TxResponse"; +import { + RpcErrorResponse, + HorizonErrorResponse, +} from "@/components/TxErrorResponse"; import { XdrLink } from "@/components/XdrLink"; import { TxHashLink } from "@/components/TxHashLink"; @@ -31,17 +40,13 @@ import { openUrl } from "@/helpers/openUrl"; import { delayedAction } from "@/helpers/delayedAction"; import { localStorageSettings } from "@/helpers/localStorageSettings"; import * as StellarXdr from "@/helpers/StellarXdr"; -import { - SETTINGS_SUBMIT_METHOD, - XDR_TYPE_TRANSACTION_ENVELOPE, -} from "@/constants/settings"; -import { Routes } from "@/constants/routes"; +import { buildEndpointHref } from "@/helpers/buildEndpointHref"; import { useScrollIntoView } from "@/hooks/useScrollIntoView"; import { trackEvent, TrackingEvent } from "@/metrics/tracking"; -import { buildEndpointHref } from "@/helpers/buildEndpointHref"; +import { BuildStepHeader } from "./BuildStepHeader"; const SUBMIT_OPTIONS = [ { @@ -82,6 +87,7 @@ export const SubmitStepContent = () => { const dropdownRef = useRef(null); const responseSuccessEl = useRef(null); + const responseErrorEl = useRef(null); const { data: submitRpcResponse, @@ -126,7 +132,10 @@ export const SubmitStepContent = () => { XDR_TYPE_TRANSACTION_ENVELOPE, xdrBlob, ); - return { jsonString, error: "" }; + return { + jsonString: JSON.stringify(JSON.parse(jsonString), null, 2), + error: "", + }; // eslint-disable-next-line @typescript-eslint/no-unused-vars } catch (e) { return { jsonString: "", error: "Unable to decode XDR" }; @@ -159,8 +168,11 @@ export const SubmitStepContent = () => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [isRpcAvailable, isSoroban]); - // Scroll to success response + const isError = Boolean(submitRpcError || submitHorizonError); + + // Scroll to success/error response useScrollIntoView(isSuccess, responseSuccessEl); + useScrollIntoView(isError, responseErrorEl); // Track submit events useEffect(() => { @@ -275,6 +287,9 @@ export const SubmitStepContent = () => { const isSubmitDisabled = !submitMethod || !xdrBlob; + console.log({ submitHorizonResponse }); + console.log({ submitRpcResponse }); + const renderSuccess = () => { if (isSubmitRpcSuccess && submitRpcResponse && network.id) { return ( @@ -290,12 +305,9 @@ export const SubmitStepContent = () => { variant="secondary" icon={} onClick={() => { - const href = buildEndpointHref( - Routes.TRANSACTION_DASHBOARD, - { - transactionHash: submitRpcResponse.hash, - }, - ); + const href = buildEndpointHref(Routes.TRANSACTION_DASHBOARD, { + transactionHash: submitRpcResponse.hash, + }); openUrl(href); }} > @@ -403,12 +415,9 @@ export const SubmitStepContent = () => { variant="secondary" icon={} onClick={() => { - const href = buildEndpointHref( - Routes.TRANSACTION_DASHBOARD, - { - transactionHash: submitHorizonResponse.hash, - }, - ); + const href = buildEndpointHref(Routes.TRANSACTION_DASHBOARD, { + transactionHash: submitHorizonResponse.hash, + }); openUrl(href); }} > @@ -502,47 +511,59 @@ export const SubmitStepContent = () => { return null; }; + const renderError = () => { + if (submitRpcError) { + return ( +
+ +
+ ); + } + + if (submitHorizonError) { + return ( +
+ +
+ ); + } + + return null; + }; + return ( - - - - - { - resetAll(); - }} - > - Clear all - - - - - {!isSuccess ? ( - <> - - - + + + + + + - {xdrJson?.jsonString ? ( - - ) : null} + {xdrJson?.jsonString ? ( + + ) : null} +