diff --git a/extension/e2e-tests/onboarding.test.ts b/extension/e2e-tests/onboarding.test.ts index 10fb4e125f..7712f6a5dd 100644 --- a/extension/e2e-tests/onboarding.test.ts +++ b/extension/e2e-tests/onboarding.test.ts @@ -369,7 +369,8 @@ test("Incorrect mnemonic phrase", async ({ page }) => { const shuffledWords = shuffle(words); for (let i = 0; i < shuffledWords.length; i++) { - await page.getByLabel(shuffledWords[i]).check({ force: true }); + // Use nth() to handle duplicate labels by selecting the first matching element + await page.getByLabel(shuffledWords[i]).first().check({ force: true }); } await page.getByTestId("display-mnemonic-phrase-confirm-btn").click(); diff --git a/extension/src/popup/components/InternalTransaction/EditMemo/index.tsx b/extension/src/popup/components/InternalTransaction/EditMemo/index.tsx index 66eedbe791..3b7705a200 100644 --- a/extension/src/popup/components/InternalTransaction/EditMemo/index.tsx +++ b/extension/src/popup/components/InternalTransaction/EditMemo/index.tsx @@ -4,6 +4,7 @@ import { Field, FieldProps, Form, Formik } from "formik"; import { useTranslation } from "react-i18next"; import { View } from "popup/basics/layout/View"; +import { useValidateMemo } from "popup/helpers/useValidateMemo"; import "./styles.scss"; @@ -19,61 +20,82 @@ interface EditMemoProps { export const EditMemo = ({ memo, onClose, onSubmit }: EditMemoProps) => { const { t } = useTranslation(); + const [localMemo, setLocalMemo] = React.useState(memo); + const { error: memoError } = useValidateMemo(localMemo); + const initialValues: FormValue = { memo, }; - const handleSubmit = async (values: FormValue) => { + + const handleSubmit = (values: FormValue) => { + // Prevent submission if there's a validation error + if (memoError) { + return; + } onSubmit(values); }; + const handleFieldChange = (value: string) => { + setLocalMemo(value); + }; + + const renderField = ({ field }: FieldProps) => ( + { + field.onChange(e); + handleFieldChange(e.target.value); + }} + error={memoError} + /> + ); + + const renderForm = () => ( +
+ {renderField} +
+ {t("What is this transaction for? (optional)")} +
+
+ + +
+
+ ); + return (
-

Memo

- - {({ errors }) => ( - <> -
- - {({ field }: FieldProps) => ( - - )} - -
- What is this transaction for? (optional) -
-
- - -
-
- - )} +

{t("Memo")}

+ + {renderForm}
diff --git a/extension/src/popup/components/InternalTransaction/EditSettings/index.tsx b/extension/src/popup/components/InternalTransaction/EditSettings/index.tsx index bb48629eef..ff0919e0cc 100644 --- a/extension/src/popup/components/InternalTransaction/EditSettings/index.tsx +++ b/extension/src/popup/components/InternalTransaction/EditSettings/index.tsx @@ -56,8 +56,8 @@ export const EditSettings = ({ fieldSize="md" autoComplete="off" id="fee" - placeholder={"Fee"} - label="Transaction Fee" + placeholder={t("Fee")} + label={t("Transaction Fee")} {...field} error={errors.fee} onChange={(e) => { @@ -92,7 +92,7 @@ export const EditSettings = ({
- {congestion} congestion + {congestion} {t("congestion")}
{({ field }: FieldProps) => ( @@ -101,8 +101,8 @@ export const EditSettings = ({ fieldSize="md" autoComplete="off" id="timeout" - placeholder={"Timeout"} - label="Timeout (seconds)" + placeholder={t("Timeout")} + label={t("Timeout (seconds)")} {...field} error={errors.timeout} onChange={(e) => { diff --git a/extension/src/popup/components/InternalTransaction/ReviewTransaction/index.tsx b/extension/src/popup/components/InternalTransaction/ReviewTransaction/index.tsx index 2d8c1044fe..be99d77124 100644 --- a/extension/src/popup/components/InternalTransaction/ReviewTransaction/index.tsx +++ b/extension/src/popup/components/InternalTransaction/ReviewTransaction/index.tsx @@ -24,11 +24,13 @@ import { IdenticonImg } from "popup/components/identicons/IdenticonImg"; import { BlockaidTxScanLabel, BlockAidTxScanExpanded, + MemoRequiredLabel, } from "popup/components/WarningMessages"; import { HardwareSign } from "popup/components/hardwareConnect/HardwareSign"; import { hardwareWalletTypeSelector } from "popup/ducks/accountServices"; import { MultiPaneSlider } from "popup/components/SlidingPaneSwitcher"; import { CopyValue } from "popup/components/CopyValue"; +import { useValidateTransactionMemo } from "popup/helpers/useValidateTransactionMemo"; import "./styles.scss"; @@ -49,6 +51,7 @@ interface ReviewTxProps { title: string; onConfirm: () => void; onCancel: () => void; + onAddMemo?: () => void; } export const ReviewTx = ({ @@ -63,6 +66,7 @@ export const ReviewTx = ({ title, onConfirm, onCancel, + onAddMemo, }: ReviewTxProps) => { const { t } = useTranslation(); const dispatch = useDispatch(); @@ -77,6 +81,14 @@ export const ReviewTx = ({ transactionData: { destination, memo, federationAddress }, } = submission; + // Validate memo requirements using the transaction XDR + const transactionXdr = simulationState.data?.transactionXdr; + const { isMemoMissing: isRequiredMemoMissing, isValidatingMemo } = + useValidateTransactionMemo(transactionXdr); + + // Disable button while validating or if memo is missing + const isSubmitDisabled = isRequiredMemoMissing || isValidatingMemo; + const asset = getAssetFromCanonical(srcAsset); const dest = dstAsset ? getAssetFromCanonical(dstAsset.canonical) : null; const assetIcons = srcAsset !== "native" ? { [srcAsset]: assetIcon } : {}; @@ -200,18 +212,21 @@ export const ReviewTx = ({ onClick={() => setActivePaneIndex(1)} /> )} + {isRequiredMemoMissing && !isValidatingMemo && ( + setActivePaneIndex(2)} /> + )}
- Memo + {t("Memo")}
- {memo || "None"} + {memo || t("None")}
@@ -244,29 +259,76 @@ export const ReviewTx = ({ scanResult={simulationState.data?.scanResult!} onClose={() => setActivePaneIndex(0)} />, +
+
+
+ +
+
setActivePaneIndex(0)} + > + +
+
+
+ {t("Memo is required")} +
+
+
+ {t( + "Some destination accounts on the Stellar network require a memo to identify your payment.", + )} +
+
+ {t( + "If a required memo is missing or incorrect, your funds may not reach the intended recipient.", + )} +
+
+
, ]} />
- + {isRequiredMemoMissing && !isValidatingMemo && onAddMemo ? ( + + ) : ( + + )}
, @@ -236,14 +251,14 @@ export const ChangeTrustInternal = ({
- Transaction Details + {t("Transaction Details")}
OPERATION_TYPES[op.type] || op.type, )} diff --git a/extension/src/popup/components/sendPayment/SendAmount/hooks/useSimulateTxData.tsx b/extension/src/popup/components/sendPayment/SendAmount/hooks/useSimulateTxData.tsx index 7892bd2bf7..5ebf569a3c 100644 --- a/extension/src/popup/components/sendPayment/SendAmount/hooks/useSimulateTxData.tsx +++ b/extension/src/popup/components/sendPayment/SendAmount/hooks/useSimulateTxData.tsx @@ -1,5 +1,5 @@ import { useReducer } from "react"; -import { useDispatch, useSelector } from "react-redux"; +import { useDispatch, useSelector, useStore } from "react-redux"; import BigNumber from "bignumber.js"; import { captureException } from "@sentry/browser"; import { @@ -40,7 +40,7 @@ import { transactionDataSelector, } from "popup/ducks/transactionSubmission"; import { findAddressBalance } from "popup/helpers/balance"; -import { AppDispatch } from "popup/App"; +import { AppDispatch, AppState } from "popup/App"; import { useScanTx } from "popup/helpers/blockaid"; import { cleanAmount } from "popup/helpers/formatters"; @@ -311,6 +311,7 @@ function useSimulateTxData({ isMainnet: boolean; }) { const reduxDispatch = useDispatch(); + const store = useStore(); const { asset, amount, transactionFee, memo } = useSelector( transactionDataSelector, ); @@ -334,6 +335,14 @@ function useSimulateTxData({ const fetchData = async () => { dispatch({ type: "FETCH_DATA_START" }); try { + // Read memo and transactionFee from Redux state inside fetchData to get the latest values + const currentTransactionData = transactionDataSelector( + store.getState() as AppState, + ); + const currentMemo = currentTransactionData.memo || memo; + const currentTransactionFee = + currentTransactionData.transactionFee || transactionFee; + const payload = { transactionXdr: "" } as SimulateTxData; let destinationAccount = await getBaseAccount(destination); @@ -382,19 +391,19 @@ function useSimulateTxData({ const simResponse = await simulateTx({ type: simParams.type, - recommendedFee: transactionFee, + recommendedFee: currentTransactionFee, options: { tokenPayment: { address: tokenAddress, publicKey, - memo, + memo: currentMemo, params: { amount: parsedAmount.toNumber(), publicKey, destination, }, networkDetails, - transactionFee, + transactionFee: currentTransactionFee, }, }, }); @@ -420,8 +429,13 @@ function useSimulateTxData({ isPathPayment, isSwap, transactionTimeout, - memo, + memo: simParamsMemo, } = simParams; + // Use memo from Redux state if simParams doesn't have one, otherwise use simParams memo + const memoToUse = simParamsMemo || currentMemo; + // Use currentTransactionFee (fresh from Redux) instead of simResponse.recommendedFee + // For classic transactions, simResponse.recommendedFee is just the recommendedFee we passed in + const feeToUse = currentTransactionFee || simResponse.recommendedFee; const transaction = await getBuiltTx( publicKey, { @@ -436,10 +450,10 @@ function useSimulateTxData({ isSwap, isFunded: destBalancesResult.isFunded!, }, - simResponse.recommendedFee, + feeToUse, transactionTimeout, networkDetails, - memo, + memoToUse, ); const xdr = transaction.build().toXDR(); payload.transactionXdr = xdr; diff --git a/extension/src/popup/components/sendPayment/SendAmount/index.tsx b/extension/src/popup/components/sendPayment/SendAmount/index.tsx index 019d61b7d4..8047af4ac0 100644 --- a/extension/src/popup/components/sendPayment/SendAmount/index.tsx +++ b/extension/src/popup/components/sendPayment/SendAmount/index.tsx @@ -579,9 +579,13 @@ export const SendAmount = ({ setIsEditingMemo(false)} - onSubmit={({ memo }: { memo: string }) => { + onSubmit={async ({ memo }: { memo: string }) => { dispatch(saveMemo(memo)); setIsEditingMemo(false); + // Regenerate transaction XDR with new memo (now reads memo from Redux state inside fetchData) + await fetchSimulationData(); + // Reopen review sheet after memo is saved and XDR is regenerated + setIsReviewingTx(true); }} />
@@ -600,7 +604,7 @@ export const SendAmount = ({ timeout={transactionData.transactionTimeout} congestion={networkCongestion} onClose={() => setIsEditingSettings(false)} - onSubmit={({ + onSubmit={async ({ fee, timeout, }: { @@ -610,6 +614,8 @@ export const SendAmount = ({ dispatch(saveTransactionFee(fee)); dispatch(saveTransactionTimeout(timeout)); setIsEditingSettings(false); + // Regenerate transaction XDR with new fee (now reads fee from Redux state inside fetchData) + await fetchSimulationData(); }} /> @@ -630,6 +636,10 @@ export const SendAmount = ({ networkDetails={sendAmountData.data?.networkDetails!} onCancel={() => setIsReviewingTx(false)} onConfirm={goToNext} + onAddMemo={() => { + setIsReviewingTx(false); + setIsEditingMemo(true); + }} sendAmount={amount} sendPriceUsd={priceValueUsd} simulationState={simulationState} diff --git a/extension/src/popup/helpers/parseTransaction.ts b/extension/src/popup/helpers/parseTransaction.ts index f6e61e63af..bd3edc53bb 100644 --- a/extension/src/popup/helpers/parseTransaction.ts +++ b/extension/src/popup/helpers/parseTransaction.ts @@ -7,6 +7,11 @@ import { ErrorMessage } from "@shared/api/types"; export const decodeMemo = (memo: any): { value: string; type: MemoType } => { const _memo = memo as Memo; + // Handle case where memo might be empty object or doesn't have type property + if (!_memo || !_memo.type || _memo.type === "none") { + return { value: "", type: "none" }; + } + if (_memo.type === "id") { return { value: _memo.value as string, type: _memo.type }; } diff --git a/extension/src/popup/helpers/useValidateMemo.ts b/extension/src/popup/helpers/useValidateMemo.ts new file mode 100644 index 0000000000..8ee7946de1 --- /dev/null +++ b/extension/src/popup/helpers/useValidateMemo.ts @@ -0,0 +1,51 @@ +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Memo } from "stellar-sdk"; + +const MAX_MEMO_BYTES = 28; + +/** + * Calculates the byte length of a string + * @param str The string to measure + * @returns The length in bytes + */ +const getByteLength = (str: string): number => + new TextEncoder().encode(str).length; + +/** + * Hook to validate a transaction memo + * Returns error message if invalid + */ +export const useValidateMemo = (memo: string) => { + const { t } = useTranslation(); + const [error, setError] = useState(null); + + useEffect(() => { + // Memo is optional, so empty is valid + if (!memo) { + setError(null); + return; + } + + // Check byte length first (Stellar has a 28-byte limit for text memos) + if (getByteLength(memo) > MAX_MEMO_BYTES) { + setError( + t("Memo is too long. Maximum {{max}} bytes allowed", { + max: String(MAX_MEMO_BYTES), + }), + ); + return; + } + + try { + // Then try creating a Stellar memo to validate + Memo.text(memo); + + setError(null); + } catch (err) { + setError(t("Invalid memo format")); + } + }, [memo, t]); + + return { error }; +}; diff --git a/extension/src/popup/helpers/useValidateTransactionMemo.ts b/extension/src/popup/helpers/useValidateTransactionMemo.ts new file mode 100644 index 0000000000..23c17c11ea --- /dev/null +++ b/extension/src/popup/helpers/useValidateTransactionMemo.ts @@ -0,0 +1,271 @@ +import { useEffect, useMemo, useState } from "react"; +import { useSelector } from "react-redux"; +import { TransactionBuilder } from "stellar-sdk"; + +import { stellarSdkServer } from "@shared/api/helpers/stellarSdkServer"; +import { getMemoRequiredAccounts as internalGetMemoRequiredAccounts } from "@shared/api/internal"; +import { MemoRequiredAccount } from "@shared/api/types"; + +import { TRANSACTION_WARNING } from "constants/transaction"; +import { isMainnet } from "helpers/stellar"; +import { + settingsNetworkDetailsSelector, + settingsPreferencesSelector, +} from "popup/ducks/settings"; +import { publicKeySelector } from "popup/ducks/accountServices"; + +/** + * Checks if a memo is required by querying cached memo-required accounts + * + * @param {ReturnType} transaction - The transaction to check + * @param {MemoRequiredAccount[]} memoRequiredAccounts - List of memo-required accounts + * @returns {boolean} True if a memo is required, false otherwise + */ +const checkMemoRequiredFromCache = ( + transaction: ReturnType, + memoRequiredAccounts: MemoRequiredAccount[], +): boolean => { + // Find destination from any operation that has a destination field + const destination = transaction.operations.find( + (operation) => "destination" in operation, + )?.destination; + + if (!destination) { + return false; + } + + // Check if the destination address is in the memo-required accounts list + const matchingAccount = memoRequiredAccounts.find( + ({ address }) => address === destination, + ); + + if (!matchingAccount) { + return false; + } + + // Check if the account has the memo-required tag + return matchingAccount.tags.some( + (tag) => tag === (TRANSACTION_WARNING.memoRequired as string), + ); +}; + +/** + * Checks if a memo is required using Stellar SDK's built-in validation + * This is a fallback method when cache validation fails + * + * @param {ReturnType} transaction - The transaction to check + * @param {string} networkUrl - The network URL for the Stellar server + * @param {string} networkPassphrase - The network passphrase + * @returns {Promise} True if a memo is required, false otherwise + */ +const checkMemoRequiredFromStellarSDK = async ( + transaction: ReturnType, + networkUrl: string, + networkPassphrase: string, +): Promise => { + const server = stellarSdkServer(networkUrl, networkPassphrase); + + try { + await server.checkMemoRequired(transaction as any); + return false; + } catch (e: any) { + if ("accountId" in e) { + return true; + } + return false; + } +}; + +/** + * Hook to validate transaction memos for addresses that require them + * + * This hook checks if a transaction destination address requires a memo by: + * 1. Checking a cached list of memo-required addresses from StellarExpert API + * 2. Falling back to Stellar SDK's checkMemoRequired method + * 3. Only validating on mainnet when memo validation is enabled in preferences + * + * @param {string | null | undefined} incomingXdr - The transaction XDR string to validate + * @returns {Object} Validation state and results + * @returns {boolean} returns.isMemoMissing - Whether a required memo is missing + * @returns {boolean} returns.isValidatingMemo - Whether validation is currently in progress + * + * @example + * ```tsx + * const { isMemoMissing, isValidatingMemo } = useValidateTransactionMemo(transactionXDR); + * + * if (isMemoMissing && !isValidatingMemo) { + * // Show warning that memo is required + * } + * ``` + */ +export const useValidateTransactionMemo = (incomingXdr?: string | null) => { + const networkDetails = useSelector(settingsNetworkDetailsSelector); + const { isMemoValidationEnabled } = useSelector(settingsPreferencesSelector); + const activePublicKey = useSelector(publicKeySelector); + const [isValidatingMemo, setIsValidatingMemo] = useState(false); + const [localTransaction, setLocalTransaction] = useState | null>(null); + const [memoRequiredAccounts, setMemoRequiredAccounts] = useState< + MemoRequiredAccount[] + >([]); + + const xdr = useMemo(() => incomingXdr, [incomingXdr]); + + /** + * Determines if memo validation should be performed + * Only validates on mainnet when the feature is enabled in preferences + */ + const shouldValidateMemo = useMemo( + () => !!(isMemoValidationEnabled && isMainnet(networkDetails)), + [isMemoValidationEnabled, networkDetails], + ); + + // Start with true to prevent button from being enabled before validation completes + // Reset to true whenever XDR changes to ensure we re-validate + const [isMemoMissing, setIsMemoMissing] = useState(true); + + /** + * Effect to fetch memo-required accounts from cache + */ + useEffect(() => { + if (!shouldValidateMemo) { + return; + } + + const fetchMemoRequiredAccounts = async () => { + if (!activePublicKey) { + return; + } + try { + const response = await internalGetMemoRequiredAccounts({ + activePublicKey, + }); + if (response && !(response instanceof Error)) { + setMemoRequiredAccounts(response.memoRequiredAccounts || []); + } + } catch (error) { + console.error("Error fetching memo-required accounts:", error); + } + }; + + fetchMemoRequiredAccounts(); + }, [shouldValidateMemo, activePublicKey]); + + /** + * Effect to parse XDR and set initial memo validation state + * Runs when XDR, network, or validation settings change + */ + useEffect(() => { + // Reset validation state when XDR changes to ensure fresh validation + setIsMemoMissing(true); + setIsValidatingMemo(false); + + if (!shouldValidateMemo) { + setIsMemoMissing(false); + setLocalTransaction(null); + return; + } + + if (!xdr || !networkDetails) { + setLocalTransaction(null); + setIsMemoMissing(false); + return; + } + + try { + const transaction = TransactionBuilder.fromXDR( + xdr, + networkDetails.networkPassphrase, + ); + setLocalTransaction(transaction); + + // Check if memo exists in transaction (check both type and value) + const hasMemo = + "memo" in transaction && + transaction.memo.type !== "none" && + transaction.memo.value && + String(transaction.memo.value).trim() !== ""; + + // If memo exists, it's not missing - otherwise keep as true to trigger validation + if (hasMemo) { + setIsMemoMissing(false); + } + // If no memo, keep isMemoMissing as true so validation will run + } catch (error) { + console.error("Error parsing transaction XDR:", error); + setLocalTransaction(null); + setIsMemoMissing(false); + } + }, [xdr, shouldValidateMemo, networkDetails]); + + /** + * Effect to perform memo requirement validation + * Checks both cache and SDK methods to determine if memo is required + */ + useEffect(() => { + if (!shouldValidateMemo) { + setIsMemoMissing(false); + return; + } + + if (!localTransaction) { + return; + } + + // Check if memo exists in transaction (check both type and value) + const hasMemo = + "memo" in localTransaction && + localTransaction.memo.type !== "none" && + localTransaction.memo.value && + String(localTransaction.memo.value).trim() !== ""; + + // If memo exists, it's not missing + if (hasMemo) { + setIsMemoMissing(false); + return; + } + + const checkIsMemoRequired = async () => { + setIsValidatingMemo(true); + + try { + // Check cache first (memo-required accounts from API) + const isMemoRequiredFromCache = checkMemoRequiredFromCache( + localTransaction, + memoRequiredAccounts, + ); + + // Also check SDK as fallback + const isMemoRequiredFromSDK = await checkMemoRequiredFromStellarSDK( + localTransaction, + networkDetails.networkUrl, + networkDetails.networkPassphrase, + ); + + // If either method indicates memo is required, set it as missing + setIsMemoMissing(isMemoRequiredFromSDK || isMemoRequiredFromCache); + } catch (error) { + console.error("Error validating memo:", error); + + // If there's any error, we assume the memo is missing to be safe + // so we prevent loss of funds due to a missing memo. + setIsMemoMissing(true); + } finally { + setIsValidatingMemo(false); + } + }; + + // Only run validation if we have memo-required accounts loaded or if we're checking via SDK + // This ensures we check against the API data when available + checkIsMemoRequired(); + }, [ + localTransaction, + memoRequiredAccounts, + shouldValidateMemo, + networkDetails.networkUrl, + networkDetails.networkPassphrase, + ]); + + return { isMemoMissing, isValidatingMemo }; +}; diff --git a/extension/src/popup/locales/en/translation.json b/extension/src/popup/locales/en/translation.json index bad4444984..6aa65547c4 100644 --- a/extension/src/popup/locales/en/translation.json +++ b/extension/src/popup/locales/en/translation.json @@ -1,6 +1,7 @@ { "* All Stellar accounts must maintain a minimum balance of lumens": "* All Stellar accounts must maintain a minimum balance of lumens.", "* payment methods may vary based on your location": "* payment methods may vary based on your location", + "A destination account requires the use of the memo field which is not present in the transaction you're about to sign": "A destination account requires the use of the memo field which is not present in the transaction you're about to sign.", "About": "About", "Account": "Account", "Account details": "Account details", @@ -16,6 +17,7 @@ "Add Custom Network": "Add Custom Network", "Add funds": "Add funds", "Add list": "Add list", + "Add Memo": "Add Memo", "Add network": "Add network", "Add new list": "Add new list", "Add Token": "Add Token", @@ -73,6 +75,7 @@ "by": "by", "By using these services, you act at your own risk, and Freighter or Stellar Development Foundation (SDF) bears no liability for any resulting losses or damages": "By using these services, you act at your own risk, and Freighter or Stellar Development Foundation (SDF) bears no liability for any resulting losses or damages.", "Cancel": "Cancel", + "Check the destination account memo requirements and include it in the transaction": "Check the destination account memo requirements and include it in the transaction.", "Choose asset": "Choose asset", "Choose Asset": "Choose Asset", "Choose Recipient": "Choose Recipient", @@ -84,6 +87,8 @@ "Confirm delete": "Confirm delete", "Confirm password": "Confirm password", "Confirm removing Network": "Confirm removing Network", + "Confirm Transaction": "Confirm Transaction", + "congestion": "congestion", "Connect": "Connect", "Connect anyway": "Connect anyway", "Connect device to computer": "Connect device to computer", @@ -150,6 +155,7 @@ "Failed to fetch your wallets": "Failed to fetch your wallets.", "Fee": "Fee", "Feedback?": "Feedback?", + "Fees": "Fees", "Fees can vary depending on the network congestion": { " Please try using the suggested fee and try again": "Fees can vary depending on the network congestion. Please try using the suggested fee and try again." }, @@ -157,6 +163,7 @@ "Finish": "Finish", "For your security, we'll check if you got it right in the next step": "For your security, we'll check if you got it right in the next step.", "Freighter - Stellar Wallet": "Freighter - Stellar Wallet", + "Freighter automatically disabled the option to sign this transaction": "Freighter automatically disabled the option to sign this transaction.", "Freighter can’t recover your imported secret key using your recovery phrase": { " Storing your secret key is your responsibility": { " Freighter will never ask for your secret key outside of the extension": "Freighter can’t recover your imported secret key using your recovery phrase. Storing your secret key is your responsibility. Freighter will never ask for your secret key outside of the extension." @@ -194,6 +201,7 @@ "I understand, start migration": "I understand, start migration", "I’m aware Freighter can’t recover the imported secret key": "I’m aware Freighter can’t recover the imported secret key", "I’ve saved my phrase somewhere safe": "I’ve saved my phrase somewhere safe", + "If a required memo is missing or incorrect, your funds may not reach the intended recipient": "If a required memo is missing or incorrect, your funds may not reach the intended recipient.", "If you delete this list, you will have to re-add it manually": "If you delete this list, you will have to re-add it manually.", "If you forget your password, you can use the recovery phrase to access your wallet": "If you forget your password, you can use the recovery phrase to access your wallet", "Import": "Import", @@ -207,6 +215,7 @@ "Insufficient Fee": "Insufficient Fee", "INSUFFICIENT FUNDS FOR FEE": "INSUFFICIENT FUNDS FOR FEE", "invalid destination address": "invalid destination address", + "Invalid memo format": "Invalid memo format.", "Invalid mnemonic phrase": "Invalid mnemonic phrase", "INVALID STELLAR ADDRESS": "INVALID STELLAR ADDRESS", "Issuer": "Issuer", @@ -243,6 +252,10 @@ "Max Price": "Max Price", "Medium Threshold": "Medium Threshold", "Memo": "Memo", + "Memo is required": "Memo is required", + "Memo is too long": { + " Maximum {{max}} bytes allowed": "Memo is too long. Maximum {{max}} bytes allowed." + }, "Memo required": "Memo required", "Merge accounts after migrating (your funding lumens used to fund the current accounts will be sent to the new ones - you lose access to the current accounts": { ")": "Merge accounts after migrating (your funding lumens used to fund the current accounts will be sent to the new ones - you lose access to the current accounts.)" @@ -273,6 +286,7 @@ "No connected apps found": "No connected apps found", "No one from Stellar Development Foundation will ever ask for your recovery phrase": "No one from Stellar Development Foundation will ever ask for your recovery phrase", "No transactions to show": "No transactions to show", + "None": "None", "Not enough lumens": "Not enough lumens", "Not funded": "Not funded", "Not migrated": "Not migrated", @@ -344,6 +358,7 @@ "Send Max": "Send Max", "Send to": "Send to", "Send XLM to this account address": "Send XLM to this account address", + "Sequence #": "Sequence #", "Set default": "Set default", "Set Flags": "Set Flags", "Set Max": "Set Max", @@ -363,6 +378,7 @@ "Signing this transaction is not possible at the moment": "Signing this transaction is not possible at the moment.", "Signs for external accounts": "Signs for external accounts", "Skip": "Skip", + "Some destination accounts on the Stellar network require a memo to identify your payment": "Some destination accounts on the Stellar network require a memo to identify your payment.", "Some features may be disabled at this time": "Some features may be disabled at this time.", "Some of your assets may not appear, but they are still safe on the network!": "Some of your assets may not appear, but they are still safe on the network!", "Soroban RPC is temporarily experiencing issues": "Soroban RPC is temporarily experiencing issues", @@ -406,6 +422,8 @@ "This transaction is expected to fail": "This transaction is expected to fail", "This transaction was flagged as malicious": "This transaction was flagged as malicious", "This will be used to unlock your wallet": "This will be used to unlock your wallet", + "Timeout": "Timeout", + "Timeout (seconds)": "Timeout (seconds)", "To access your wallet, click Freighter from your browser Extensions browser menu": "To access your wallet, click Freighter from your browser Extensions browser menu.", "To create a new account you need to send at least 1 XLM to it": "To create a new account you need to send at least 1 XLM to it.", "To start using this account, fund it with at least 1 XLM": "To start using this account, fund it with at least 1 XLM.", @@ -414,13 +432,17 @@ "Total Available": "Total Available", "Total Balance": "Total Balance", "Transaction": "Transaction", + "Transaction details": "Transaction details", + "Transaction Details": "Transaction Details", "Transaction failed": "Transaction failed", + "Transaction Fee": "Transaction Fee", "Transaction Request": "Transaction Request", "Transfer from another account": "Transfer from another account", "Transfer from Coinbase, buy with debit and credit cards or bank transfer *": "Transfer from Coinbase, buy with debit and credit cards or bank transfer *", "trustlines": "trustlines", "Trustor": "Trustor", "Type": "Type", + "Type your memo": "Type your memo", "Unable to connect to": "Unable to connect to", "Unable to find your asset": { " Please check the asset code or address": "Unable to find your asset. Please check the asset code or address." @@ -439,13 +461,16 @@ "Verification with": "Verification with", "View on": "View on", "View options": "View options", + "Wallet": "Wallet", "Wallet Address": "Wallet Address", "wasm": "wasm", "Wasm Hash": "Wasm Hash", "WEBSITE CONNECTION IS NOT SECURE": "WEBSITE CONNECTION IS NOT SECURE", "Welcome back": "Welcome back", + "What is this transaction for? (optional)": "What is this transaction for? (optional)", "What’s new": "What’s new", "Wrong simulation result": "Wrong simulation result", + "XDR": "XDR", "XLM": "XLM", "You are in fullscreen mode": "You are in fullscreen mode", "You are overwriting an existing account": { diff --git a/extension/src/popup/locales/pt/translation.json b/extension/src/popup/locales/pt/translation.json index bad4444984..bdee4d2c9f 100644 --- a/extension/src/popup/locales/pt/translation.json +++ b/extension/src/popup/locales/pt/translation.json @@ -1,6 +1,7 @@ { "* All Stellar accounts must maintain a minimum balance of lumens": "* All Stellar accounts must maintain a minimum balance of lumens.", "* payment methods may vary based on your location": "* payment methods may vary based on your location", + "A destination account requires the use of the memo field which is not present in the transaction you're about to sign": "A destination account requires the use of the memo field which is not present in the transaction you're about to sign.", "About": "About", "Account": "Account", "Account details": "Account details", @@ -16,6 +17,7 @@ "Add Custom Network": "Add Custom Network", "Add funds": "Add funds", "Add list": "Add list", + "Add Memo": "Adicionar Memo", "Add network": "Add network", "Add new list": "Add new list", "Add Token": "Add Token", @@ -73,17 +75,20 @@ "by": "by", "By using these services, you act at your own risk, and Freighter or Stellar Development Foundation (SDF) bears no liability for any resulting losses or damages": "By using these services, you act at your own risk, and Freighter or Stellar Development Foundation (SDF) bears no liability for any resulting losses or damages.", "Cancel": "Cancel", + "Check the destination account memo requirements and include it in the transaction": "Check the destination account memo requirements and include it in the transaction.", "Choose asset": "Choose asset", "Choose Asset": "Choose Asset", "Choose Recipient": "Choose Recipient", "Choose your method": "Choose your method", "Clear Flags": "Clear Flags", "Close": "Close", - "Confirm": "Confirm", + "Confirm": "Confirmar", "Confirm anyway": "Confirm anyway", "Confirm delete": "Confirm delete", "Confirm password": "Confirm password", "Confirm removing Network": "Confirm removing Network", + "Confirm Transaction": "Confirmar Transação", + "congestion": "congestionamento", "Connect": "Connect", "Connect anyway": "Connect anyway", "Connect device to computer": "Connect device to computer", @@ -148,8 +153,9 @@ "Failed to fetch your account data": "Failed to fetch your account data.", "Failed to fetch your transaction details": "Failed to fetch your transaction details", "Failed to fetch your wallets": "Failed to fetch your wallets.", - "Fee": "Fee", + "Fee": "Taxa", "Feedback?": "Feedback?", + "Fees": "Taxas", "Fees can vary depending on the network congestion": { " Please try using the suggested fee and try again": "Fees can vary depending on the network congestion. Please try using the suggested fee and try again." }, @@ -157,6 +163,7 @@ "Finish": "Finish", "For your security, we'll check if you got it right in the next step": "For your security, we'll check if you got it right in the next step.", "Freighter - Stellar Wallet": "Freighter - Stellar Wallet", + "Freighter automatically disabled the option to sign this transaction": "Freighter automatically disabled the option to sign this transaction.", "Freighter can’t recover your imported secret key using your recovery phrase": { " Storing your secret key is your responsibility": { " Freighter will never ask for your secret key outside of the extension": "Freighter can’t recover your imported secret key using your recovery phrase. Storing your secret key is your responsibility. Freighter will never ask for your secret key outside of the extension." @@ -194,6 +201,7 @@ "I understand, start migration": "I understand, start migration", "I’m aware Freighter can’t recover the imported secret key": "I’m aware Freighter can’t recover the imported secret key", "I’ve saved my phrase somewhere safe": "I’ve saved my phrase somewhere safe", + "If a required memo is missing or incorrect, your funds may not reach the intended recipient": "If a required memo is missing or incorrect, your funds may not reach the intended recipient.", "If you delete this list, you will have to re-add it manually": "If you delete this list, you will have to re-add it manually.", "If you forget your password, you can use the recovery phrase to access your wallet": "If you forget your password, you can use the recovery phrase to access your wallet", "Import": "Import", @@ -207,6 +215,7 @@ "Insufficient Fee": "Insufficient Fee", "INSUFFICIENT FUNDS FOR FEE": "INSUFFICIENT FUNDS FOR FEE", "invalid destination address": "invalid destination address", + "Invalid memo format": "Invalid memo format.", "Invalid mnemonic phrase": "Invalid mnemonic phrase", "INVALID STELLAR ADDRESS": "INVALID STELLAR ADDRESS", "Issuer": "Issuer", @@ -243,6 +252,10 @@ "Max Price": "Max Price", "Medium Threshold": "Medium Threshold", "Memo": "Memo", + "Memo is required": "Memo é obrigatório", + "Memo is too long": { + " Maximum {{max}} bytes allowed": "Memo is too long. Maximum {{max}} bytes allowed." + }, "Memo required": "Memo required", "Merge accounts after migrating (your funding lumens used to fund the current accounts will be sent to the new ones - you lose access to the current accounts": { ")": "Merge accounts after migrating (your funding lumens used to fund the current accounts will be sent to the new ones - you lose access to the current accounts.)" @@ -273,6 +286,7 @@ "No connected apps found": "No connected apps found", "No one from Stellar Development Foundation will ever ask for your recovery phrase": "No one from Stellar Development Foundation will ever ask for your recovery phrase", "No transactions to show": "No transactions to show", + "None": "Nenhum", "Not enough lumens": "Not enough lumens", "Not funded": "Not funded", "Not migrated": "Not migrated", @@ -344,6 +358,7 @@ "Send Max": "Send Max", "Send to": "Send to", "Send XLM to this account address": "Send XLM to this account address", + "Sequence #": "Sequência #", "Set default": "Set default", "Set Flags": "Set Flags", "Set Max": "Set Max", @@ -363,6 +378,7 @@ "Signing this transaction is not possible at the moment": "Signing this transaction is not possible at the moment.", "Signs for external accounts": "Signs for external accounts", "Skip": "Skip", + "Some destination accounts on the Stellar network require a memo to identify your payment": "Some destination accounts on the Stellar network require a memo to identify your payment.", "Some features may be disabled at this time": "Some features may be disabled at this time.", "Some of your assets may not appear, but they are still safe on the network!": "Some of your assets may not appear, but they are still safe on the network!", "Soroban RPC is temporarily experiencing issues": "Soroban RPC is temporarily experiencing issues", @@ -406,6 +422,8 @@ "This transaction is expected to fail": "This transaction is expected to fail", "This transaction was flagged as malicious": "This transaction was flagged as malicious", "This will be used to unlock your wallet": "This will be used to unlock your wallet", + "Timeout": "Timeout", + "Timeout (seconds)": "Tempo limite (segundos)", "To access your wallet, click Freighter from your browser Extensions browser menu": "To access your wallet, click Freighter from your browser Extensions browser menu.", "To create a new account you need to send at least 1 XLM to it": "To create a new account you need to send at least 1 XLM to it.", "To start using this account, fund it with at least 1 XLM": "To start using this account, fund it with at least 1 XLM.", @@ -414,13 +432,17 @@ "Total Available": "Total Available", "Total Balance": "Total Balance", "Transaction": "Transaction", + "Transaction details": "Detalhes da transação", + "Transaction Details": "Detalhes da Transação", "Transaction failed": "Transaction failed", + "Transaction Fee": "Taxa de Transação", "Transaction Request": "Transaction Request", "Transfer from another account": "Transfer from another account", "Transfer from Coinbase, buy with debit and credit cards or bank transfer *": "Transfer from Coinbase, buy with debit and credit cards or bank transfer *", "trustlines": "trustlines", "Trustor": "Trustor", "Type": "Type", + "Type your memo": "Digite seu memo", "Unable to connect to": "Unable to connect to", "Unable to find your asset": { " Please check the asset code or address": "Unable to find your asset. Please check the asset code or address." @@ -439,13 +461,16 @@ "Verification with": "Verification with", "View on": "View on", "View options": "View options", + "Wallet": "Carteira", "Wallet Address": "Wallet Address", "wasm": "wasm", "Wasm Hash": "Wasm Hash", "WEBSITE CONNECTION IS NOT SECURE": "WEBSITE CONNECTION IS NOT SECURE", "Welcome back": "Welcome back", + "What is this transaction for? (optional)": "Para que é esta transação? (opcional)", "What’s new": "What’s new", "Wrong simulation result": "Wrong simulation result", + "XDR": "XDR", "XLM": "XLM", "You are in fullscreen mode": "You are in fullscreen mode", "You are overwriting an existing account": { diff --git a/extension/src/popup/views/SignTransaction/Preview/Summary/index.tsx b/extension/src/popup/views/SignTransaction/Preview/Summary/index.tsx index be835093be..1457e8ceee 100644 --- a/extension/src/popup/views/SignTransaction/Preview/Summary/index.tsx +++ b/extension/src/popup/views/SignTransaction/Preview/Summary/index.tsx @@ -1,17 +1,21 @@ import React from "react"; import { MemoType } from "stellar-sdk"; +import { useTranslation } from "react-i18next"; import { stroopToXlm } from "helpers/stellar"; import { CopyValue } from "popup/components/CopyValue"; import "./styles.scss"; -const mapMemoLabel: any = { - id: "MEMO_ID", - hash: "MEMO_HASH", - text: "MEMO_TEXT", - return: "MEMO_RETURN", - none: "MEMO_NONE", +const getMemoLabel = (type: MemoType): string => { + const map: Record = { + id: "MEMO_ID", + hash: "MEMO_HASH", + text: "MEMO_TEXT", + return: "MEMO_RETURN", + none: "MEMO_NONE", + }; + return map[type] || "MEMO_NONE"; }; interface SummaryProps { @@ -22,45 +26,48 @@ interface SummaryProps { xdr: string; } -export const Summary = (props: SummaryProps) => ( -
-
-
-

Operations

+export const Summary = (props: SummaryProps) => { + const { t } = useTranslation(); + return ( +
+
+
+

{t("Operations")}

+
+

{props.operationNames.length}

-

{props.operationNames.length}

-
-
-
-

Fees

+
+
+

{t("Fees")}

+
+

+ {stroopToXlm(props.fee).toString()} XLM +

-

- {stroopToXlm(props.fee).toString()} XLM -

-
-
-
-

Sequence #

+
+
+

{t("Sequence #")}

+
+

{props.sequenceNumber}

-

{props.sequenceNumber}

-
- {props.memo && props.memo.value && (
-

Memo

+

{t("Memo")}

-

{`${props.memo.value} (${ - mapMemoLabel[props.memo.type] - })`}

+

+ {props.memo && props.memo.value + ? `${props.memo.value} (${getMemoLabel(props.memo.type)})` + : `${t("None")} (${getMemoLabel("none")})`} +

- )} -
-
-

XDR

+
+
+

{t("XDR")}

+
+ + +
- - -
-
-); + ); +}; diff --git a/extension/src/popup/views/SignTransaction/index.tsx b/extension/src/popup/views/SignTransaction/index.tsx index 8f17d68278..4aa9fbf20e 100644 --- a/extension/src/popup/views/SignTransaction/index.tsx +++ b/extension/src/popup/views/SignTransaction/index.tsx @@ -37,6 +37,7 @@ import { } from "helpers/stellar"; import { decodeMemo } from "popup/helpers/parseTransaction"; import { useIsDomainListedAllowed } from "popup/helpers/useIsDomainListedAllowed"; +import { useValidateTransactionMemo } from "popup/helpers/useValidateTransactionMemo"; import { openTab } from "popup/helpers/navigate"; import { METRIC_NAMES } from "popup/constants/metricsNames"; @@ -47,6 +48,7 @@ import { BlockaidTxScanLabel, BlockAidTxScanExpanded, DomainNotAllowedWarningMessage, + MemoRequiredLabel, } from "popup/components/WarningMessages"; import { HardwareSign } from "popup/components/hardwareConnect/HardwareSign"; import { Loading } from "popup/components/Loading"; @@ -141,11 +143,24 @@ export const SignTransaction = () => { const memo = decodedMemo?.value; + // Use the hook to validate memo requirements + const { isMemoMissing: isMemoMissingFromHook, isValidatingMemo } = + useValidateTransactionMemo(transactionXdr); + const flaggedKeyValues = Object.values(flaggedKeys); - const isMemoRequired = flaggedKeyValues.some( + const isMemoRequiredFromFlaggedKeys = flaggedKeyValues.some( ({ tags }) => tags.includes(TRANSACTION_WARNING.memoRequired) && !memo, ); + // Combine both validation methods - use hook result if available, fallback to flaggedKeys + // isRequiredMemoMissing matches mobile app naming convention + const isRequiredMemoMissing = isValidatingMemo + ? isMemoRequiredFromFlaggedKeys + : isMemoMissingFromHook || isMemoRequiredFromFlaggedKeys; + + // Keep isMemoRequired for backward compatibility with existing components + const isMemoRequired = isRequiredMemoMissing; + const resolveFederatedAddress = useCallback(async (inputDest: string) => { let resolvedPublicKey; try { @@ -187,7 +202,10 @@ export const SignTransaction = () => { } }, [isMemoRequired]); - const isSubmitDisabled = isMemoRequired || !isDomainListedAllowed; + // Disable submit when memo is missing, validating, or domain not allowed + // Matches mobile app pattern: disabled={!!isMemoMissing || isSigning || !!isValidatingMemo} + const isSubmitDisabled = + isRequiredMemoMissing || isValidatingMemo || !isDomainListedAllowed; if ( signTxState.state === RequestState.IDLE || @@ -333,7 +351,7 @@ export const SignTransaction = () => { />
- Confirm Transaction + {t("Confirm Transaction")} {validDomain} @@ -349,6 +367,9 @@ export const SignTransaction = () => { {!isDomainListedAllowed && ( )} + {isRequiredMemoMissing && !isValidatingMemo && ( + setActivePaneIndex(3)} /> + )} {assetDiffs && ( {
- Wallet + {t("Wallet")}
@@ -374,7 +395,7 @@ export const SignTransaction = () => {
- Fee + {t("Fee")}
@@ -386,13 +407,26 @@ export const SignTransaction = () => {
+
+
+ + Memo +
+
+ + {decodedMemo && decodedMemo.value + ? decodedMemo.value + : t("None")} + +
+
setActivePaneIndex(2)} > - Transaction details + {t("Transaction details")}
, @@ -415,7 +449,7 @@ export const SignTransaction = () => {
- Transaction Details + {t("Transaction Details")}
{
, +
+
+
+
+
+ +
+
setActivePaneIndex(0)} + > + +
+
+
+ {t("Memo is required")} +
+
+
+
+ +
{t("Memo is required")}
+
+
+
+ {t( + "A destination account requires the use of the memo field which is not present in the transaction you're about to sign.", + )} +
+
+ {t( + "Freighter automatically disabled the option to sign this transaction.", + )} +
+
+ {t( + "Check the destination account memo requirements and include it in the transaction.", + )} +
+
+
+
+
, ]} />
@@ -468,7 +545,7 @@ export const SignTransaction = () => { isFullWidth isRounded size="lg" - isLoading={isConfirming} + isLoading={isConfirming || isValidatingMemo} onClick={() => handleApprove()} className={`SignTransaction__Action__ConfirmAnyway ${btnIsDestructive ? "" : "Warning"}`} > @@ -493,7 +570,7 @@ export const SignTransaction = () => { isFullWidth isRounded size="lg" - isLoading={isConfirming} + isLoading={isConfirming || isValidatingMemo} onClick={() => handleApprove()} > {t("Confirm")} diff --git a/extension/src/popup/views/SignTransaction/styles.scss b/extension/src/popup/views/SignTransaction/styles.scss index 93120797d3..a89ea8f3a1 100644 --- a/extension/src/popup/views/SignTransaction/styles.scss +++ b/extension/src/popup/views/SignTransaction/styles.scss @@ -365,6 +365,11 @@ border-radius: pxToRem(16px); background-color: var(--sds-clr-gray-03); margin-top: pxToRem(12px); + + > div:not(:first-child) { + margin-top: pxToRem(16px); + color: var(--sds-clr-gray-11); + } } &__Title {