Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions extension/e2e-tests/helpers/stubs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
121 changes: 115 additions & 6 deletions extension/e2e-tests/sendPayment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
stubAccountBalancesWithUnfundedDestination,
stubAccountBalancesWithUSDC,
stubContractSpec,
stubFederationWithMemo,
stubScanTxWithUnfundedWarning,
stubScanTxWithUnfundedNonNativeWarning,
stubScanTx,
Expand Down Expand Up @@ -1013,11 +1014,119 @@ 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 });
});
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ interface FormValue {

interface EditMemoProps {
memo: string;
memoType?: string;
onClose: () => void;
onSubmit: (args: FormValue) => void;
disabled?: boolean;
Expand All @@ -22,14 +23,15 @@ interface EditMemoProps {

export const EditMemo = ({
memo,
memoType,
onClose,
onSubmit,
disabled = false,
disabledMessage,
}: 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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import {
Asset,
BASE_FEE,
extractBaseAddress,
Memo,
Networks,
Operation,
TransactionBuilder,
Expand Down Expand Up @@ -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 {
checkIsMuxedSupported,
determineMuxedDestination,
Expand Down Expand Up @@ -234,6 +234,7 @@ const getBuiltTx = async (
transactionTimeout: number,
networkDetails: NetworkDetails,
memo?: string,
memoType?: string,
) => {
const {
sourceAsset,
Expand Down Expand Up @@ -274,7 +275,7 @@ const getBuiltTx = async (
.setTimeout(transactionTimeout);

if (memo) {
transaction.addMemo(Memo.text(memo));
transaction.addMemo(buildMemoFromFederation(memo, memoType ?? ""));
}
Comment thread
leofelix077 marked this conversation as resolved.

return transaction;
Expand Down Expand Up @@ -407,7 +408,7 @@ function useSimulateTxData({
const { t } = useTranslation();
const reduxDispatch = useDispatch<AppDispatch>();
const store = useStore();
const { asset, amount, transactionFee, memo } = useSelector(
const { asset, amount, transactionFee, memo, memoType } = useSelector(
transactionDataSelector,
);

Expand All @@ -434,6 +435,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;
Comment thread
leofelix077 marked this conversation as resolved.
const currentTransactionFee =
Expand Down Expand Up @@ -594,6 +596,7 @@ function useSimulateTxData({
transactionTimeout,
networkDetails,
memoToUse,
currentMemoType,
);
const xdr = transaction.build().toXDR();
payload.transactionXdr = xdr;
Expand Down
1 change: 1 addition & 0 deletions extension/src/popup/components/send/SendAmount/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -749,6 +749,7 @@ export const SendAmount = ({
<div className="EditMemoWrapper">
<EditMemo
memo={transactionData.memo || ""}
memoType={transactionData.memoType}
onClose={() => {
setIsEditingMemo(false);
// Reopen review sheet if user came from review flow
Expand Down
51 changes: 41 additions & 10 deletions extension/src/popup/components/send/SendTo/hooks/useSendToData.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -25,6 +26,8 @@ interface ResolvedSendToData {
destinationBalances?: AccountBalances;
validatedAddress: string;
fedAddress: string;
federationMemo: string;
federationMemoType: FederationMemoType | "";
applicationState: APPLICATION_STATE;
publicKey: string;
networkDetails: NetworkDetails;
Expand All @@ -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"));
}
Comment thread
leofelix077 marked this conversation as resolved.
Comment thread
leofelix077 marked this conversation as resolved.

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,
};
Comment thread
leofelix077 marked this conversation as resolved.
}

return {
validatedAddress: userInput,
fedAddress: "",
federationMemo: "",
federationMemoType: "" as const,
};
};

Expand All @@ -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,
Expand All @@ -84,6 +109,8 @@ function useSendToData() {
recentAddresses,
validatedAddress,
fedAddress,
federationMemo,
federationMemoType,
applicationState,
publicKey,
networkDetails,
Expand Down Expand Up @@ -142,6 +169,8 @@ function useSendToData() {
recentAddresses: [],
validatedAddress: "",
fedAddress: "",
federationMemo: "",
federationMemoType: "",
applicationState,
publicKey,
networkDetails,
Expand All @@ -168,6 +197,8 @@ function useSendToData() {
recentAddresses,
validatedAddress: "",
fedAddress: "",
federationMemo: "",
federationMemoType: "",
applicationState,
publicKey,
networkDetails,
Expand Down
Loading
Loading