From eee6926d32f89b4a2edb4e8a8555b8e78a9375ed Mon Sep 17 00:00:00 2001 From: Jeesun Kim Date: Mon, 6 Jul 2026 22:41:28 -0700 Subject: [PATCH 1/5] Add and leave the default as it is for multisig --- .../components/SignStepContent.tsx | 9 ++ .../transaction/components/Signatures.tsx | 2 +- .../import/components/ImportStepContent.tsx | 14 --- .../components/SignStepSignatureContext.tsx | 112 ++++++++++++++++++ src/app/(sidebar)/transaction/import/page.tsx | 35 ++++-- .../TransactionFlowFooter/styles.scss | 1 + src/constants/networkLimits.ts | 99 ++++++++++++++++ src/helpers/checkRequiredSignatures.ts | 24 ++-- 8 files changed, 258 insertions(+), 38 deletions(-) create mode 100644 src/app/(sidebar)/transaction/import/components/SignStepSignatureContext.tsx diff --git a/src/app/(sidebar)/transaction/components/SignStepContent.tsx b/src/app/(sidebar)/transaction/components/SignStepContent.tsx index dc9437751..4170a35e3 100644 --- a/src/app/(sidebar)/transaction/components/SignStepContent.tsx +++ b/src/app/(sidebar)/transaction/components/SignStepContent.tsx @@ -13,6 +13,12 @@ type Props = { 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,6 +44,7 @@ export const SignStepContent = ({ signedXdr, onSigned, onClearAll, + signatureContext, }: Props) => { const [errorMessage, setErrorMessage] = useState(null); @@ -49,6 +56,8 @@ export const SignStepContent = ({ xdr={xdrToSign} /> + {signatureContext} + To be included in the ledger, the transaction must be signed and submitted to the network. diff --git a/src/app/(sidebar)/transaction/components/Signatures.tsx b/src/app/(sidebar)/transaction/components/Signatures.tsx index 4ef293396..0cb270f77 100644 --- a/src/app/(sidebar)/transaction/components/Signatures.tsx +++ b/src/app/(sidebar)/transaction/components/Signatures.tsx @@ -87,7 +87,7 @@ 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) from signers that can’t be verified offline (e.g. multisig cosigners). You can submit to verify.", }; } return { diff --git a/src/app/(sidebar)/transaction/import/components/ImportStepContent.tsx b/src/app/(sidebar)/transaction/import/components/ImportStepContent.tsx index 63f49d7a1..2e0486cd6 100644 --- a/src/app/(sidebar)/transaction/import/components/ImportStepContent.tsx +++ b/src/app/(sidebar)/transaction/import/components/ImportStepContent.tsx @@ -11,8 +11,6 @@ import { Notification } from "@stellar/design-system"; import { useImportFlowStore } from "@/store/createTransactionFlowStore"; import { useStore } from "@/store/useStore"; -import { useImportSignatureCompleteness } from "@/hooks/useImportSignatureCompleteness"; - import { parseImportXdr, ParsedImportXdr } from "@/helpers/parseImportXdr"; import { trackEvent, TrackingEvent } from "@/metrics/tracking"; @@ -64,11 +62,6 @@ export const ImportStepContent = ({ const parseError = importState?.parseError ?? null; const parsedTxType = importState?.parsedTxType ?? null; - const signatureCompleteness = useImportSignatureCompleteness(); - const isMultisigDeferred = - Boolean(isReadyToSubmit) && - (signatureCompleteness?.missingSigners.length ?? 0) > 0; - const parsedTx: Transaction | FeeBumpTransaction | null = (() => { if (!importXdr || parseError || !parsedTxType) return null; try { @@ -121,13 +114,6 @@ export const ImportStepContent = ({ }, []); const renderSuccessImportAlert = () => { - if (isMultisigDeferred) { - return ( - - Signatures from unrecognized signers detected. Submit to verify. - - ); - } if (isReadyToSubmit) { return ( + tx instanceof FeeBumpTransaction + ? tx.signatures.length > 0 || tx.innerTransaction.signatures.length > 0 + : tx.signatures.length > 0; + +/** + * The one actionable takeaway for the sign step. Deliberately generic — the + * `` table below carries the per-signer specifics (which signers + * are missing, which can't be verified). We never claim "this step is + * optional": for a multisig tx we can't tell offline whether the account's + * threshold is met, so the copy leaves that decision to the user. + */ +const getContextMessage = ( + completeness: TxSignatureCompleteness, +): { variant: AlertVariant; message: string } => { + if (completeness.hasInvalid) { + return { + variant: "error", + message: + "This transaction carries invalid signature(s) that won’t be accepted at submission. Review the signatures below before signing.", + }; + } + + if (completeness.missingSigners.length > 0) { + return { + variant: "warning", + message: + "This transaction still needs additional signature(s). If you’re a required signer, add yours below.", + }; + } + + if (completeness.hasUnrecognizedSigners) { + return { + variant: "primary", + message: + "This transaction already carries signature(s) that can’t be verified offline (e.g. multisig cosigners). Add another only if a cosigner still needs to sign — otherwise you can continue to submit.", + }; + } + + return { + variant: "success", + message: + "This transaction already has every signature that can be verified offline. Adding more may be rejected as unnecessary — you can continue to submit.", + }; +}; + +/** + * Signature context shown at the top of the sign step for the import flow. + * + * Renders an accurate, state-driven message plus the full `` + * breakdown so a co-signer can see who has signed (and who hasn't) before + * deciding whether to add their signature. Reflects the freshly-signed + * envelope once the user signs, so an added signature shows immediately. + * + * Returns `null` when the transaction has no signatures (e.g. the build flow, + * or an unsigned import), leaving the sign step unchanged for those cases. + */ +export const SignStepSignatureContext = ({ xdr, parsedTxType }: Props) => { + const { network } = useStore(); + + const tx = useMemo(() => { + if (!xdr) return null; + try { + return TransactionBuilder.fromXDR(xdr, network.passphrase) as + | Transaction + | FeeBumpTransaction; + } catch { + return null; + } + }, [xdr, network.passphrase]); + + if (!tx || !hasAnySignature(tx)) { + return null; + } + + const { variant, message } = getContextMessage(getTxSignatureCompleteness(tx)); + + return ( + + + {message} + + + + + ); +}; diff --git a/src/app/(sidebar)/transaction/import/page.tsx b/src/app/(sidebar)/transaction/import/page.tsx index 906914480..4b860c3c3 100644 --- a/src/app/(sidebar)/transaction/import/page.tsx +++ b/src/app/(sidebar)/transaction/import/page.tsx @@ -17,6 +17,7 @@ import { SignStepContent } from "@/app/(sidebar)/transaction/components/SignStep import { SubmitStepContent } from "@/app/(sidebar)/transaction/components/SubmitStepContent"; import { ImportStepContent } from "./components/ImportStepContent"; import { SimulateStepContent } from "./components/SimulateStepContent"; +import { SignStepSignatureContext } from "./components/SignStepSignatureContext"; import "../styles.scss"; @@ -146,18 +147,28 @@ export default function ImportTransaction() { {activeStep === "import" && ( )} - {activeStep === "sign" && ( - { - setSignedXdr(signedXdr); - }} - onClearAll={resetAll} - /> - )} + {activeStep === "sign" && + (() => { + const xdrToSign = + simulate.assembledXdr || importState?.importXdr || ""; + + return ( + { + setSignedXdr(signedXdr); + }} + onClearAll={resetAll} + signatureContext={ + + } + /> + ); + })()} {activeStep === "simulate" && } {activeStep === "submit" && ( >>>>>> 703f29e7 (Add and leave the default as it is for multisig) ], "state_target_size_bytes": "3000000000", "rent_fee_1kb_state_size_low": "-17000", @@ -157,6 +190,7 @@ export const TESTNET_LIMITS: NetworkLimits = { "persistent_rent_rate_denominator": "1215", "temp_rent_rate_denominator": "2430", "live_soroban_state_size_window": [ +<<<<<<< HEAD "2579727827", "2579795127", "2579916793", @@ -187,6 +221,38 @@ export const TESTNET_LIMITS: NetworkLimits = { "2585838975", "2585912907", "2588466663" +======= + "2950263231", + "2950317355", + "2950365327", + "2951150733", + "2951633185", + "2952265319", + "2953907009", + "2953940109", + "2955621437", + "2955984640", + "2956724628", + "2957636647", + "2958315336", + "2958057513", + "2945478935", + "2926608405", + "2926636877", + "2926668349", + "2926693025", + "2926477604", + "2927761212", + "2927803340", + "2929165954", + "2930856875", + "2930891647", + "2930890811", + "2932375901", + "2934400536", + "2934078159", + "2933863551" +>>>>>>> 703f29e7 (Add and leave the default as it is for multisig) ], "state_target_size_bytes": "4000000000", "rent_fee_1kb_state_size_low": "-17000", @@ -227,6 +293,7 @@ export const FUTURENET_LIMITS: NetworkLimits = { "persistent_rent_rate_denominator": "1215", "temp_rent_rate_denominator": "2430", "live_soroban_state_size_window": [ +<<<<<<< HEAD "1370772", "1370772", "1370772", @@ -257,6 +324,38 @@ export const FUTURENET_LIMITS: NetworkLimits = { "1370772", "1370772", "1370772" +======= + "45569719", + "45569719", + "45570079", + "45570079", + "45570439", + "45570439", + "45570439", + "45570439", + "45570439", + "45570439", + "45570439", + "45570439", + "45570439", + "45570439", + "45570439", + "45570439", + "45570439", + "45570439", + "45570439", + "45570439", + "45570439", + "45570439", + "45570439", + "45570439", + "45570439", + "45570439", + "45570439", + "45570439", + "45570439", + "45570439" +>>>>>>> 703f29e7 (Add and leave the default as it is for multisig) ], "state_target_size_bytes": "4000000000", "rent_fee_1kb_state_size_low": "-17000", diff --git a/src/helpers/checkRequiredSignatures.ts b/src/helpers/checkRequiredSignatures.ts index 39a2cb98f..bdfc2ddd3 100644 --- a/src/helpers/checkRequiredSignatures.ts +++ b/src/helpers/checkRequiredSignatures.ts @@ -88,13 +88,18 @@ export type TxSignatureCompleteness = { * * Multisig caveat: a multisig account is often signed by on-chain cosigners * rather than the account key itself, so the required source account can show - * as "missing" while the tx is actually fully signed. Those cosigner - * signatures surface as unrecognized (their hints match no required signer). - * When unrecognized signatures are present we therefore can't claim the tx is - * incomplete — we defer to the network, which is the authority on whether the - * signature set satisfies account thresholds. Use the result to decide whether - * to route the user to submit (complete) versus the sign step (a required - * source account hasn't signed and nothing could be standing in for it). + * as "missing" while the tx may in fact satisfy the account's thresholds. Those + * cosigner signatures surface as unrecognized (their hints match no required + * signer), flagged separately via `hasUnrecognizedSigners`. + * + * `isComplete` reflects only what can be verified offline: it is true only when + * every envelope-derivable required signer has a valid signature. It does NOT + * defer to the network for the unrecognized case — a multisig tx signed solely + * by cosigners reads as incomplete here so the flow routes the user through the + * sign step (rather than skipping straight to submit), where they can review + * the existing signatures and add their own if needed. `hasUnrecognizedSigners` + * is surfaced so the UI can explain that offline completeness is inconclusive + * and the network remains the final authority on thresholds. */ export const getTxSignatureCompleteness = ( tx: Transaction | FeeBumpTransaction, @@ -137,10 +142,7 @@ export const getTxSignatureCompleteness = ( } return { - // Unrecognized signatures may cover the missing required signer(s) via - // on-chain multisig — let the network be the judge rather than blocking. - isComplete: - !hasInvalid && (missingSigners.length === 0 || hasUnrecognizedSigners), + isComplete: !hasInvalid && missingSigners.length === 0, hasInvalid, missingSigners, hasUnrecognizedSigners, From 13c663eda0232f05ac73afbe0a004537cbc5ff8d Mon Sep 17 00:00:00 2001 From: Jeesun Kim Date: Tue, 7 Jul 2026 18:31:29 -0700 Subject: [PATCH 2/5] Update import transaction --- src/app/(sidebar)/transaction/build/page.tsx | 40 +++++++- .../components/SignStepContent.tsx | 71 +++++++++---- .../transaction/components/Signatures.tsx | 8 +- .../components/SubmitStepContent.tsx | 27 +++-- .../components/SignStepSignatureContext.tsx | 32 +++--- src/app/(sidebar)/transaction/import/page.tsx | 14 ++- src/app/(sidebar)/transaction/styles.scss | 21 ---- .../TransactionFlowFooter/styles.scss | 2 +- src/constants/networkLimits.ts | 99 +++++++++++++++++++ tests/e2e/buildFlowResetOnEdit.test.ts | 93 +++++++++++++++++ tests/e2e/importMultisigSubmit.test.ts | 47 +++++++++ 11 files changed, 381 insertions(+), 73 deletions(-) create mode 100644 tests/e2e/buildFlowResetOnEdit.test.ts create mode 100644 tests/e2e/importMultisigSubmit.test.ts diff --git a/src/app/(sidebar)/transaction/build/page.tsx b/src/app/(sidebar)/transaction/build/page.tsx index 1c7ba314c..4bd95560a 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,43 @@ 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(() => { + // Ignore empty values: the built XDR is transiently cleared while the build + // step remounts (XDR encoder init) or while inputs are mid-edit/invalid. + // Treating those as edits would wipe downstream state on plain navigation. + if (!currentXdr) { + return; + } + // Record the first real XDR (initial build / sessionStorage rehydrate) + // without resetting, so restored progress isn't wiped on page load. + 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 4170a35e3..153ae5827 100644 --- a/src/app/(sidebar)/transaction/components/SignStepContent.tsx +++ b/src/app/(sidebar)/transaction/components/SignStepContent.tsx @@ -3,10 +3,19 @@ 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; @@ -46,7 +55,23 @@ export const SignStepContent = ({ 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 ( @@ -56,8 +81,6 @@ export const SignStepContent = ({ xdr={xdrToSign} /> - {signatureContext} - To be included in the ledger, the transaction must be signed and submitted to the network. @@ -72,6 +95,8 @@ export const SignStepContent = ({ }} /> + {signatureContext} + {errorMessage ? ( {errorMessage} @@ -86,25 +111,29 @@ export const SignStepContent = ({ - - - 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 0cb270f77..b0bb27036 100644 --- a/src/app/(sidebar)/transaction/components/Signatures.tsx +++ b/src/app/(sidebar)/transaction/components/Signatures.tsx @@ -91,7 +91,7 @@ const getEnvelopeSummary = ( }; } 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} +