Skip to content
Merged
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
2 changes: 2 additions & 0 deletions @shared/api/types/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 & {
Expand Down
65 changes: 65 additions & 0 deletions extension/e2e-tests/accountHistory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down
114 changes: 114 additions & 0 deletions extension/src/popup/helpers/__tests__/soroban.test.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
Account,
Address,
Keypair,
nativeToScVal,
Expand All @@ -19,6 +20,7 @@ import {
getAvailableBalance,
getDecimalsForAsset,
getContractIdFromTransactionData,
getAttrsFromSorobanHorizonOp,
isAssetSac,
scValToDisplayValue,
CLASSIC_ASSET_DECIMALS,
Expand Down Expand Up @@ -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();
});
});
46 changes: 37 additions & 9 deletions extension/src/popup/helpers/soroban.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import {
Asset,
Operation,
StrKey,
Transaction,
TransactionBuilder,
scValToNative,
xdr,
Expand Down Expand Up @@ -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<typeof getInvocationArgsFromInvokeHostFn>
>();
const SOROBAN_OP_ATTRS_CACHE_MAX = 500;

export const getAttrsFromSorobanHorizonOp = (
operation: HorizonOperation,
networkDetails: NetworkDetails,
Expand All @@ -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<typeof getInvocationArgsFromInvokeHostFn> = 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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
Account,
Asset,
Keypair,
MuxedAccount,
Networks,
Operation,
TransactionBuilder,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 =
Expand Down
Loading
Loading