Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
53 changes: 53 additions & 0 deletions extension/e2e-tests/accountHistory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,59 @@ test("View Account History", async ({ page, extensionId, context }) => {
});
});

test("Classifies a payment received to a muxed (M...) address as Received", async ({
page,
extensionId,
context,
}) => {
// Horizon reports a payment received to the wallet's muxed address with the
// base (G...) account in `to` and the muxed (M...) account in `to_muxed`.
// The test account is GDF32...ZEFY; MDF32...LVH4 is one of its muxed forms.
const TEST_ACCOUNT_MUXED =
"MDF32CQINROD3E2LMCGZUDVMWTXCJFR5SBYVRJ7WAAIAS3P7DCVWYAAAAAAAAAAAFLVH4";
const SENDER = "GCKUVXILBNYS4FDNWCGCYSJBY2PBQ4KAW2M5CODRVJPUFM62IJFH67J2";

const mockAccountHistoryData = [
{
amount: "5.0000000",
asset_type: "native",
created_at: "2025-03-21T22:28:46Z",
from: SENDER,
id: "164007621169153",
paging_token: "164007621169153",
source_account: SENDER,
to: "GDF32CQINROD3E2LMCGZUDVMWTXCJFR5SBYVRJ7WAAIAS3P7DCVWZEFY",
to_muxed: TEST_ACCOUNT_MUXED,
to_muxed_id: "42",
transaction_attr: {
operation_count: 1,
},
transaction_hash:
"686601028de9ddf40a1c24461a6a9c0415d60a39255c35eccad0b52ac1e700a6",
transaction_successful: true,
type: "payment",
type_i: 1,
},
];

const stubOverrides = async () => {
await stubAccountHistoryWith(page, context, mockAccountHistoryData);
};

await loginToTestAccount({ page, extensionId, context, stubOverrides });
await page.getByTestId("nav-link-account-history").click();

const amountCell = page.getByTestId("history-item-amount-component");
await expect(amountCell).toContainText("Mar 21", { timeout: 30000 });

// The received payment must show a positive amount and a "Received" action,
// not a negative "Sent" amount.
await expect(amountCell).toContainText("+5");
await expect(amountCell).toContainText("XLM");
await expect(amountCell).toHaveClass(/credit/);
await expect(page.getByText("Received")).toBeVisible();
});

test("View failed transaction", async ({ page, extensionId, context }) => {
test.slow();
const mockAccountHistoryData = [
Expand Down
33 changes: 33 additions & 0 deletions extension/e2e-tests/sendPayment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,39 @@ test("Send doesn't throw error when account is unfunded", async ({
await expect(page.getByTestId("send-amount-amount-input")).toBeVisible();
});

test("Blocks sending to your own account (base G... and muxed M...)", async ({
page,
extensionId,
context,
}) => {
test.slow();
// The logged-in test account and one of its muxed (M...) forms.
const OWN_BASE = "GDF32CQINROD3E2LMCGZUDVMWTXCJFR5SBYVRJ7WAAIAS3P7DCVWZEFY";
const OWN_MUXED =
"MDF32CQINROD3E2LMCGZUDVMWTXCJFR5SBYVRJ7WAAIAS3P7DCVWYAAAAAAAAAAAFLVH4";

await loginToTestAccount({ page, extensionId, context });
await page.getByTestId("nav-link-send").click({ force: true });

await expect(page.getByTestId("token-list")).toBeVisible();
await page.getByTestId("SendRow-native").click();

// Sending to your own base (G...) address is blocked.
await page.getByTestId("send-to-input").fill(OWN_BASE);
await expect(page.getByText("You cannot send to yourself")).toBeVisible({
timeout: 10000,
});
await expect(page.getByTestId("send-to-btn-continue")).toHaveCount(0);
await expect(page.getByTestId("send-amount-amount-input")).toHaveCount(0);

// Sending to one of your own muxed (M...) addresses is also blocked.
await page.getByTestId("send-to-input").fill(OWN_MUXED);
await expect(page.getByText("You cannot send to yourself")).toBeVisible({
timeout: 10000,
});
await expect(page.getByTestId("send-to-btn-continue")).toHaveCount(0);
});

test("Send XLM below minimum to unfunded destination shows warning", async ({
page,
extensionId,
Expand Down
42 changes: 42 additions & 0 deletions extension/src/helpers/__tests__/stellar.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
stroopToXlm,
xlmToStroop,
encodeSep53Message,
isSameAccount,
} from "../stellar";
import * as urls from "../urls";

Expand Down Expand Up @@ -117,6 +118,47 @@ describe("xlmToStroop", () => {
});
});

describe("isSameAccount", () => {
// GAJV... and its muxed forms (memo ids 1 and 12345) share the same base account
const BASE_G = "GAJVUHQV535IYW25XBTWTCUXNHLQN4F2PGIPOOX4DDKL2UPNXUHWU7B3";
const MUXED_1 =
"MAJVUHQV535IYW25XBTWTCUXNHLQN4F2PGIPOOX4DDKL2UPNXUHWUAAAAAAAAAAAAGPZI";
const MUXED_12345 =
"MAJVUHQV535IYW25XBTWTCUXNHLQN4F2PGIPOOX4DDKL2UPNXUHWUAAAAAAAAABQHFISM";
const OTHER_G = "GBE5XHPAMKKVHJJB6CWOFXIIAWKEJ7SSUNUMYFISYR47HOKIJ6JRA43Y";
const CONTRACT_ID =
"CAAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQC526";

it("returns true for two identical base (G...) addresses", () => {
expect(isSameAccount(BASE_G, BASE_G)).toBe(true);
});

it("returns true when a muxed (M...) address resolves to the base (G...) account", () => {
expect(isSameAccount(MUXED_1, BASE_G)).toBe(true);
expect(isSameAccount(BASE_G, MUXED_1)).toBe(true);
});

it("returns true for two muxed addresses sharing the same base account", () => {
expect(isSameAccount(MUXED_1, MUXED_12345)).toBe(true);
});

it("returns false for different base accounts", () => {
expect(isSameAccount(BASE_G, OTHER_G)).toBe(false);
});

it("returns false when comparing a contract id against a G address", () => {
expect(isSameAccount(CONTRACT_ID, BASE_G)).toBe(false);
});

it("returns false for empty or nullish inputs", () => {
expect(isSameAccount("", BASE_G)).toBe(false);
expect(isSameAccount(BASE_G, "")).toBe(false);
expect(isSameAccount(undefined, BASE_G)).toBe(false);
expect(isSameAccount(BASE_G, undefined)).toBe(false);
expect(isSameAccount("", "")).toBe(false);
});
});

describe("encodeSep53Message", () => {
test("should encode a simple ascii message", () => {
const message = "Hello, World!";
Expand Down
32 changes: 32 additions & 0 deletions extension/src/helpers/stellar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,38 @@ export const getBaseAccount = (muxedAddress: string): string | null => {
}
};

/**
* Compares two Stellar addresses, treating a muxed account (M...) as equal to
* its base account (G...).
*
* Horizon and Soroban can surface the recipient of a payment/transfer as a muxed
* (M...) address, while the wallet only ever knows its own base (G...) public key.
* A strict `===` comparison between the two formats always fails, which would
* misclassify payments received to a muxed address as "sent". Normalizing both
* sides to their base account before comparing avoids that.
*
* @param addressA First address (G..., M..., C..., or empty)
* @param addressB Second address (G..., M..., C..., or empty)
* @returns True if both addresses resolve to the same base account
*/
export const isSameAccount = (
addressA?: string | null,
addressB?: string | null,
): boolean => {
if (!addressA || !addressB) {
return false;
}

const baseA = getBaseAccount(addressA);
const baseB = getBaseAccount(addressB);

if (!baseA || !baseB) {
return false;
}

return baseA === baseB;
};

/**
* Creates a muxed account address from a base account and a muxed ID (memo)
* This is used for CAP-0067 to support memo in Soroban transfers
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { initialState, isError, reducer } from "helpers/request";
import { AccountBalances, useGetBalances } from "helpers/hooks/useGetBalances";
import { loadRecentAddresses } from "@shared/api/internal";
import { getBaseAccount } from "popup/helpers/account";
import { isFederationAddress, isMainnet } from "helpers/stellar";
import { isFederationAddress, isMainnet, isSameAccount } from "helpers/stellar";
import { isContractId } from "popup/helpers/soroban";
import {
AppDataType,
Expand Down Expand Up @@ -100,6 +100,14 @@ function useSendToData() {
federationMemoType,
} = await getAddressFromInput(userInput);

// Block self-sends. isSameAccount resolves muxed (M...) addresses to
// their base (G...) account, so sending to one of your own muxed
// addresses is caught too - as is a federation address that resolves to
// your own account (validatedAddress is the resolved G... here).
if (isSameAccount(validatedAddress, publicKey)) {
throw new Error(i18n.t("You cannot send to yourself"));
}

const { recentAddresses } = await loadRecentAddresses({
activePublicKey: publicKey,
});
Expand Down
32 changes: 24 additions & 8 deletions extension/src/popup/components/send/SendTo/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { useTranslation } from "react-i18next";

import {
isFederationAddress,
isSameAccount,
isValidFederatedDomain,
truncatedPublicKey,
} from "helpers/stellar";
Expand Down Expand Up @@ -71,10 +72,10 @@ const isResolvedSuggestionData = (
): data is ResolvedSuggestionData =>
Boolean(
data &&
typeof data === "object" &&
"type" in data &&
(data as { type?: AppDataType }).type === AppDataType.RESOLVED &&
"validatedAddress" in data,
typeof data === "object" &&
"type" in data &&
(data as { type?: AppDataType }).type === AppDataType.RESOLVED &&
"validatedAddress" in data,
);

export const AccountDoesntExistWarning = () => {
Expand Down Expand Up @@ -256,10 +257,11 @@ export const SendTo = ({
// don't render until the data is ready (avoids a layout shift / reflow flash).
const isInitialLoad = isLoading && !hasLoadedOnceRef.current;

const visibleRecentAddresses = cachedRecentAddressesRef.current.slice(
0,
MAX_VISIBLE_RECENT_ADDRESSES,
);
// Exclude any recent address that resolves to the active account (including
// its muxed M... forms) - you can't send to yourself.
const visibleRecentAddresses = cachedRecentAddressesRef.current
.filter((address) => !isSameAccount(address, activePublicKey))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Normalize federation recents before allowing selection

When a recent entry is a federation address, this filter does not resolve it, so a saved/reassigned federation address that resolves to the active G account remains visible even though the new self-send guard is meant to block it. The recent-address click path then resolves the address with getAddressFromInput(address) and calls handleContinue(...) regardless of fetchData returning/dispatching the "You cannot send to yourself" error, so selecting that recent bypasses the new protection. Pre-resolve/filter federation recents or gate handleContinue on a successful validated fetch result.

Useful? React with 👍 / 👎.

.slice(0, MAX_VISIBLE_RECENT_ADDRESSES);

if (isInitialLoad) {
return <Loading />;
Expand Down Expand Up @@ -380,6 +382,19 @@ export const SendTo = ({
onClick={async () => {
const addressFromInput =
await getAddressFromInput(address);
// A recent that resolves to the active account (e.g. a
// federation address the synchronous list filter can't
// resolve) is a self-send. Don't continue - surface the
// error through the normal input flow instead.
if (
isSameAccount(
addressFromInput.validatedAddress,
activePublicKey,
)
) {
formik.setFieldValue("destination", address);
return;
}
emitMetric(METRIC_NAMES.sendPaymentRecentAddress);
await fetchData(address, {});
handleContinue(
Expand Down Expand Up @@ -447,6 +462,7 @@ export const SendTo = ({
</View.Content>
<View.Footer>
{!isLoading &&
!hasError &&
isSearchSettled &&
formik.values.destination &&
formik.isValid ? (
Expand Down
3 changes: 3 additions & 0 deletions extension/src/popup/locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@
"Authorizations": "Authorizations",
"Authorize": "Authorize",
"Authorized address": "Authorized address",
"Auto-lock timer": "Auto-lock timer",

@leofelix077 leofelix077 Jun 19, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

translation was apparently missing from on the previous PR. so the commit rules added it. if I remove manually, it adds them back again before pushing

"Auto-Lock Timer": "Auto-Lock Timer",
"available": "available",
"Back": "Back",
"Balance": "Balance",
Expand Down Expand Up @@ -737,6 +739,7 @@
"You can choose to merge your current account into the new accounts after the migration, which will effectively destroy your current account.": "You can choose to merge your current account into the new accounts after the migration, which will effectively destroy your current account.",
"You can close this screen, your transaction should be complete in less than a minute.": "You can close this screen, your transaction should be complete in less than a minute.",
"You can define your own assets lists in Settings.": "You can define your own assets lists in Settings.",
"You cannot send to yourself": "You cannot send to yourself",
"You don’t have enough {{asset}} in your account": "You don’t have enough {{asset}} in your account",
"You have no assets added.": "You have no assets added.",
"You have no collectibles added.": "You have no collectibles added.",
Expand Down
3 changes: 3 additions & 0 deletions extension/src/popup/locales/pt/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@
"Authorizations": "Autorizações",
"Authorize": "Autorizar",
"Authorized address": "Endereço autorizado",
"Auto-lock timer": "Auto-lock timer",
"Auto-Lock Timer": "Auto-Lock Timer",
"available": "disponível",
"Back": "Voltar",
"Balance": "Saldo",
Expand Down Expand Up @@ -735,6 +737,7 @@
"You can choose to merge your current account into the new accounts after the migration, which will effectively destroy your current account.": "Você pode escolher mesclar sua conta atual nas novas contas após a migração, o que efetivamente destruirá sua conta atual.",
"You can close this screen, your transaction should be complete in less than a minute.": "Você pode fechar esta tela, sua transação deve estar completa em menos de um minuto.",
"You can define your own assets lists in Settings.": "Você pode definir suas próprias listas de ativos nas Configurações.",
"You cannot send to yourself": "Você não pode enviar para si mesmo",
"You don’t have enough {{asset}} in your account": "Você não tem {{asset}} suficiente em sua conta",
"You have no assets added.": "Você não tem ativos adicionados.",
"You have no collectibles added.": "Você não tem colecionáveis adicionados.",
Expand Down
Loading
Loading