diff --git a/@shared/api/__tests__/internal.test.ts b/@shared/api/__tests__/internal.test.ts index d902c83f5b..4687db43da 100644 --- a/@shared/api/__tests__/internal.test.ts +++ b/@shared/api/__tests__/internal.test.ts @@ -163,10 +163,7 @@ describe("internalApi", () => { it("excludes liquidity-pool IDs from the indexer request", async () => { const fetchSpy = mockFetchOk(); - await internalApi.getTokenPrices([ - "native", - "abc123:lp", - ]); + await internalApi.getTokenPrices(["native", "abc123:lp"]); const requestInit = fetchSpy.mock.calls[0][1] as RequestInit; const body = JSON.parse(requestInit.body as string); diff --git a/extension/e2e-tests/helpers/stubs.ts b/extension/e2e-tests/helpers/stubs.ts index e2099cafeb..15dd214718 100644 --- a/extension/e2e-tests/helpers/stubs.ts +++ b/extension/e2e-tests/helpers/stubs.ts @@ -231,6 +231,26 @@ export const stubFederation = async (page: Page) => { }); }; +/** + * Overrides the default federation stub to return a custom response. + * Useful for testing SEP-0002 memo fields. Must be called inside stubOverrides + * after loginToTestAccount has already registered the default routes. + */ +export const stubFederationWithMemo = async ( + page: Page, + response: { + account_id: string; + memo?: string; + memo_type?: string; + stellar_address?: string; + }, +) => { + await page.unroute("**/federation**"); + await page.route("**/federation**", async (route) => { + await route.fulfill({ json: response }); + }); +}; + export const stubDefaultAccountBalances = async (page: Page) => { await page.route("**/account-balances/**", async (route) => { const json = { diff --git a/extension/e2e-tests/sendPayment.test.ts b/extension/e2e-tests/sendPayment.test.ts index 91153aae97..2db6b29795 100644 --- a/extension/e2e-tests/sendPayment.test.ts +++ b/extension/e2e-tests/sendPayment.test.ts @@ -6,6 +6,7 @@ import { stubAccountBalancesWithUnfundedDestination, stubAccountBalancesWithUSDC, stubContractSpec, + stubFederationWithMemo, stubScanTxWithUnfundedWarning, stubScanTxWithUnfundedNonNativeWarning, stubScanTx, @@ -1013,11 +1014,233 @@ test("Send XLM payment from Asset Detail", async ({ await expect(page.getByTestId("SubmitAction")).toBeEnabled(); }); -// Reset environment variables before each memo-related test -// This ensures IS_PLAYWRIGHT is set for memo validation bypass -test.beforeEach(async ({ page }) => { - await page.evaluate(() => { - // Ensure IS_PLAYWRIGHT is set for memo validation bypass - (window as any).IS_PLAYWRIGHT = "true"; +// --- Federation address memo tests (SEP-0002) --- +// These tests stub the federation server endpoint that is already intercepted by +// the default stubAllExternalApis setup. stubFederationWithMemo overrides that +// stub to inject memo / memo_type fields in the federation response. +// +// Navigation flow for the Send screen: +// nav-link-send → SendAmount (send-amount-amount-input) +// address-tile → SendTo (send-to-input) +// Continue → SendAmount (send-amount-btn-memo) + +// The default federation stub (stubFederation) resolves "freighter.pb*lobstr.co" +// to GBTYAFHGNZSTE4VBWZYAGB3SRGJEPTI5I4Y22KZ4JTVAN56LESB6JZOF without a memo. +// We use the same address so the stellar.toml stub (pointing to lobstr.co) works. +const FEDERATION_ADDRESS = "freighter.pb*lobstr.co"; + +test("Federation address with text memo pre-populates memo field", async ({ + page, + extensionId, + context, +}) => { + test.slow(); + + const stubOverrides = async () => { + await stubFederationWithMemo(page, { + stellar_address: FEDERATION_ADDRESS, + account_id: "GBTYAFHGNZSTE4VBWZYAGB3SRGJEPTI5I4Y22KZ4JTVAN56LESB6JZOF", + memo: "payment-ref-42", + memo_type: "text", + }); + }; + + await loginToTestAccount({ page, extensionId, context, stubOverrides }); + + await page.getByTestId("nav-link-send").click({ force: true }); + await expect(page.getByTestId("send-amount-amount-input")).toBeVisible(); + + await page.getByTestId("address-tile").click(); + await page.getByTestId("send-to-input").fill(FEDERATION_ADDRESS); + + await expect(page.getByTestId("send-to-btn-continue")).toBeVisible({ + timeout: 10000, + }); + await page.getByTestId("send-to-btn-continue").click({ force: true }); + + // Back on SendAmount — open the memo editor and verify it's pre-populated + await expect(page.getByTestId("send-amount-btn-memo")).toBeVisible({ + timeout: 10000, + }); + await page.getByTestId("send-amount-btn-memo").click(); + await expect(page.getByTestId("edit-memo-input")).toHaveValue( + "payment-ref-42", + ); +}); + +test("Federation address with id memo pre-populates memo field", async ({ + page, + extensionId, + context, +}) => { + test.slow(); + + const stubOverrides = async () => { + await stubFederationWithMemo(page, { + stellar_address: FEDERATION_ADDRESS, + account_id: "GBTYAFHGNZSTE4VBWZYAGB3SRGJEPTI5I4Y22KZ4JTVAN56LESB6JZOF", + memo: "12345", + memo_type: "id", + }); + }; + + await loginToTestAccount({ page, extensionId, context, stubOverrides }); + + await page.getByTestId("nav-link-send").click({ force: true }); + await expect(page.getByTestId("send-amount-amount-input")).toBeVisible(); + + await page.getByTestId("address-tile").click(); + await page.getByTestId("send-to-input").fill(FEDERATION_ADDRESS); + + await expect(page.getByTestId("send-to-btn-continue")).toBeVisible({ + timeout: 10000, + }); + await page.getByTestId("send-to-btn-continue").click({ force: true }); + + await expect(page.getByTestId("send-amount-btn-memo")).toBeVisible({ + timeout: 10000, + }); + await page.getByTestId("send-amount-btn-memo").click(); + await expect(page.getByTestId("edit-memo-input")).toHaveValue("12345"); +}); + +test("Federation address with invalid account_id shows error notification", async ({ + page, + extensionId, + context, +}) => { + test.slow(); + + const stubOverrides = async () => { + await stubFederationWithMemo(page, { + account_id: "not-a-valid-stellar-key", + }); + }; + + await loginToTestAccount({ page, extensionId, context, stubOverrides }); + + await page.getByTestId("nav-link-send").click({ force: true }); + await expect(page.getByTestId("send-amount-amount-input")).toBeVisible(); + + await page.getByTestId("address-tile").click(); + await page.getByTestId("send-to-input").fill(FEDERATION_ADDRESS); + + // The error notification should appear with the validation message + await expect( + page.getByText("Federation server returned an invalid address"), + ).toBeVisible({ timeout: 10000 }); +}); + +test("Switching from federation address to regular address clears memo", async ({ + page, + extensionId, + context, +}) => { + test.slow(); + + const federationAccountId = + "GBTYAFHGNZSTE4VBWZYAGB3SRGJEPTI5I4Y22KZ4JTVAN56LESB6JZOF"; + + const stubOverrides = async () => { + await stubAccountBalancesE2e(page); + await stubFederationWithMemo(page, { + account_id: federationAccountId, + memo: "test-memo-123", + memo_type: "text", + stellar_address: FEDERATION_ADDRESS, + }); + }; + + await loginToTestAccount({ page, extensionId, context, stubOverrides }); + + // Scenario 1: Federation address with memo + await page.getByTestId("nav-link-send").click({ force: true }); + await expect(page.getByTestId("send-amount-amount-input")).toBeVisible(); + + await page.getByTestId("address-tile").click(); + await expect(page.getByTestId("send-to-input")).toBeVisible(); + + await page.getByTestId("send-to-input").fill(FEDERATION_ADDRESS); + await expect(page.getByTestId("send-to-btn-continue")).toBeVisible({ + timeout: 10000, + }); + await page.getByTestId("send-to-btn-continue").click({ force: true }); + + // Verify memo is set from federation + await expect(page.getByTestId("send-amount-amount-input")).toBeVisible({ + timeout: 10000, + }); + await page.getByTestId("send-amount-btn-memo").click(); + await expect(page.getByTestId("edit-memo-input")).toHaveValue( + "test-memo-123", + ); + + // Save and close memo modal by pressing Enter + await page.getByTestId("edit-memo-input").press("Enter"); + await expect(page.getByTestId("send-to-input")).not.toBeVisible(); + + // Now switch to regular address from the send amount screen + await page.getByTestId("address-tile").click(); + await expect(page.getByTestId("send-to-input")).toBeVisible(); + + // Clear and use regular address + await page.getByTestId("send-to-input").clear(); + await page.getByTestId("send-to-input").fill(FUNDED_DESTINATION); + + await expect(page.getByTestId("send-to-btn-continue")).toBeVisible({ + timeout: 10000, + }); + await page.getByTestId("send-to-btn-continue").click({ force: true }); + + // Verify memo is cleared for regular address + await expect(page.getByTestId("send-amount-btn-memo")).toBeVisible({ + timeout: 10000, + }); + await page.getByTestId("send-amount-btn-memo").click(); + await expect(page.getByTestId("edit-memo-input")).toHaveValue(""); +}); + +test("Federation address with hash memo type prepopulates memo", async ({ + page, + extensionId, + context, +}) => { + test.slow(); + + const federationAccountId = + "GBTYAFHGNZSTE4VBWZYAGB3SRGJEPTI5I4Y22KZ4JTVAN56LESB6JZOF"; + // SEP-0002 specifies hash memos as base64-encoded 32-byte values + const hashMemo = "A".repeat(43) + "="; + + const stubOverrides = async () => { + await stubAccountBalancesE2e(page); + await stubFederationWithMemo(page, { + account_id: federationAccountId, + memo: hashMemo, + memo_type: "hash", + stellar_address: FEDERATION_ADDRESS, + }); + }; + + await loginToTestAccount({ page, extensionId, context, stubOverrides }); + + await page.getByTestId("nav-link-send").click({ force: true }); + await expect(page.getByTestId("send-amount-amount-input")).toBeVisible(); + + await page.getByTestId("address-tile").click(); + await expect(page.getByTestId("send-to-input")).toBeVisible(); + + await page.getByTestId("send-to-input").fill(FEDERATION_ADDRESS); + + await expect(page.getByTestId("send-to-btn-continue")).toBeVisible({ + timeout: 10000, + }); + await page.getByTestId("send-to-btn-continue").click({ force: true }); + + // Verify memo type is set to hash and memo value matches + await expect(page.getByTestId("send-amount-btn-memo")).toBeVisible({ + timeout: 10000, }); + await page.getByTestId("send-amount-btn-memo").click(); + await expect(page.getByTestId("edit-memo-input")).toHaveValue(hashMemo); }); diff --git a/extension/src/popup/components/InternalTransaction/EditMemo/index.tsx b/extension/src/popup/components/InternalTransaction/EditMemo/index.tsx index 139ff70a42..0093b95b2e 100644 --- a/extension/src/popup/components/InternalTransaction/EditMemo/index.tsx +++ b/extension/src/popup/components/InternalTransaction/EditMemo/index.tsx @@ -14,6 +14,7 @@ interface FormValue { interface EditMemoProps { memo: string; + memoType?: string; onClose: () => void; onSubmit: (args: FormValue) => void; disabled?: boolean; @@ -22,6 +23,7 @@ interface EditMemoProps { export const EditMemo = ({ memo, + memoType, onClose, onSubmit, disabled = false, @@ -29,7 +31,7 @@ export const EditMemo = ({ }: EditMemoProps) => { const { t } = useTranslation(); const [localMemo, setLocalMemo] = React.useState(memo); - const { error: memoError } = useValidateMemo(localMemo); + const { error: memoError } = useValidateMemo(localMemo, memoType); const initialValues: FormValue = { memo, diff --git a/extension/src/popup/components/manageAssets/ChooseAsset/__tests__/ChooseAsset.errorState.test.tsx b/extension/src/popup/components/manageAssets/ChooseAsset/__tests__/ChooseAsset.errorState.test.tsx index ed65159747..0ecf6695df 100644 --- a/extension/src/popup/components/manageAssets/ChooseAsset/__tests__/ChooseAsset.errorState.test.tsx +++ b/extension/src/popup/components/manageAssets/ChooseAsset/__tests__/ChooseAsset.errorState.test.tsx @@ -13,10 +13,7 @@ describe("ChooseAsset RequestState.ERROR", () => { it("renders the fetch-fail notification without throwing on RequestState.ERROR", () => { jest - .spyOn( - UseGetAssetDomainsWithBalances, - "useGetAssetDomainsWithBalances", - ) + .spyOn(UseGetAssetDomainsWithBalances, "useGetAssetDomainsWithBalances") .mockReturnValue({ state: { state: RequestState.ERROR, diff --git a/extension/src/popup/components/send/SendAmount/__tests__/SendAmount.errorState.test.tsx b/extension/src/popup/components/send/SendAmount/__tests__/SendAmount.errorState.test.tsx index dfc1a2ce15..8ed77d0ca4 100644 --- a/extension/src/popup/components/send/SendAmount/__tests__/SendAmount.errorState.test.tsx +++ b/extension/src/popup/components/send/SendAmount/__tests__/SendAmount.errorState.test.tsx @@ -12,16 +12,14 @@ describe("SendAmount RequestState.ERROR", () => { }); it("renders the fetch-fail notification without throwing on RequestState.ERROR", () => { - jest - .spyOn(UseGetSendAmountData, "useGetSendAmountData") - .mockReturnValue({ - state: { - state: RequestState.ERROR, - data: null, - error: new Error("boom"), - }, - fetchData: jest.fn().mockResolvedValue(undefined), - } as any); + jest.spyOn(UseGetSendAmountData, "useGetSendAmountData").mockReturnValue({ + state: { + state: RequestState.ERROR, + data: null, + error: new Error("boom"), + }, + fetchData: jest.fn().mockResolvedValue(undefined), + } as any); render( @@ -44,8 +42,6 @@ describe("SendAmount RequestState.ERROR", () => { , ); - expect( - screen.getByTestId("send-amount-fetch-fail"), - ).toBeInTheDocument(); + expect(screen.getByTestId("send-amount-fetch-fail")).toBeInTheDocument(); }); }); diff --git a/extension/src/popup/components/send/SendAmount/hooks/useSimulateTxData.tsx b/extension/src/popup/components/send/SendAmount/hooks/useSimulateTxData.tsx index 6cfd630999..3d332fef86 100644 --- a/extension/src/popup/components/send/SendAmount/hooks/useSimulateTxData.tsx +++ b/extension/src/popup/components/send/SendAmount/hooks/useSimulateTxData.tsx @@ -8,7 +8,6 @@ import { Asset, BASE_FEE, extractBaseAddress, - Memo, Networks, Operation, TransactionBuilder, @@ -44,6 +43,7 @@ import { findAddressBalance } from "popup/helpers/balance"; import { AppDispatch, AppState } from "popup/App"; import { useScanTx } from "popup/helpers/blockaid"; import { cleanAmount } from "popup/helpers/formatters"; +import { buildMemoFromFederation } from "popup/helpers/federationMemo"; import { shouldCheckUnfundedDestinationWarning } from "popup/helpers/sendWarnings"; import { checkIsMuxedSupported, @@ -240,6 +240,7 @@ const getBuiltTx = async ( transactionTimeout: number, networkDetails: NetworkDetails, memo?: string, + memoType?: string, ) => { const { sourceAsset, @@ -280,7 +281,7 @@ const getBuiltTx = async ( .setTimeout(transactionTimeout); if (memo) { - transaction.addMemo(Memo.text(memo)); + transaction.addMemo(buildMemoFromFederation(memo, memoType ?? "")); } return transaction; @@ -415,9 +416,8 @@ function useSimulateTxData({ const { t } = useTranslation(); const reduxDispatch = useDispatch(); const store = useStore(); - const { asset, amount, transactionFee, memo, isCollectible } = useSelector( - transactionDataSelector, - ); + const { asset, amount, transactionFee, memo, memoType, isCollectible } = + useSelector(transactionDataSelector); const { scanTx } = useScanTx(); const [state, dispatch] = useReducer( @@ -442,6 +442,7 @@ function useSimulateTxData({ store.getState() as AppState, ); const currentMemo = currentTransactionData.memo || memo; + const currentMemoType = currentTransactionData.memoType || memoType; const currentAmount = currentTransactionData.amount || amount; const currentAsset = currentTransactionData.asset || asset; const currentTransactionFee = getCurrentTransactionFee({ @@ -613,6 +614,7 @@ function useSimulateTxData({ transactionTimeout, networkDetails, memoToUse, + currentMemoType, ); const xdr = transaction.build().toXDR(); payload.transactionXdr = xdr; diff --git a/extension/src/popup/components/send/SendAmount/index.tsx b/extension/src/popup/components/send/SendAmount/index.tsx index 209dfcc2e2..a1b45399da 100644 --- a/extension/src/popup/components/send/SendAmount/index.tsx +++ b/extension/src/popup/components/send/SendAmount/index.tsx @@ -36,7 +36,7 @@ import { saveAmount, saveAsset, saveIsToken, - saveMemo, + saveMemoAndType, saveTransactionFee, saveManualTransactionFee, saveTransactionTimeout, @@ -941,6 +941,7 @@ export const SendAmount = ({
{ setIsEditingMemo(false); // Reopen review sheet if user came from review flow @@ -950,7 +951,7 @@ export const SendAmount = ({ setMemoEditingContext(null); }} onSubmit={async ({ memo }: { memo: string }) => { - dispatch(saveMemo(memo)); + dispatch(saveMemoAndType({ memo, memoType: "" })); setIsEditingMemo(false); // Regenerate transaction XDR with new memo (now reads memo from Redux state inside fetchData) await fetchSimulationData(); diff --git a/extension/src/popup/components/send/SendTo/hooks/useSendToData.tsx b/extension/src/popup/components/send/SendTo/hooks/useSendToData.tsx index 1ef6db7ccc..71fd1e12b0 100644 --- a/extension/src/popup/components/send/SendTo/hooks/useSendToData.tsx +++ b/extension/src/popup/components/send/SendTo/hooks/useSendToData.tsx @@ -1,9 +1,10 @@ import { useReducer } from "react"; -import { Federation } from "stellar-sdk"; +import { Federation, StrKey } from "stellar-sdk"; import { FormikErrors } from "formik"; import debounce from "lodash/debounce"; -import * as Sentry from "@sentry/browser"; +import { captureException } from "@sentry/browser"; import i18n from "popup/helpers/localizationConfig"; +import { FederationMemoType } from "popup/helpers/federationMemo"; import { initialState, isError, reducer } from "helpers/request"; import { AccountBalances, useGetBalances } from "helpers/hooks/useGetBalances"; @@ -25,6 +26,8 @@ interface ResolvedSendToData { destinationBalances?: AccountBalances; validatedAddress: string; fedAddress: string; + federationMemo: string; + federationMemoType: FederationMemoType | ""; applicationState: APPLICATION_STATE; publicKey: string; networkDetails: NetworkDetails; @@ -34,21 +37,39 @@ type SendToData = NeedsReRoute | ResolvedSendToData; export const getAddressFromInput = async (userInput: string) => { if (isFederationAddress(userInput)) { + let fedResp; try { - const fedResp = await Federation.Server.resolve(userInput); - return { - validatedAddress: fedResp.account_id, - fedAddress: userInput, - }; + fedResp = await Federation.Server.resolve(userInput); } catch (error) { - Sentry.captureException(`Failed to fetch toml for ${userInput}`); + captureException(error); throw new Error(i18n.t("Failed to resolve federated address")); } + + if (!StrKey.isValidEd25519PublicKey(fedResp.account_id)) { + throw new Error(i18n.t("Federation server returned an invalid address")); + } + + const rawMemoType = fedResp.memo_type ?? ""; + const memoType = (Object.values(FederationMemoType) as string[]).includes( + rawMemoType, + ) + ? (rawMemoType as FederationMemoType) + : ("" as const); + const memo = fedResp.memo != null ? String(fedResp.memo) : ""; + + return { + validatedAddress: fedResp.account_id, + fedAddress: userInput, + federationMemo: memo, + federationMemoType: memoType, + }; } return { validatedAddress: userInput, fedAddress: "", + federationMemo: "", + federationMemoType: "" as const, }; }; @@ -72,8 +93,12 @@ function useSendToData() { _isMainnet: boolean, ) => { try { - const { validatedAddress, fedAddress } = - await getAddressFromInput(userInput); + const { + validatedAddress, + fedAddress, + federationMemo, + federationMemoType, + } = await getAddressFromInput(userInput); const { recentAddresses } = await loadRecentAddresses({ activePublicKey: publicKey, @@ -84,6 +109,8 @@ function useSendToData() { recentAddresses, validatedAddress, fedAddress, + federationMemo, + federationMemoType, applicationState, publicKey, networkDetails, @@ -142,6 +169,8 @@ function useSendToData() { recentAddresses: [], validatedAddress: "", fedAddress: "", + federationMemo: "", + federationMemoType: "", applicationState, publicKey, networkDetails, @@ -168,6 +197,8 @@ function useSendToData() { recentAddresses, validatedAddress: "", fedAddress: "", + federationMemo: "", + federationMemoType: "", applicationState, publicKey, networkDetails, diff --git a/extension/src/popup/components/send/SendTo/index.tsx b/extension/src/popup/components/send/SendTo/index.tsx index 00222fc578..194cd95244 100644 --- a/extension/src/popup/components/send/SendTo/index.tsx +++ b/extension/src/popup/components/send/SendTo/index.tsx @@ -32,8 +32,10 @@ import { saveDestination, saveDestinationAsset, saveFederationAddress, + saveMemoAndType, transactionDataSelector, } from "popup/ducks/transactionSubmission"; +import type { FederationMemoType } from "popup/helpers/federationMemo"; import { RequestState } from "constants/request"; import { useSendToData, getAddressFromInput } from "./hooks/useSendToData"; @@ -105,10 +107,22 @@ export const SendTo = ({ const handleContinue = ( validatedDestination: string, validatedFedAdress?: string, + federationMemo?: string, + federationMemoType?: FederationMemoType | "", ) => { dispatch(saveDestination(validatedDestination)); dispatch(saveDestinationAsset("")); dispatch(saveFederationAddress(validatedFedAdress || "")); + if (validatedFedAdress && federationMemo !== undefined) { + dispatch( + saveMemoAndType({ + memo: federationMemo, + memoType: federationMemoType || "", + }), + ); + } else { + dispatch(saveMemoAndType({ memo: "", memoType: "" })); + } goToNext(); }; @@ -122,6 +136,8 @@ export const SendTo = ({ handleContinue( sendDataState.data.validatedAddress, sendDataState.data.fedAddress, + sendDataState.data.federationMemo, + sendDataState.data.federationMemoType, ); } }, @@ -246,6 +262,8 @@ export const SendTo = ({ handleContinue( addressFromInput.validatedAddress, addressFromInput.fedAddress, + addressFromInput.federationMemo, + addressFromInput.federationMemoType, ); }} className="SendTo__subheading-identicon" diff --git a/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.errorState.test.tsx b/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.errorState.test.tsx index a23d6ec0d5..c128e5bd8d 100644 --- a/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.errorState.test.tsx +++ b/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.errorState.test.tsx @@ -29,16 +29,14 @@ describe("SwapAmount RequestState.ERROR", () => { }); it("renders the fetch-fail notification without throwing on RequestState.ERROR", () => { - jest - .spyOn(UseGetSwapAmountData, "useGetSwapAmountData") - .mockReturnValue({ - state: { - state: RequestState.ERROR, - data: null, - error: new Error("boom"), - }, - fetchData: jest.fn().mockResolvedValue(undefined), - } as any); + jest.spyOn(UseGetSwapAmountData, "useGetSwapAmountData").mockReturnValue({ + state: { + state: RequestState.ERROR, + data: null, + error: new Error("boom"), + }, + fetchData: jest.fn().mockResolvedValue(undefined), + } as any); render( @@ -53,8 +51,6 @@ describe("SwapAmount RequestState.ERROR", () => { , ); - expect( - screen.getByTestId("swap-amount-fetch-fail"), - ).toBeInTheDocument(); + expect(screen.getByTestId("swap-amount-fetch-fail")).toBeInTheDocument(); }); }); diff --git a/extension/src/popup/ducks/transactionSubmission.ts b/extension/src/popup/ducks/transactionSubmission.ts index 6d3254b930..78fcb98b01 100644 --- a/extension/src/popup/ducks/transactionSubmission.ts +++ b/extension/src/popup/ducks/transactionSubmission.ts @@ -37,6 +37,7 @@ import { getSoroswapTokens as getSoroswapTokensService, } from "popup/helpers/sorobanSwap"; import { hardwareSign, hardwareSignAuth } from "popup/helpers/hardwareConnect"; +import type { FederationMemoType } from "popup/helpers/federationMemo"; import { AppState } from "popup/App"; import { publicKeySelector } from "./accountServices"; @@ -425,6 +426,7 @@ interface TransactionData { transactionFee: string; transactionTimeout: number; memo?: string; + memoType?: FederationMemoType | ""; destinationAsset: string; destinationDecimals?: number; destinationAmount: string; @@ -495,6 +497,7 @@ export const initialState: InitialState = { transactionFee: "", transactionTimeout: 180, memo: "", + memoType: "", destinationAsset: "", destinationAmount: "", destinationIcon: "", @@ -563,8 +566,17 @@ const transactionSubmissionSlice = createSlice({ saveTransactionTimeout: (state, action) => { state.transactionData.transactionTimeout = action.payload; }, - saveMemo: (state, action) => { - state.transactionData.memo = action.payload; + saveMemoAndType: ( + state, + action: { + payload: { + memo: string; + memoType?: FederationMemoType | ""; + }; + }, + ) => { + state.transactionData.memo = action.payload.memo; + state.transactionData.memoType = action.payload.memoType ?? ""; }, saveDestinationAsset: (state, action) => { state.transactionData.destinationAsset = action.payload; @@ -756,7 +768,7 @@ export const { saveTransactionFee, saveManualTransactionFee, saveTransactionTimeout, - saveMemo, + saveMemoAndType, saveDestinationAsset, saveDestinationIcon, saveIsSoroswap, diff --git a/extension/src/popup/helpers/__tests__/federationMemo.test.ts b/extension/src/popup/helpers/__tests__/federationMemo.test.ts new file mode 100644 index 0000000000..3821e98800 --- /dev/null +++ b/extension/src/popup/helpers/__tests__/federationMemo.test.ts @@ -0,0 +1,200 @@ +import { Memo } from "stellar-sdk"; +import { + validateFederationMemo, + buildMemoFromFederation, + FederationMemoType, +} from "../federationMemo"; + +jest.mock("@sentry/browser", () => ({ captureException: jest.fn() })); + +// --- validateFederationMemo --- + +describe("validateFederationMemo", () => { + describe("empty memo", () => { + it("accepts empty string for any memo type", () => { + expect(() => + validateFederationMemo("", FederationMemoType.Text), + ).not.toThrow(); + expect(() => + validateFederationMemo("", FederationMemoType.Id), + ).not.toThrow(); + expect(() => + validateFederationMemo("", FederationMemoType.Hash), + ).not.toThrow(); + expect(() => validateFederationMemo("", "unknown")).not.toThrow(); + expect(() => validateFederationMemo("", "")).not.toThrow(); + }); + }); + + describe("text memo", () => { + it("accepts a short ASCII string", () => { + expect(() => + validateFederationMemo("hello", FederationMemoType.Text), + ).not.toThrow(); + }); + + it("accepts a string at exactly the 28-byte boundary", () => { + expect(() => + validateFederationMemo("a".repeat(28), FederationMemoType.Text), + ).not.toThrow(); + }); + + it("rejects a string exceeding 28 bytes", () => { + expect(() => + validateFederationMemo("a".repeat(29), FederationMemoType.Text), + ).toThrow("exceeds 28 bytes"); + }); + + it("measures byte length, not character length (multibyte chars)", () => { + // Each '€' is 3 bytes in UTF-8; 10 × 3 = 30 bytes > 28 + expect(() => + validateFederationMemo("€".repeat(10), FederationMemoType.Text), + ).toThrow("exceeds 28 bytes"); + // 9 × '€' = 27 bytes — should pass + expect(() => + validateFederationMemo("€".repeat(9), FederationMemoType.Text), + ).not.toThrow(); + }); + }); + + describe("id memo", () => { + it("accepts a valid non-negative integer string", () => { + expect(() => + validateFederationMemo("0", FederationMemoType.Id), + ).not.toThrow(); + expect(() => + validateFederationMemo("12345", FederationMemoType.Id), + ).not.toThrow(); + }); + + it("accepts the maximum uint64 value", () => { + expect(() => + validateFederationMemo("18446744073709551615", FederationMemoType.Id), + ).not.toThrow(); + }); + + it("rejects non-integer strings", () => { + expect(() => + validateFederationMemo("not-a-number", FederationMemoType.Id), + ).toThrow("non-negative integer"); + expect(() => + validateFederationMemo("1.5", FederationMemoType.Id), + ).toThrow("non-negative integer"); + expect(() => validateFederationMemo("-1", FederationMemoType.Id)).toThrow( + "non-negative integer", + ); + }); + + it("rejects values exceeding uint64 max", () => { + expect(() => + validateFederationMemo("18446744073709551616", FederationMemoType.Id), + ).toThrow("exceeds maximum uint64 value"); + }); + }); + + describe("hash memo", () => { + // 32 zero-bytes encoded as base64 (43 chars + 1 padding '=') + const VALID_B64 = "A".repeat(43) + "="; + + it("accepts a valid base64-encoded 32-byte hash", () => { + expect(() => + validateFederationMemo(VALID_B64, FederationMemoType.Hash), + ).not.toThrow(); + }); + + it("rejects a string that decodes to fewer than 32 bytes", () => { + expect(() => + validateFederationMemo("AAAA", FederationMemoType.Hash), + ).toThrow("base64-encoded 32-byte value"); + }); + + it("rejects a string that decodes to more than 32 bytes", () => { + // 33 zero-bytes in base64 = 44 chars + padding + const tooLong = "A".repeat(44) + "=="; + expect(() => + validateFederationMemo(tooLong, FederationMemoType.Hash), + ).toThrow("base64-encoded 32-byte value"); + }); + + it("rejects a non-base64 string of the wrong length", () => { + expect(() => + validateFederationMemo( + "not-valid-base64-string!!!", + FederationMemoType.Hash, + ), + ).toThrow("base64-encoded 32-byte value"); + }); + }); + + describe("unknown memo type", () => { + it("does not throw for an unknown type (pass-through)", () => { + expect(() => + validateFederationMemo("anything", "totally_unknown"), + ).not.toThrow(); + }); + + it("does not throw for an empty type string", () => { + expect(() => validateFederationMemo("anything", "")).not.toThrow(); + }); + }); +}); + +// --- buildMemoFromFederation --- + +describe("buildMemoFromFederation", () => { + describe("text memo", () => { + it("returns Memo.text for type 'text'", () => { + const memo = buildMemoFromFederation( + "payment-ref", + FederationMemoType.Text, + ); + expect(memo).toEqual(Memo.text("payment-ref")); + }); + + it("defaults to Memo.text for an unknown type", () => { + const memo = buildMemoFromFederation("fallback", "weird_type"); + expect(memo).toEqual(Memo.text("fallback")); + }); + + it("throws when the text value exceeds 28 bytes", () => { + expect(() => + buildMemoFromFederation("a".repeat(29), FederationMemoType.Text), + ).toThrow("Failed to resolve federated address"); + }); + }); + + describe("id memo", () => { + it("returns Memo.id for type 'id'", () => { + const memo = buildMemoFromFederation("12345", FederationMemoType.Id); + expect(memo).toEqual(Memo.id("12345")); + }); + + it("throws for a non-integer id", () => { + expect(() => + buildMemoFromFederation("abc", FederationMemoType.Id), + ).toThrow("Failed to resolve federated address"); + }); + + it("throws for an id exceeding uint64 max", () => { + expect(() => + buildMemoFromFederation("18446744073709551616", FederationMemoType.Id), + ).toThrow("Failed to resolve federated address"); + }); + }); + + describe("hash memo", () => { + // 32 zero-bytes encoded as base64 per SEP-0002 + const VALID_B64 = "A".repeat(43) + "="; + + it("returns Memo.hash for type 'hash'", () => { + const memo = buildMemoFromFederation(VALID_B64, FederationMemoType.Hash); + expect(memo).toEqual(Memo.hash(Buffer.from(VALID_B64, "base64"))); + }); + + it("throws for an invalid hash value", () => { + expect(() => + buildMemoFromFederation("not-valid", FederationMemoType.Hash), + ).toThrow("Failed to resolve federated address"); + }); + }); +}); diff --git a/extension/src/popup/helpers/federationMemo.ts b/extension/src/popup/helpers/federationMemo.ts new file mode 100644 index 0000000000..d8a05e5a5b --- /dev/null +++ b/extension/src/popup/helpers/federationMemo.ts @@ -0,0 +1,100 @@ +import { Memo } from "stellar-sdk"; +import * as Sentry from "@sentry/browser"; +import i18n from "popup/helpers/localizationConfig"; + +/** + * Memo types defined by SEP-0002 federation protocol. + * https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0002.md + */ +export enum FederationMemoType { + Text = "text", + Id = "id", + Hash = "hash", +} + +const FEDERATION_MEMO_TEXT_MAX_BYTES = 28; + +// Max value of a 64-bit unsigned integer +const MAX_UINT64 = BigInt("18446744073709551615"); + +/** + * Validates a federation memo value against the constraints for its SEP-0002 + * memo type. Throws a descriptive `Error` if the value is invalid. These + * errors are intended for Sentry logging only — do not surface them to users. + * + * Spec: https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0002.md + */ +export const validateFederationMemo = ( + memo: string, + memoType: FederationMemoType | string, +): void => { + // An empty memo is always valid regardless of type + if (!memo) { + return; + } + + switch (memoType) { + case FederationMemoType.Text: { + const byteLength = new TextEncoder().encode(memo).length; + if (byteLength > FEDERATION_MEMO_TEXT_MAX_BYTES) { + throw new Error( + `Federation memo text exceeds ${FEDERATION_MEMO_TEXT_MAX_BYTES} bytes`, + ); + } + break; + } + + case FederationMemoType.Id: { + if (!/^\d+$/.test(memo)) { + throw new Error("Federation memo id must be a non-negative integer"); + } + if (BigInt(memo) > MAX_UINT64) { + throw new Error("Federation memo id exceeds maximum uint64 value"); + } + break; + } + + case FederationMemoType.Hash: { + // SEP-0002 specifies hash memos as base64-encoded 32-byte values + if (Buffer.from(memo, "base64").length !== 32) { + throw new Error( + "Federation memo hash must be a base64-encoded 32-byte value", + ); + } + break; + } + + default: + // Unknown memo type — pass through and let stellar-sdk validate + break; + } +}; + +/** + * Builds a Stellar `Memo` object from a federation server memo value and type, + * running SEP-0002 validation before construction. + * + * Validation errors are logged to Sentry; a generic user-facing error is thrown + * so implementation details are never surfaced to users. + */ +export const buildMemoFromFederation = ( + memo: string, + memoType: FederationMemoType | string, +): Memo => { + try { + validateFederationMemo(memo, memoType); + + switch (memoType) { + case FederationMemoType.Id: + return Memo.id(memo); + case FederationMemoType.Hash: + return Memo.hash(Buffer.from(memo, "base64")); + default: + return Memo.text(memo); + } + } catch (err) { + // capture sentry specific message, but throw a generic error for users + Sentry.captureException(err); + throw new Error(i18n.t("Failed to resolve federated address")); + } +}; diff --git a/extension/src/popup/helpers/useValidateMemo.ts b/extension/src/popup/helpers/useValidateMemo.ts index 8ee7946de1..682e924d2e 100644 --- a/extension/src/popup/helpers/useValidateMemo.ts +++ b/extension/src/popup/helpers/useValidateMemo.ts @@ -1,33 +1,46 @@ import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { Memo } from "stellar-sdk"; +import { FederationMemoType } from "popup/helpers/federationMemo"; const MAX_MEMO_BYTES = 28; +const MAX_UINT64 = BigInt("18446744073709551615"); -/** - * 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) => { +export const useValidateMemo = (memo: string, memoType?: 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 (memoType === FederationMemoType.Hash) { + setError( + Buffer.from(memo, "base64").length === 32 + ? null + : t("Memo hash must be a base64-encoded 32-byte value"), + ); + return; + } + + if (memoType === FederationMemoType.Id) { + if (!/^\d+$/.test(memo)) { + setError(t("Memo ID must be a non-negative integer")); + return; + } + setError( + BigInt(memo) > MAX_UINT64 + ? t("Memo ID exceeds maximum uint64 value") + : null, + ); + return; + } + if (getByteLength(memo) > MAX_MEMO_BYTES) { setError( t("Memo is too long. Maximum {{max}} bytes allowed", { @@ -38,14 +51,12 @@ export const useValidateMemo = (memo: string) => { } try { - // Then try creating a Stellar memo to validate Memo.text(memo); - setError(null); } catch (err) { setError(t("Invalid memo format")); } - }, [memo, t]); + }, [memo, memoType, t]); return { error }; }; diff --git a/extension/src/popup/locales/en/translation.json b/extension/src/popup/locales/en/translation.json index d1ac9715da..438b995cb7 100644 --- a/extension/src/popup/locales/en/translation.json +++ b/extension/src/popup/locales/en/translation.json @@ -210,12 +210,16 @@ "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.", + "Failed to load assets.": "Failed to load assets.", + "Failed to load send data.": "Failed to load send data.", + "Failed to load swap data.": "Failed to load swap data.", "Failed to resolve federated address": "Failed to resolve federated address.", "failed to sign transaction": "failed to sign transaction", "failed to simulate token transfer": "failed to simulate token transfer", "Failed to simulate transaction": "Failed to simulate transaction", "failed to submit transaction": "failed to submit transaction", "Failed!": "Failed!", + "Federation server returned an invalid address": "Federation server returned an invalid address", "Fee": "Fee", "Fee breakdown": "Fee breakdown", "Fee is required": "Fee is required", @@ -352,6 +356,9 @@ "Medium": "Medium", "Medium Threshold": "Medium Threshold", "Memo": "Memo", + "Memo hash must be a base64-encoded 32-byte value": "Memo hash must be a base64-encoded 32-byte value", + "Memo ID exceeds maximum uint64 value": "Memo ID exceeds maximum uint64 value", + "Memo ID must be a non-negative integer": "Memo ID must be a non-negative integer", "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", @@ -737,13 +744,16 @@ "Your account balances could not be fetched at this time.": "Your account balances could not be fetched at this time.", "Your account data could not be fetched at this time.": "Your account data could not be fetched at this time.", "Your assets": "Your assets", + "Your assets could not be fetched at this time.": "Your assets could not be fetched at this time.", "Your available XLM balance is not enough to pay for the transaction fee.": "Your available XLM balance is not enough to pay for the transaction fee.", "Your gateway to the Stellar ecosystem. Browse and connect to decentralized applications built on Stellar.": "Your gateway to the Stellar ecosystem. Browse and connect to decentralized applications built on Stellar.", "Your recovery phrase": "Your recovery phrase", "Your Recovery Phrase": "Your Recovery Phrase", "Your recovery phrase gives you access to your account and is the only way to access it in a new browser.": "Your recovery phrase gives you access to your account and is the only way to access it in a new browser.", "Your recovery phrase gives you full access to your wallets and funds": "Your recovery phrase gives you full access to your wallets and funds", + "Your send data could not be fetched at this time.": "Your send data could not be fetched at this time.", "Your Stellar secret key": "Your Stellar secret key", + "Your swap data could not be fetched at this time.": "Your swap data could not be fetched at this time.", "Your Tokens": "Your Tokens", "Your wallets could not be fetched at this time.": "Your wallets could not be fetched at this time." } diff --git a/extension/src/popup/locales/pt/translation.json b/extension/src/popup/locales/pt/translation.json index 98965a9b1f..b37b186114 100644 --- a/extension/src/popup/locales/pt/translation.json +++ b/extension/src/popup/locales/pt/translation.json @@ -210,12 +210,16 @@ "Failed to fetch your account data.": "Falha ao buscar os dados da sua conta.", "Failed to fetch your transaction details": "Falha ao buscar os detalhes da sua transação", "Failed to fetch your wallets.": "Falha ao buscar suas carteiras.", + "Failed to load assets.": "Failed to load assets.", + "Failed to load send data.": "Failed to load send data.", + "Failed to load swap data.": "Failed to load swap data.", "Failed to resolve federated address": "Falha ao resolver endereço federado.", "failed to sign transaction": "falha ao assinar transação", "failed to simulate token transfer": "falha ao simular transferência de token", "Failed to simulate transaction": "Falha ao simular transação", "failed to submit transaction": "falha ao enviar transação", "Failed!": "Falhou!", + "Federation server returned an invalid address": "O servidor de federação retornou um endereço inválido", "Fee": "Taxa", "Fee breakdown": "Detalhes da taxa", "Fee is required": "A taxa é obrigatória", @@ -352,6 +356,9 @@ "Medium": "Média", "Medium Threshold": "Limite Médio", "Memo": "Memo", + "Memo hash must be a base64-encoded 32-byte value": "O hash do memo deve ser um valor de 32 bytes codificado em base64", + "Memo ID exceeds maximum uint64 value": "O ID do memo excede o valor máximo uint64", + "Memo ID must be a non-negative integer": "O ID do memo deve ser um número inteiro não negativo", "Memo is disabled for this transaction": "Memo está desabilitado para esta transação", "Memo is not supported for this operation": "Memo não é suportado para esta operação", "Memo is required": "Memo é obrigatório", @@ -737,13 +744,16 @@ "Your account balances could not be fetched at this time.": "Os saldos da sua conta não puderam ser buscados neste momento.", "Your account data could not be fetched at this time.": "Os dados da sua conta não puderam ser buscados neste momento.", "Your assets": "Seus ativos", + "Your assets could not be fetched at this time.": "Your assets could not be fetched at this time.", "Your available XLM balance is not enough to pay for the transaction fee.": "Seu saldo XLM disponível não é suficiente para pagar a taxa de transação.", "Your gateway to the Stellar ecosystem. Browse and connect to decentralized applications built on Stellar.": "Sua porta de entrada para o ecossistema Stellar. Navegue e conecte-se a aplicações descentralizadas construídas na Stellar.", "Your recovery phrase": "Sua frase de recuperação", "Your Recovery Phrase": "Sua Frase de Recuperação", "Your recovery phrase gives you access to your account and is the only way to access it in a new browser.": "Sua frase de recuperação lhe dá acesso à sua conta e é a única maneira de acessá-la em um novo navegador.", "Your recovery phrase gives you full access to your wallets and funds": "Sua frase de recuperação lhe dá acesso total às suas carteiras e fundos", + "Your send data could not be fetched at this time.": "Your send data could not be fetched at this time.", "Your Stellar secret key": "Sua Stellar secret key", + "Your swap data could not be fetched at this time.": "Your swap data could not be fetched at this time.", "Your Tokens": "Seus Tokens", "Your wallets could not be fetched at this time.": "Suas carteiras não puderam ser buscadas neste momento." }