From 0ad315b92e82b026d4103562426971cfbd57d05a Mon Sep 17 00:00:00 2001 From: leofelix077 Date: Thu, 18 Jun 2026 17:53:05 -0300 Subject: [PATCH 1/5] fix base address checks for muxed --- extension/e2e-tests/accountHistory.test.ts | 53 +++++ .../src/helpers/__tests__/stellar.test.ts | 42 ++++ extension/src/helpers/stellar.ts | 32 +++ .../src/popup/locales/en/translation.json | 2 + .../src/popup/locales/pt/translation.json | 2 + .../__tests__/useGetHistoryData.test.tsx | 222 ++++++++++++++++++ .../hooks/useGetHistoryData.tsx | 33 ++- 7 files changed, 377 insertions(+), 9 deletions(-) create mode 100644 extension/src/popup/views/AccountHistory/hooks/__tests__/useGetHistoryData.test.tsx diff --git a/extension/e2e-tests/accountHistory.test.ts b/extension/e2e-tests/accountHistory.test.ts index 462ea6da49..6a81ba9655 100644 --- a/extension/e2e-tests/accountHistory.test.ts +++ b/extension/e2e-tests/accountHistory.test.ts @@ -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 = [ diff --git a/extension/src/helpers/__tests__/stellar.test.ts b/extension/src/helpers/__tests__/stellar.test.ts index 87c40a6c6b..334eb7bb2e 100644 --- a/extension/src/helpers/__tests__/stellar.test.ts +++ b/extension/src/helpers/__tests__/stellar.test.ts @@ -6,6 +6,7 @@ import { stroopToXlm, xlmToStroop, encodeSep53Message, + isSameAccount, } from "../stellar"; import * as urls from "../urls"; @@ -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!"; diff --git a/extension/src/helpers/stellar.ts b/extension/src/helpers/stellar.ts index aea67e39ef..2ff60ac31e 100644 --- a/extension/src/helpers/stellar.ts +++ b/extension/src/helpers/stellar.ts @@ -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 diff --git a/extension/src/popup/locales/en/translation.json b/extension/src/popup/locales/en/translation.json index 52339ec4bb..0ddd30cdd0 100644 --- a/extension/src/popup/locales/en/translation.json +++ b/extension/src/popup/locales/en/translation.json @@ -77,6 +77,8 @@ "Authorizations": "Authorizations", "Authorize": "Authorize", "Authorized address": "Authorized address", + "Auto-lock timer": "Auto-lock timer", + "Auto-Lock Timer": "Auto-Lock Timer", "available": "available", "Back": "Back", "Balance": "Balance", diff --git a/extension/src/popup/locales/pt/translation.json b/extension/src/popup/locales/pt/translation.json index db2c569c1c..a0cb327877 100644 --- a/extension/src/popup/locales/pt/translation.json +++ b/extension/src/popup/locales/pt/translation.json @@ -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", diff --git a/extension/src/popup/views/AccountHistory/hooks/__tests__/useGetHistoryData.test.tsx b/extension/src/popup/views/AccountHistory/hooks/__tests__/useGetHistoryData.test.tsx new file mode 100644 index 0000000000..da82f2076e --- /dev/null +++ b/extension/src/popup/views/AccountHistory/hooks/__tests__/useGetHistoryData.test.tsx @@ -0,0 +1,222 @@ +import { TESTNET_NETWORK_DETAILS } from "@shared/constants/stellar"; +import { SorobanTokenInterface } from "@shared/constants/soroban/token"; +import { HistoryItemOperation } from "popup/components/accountHistory/HistoryItem"; +import * as sorobanHelpers from "popup/helpers/soroban"; +import { AssetType } from "@shared/api/types/account-balance"; +import { getRowDataByOpType } from "../useGetHistoryData"; + +// Base account owned by the wallet and its muxed (M...) forms. +const PUBLIC_KEY = "GAJVUHQV535IYW25XBTWTCUXNHLQN4F2PGIPOOX4DDKL2UPNXUHWU7B3"; +const MY_MUXED = + "MAJVUHQV535IYW25XBTWTCUXNHLQN4F2PGIPOOX4DDKL2UPNXUHWUAAAAAAAAAAAAGPZI"; +const COUNTERPARTY = "GBE5XHPAMKKVHJJB6CWOFXIIAWKEJ7SSUNUMYFISYR47HOKIJ6JRA43Y"; + +const fetchTokenDetails = jest.fn(); + +const buildPaymentOperation = ({ + to, + toMuxed, + from, +}: { + to: string; + toMuxed?: string; + from: string; +}): HistoryItemOperation => + ({ + id: "op-1", + type: "payment", + type_i: 1, + created_at: "2024-01-01T00:00:00Z", + asset_type: "native", + amount: "5", + to, + to_muxed: toMuxed, + from, + transaction_attr: { + operation_count: 1, + fee_charged: "100", + memo: "", + envelope_xdr: "", + }, + isPayment: true, + isSwap: false, + isDustPayment: false, + isCreateExternalAccount: false, + }) as unknown as HistoryItemOperation; + +const CONTRACT_ID = "CAAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQC526"; + +const callGetRowData = ( + operation: HistoryItemOperation, + balances: AssetType[] = [], +) => + getRowDataByOpType( + PUBLIC_KEY, + balances, + operation, + TESTNET_NETWORK_DETAILS, + {}, + fetchTokenDetails, + {}, + new Map(), + [], + ); + +const buildInvokeHostFnOperation = ( + overrides: Record = {}, +): HistoryItemOperation => + ({ + id: "soroban-op-1", + type: "invoke_host_function", + type_i: 24, + created_at: "2024-01-01T00:00:00Z", + asset_balance_changes: null, + transaction_attr: { + operation_count: 1, + fee_charged: "100", + memo: "", + envelope_xdr: "", + }, + isPayment: false, + isSwap: false, + isDustPayment: false, + isCreateExternalAccount: false, + ...overrides, + }) as unknown as HistoryItemOperation; + +describe("getRowDataByOpType - classic payment muxed classification", () => { + it("classifies a payment received to the wallet's MUXED address as Received", async () => { + const operation = buildPaymentOperation({ + to: PUBLIC_KEY, + toMuxed: MY_MUXED, + from: COUNTERPARTY, + }); + + const row = await callGetRowData(operation); + + expect(row.action).toBe("Received"); + expect(row.actionIcon).toBe("received"); + expect(row.amount).toMatch(/^\+/); + expect(row.metadata.isReceiving).toBe(true); + }); + + it("classifies a payment received to the wallet's base (G...) address as Received", async () => { + const operation = buildPaymentOperation({ + to: PUBLIC_KEY, + from: COUNTERPARTY, + }); + + const row = await callGetRowData(operation); + + expect(row.action).toBe("Received"); + expect(row.amount).toMatch(/^\+/); + }); + + it("classifies a payment sent to another account as Sent", async () => { + const operation = buildPaymentOperation({ + to: COUNTERPARTY, + from: PUBLIC_KEY, + }); + + const row = await callGetRowData(operation); + + expect(row.action).toBe("Sent"); + expect(row.actionIcon).toBe("sent"); + expect(row.amount).toMatch(/^-/); + }); + + it("treats a self-payment to the wallet's own muxed address as Sent", async () => { + const operation = buildPaymentOperation({ + to: PUBLIC_KEY, + toMuxed: MY_MUXED, + from: PUBLIC_KEY, + }); + + const row = await callGetRowData(operation); + + expect(row.action).toBe("Sent"); + }); +}); + +describe("getRowDataByOpType - Soroban token transfer muxed classification", () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("classifies a Soroban token transfer received to the wallet's MUXED address as Received", async () => { + jest.spyOn(sorobanHelpers, "getAttrsFromSorobanHorizonOp").mockReturnValue({ + fnName: SorobanTokenInterface.transfer, + contractId: CONTRACT_ID, + from: COUNTERPARTY, + to: MY_MUXED, + amount: "100", + } as any); + fetchTokenDetails.mockResolvedValue({ symbol: "TEST", decimals: 7 }); + + // asset_issuer as a contract id keeps icon resolution offline in tests + const operation = buildInvokeHostFnOperation({ asset_issuer: CONTRACT_ID }); + const row = await callGetRowData(operation); + + expect(row.action).toBe("Received"); + expect(row.actionIcon).toBe("received"); + expect(row.amount).toMatch(/^\+/); + }); +}); + +describe("getRowDataByOpType - Soroban mint muxed classification", () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("classifies a mint to the wallet's MUXED address as Received (not Minted)", async () => { + jest.spyOn(sorobanHelpers, "getAttrsFromSorobanHorizonOp").mockReturnValue({ + fnName: SorobanTokenInterface.mint, + contractId: CONTRACT_ID, + to: MY_MUXED, + amount: "100", + } as any); + + const balances = [ + { + contractId: CONTRACT_ID, + token: { code: "TEST" }, + decimals: 7, + }, + ] as unknown as AssetType[]; + + const operation = buildInvokeHostFnOperation(); + const row = await callGetRowData(operation, balances); + + expect(row.action).toBe("Received"); + expect(row.actionIcon).toBe("received"); + expect(row.amount).toMatch(/^\+/); + }); +}); + +describe("getRowDataByOpType - Soroban asset balance changes muxed classification", () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("counts a balance change credited to the wallet's MUXED address as a credit", async () => { + jest + .spyOn(sorobanHelpers, "getAttrsFromSorobanHorizonOp") + .mockReturnValue(null as any); + + const operation = buildInvokeHostFnOperation({ + asset_balance_changes: [ + { + asset_type: "native", + from: COUNTERPARTY, + to: MY_MUXED, + amount: "5", + }, + ], + }); + + const row = await callGetRowData(operation); + + expect(row.metadata.isReceiving).toBe(true); + expect(row.amount).toMatch(/^\+/); + }); +}); diff --git a/extension/src/popup/views/AccountHistory/hooks/useGetHistoryData.tsx b/extension/src/popup/views/AccountHistory/hooks/useGetHistoryData.tsx index c2904c39e3..a27cdeec6c 100644 --- a/extension/src/popup/views/AccountHistory/hooks/useGetHistoryData.tsx +++ b/extension/src/popup/views/AccountHistory/hooks/useGetHistoryData.tsx @@ -28,7 +28,11 @@ import { NeedsReRoute, useGetAppData, } from "helpers/hooks/useGetAppData"; -import { getCanonicalFromAsset, isMainnet } from "helpers/stellar"; +import { + getCanonicalFromAsset, + isMainnet, + isSameAccount, +} from "helpers/stellar"; import { APPLICATION_STATE } from "@shared/constants/applicationState"; import { AssetIcons, @@ -436,8 +440,12 @@ const processAssetBalanceChanges = async ( const results: AssetDiffSummary[] = []; for (const change of operation.asset_balance_changes) { - // Filter to only changes involving this public key - if (change.from !== publicKey && change.to !== publicKey) { + // Filter to only changes involving this public key. + // isSameAccount resolves muxed (M...) counterparties to their base account. + if ( + !isSameAccount(change.from, publicKey) && + !isSameAccount(change.to, publicKey) + ) { continue; } @@ -454,7 +462,7 @@ const processAssetBalanceChanges = async ( } // Determine if this is a credit (receiving) or debit (sending) - const isCredit = change.to === publicKey; + const isCredit = isSameAccount(change.to, publicKey); // Destination is the counterparty (from for credits, to for debits) const destination = isCredit ? change.from : change.to; @@ -506,7 +514,9 @@ const processAssetBalanceChanges = async ( amount: trimTrailingZeros(change.amount), isCredit, destination: - destination && destination !== publicKey ? destination : undefined, + destination && !isSameAccount(destination, publicKey) + ? destination + : undefined, icon, }); } @@ -682,8 +692,12 @@ export const getRowDataByOpType = async ( const destination = to_muxed || to || ""; const sender = from || ""; - // default to Sent if a payment to self - const isReceiving = destination === publicKey && sender !== publicKey; + // default to Sent if a payment to self. + // isSameAccount resolves muxed (M...) addresses to their base (G...) account, + // so payments received to the user's muxed address aren't misclassified as sent. + const isReceiving = + isSameAccount(destination, publicKey) && + !isSameAccount(sender, publicKey); const paymentDifference = isReceiving ? "+" : "-"; const nonLabelAmount = formatAmount(new BigNumber(amount!).toString()); const formattedAmount = `${paymentDifference}${nonLabelAmount} ${destAssetCode}`; @@ -782,7 +796,7 @@ export const getRowDataByOpType = async ( } if (attrs.fnName === SorobanTokenInterface.mint) { - const isReceiving = attrs.to === publicKey; + const isReceiving = isSameAccount(attrs.to, publicKey); const assetBalance = getBalanceByKey( attrs.contractId, balances, @@ -830,7 +844,8 @@ export const getRowDataByOpType = async ( ); const isReceiving = - actualDestination === publicKey && attrs.from !== publicKey; + isSameAccount(actualDestination, publicKey) && + !isSameAccount(attrs.from, publicKey); // if the amount is present, we can surmise this is a token transfer if (attrs.amount) { From 71bedfe58b46317460889e1f400bc35ba5587e01 Mon Sep 17 00:00:00 2001 From: leofelix077 Date: Fri, 19 Jun 2026 09:52:33 -0300 Subject: [PATCH 2/5] revert unrelated translation file changes From 265db53d43bc4361575f49f08b473a89267ea9a0 Mon Sep 17 00:00:00 2001 From: leofelix077 Date: Fri, 19 Jun 2026 11:08:19 -0300 Subject: [PATCH 3/5] add guard for self transfers --- extension/e2e-tests/accountHistory.test.ts | 98 +++++++++++++++++++ .../__tests__/useGetHistoryData.test.tsx | 24 +++++ .../hooks/useGetHistoryData.tsx | 9 +- 3 files changed, 129 insertions(+), 2 deletions(-) diff --git a/extension/e2e-tests/accountHistory.test.ts b/extension/e2e-tests/accountHistory.test.ts index 6a81ba9655..cce263a094 100644 --- a/extension/e2e-tests/accountHistory.test.ts +++ b/extension/e2e-tests/accountHistory.test.ts @@ -697,6 +697,104 @@ test.describe("Asset Diffs in Transaction History", () => { await expect(creditValue).toContainText("XLM"); }); + test("Marks a Soroban self-transfer (own base G... -> own muxed M...) as Sent, not Received", async ({ + page, + extensionId, + context, + }) => { + // MDF32...LVH4 is a muxed (M...) form of TEST_ACCOUNT (GDF32...ZEFY). + // A transfer from the base account to one of its own muxed addresses is a + // self-transfer and must stay Sent (debit), even though `to` resolves to + // this wallet. Regression guard for processAssetBalanceChanges. + const TEST_ACCOUNT_MUXED = + "MDF32CQINROD3E2LMCGZUDVMWTXCJFR5SBYVRJ7WAAIAS3P7DCVWYAAAAAAAAAAAFLVH4"; + + await stubAccountBalances(page); + await stubTokenDetails(page); + + // A valid (non-contract) envelope so getAttrsFromSorobanHorizonOp parses it + // and returns null instead of throwing; the asset_balance_changes drive the + // credit/debit classification under test. + const sourceKeypair = Keypair.fromSecret( + "SBPQUZ6G4FZNWFHKUWC5BEYWF6R52E3SEP7R3GWYSM2XTKGF5LNTWW4R", + ); + const sourceAccount = { + accountId: () => sourceKeypair.publicKey(), + sequenceNumber: () => "376114581078717", + incrementSequenceNumber: () => {}, + }; + const envelopeXdr = new TransactionBuilder(sourceAccount as any, { + fee: "100", + networkPassphrase: Networks.TESTNET, + }) + .addOperation( + Operation.payment({ + destination: TEST_ACCOUNT, + asset: Asset.native(), + amount: "1.0000000", + }), + ) + .setTimeout(30) + .build() + .toXDR(); + + const stubOverrides = async () => { + await context.route("*/**/account-history/*", async (route) => { + const json = [ + { + amount: "100.0000000", + asset_type: "native", + created_at: "2025-03-21T22:28:46Z", + from: TEST_ACCOUNT, + id: "100000000010", + paging_token: "100000000010", + source_account: TEST_ACCOUNT, + to: TEST_ACCOUNT_MUXED, + transaction_hash: + "self1234def456ghi789jkl012mno345pqr678stu901vwx234yz567890", + transaction_successful: true, + type: "invoke_host_function", + type_i: 24, + asset_balance_changes: [ + { + asset_type: "native", + from: TEST_ACCOUNT, + to: TEST_ACCOUNT_MUXED, + amount: "100.0000000", + }, + ], + transaction_attr: { + hash: "self1234def456ghi789jkl012mno345pqr678stu901vwx234yz567890", + memo: null, + fee_charged: "100", + operation_count: 1, + envelope_xdr: envelopeXdr, + }, + }, + ]; + await route.fulfill({ json }); + }); + }; + + await loginToTestAccount({ page, extensionId, context, stubOverrides }); + await page.getByTestId("nav-link-account-history").click(); + + const amountCell = page + .getByTestId("history-item-amount-component") + .first(); + await expect(amountCell).toBeVisible({ timeout: 10000 }); + + // The self-transfer must show a negative (debit) amount, not a credit. + await expect(amountCell).toContainText("-100"); + await expect(amountCell).toHaveClass(/debit/); + await expect(amountCell).not.toContainText("+"); + + // The transaction detail must label it Sent, never Received. + await page.getByTestId("history-item").first().click(); + await expect(page.locator(".AssetDiff__label.debit")).toContainText("Sent"); + await expect(page.locator(".AssetDiff__label.credit")).toHaveCount(0); + }); + test("Display both credit and debit for swap operation", async ({ page, extensionId, diff --git a/extension/src/popup/views/AccountHistory/hooks/__tests__/useGetHistoryData.test.tsx b/extension/src/popup/views/AccountHistory/hooks/__tests__/useGetHistoryData.test.tsx index da82f2076e..5ad114fcf3 100644 --- a/extension/src/popup/views/AccountHistory/hooks/__tests__/useGetHistoryData.test.tsx +++ b/extension/src/popup/views/AccountHistory/hooks/__tests__/useGetHistoryData.test.tsx @@ -219,4 +219,28 @@ describe("getRowDataByOpType - Soroban asset balance changes muxed classificatio expect(row.metadata.isReceiving).toBe(true); expect(row.amount).toMatch(/^\+/); }); + + it("treats a self-transfer (own base G... -> own muxed M...) as a debit, not a credit", async () => { + jest + .spyOn(sorobanHelpers, "getAttrsFromSorobanHorizonOp") + .mockReturnValue(null as any); + + const operation = buildInvokeHostFnOperation({ + asset_balance_changes: [ + { + asset_type: "native", + from: PUBLIC_KEY, + to: MY_MUXED, + amount: "5", + }, + ], + }); + + const row = await callGetRowData(operation); + + // Both ends resolve to this wallet, so it is a self-transfer and must stay + // Sent (debit) - matching the classic payment and Soroban transfer paths. + expect(row.metadata.isReceiving).toBe(false); + expect(row.amount).toMatch(/^-/); + }); }); diff --git a/extension/src/popup/views/AccountHistory/hooks/useGetHistoryData.tsx b/extension/src/popup/views/AccountHistory/hooks/useGetHistoryData.tsx index a27cdeec6c..0eca49c430 100644 --- a/extension/src/popup/views/AccountHistory/hooks/useGetHistoryData.tsx +++ b/extension/src/popup/views/AccountHistory/hooks/useGetHistoryData.tsx @@ -461,8 +461,13 @@ const processAssetBalanceChanges = async ( assetIssuer = change.asset_issuer || null; } - // Determine if this is a credit (receiving) or debit (sending) - const isCredit = isSameAccount(change.to, publicKey); + // Determine if this is a credit (receiving) or debit (sending). + // Require the sender not to be this wallet so a self-transfer (e.g. base + // G... -> own M... address) stays a debit, matching the classic payment and + // Soroban transfer paths which default self-transfers to Sent. + const isCredit = + isSameAccount(change.to, publicKey) && + !isSameAccount(change.from, publicKey); // Destination is the counterparty (from for credits, to for debits) const destination = isCredit ? change.from : change.to; From 1115702e95c5ebdcd645cc54d6585bc442abe92d Mon Sep 17 00:00:00 2001 From: leofelix077 Date: Fri, 19 Jun 2026 11:31:40 -0300 Subject: [PATCH 4/5] block sending to yourself --- extension/e2e-tests/accountHistory.test.ts | 98 ------------------- extension/e2e-tests/sendPayment.test.ts | 33 +++++++ .../send/SendTo/hooks/useSendToData.tsx | 10 +- .../popup/components/send/SendTo/index.tsx | 19 ++-- .../src/popup/locales/en/translation.json | 1 + .../src/popup/locales/pt/translation.json | 1 + .../__tests__/useGetHistoryData.test.tsx | 24 ----- .../hooks/useGetHistoryData.tsx | 9 +- 8 files changed, 57 insertions(+), 138 deletions(-) diff --git a/extension/e2e-tests/accountHistory.test.ts b/extension/e2e-tests/accountHistory.test.ts index cce263a094..6a81ba9655 100644 --- a/extension/e2e-tests/accountHistory.test.ts +++ b/extension/e2e-tests/accountHistory.test.ts @@ -697,104 +697,6 @@ test.describe("Asset Diffs in Transaction History", () => { await expect(creditValue).toContainText("XLM"); }); - test("Marks a Soroban self-transfer (own base G... -> own muxed M...) as Sent, not Received", async ({ - page, - extensionId, - context, - }) => { - // MDF32...LVH4 is a muxed (M...) form of TEST_ACCOUNT (GDF32...ZEFY). - // A transfer from the base account to one of its own muxed addresses is a - // self-transfer and must stay Sent (debit), even though `to` resolves to - // this wallet. Regression guard for processAssetBalanceChanges. - const TEST_ACCOUNT_MUXED = - "MDF32CQINROD3E2LMCGZUDVMWTXCJFR5SBYVRJ7WAAIAS3P7DCVWYAAAAAAAAAAAFLVH4"; - - await stubAccountBalances(page); - await stubTokenDetails(page); - - // A valid (non-contract) envelope so getAttrsFromSorobanHorizonOp parses it - // and returns null instead of throwing; the asset_balance_changes drive the - // credit/debit classification under test. - const sourceKeypair = Keypair.fromSecret( - "SBPQUZ6G4FZNWFHKUWC5BEYWF6R52E3SEP7R3GWYSM2XTKGF5LNTWW4R", - ); - const sourceAccount = { - accountId: () => sourceKeypair.publicKey(), - sequenceNumber: () => "376114581078717", - incrementSequenceNumber: () => {}, - }; - const envelopeXdr = new TransactionBuilder(sourceAccount as any, { - fee: "100", - networkPassphrase: Networks.TESTNET, - }) - .addOperation( - Operation.payment({ - destination: TEST_ACCOUNT, - asset: Asset.native(), - amount: "1.0000000", - }), - ) - .setTimeout(30) - .build() - .toXDR(); - - const stubOverrides = async () => { - await context.route("*/**/account-history/*", async (route) => { - const json = [ - { - amount: "100.0000000", - asset_type: "native", - created_at: "2025-03-21T22:28:46Z", - from: TEST_ACCOUNT, - id: "100000000010", - paging_token: "100000000010", - source_account: TEST_ACCOUNT, - to: TEST_ACCOUNT_MUXED, - transaction_hash: - "self1234def456ghi789jkl012mno345pqr678stu901vwx234yz567890", - transaction_successful: true, - type: "invoke_host_function", - type_i: 24, - asset_balance_changes: [ - { - asset_type: "native", - from: TEST_ACCOUNT, - to: TEST_ACCOUNT_MUXED, - amount: "100.0000000", - }, - ], - transaction_attr: { - hash: "self1234def456ghi789jkl012mno345pqr678stu901vwx234yz567890", - memo: null, - fee_charged: "100", - operation_count: 1, - envelope_xdr: envelopeXdr, - }, - }, - ]; - await route.fulfill({ json }); - }); - }; - - await loginToTestAccount({ page, extensionId, context, stubOverrides }); - await page.getByTestId("nav-link-account-history").click(); - - const amountCell = page - .getByTestId("history-item-amount-component") - .first(); - await expect(amountCell).toBeVisible({ timeout: 10000 }); - - // The self-transfer must show a negative (debit) amount, not a credit. - await expect(amountCell).toContainText("-100"); - await expect(amountCell).toHaveClass(/debit/); - await expect(amountCell).not.toContainText("+"); - - // The transaction detail must label it Sent, never Received. - await page.getByTestId("history-item").first().click(); - await expect(page.locator(".AssetDiff__label.debit")).toContainText("Sent"); - await expect(page.locator(".AssetDiff__label.credit")).toHaveCount(0); - }); - test("Display both credit and debit for swap operation", async ({ page, extensionId, diff --git a/extension/e2e-tests/sendPayment.test.ts b/extension/e2e-tests/sendPayment.test.ts index 9e9cbc10a3..1d5e464cff 100644 --- a/extension/e2e-tests/sendPayment.test.ts +++ b/extension/e2e-tests/sendPayment.test.ts @@ -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, diff --git a/extension/src/popup/components/send/SendTo/hooks/useSendToData.tsx b/extension/src/popup/components/send/SendTo/hooks/useSendToData.tsx index 71fd1e12b0..b2f210a291 100644 --- a/extension/src/popup/components/send/SendTo/hooks/useSendToData.tsx +++ b/extension/src/popup/components/send/SendTo/hooks/useSendToData.tsx @@ -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, @@ -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, }); diff --git a/extension/src/popup/components/send/SendTo/index.tsx b/extension/src/popup/components/send/SendTo/index.tsx index c6e9b45de9..c5297e2757 100644 --- a/extension/src/popup/components/send/SendTo/index.tsx +++ b/extension/src/popup/components/send/SendTo/index.tsx @@ -13,6 +13,7 @@ import { useTranslation } from "react-i18next"; import { isFederationAddress, + isSameAccount, isValidFederatedDomain, truncatedPublicKey, } from "helpers/stellar"; @@ -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 = () => { @@ -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)) + .slice(0, MAX_VISIBLE_RECENT_ADDRESSES); if (isInitialLoad) { return ; @@ -447,6 +449,7 @@ export const SendTo = ({ {!isLoading && + !hasError && isSearchSettled && formik.values.destination && formik.isValid ? ( diff --git a/extension/src/popup/locales/en/translation.json b/extension/src/popup/locales/en/translation.json index 0ddd30cdd0..a94ab72280 100644 --- a/extension/src/popup/locales/en/translation.json +++ b/extension/src/popup/locales/en/translation.json @@ -739,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.", diff --git a/extension/src/popup/locales/pt/translation.json b/extension/src/popup/locales/pt/translation.json index a0cb327877..b6dd07a7bc 100644 --- a/extension/src/popup/locales/pt/translation.json +++ b/extension/src/popup/locales/pt/translation.json @@ -737,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.", diff --git a/extension/src/popup/views/AccountHistory/hooks/__tests__/useGetHistoryData.test.tsx b/extension/src/popup/views/AccountHistory/hooks/__tests__/useGetHistoryData.test.tsx index 5ad114fcf3..da82f2076e 100644 --- a/extension/src/popup/views/AccountHistory/hooks/__tests__/useGetHistoryData.test.tsx +++ b/extension/src/popup/views/AccountHistory/hooks/__tests__/useGetHistoryData.test.tsx @@ -219,28 +219,4 @@ describe("getRowDataByOpType - Soroban asset balance changes muxed classificatio expect(row.metadata.isReceiving).toBe(true); expect(row.amount).toMatch(/^\+/); }); - - it("treats a self-transfer (own base G... -> own muxed M...) as a debit, not a credit", async () => { - jest - .spyOn(sorobanHelpers, "getAttrsFromSorobanHorizonOp") - .mockReturnValue(null as any); - - const operation = buildInvokeHostFnOperation({ - asset_balance_changes: [ - { - asset_type: "native", - from: PUBLIC_KEY, - to: MY_MUXED, - amount: "5", - }, - ], - }); - - const row = await callGetRowData(operation); - - // Both ends resolve to this wallet, so it is a self-transfer and must stay - // Sent (debit) - matching the classic payment and Soroban transfer paths. - expect(row.metadata.isReceiving).toBe(false); - expect(row.amount).toMatch(/^-/); - }); }); diff --git a/extension/src/popup/views/AccountHistory/hooks/useGetHistoryData.tsx b/extension/src/popup/views/AccountHistory/hooks/useGetHistoryData.tsx index 0eca49c430..a27cdeec6c 100644 --- a/extension/src/popup/views/AccountHistory/hooks/useGetHistoryData.tsx +++ b/extension/src/popup/views/AccountHistory/hooks/useGetHistoryData.tsx @@ -461,13 +461,8 @@ const processAssetBalanceChanges = async ( assetIssuer = change.asset_issuer || null; } - // Determine if this is a credit (receiving) or debit (sending). - // Require the sender not to be this wallet so a self-transfer (e.g. base - // G... -> own M... address) stays a debit, matching the classic payment and - // Soroban transfer paths which default self-transfers to Sent. - const isCredit = - isSameAccount(change.to, publicKey) && - !isSameAccount(change.from, publicKey); + // Determine if this is a credit (receiving) or debit (sending) + const isCredit = isSameAccount(change.to, publicKey); // Destination is the counterparty (from for credits, to for debits) const destination = isCredit ? change.from : change.to; From 818b989fc35af38c576f3900bbc564bb8d1e718c Mon Sep 17 00:00:00 2001 From: leofelix077 Date: Mon, 22 Jun 2026 11:10:41 -0300 Subject: [PATCH 5/5] add additional checks for self send and fix ci tests --- .../popup/components/send/SendTo/index.tsx | 13 ++++ .../src/popup/views/__tests__/Send.test.tsx | 60 ++++++++++++++++++- 2 files changed, 71 insertions(+), 2 deletions(-) diff --git a/extension/src/popup/components/send/SendTo/index.tsx b/extension/src/popup/components/send/SendTo/index.tsx index c5297e2757..4c02074c47 100644 --- a/extension/src/popup/components/send/SendTo/index.tsx +++ b/extension/src/popup/components/send/SendTo/index.tsx @@ -382,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( diff --git a/extension/src/popup/views/__tests__/Send.test.tsx b/extension/src/popup/views/__tests__/Send.test.tsx index e774836d46..7d0d1f2bae 100644 --- a/extension/src/popup/views/__tests__/Send.test.tsx +++ b/extension/src/popup/views/__tests__/Send.test.tsx @@ -154,6 +154,11 @@ jest.mock("react-router-dom", () => { }); const publicKey = "GA4UFF2WJM7KHHG4R5D5D2MZQ6FWMDOSVITVF7C5OLD5NFP6RBBW2FGV"; +// A destination distinct from the active account - sending to your own address +// is blocked ("You cannot send to yourself"), so payment-flow tests must use +// a different recipient. +const destinationPublicKey = + "GBTYAFHGNZSTE4VBWZYAGB3SRGJEPTI5I4Y22KZ4JTVAN56LESB6JZOF"; describe("Send", () => { beforeEach(() => { @@ -352,6 +357,57 @@ describe("Send", () => { }); }); + it("blocks sending to your own account", async () => { + render( + + + , + ); + + await waitFor(() => { + expect(screen.getByTestId("token-list")).toBeDefined(); + }); + await waitFor(() => { + fireEvent.click(screen.getByTestId("SendRow-native")); + }); + + const input = await screen.findByTestId("send-to-input"); + // Entering the active account's own address is rejected. + fireEvent.change(input, { target: { value: publicKey } }); + + await waitFor(() => { + expect( + screen.getByText("You cannot send to yourself"), + ).toBeInTheDocument(); + }); + expect( + screen.queryByTestId("send-to-suggestion-button"), + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId("send-to-btn-continue"), + ).not.toBeInTheDocument(); + }); + it("pre-populates asset from query params", async () => { const testAsset = "USDC:GCK3D3V2XNLLKRFGFFFDEJXA4O2J4X36HET2FE446AV3M4U7DPHO3PEM"; @@ -390,7 +446,7 @@ describe("Send", () => { }); fireEvent.change(screen.getByTestId("send-to-input"), { - target: { value: publicKey }, + target: { value: destinationPublicKey }, }); await waitFor(() => { expect( @@ -712,7 +768,7 @@ const testPaymentFlow = async (asset: string, isMainnet: boolean) => { await waitFor(() => { const input = screen.getByTestId("send-to-input"); - fireEvent.change(input, { target: { value: publicKey } }); + fireEvent.change(input, { target: { value: destinationPublicKey } }); }); await waitFor(