diff --git a/@shared/api/types/types.ts b/@shared/api/types/types.ts index 53138ec620..ad2d12343c 100644 --- a/@shared/api/types/types.ts +++ b/@shared/api/types/types.ts @@ -363,6 +363,8 @@ export interface AssetBalanceChange { from: string; to: string; amount: string; + // Mux id of the destination when it was muxed; `to` holds the base address. + destination_muxed_id?: string; } export type HorizonOperation = Horizon.ServerApi.OperationRecord & { diff --git a/extension/e2e-tests/accountHistory.test.ts b/extension/e2e-tests/accountHistory.test.ts index a2490f4b40..a0082b09e0 100644 --- a/extension/e2e-tests/accountHistory.test.ts +++ b/extension/e2e-tests/accountHistory.test.ts @@ -83,6 +83,71 @@ test("Classifies a payment received to a muxed (M...) address as Received", asyn await expect(page.getByText("Received")).toBeVisible(); }); +test("Shows a SAC transfer to a muxed contract (W...) as sent to the W address", async ({ + page, + extensionId, + context, +}) => { + // Horizon reports a transfer to a muxed contract (W...) with the base + // contract (C...) in `to` and the mux id in `destination_muxed_id`. + // W_ADDRESS is BASE_CONTRACT with mux id 0. + const TEST_ACCOUNT = + "GDF32CQINROD3E2LMCGZUDVMWTXCJFR5SBYVRJ7WAAIAS3P7DCVWZEFY"; + const BASE_CONTRACT = + "CA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUWDA"; + const W_ADDRESS = + "WA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUAAAAAAAAAAAAAWWC"; + + const mockAccountHistoryData = [ + { + created_at: "2025-03-21T22:28:46Z", + id: "164007621169154", + paging_token: "164007621169154", + source_account: TEST_ACCOUNT, + transaction_hash: + "686601028de9ddf40a1c24461a6a9c0415d60a39255c35eccad0b52ac1e700a7", + transaction_successful: true, + type: "invoke_host_function", + type_i: 24, + asset_balance_changes: [ + { + asset_type: "native", + type: "transfer", + from: TEST_ACCOUNT, + to: BASE_CONTRACT, + amount: "5.0000000", + destination_muxed_id: "0", + }, + ], + transaction_attr: { + memo: null, + fee_charged: "100", + operation_count: 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 }); + await expect(amountCell).toContainText("-5"); + + await page.getByTestId("history-item").first().click(); + + const toAddress = page.getByTestId("AssetDiff__to-from-address"); + await expect(toAddress).toContainText(W_ADDRESS.slice(0, 4)); + await expect(toAddress).toContainText(W_ADDRESS.slice(-4)); + + // The mux id travels in the W address, so there is no memo row. + await expect(page.locator(".Metadata--memo")).not.toBeVisible(); +}); + test("View failed transaction", async ({ page, extensionId, context }) => { test.slow(); const mockAccountHistoryData = [ diff --git a/extension/src/popup/helpers/__tests__/soroban.test.js b/extension/src/popup/helpers/__tests__/soroban.test.js index 5a10a46cf8..95fb31c9e9 100644 --- a/extension/src/popup/helpers/__tests__/soroban.test.js +++ b/extension/src/popup/helpers/__tests__/soroban.test.js @@ -1,4 +1,5 @@ import { + Account, Address, Keypair, nativeToScVal, @@ -19,6 +20,7 @@ import { getAvailableBalance, getDecimalsForAsset, getContractIdFromTransactionData, + getAttrsFromSorobanHorizonOp, isAssetSac, scValToDisplayValue, CLASSIC_ASSET_DECIMALS, @@ -834,3 +836,115 @@ describe("getContractIdFromTransactionData", () => { expect(result).toBeUndefined(); }); }); + +describe("getAttrsFromSorobanHorizonOp", () => { + it("returns null instead of throwing for an undecodable envelope", () => { + const operation = { + type: "invoke_host_function", + transaction_attr: { envelope_xdr: "not-xdr" }, + }; + + expect( + getAttrsFromSorobanHorizonOp(operation, TESTNET_NETWORK_DETAILS), + ).toBeNull(); + }); + + it("reads the operation of a fee-bumped envelope", () => { + const from = Keypair.random(); + const to = Keypair.random().publicKey(); + const inner = new TransactionBuilder(new Account(from.publicKey(), "0"), { + fee: "100", + networkPassphrase: TESTNET_NETWORK_DETAILS.networkPassphrase, + }) + .addOperation( + Operation.invokeHostFunction({ + func: xdr.HostFunction.hostFunctionTypeInvokeContract( + new xdr.InvokeContractArgs({ + contractAddress: new Address( + "CA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUWDA", + ).toScAddress(), + functionName: "transfer", + args: [ + nativeToScVal(from.publicKey(), { type: "address" }), + nativeToScVal(to, { type: "address" }), + nativeToScVal(BigInt(100), { type: "i128" }), + ], + }), + ), + auth: [], + }), + ) + .setTimeout(0) + .build(); + const feeBump = TransactionBuilder.buildFeeBumpTransaction( + Keypair.random().publicKey(), + "200", + inner, + TESTNET_NETWORK_DETAILS.networkPassphrase, + ); + const operation = { + type: "invoke_host_function", + transaction_attr: { envelope_xdr: feeBump.toXDR() }, + }; + + expect( + getAttrsFromSorobanHorizonOp(operation, TESTNET_NETWORK_DETAILS), + ).toMatchObject({ + fnName: "transfer", + from: from.publicKey(), + to, + amount: BigInt(100), + }); + }); + + it("decodes an envelope once however many times it is asked for", () => { + const fromXdr = jest.spyOn(TransactionBuilder, "fromXdr"); + // A fresh source key makes this envelope unique, so no earlier test has + // already cached it. + const from = Keypair.random(); + const inner = new TransactionBuilder(new Account(from.publicKey(), "0"), { + fee: "100", + networkPassphrase: TESTNET_NETWORK_DETAILS.networkPassphrase, + }) + .addOperation( + Operation.invokeHostFunction({ + func: xdr.HostFunction.hostFunctionTypeInvokeContract( + new xdr.InvokeContractArgs({ + contractAddress: new Address( + "CA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUWDA", + ).toScAddress(), + functionName: "transfer", + args: [ + nativeToScVal(from.publicKey(), { type: "address" }), + nativeToScVal(Keypair.random().publicKey(), { + type: "address", + }), + nativeToScVal(BigInt(1), { type: "i128" }), + ], + }), + ), + auth: [], + }), + ) + .setTimeout(0) + .build(); + const operation = { + type: "invoke_host_function", + transaction_attr: { envelope_xdr: inner.toXDR() }, + }; + + const first = getAttrsFromSorobanHorizonOp( + operation, + TESTNET_NETWORK_DETAILS, + ); + const second = getAttrsFromSorobanHorizonOp( + { ...operation }, + TESTNET_NETWORK_DETAILS, + ); + + expect(first).toMatchObject({ fnName: "transfer" }); + expect(second).toBe(first); + expect(fromXdr).toHaveBeenCalledTimes(1); + fromXdr.mockRestore(); + }); +}); diff --git a/extension/src/popup/helpers/soroban.ts b/extension/src/popup/helpers/soroban.ts index 9a53ae0d52..63240dd38d 100644 --- a/extension/src/popup/helpers/soroban.ts +++ b/extension/src/popup/helpers/soroban.ts @@ -5,7 +5,6 @@ import { Asset, Operation, StrKey, - Transaction, TransactionBuilder, scValToNative, xdr, @@ -466,6 +465,14 @@ export const getInvocationArgsFromInvokeHostFn = ( }; }; +// Several call sites decode the same operation during one history load. +// Cache by envelope so each one is decoded, and any failure reported, once. +const sorobanOpAttrsCache = new Map< + string, + ReturnType +>(); +const SOROBAN_OP_ATTRS_CACHE_MAX = 500; + export const getAttrsFromSorobanHorizonOp = ( operation: HorizonOperation, networkDetails: NetworkDetails, @@ -474,16 +481,37 @@ export const getAttrsFromSorobanHorizonOp = ( return null; } - const txEnvelope = TransactionBuilder.fromXdr( - operation.transaction_attr.envelope_xdr as string, - networkDetails.networkPassphrase, - ) as Transaction; + const envelopeXdr = operation.transaction_attr.envelope_xdr as string; + const cacheKey = `${networkDetails.networkPassphrase}:${envelopeXdr}`; + const cached = sorobanOpAttrsCache.get(cacheKey); + if (cached !== undefined) { + return cached; + } - // only one op per tx in Soroban right now; isSorobanOp above guarantees it - // is an invokeHostFunction operation - const invokeHostFn = txEnvelope.operations[0] as Operation.InvokeHostFunction; + let attrs: ReturnType = null; + // One undecodable envelope must not break the whole history load. + try { + // For a fee bump, `operations` reads through to the inner transaction. + const transaction = TransactionBuilder.fromXdr( + envelopeXdr, + networkDetails.networkPassphrase, + ); + + // only one op per tx in Soroban right now; isSorobanOp above guarantees it + // is an invokeHostFunction operation + const invokeHostFn = transaction + .operations[0] as Operation.InvokeHostFunction; + + attrs = getInvocationArgsFromInvokeHostFn(invokeHostFn); + } catch (error) { + captureException(error, { extra: { operationId: operation.id } }); + } - return getInvocationArgsFromInvokeHostFn(invokeHostFn); + if (sorobanOpAttrsCache.size >= SOROBAN_OP_ATTRS_CACHE_MAX) { + sorobanOpAttrsCache.clear(); + } + sorobanOpAttrsCache.set(cacheKey, attrs); + return attrs; }; export interface InvocationTree { 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 b88dd37df6..43b34112bd 100644 --- a/extension/src/popup/views/AccountHistory/hooks/__tests__/useGetHistoryData.test.tsx +++ b/extension/src/popup/views/AccountHistory/hooks/__tests__/useGetHistoryData.test.tsx @@ -2,6 +2,7 @@ import { Account, Asset, Keypair, + MuxedAccount, Networks, Operation, TransactionBuilder, @@ -54,6 +55,11 @@ const buildPaymentOperation = ({ }) as unknown as HistoryItemOperation; const CONTRACT_ID = "CAAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQC526"; +// W_ADDRESS is W_BASE_CONTRACT with mux id 0. +const W_BASE_CONTRACT = + "CA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUWDA"; +const W_ADDRESS = + "WA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUAAAAAAAAAAAAAWWC"; const callGetRowData = ( operation: HistoryItemOperation, @@ -228,6 +234,80 @@ describe("getRowDataByOpType - Soroban asset balance changes muxed classificatio expect(row.metadata.isReceiving).toBe(true); expect(row.amount).toMatch(/^\+/); }); + + it("shows a transfer to a muxed contract (W) as sent to the W address", async () => { + jest + .spyOn(sorobanHelpers, "getAttrsFromSorobanHorizonOp") + .mockReturnValue(null as any); + + // Horizon reports the base contract as `to` and the mux id separately. + const operation = buildInvokeHostFnOperation({ + asset_balance_changes: [ + { + asset_type: "native", + from: PUBLIC_KEY, + to: W_BASE_CONTRACT, + amount: "5", + destination_muxed_id: "0", + }, + ], + }); + + const row = await callGetRowData(operation); + + expect(row.metadata.isReceiving).toBe(false); + expect(row.amount).toMatch(/^-/); + expect(row.metadata.to).toBe(W_ADDRESS); + }); + + it("shows a transfer to a muxed account (M) as sent to the M address", async () => { + jest + .spyOn(sorobanHelpers, "getAttrsFromSorobanHorizonOp") + .mockReturnValue(null as any); + + // Horizon reports the base account as `to` and the mux id separately, + // the same way it does for a muxed contract. + const operation = buildInvokeHostFnOperation({ + asset_balance_changes: [ + { + asset_type: "native", + from: PUBLIC_KEY, + to: COUNTERPARTY, + amount: "5", + destination_muxed_id: "1", + }, + ], + }); + + const row = await callGetRowData(operation); + + expect(row.metadata.to).toBe( + new MuxedAccount(new Account(COUNTERPARTY, "0"), "1").accountId(), + ); + }); + + it("falls back to the base address when the mux id cannot be encoded", async () => { + jest + .spyOn(sorobanHelpers, "getAttrsFromSorobanHorizonOp") + .mockReturnValue(null as any); + + const operation = buildInvokeHostFnOperation({ + asset_balance_changes: [ + { + asset_type: "native", + from: PUBLIC_KEY, + to: W_BASE_CONTRACT, + amount: "5", + destination_muxed_id: "not-a-number", + }, + ], + }); + + const row = await callGetRowData(operation); + + expect(row.metadata.to).toBe(W_BASE_CONTRACT); + expect(row.amount).toMatch(/^-/); + }); }); const NON_NATIVE_CONTRACT = diff --git a/extension/src/popup/views/AccountHistory/hooks/useGetHistoryData.tsx b/extension/src/popup/views/AccountHistory/hooks/useGetHistoryData.tsx index 5fe6e51c23..6fe6871e78 100644 --- a/extension/src/popup/views/AccountHistory/hooks/useGetHistoryData.tsx +++ b/extension/src/popup/views/AccountHistory/hooks/useGetHistoryData.tsx @@ -1,6 +1,11 @@ import React, { ReactNode, useReducer } from "react"; import { useSelector, useDispatch } from "react-redux"; -import { Horizon, TransactionBuilder } from "stellar-sdk"; +import { + encodeMuxedContractAddress, + Horizon, + StrKey, + TransactionBuilder, +} from "stellar-sdk"; import { camelCase } from "lodash"; import BigNumber from "bignumber.js"; import { @@ -29,6 +34,7 @@ import { useGetAppData, } from "helpers/hooks/useGetAppData"; import { + createMuxedAccount, getCanonicalFromAsset, isMainnet, isSameAccount, @@ -418,6 +424,29 @@ export interface AssetDiffSummary { sourceIcon?: string; } +/** + * Rebuilds a muxed address from the base address and mux id that Horizon + * reports separately. Returns null when the pair cannot be encoded, so one + * bad record falls back to the base address instead of failing the load. + */ +const rebuildMuxedDestination = ( + baseAddress: string, + muxedId: string, + operationId: string, +): string | null => { + try { + if (StrKey.isValidContract(baseAddress)) { + return encodeMuxedContractAddress(baseAddress, muxedId); + } + if (StrKey.isValidEd25519PublicKey(baseAddress)) { + return createMuxedAccount(baseAddress, muxedId); + } + } catch (error) { + captureException(error, { extra: { operationId, baseAddress, muxedId } }); + } + return null; +}; + /** * Processes asset_balance_changes for a given public key and returns summaries for each asset * @returns Array of asset diff summaries, or empty array if none @@ -469,7 +498,17 @@ const processAssetBalanceChanges = async ( // 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; + let destination = isCredit ? change.from : change.to; + // A muxed destination arrives as the base address plus a mux id. Rebuild + // the muxed address for display; matching above stays on the base address. + if (!isCredit && change.destination_muxed_id) { + destination = + rebuildMuxedDestination( + change.to, + change.destination_muxed_id, + operation.id, + ) ?? change.to; + } // Get asset icon const icon = isNativeAssetPair(assetCode, assetIssuer)