diff --git a/src/app/(sidebar)/transaction/build/page.tsx b/src/app/(sidebar)/transaction/build/page.tsx index 1c7ba314c..18a342584 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,39 @@ export default function BuildTransaction() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [isNextDisabled, activeStep]); + // 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(() => { + if (!currentXdr) { + return; + } + + if (prevBuiltXdrRef.current === null) { + prevBuiltXdrRef.current = currentXdr; + return; + } + if (prevBuiltXdrRef.current === currentXdr) { + return; + } + prevBuiltXdrRef.current = currentXdr; + + 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]); + const renderError = () => { if (paramsError.length > 0 || operationsError.length > 0) { return ( diff --git a/src/app/(sidebar)/transaction/components/SignStepContent.tsx b/src/app/(sidebar)/transaction/components/SignStepContent.tsx index dc9437751..4b9313ff2 100644 --- a/src/app/(sidebar)/transaction/components/SignStepContent.tsx +++ b/src/app/(sidebar)/transaction/components/SignStepContent.tsx @@ -3,16 +3,31 @@ import { useState } from "react"; import { Notification, Card, Text } from "@stellar/design-system"; +import { useStore } from "@/store/useStore"; + +import { decodeXdr } from "@/helpers/decodeXdr"; + +import { useIsXdrInit } from "@/hooks/useIsXdrInit"; + import { SignTransactionXdr } from "@/components/SignTransactionXdr"; import { Box } from "@/components/layout/Box"; +import { TransactionHashReadOnlyField } from "@/components/TransactionHashReadOnlyField"; +import { prettifyJsonString } from "@/helpers/prettifyJsonString"; import { TransactionStepHeader } from "./TransactionStepHeader"; +import { CodeEditor } from "@/components/CodeEditor"; type Props = { xdrToSign: string; signedXdr: string; onSigned: (signedXdr: string) => void; onClearAll: () => void; + /** + * Optional slot rendered above the signing UI — the import flow passes a + * signature-status panel here so a co-signer can review existing signatures + * before adding their own. Omitted by the build flow. + */ + signatureContext?: React.ReactNode; }; /** @@ -38,8 +53,25 @@ export const SignStepContent = ({ signedXdr, onSigned, onClearAll, + signatureContext, }: Props) => { + const { network } = useStore(); const [errorMessage, setErrorMessage] = useState(null); + const [selectedLanguage, setSelectedLanguage] = useState<"json" | "xdr">( + "json", + ); + + const isXdrInit = useIsXdrInit(); + + const xdrJsonDecoded = decodeXdr({ + xdrType: "TransactionEnvelope", + xdrBlob: xdrToSign, + isReady: isXdrInit, + }); + + const signedXdrJsonString = xdrJsonDecoded?.jsonString + ? `${prettifyJsonString(xdrJsonDecoded.jsonString)}\n` + : ""; return ( @@ -54,6 +86,8 @@ export const SignStepContent = ({ submitted to the network. + {signatureContext} + - - - Signed transaction (Base64 XDR) - - -
- - {signedXdr} - -
+ + + + {signedXdrJsonString ? ( + { + const selectedValue = id === "xdr" ? "xdr" : "json"; + setSelectedLanguage(selectedValue); + }} + maxHeightInRem="20" + /> + ) : null}
diff --git a/src/app/(sidebar)/transaction/components/Signatures.tsx b/src/app/(sidebar)/transaction/components/Signatures.tsx index 4ef293396..ee4b96a56 100644 --- a/src/app/(sidebar)/transaction/components/Signatures.tsx +++ b/src/app/(sidebar)/transaction/components/Signatures.tsx @@ -87,11 +87,11 @@ const getEnvelopeSummary = ( if (hasUnrecognized) { return { message: - "Includes signature(s) from signers that can’t be verified offline (e.g. multisig cosigners). Submit to verify.", + "Includes signature(s) that can't be verified offline (e.g. from multisig cosigners)", }; } return { - message: `Missing signature${missing.length > 1 ? "s" : ""} from ${missing.join(", ")}.`, + message: `Couldn’t verify a signature for ${missing.join(", ")} offline. If it’s a multisig account, an existing signature may already cover it on-chain — you can submit to let the network verify, or add a signature first.`, }; } @@ -106,7 +106,9 @@ const getEnvelopeSummary = ( ); } if (hasUnrecognized) { - notes.push("Signature(s) from unrecognized signers were also found."); + notes.push( + "Signature(s) from unverified existing signers were also found.", + ); } return { @@ -339,7 +341,7 @@ const renderSigner = (matchStatus: MatchStatus, signer?: string) => { return ( - Unrecognized signer + Existing signer (unverified) ); }; diff --git a/src/app/(sidebar)/transaction/components/SubmitStepContent.tsx b/src/app/(sidebar)/transaction/components/SubmitStepContent.tsx index 80aed8b47..af63c80b1 100644 --- a/src/app/(sidebar)/transaction/components/SubmitStepContent.tsx +++ b/src/app/(sidebar)/transaction/components/SubmitStepContent.tsx @@ -23,7 +23,6 @@ import { useSubmitHorizonTx } from "@/query/useSubmitHorizonTx"; import { Box } from "@/components/layout/Box"; 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 { @@ -32,6 +31,7 @@ import { } from "@/components/TxErrorResponse"; import { XdrLink } from "@/components/XdrLink"; import { TxHashLink } from "@/components/TxHashLink"; +import { PrettyJsonTransaction } from "@/components/PrettyJsonTransaction"; import { getNetworkHeaders } from "@/helpers/getNetworkHeaders"; import { getBlockExplorerLink } from "@/helpers/getBlockExplorerLink"; @@ -40,6 +40,7 @@ import { delayedAction } from "@/helpers/delayedAction"; import { localStorageSettings } from "@/helpers/localStorageSettings"; import * as StellarXdr from "@/helpers/StellarXdr"; import { buildEndpointHref } from "@/helpers/buildEndpointHref"; +import { parseToLosslessJson } from "@/helpers/parseToLosslessJson"; import { useScrollIntoView } from "@/hooks/useScrollIntoView"; @@ -145,16 +146,22 @@ export const SubmitStepContent = ({ ); return { jsonString: JSON.stringify(JSON.parse(jsonString), null, 2), + jsonObject: parseToLosslessJson(jsonString), error: "", }; // eslint-disable-next-line @typescript-eslint/no-unused-vars } catch (e) { - return { jsonString: "", error: "Unable to decode XDR" }; + return { + jsonString: "", + jsonObject: null, + error: "Unable to decode XDR", + }; } }, [xdrBlob]); const [xdrJson, setXdrJson] = useState<{ jsonString: string; + jsonObject: Record | null; error: string; } | null>(null); @@ -580,13 +587,15 @@ export const SubmitStepContent = ({ networkPassphrase={network.passphrase} /> - {xdrJson?.jsonString ? ( - - ) : null} +
+ {xdrJson?.jsonObject ? ( + + ) : null} +