Skip to content
Open
174 changes: 173 additions & 1 deletion @shared/api/__tests__/internal.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,15 @@
import { Networks } from "stellar-sdk";
import {
Account,
Address,
MuxedAccount,
Networks,
SorobanDataBuilder,
Transaction,
TransactionBuilder,
nativeToScVal,
rpc,
scValToNative,
} from "stellar-sdk";
import {
FUTURENET_NETWORK_DETAILS,
MAINNET_NETWORK_DETAILS,
Expand All @@ -8,6 +19,7 @@ import * as GetLedgerKeyAccounts from "../helpers/getLedgerKeyAccounts";
import * as internalApi from "../internal";
import { sendMessageToBackground } from "@shared/api/helpers/extensionMessaging";
import { SERVICE_TYPES } from "@shared/constants/services";
import { CUSTOM_NETWORK } from "@shared/helpers/stellar";

jest.mock("@shared/api/helpers/extensionMessaging");
const mockedSend = sendMessageToBackground as jest.Mock;
Expand Down Expand Up @@ -194,6 +206,166 @@ describe("internalApi", () => {
}),
);
});

const payer = "GBHKTFVBDUA6RYP5JM4SPZ76OXYAAHV4QHUOFV4S4TK342FMVGPHA2WN";
const recipient =
"GBES5UHJYI445RV4XBGWHZOMBW4RYXBHOX47ZNZAJZAH2WP42ZEP2DYQ";
const contract = "CCVKI6UYJDO34LO4D653IXCTPBGJOHJSJGSIOB4A46IH4ULNHS2MPQL7";
const muxed = new MuxedAccount(
new Account(recipient, "0"),
"18446744073709551615",
).accountId();

const expectTransfer = (
transaction: Transaction,
destination: string,
amount: string,
) => {
expect(transaction.source).toBe(payer);
expect(transaction.sequence).toBe("124");
expect(transaction.signatures).toHaveLength(0);
expect(transaction.operations).toHaveLength(1);
const operation = transaction.operations[0];
if (
operation.type !== "invokeHostFunction" ||
operation.func.type !== "hostFunctionTypeInvokeContract"
) {
throw new Error("Expected a token transfer");
}
const invocation = operation.func.invokeContract;
expect(Address.fromScAddress(invocation.contractAddress).toString()).toBe(
contract,
);
expect(invocation.functionName.toString()).toBe("transfer");
expect(invocation.args).toHaveLength(3);
expect(Address.fromScVal(invocation.args[0]).toString()).toBe(payer);
expect(Address.fromScVal(invocation.args[1]).toString()).toBe(
destination,
);
expect(scValToNative(invocation.args[2])).toBe(BigInt(amount));
};

describe.each([
{ route: "classic", destination: recipient },
{ route: "muxed", destination: muxed },
{ route: "contract", destination: contract },
])("exact $route transfer", ({ destination }) => {
it.each(["9007199254740993", "170141183460469231731687303715884105727"])(
"encodes %s into the submitted simulation XDR without numeric rounding",
async (amount) => {
const account = jest
.spyOn(rpc.Server.prototype, "getAccount")
.mockResolvedValue(new Account(payer, "123"));
const submit = jest.spyOn(rpc.Server.prototype, "sendTransaction");
const simulationResponse = {
preparedTransaction: "prepared-xdr",
simulationResponse: { minResourceFee: "100" },
};
const fetchSpy = jest.spyOn(global, "fetch").mockResolvedValue({
ok: true,
json: async () => simulationResponse,
} as Response);

const result = await internalApi.simulateTokenTransfer({
address: contract,
publicKey: payer,
memo: "",
params: { publicKey: payer, destination, amount },
networkDetails: TESTNET_NETWORK_DETAILS,
transactionFee: "0.00001",
});

expect(account).toHaveBeenCalledWith(payer);
expect(fetchSpy).toHaveBeenCalledTimes(1);
const [url, options] = fetchSpy.mock.calls[0];
expect(url).toEqual(expect.stringContaining("/simulate-tx"));
const body = JSON.parse(options!.body as string);
expect(body.network_passphrase).toBe(
TESTNET_NETWORK_DETAILS.networkPassphrase,
);
expect(body).not.toHaveProperty("params");
const transaction = TransactionBuilder.fromXDR(
body.xdr,
body.network_passphrase,
);
expect(transaction).toBeInstanceOf(Transaction);
expectTransfer(transaction as Transaction, destination, amount);
expect(transaction.fee).toBe("100");
expect(result).toEqual({ ok: true, response: simulationResponse });
expect(submit).not.toHaveBeenCalled();
},
);
});

it.each(["9007199254740993", 10])(
"keeps custom-network RPC simulation exact for %s",
async (amount) => {
jest
.spyOn(rpc.Server.prototype, "getAccount")
.mockResolvedValue(new Account(payer, "123"));
const simulate = jest
.spyOn(rpc.Server.prototype, "simulateTransaction")
.mockResolvedValue({
_parsed: true,
id: "simulation",
latestLedger: 1,
minResourceFee: "100",
events: [],
transactionData: new SorobanDataBuilder().setResourceFee("100"),
result: { auth: [], retval: nativeToScVal(null) },
});
const fetchSpy = jest.spyOn(global, "fetch");
const networkDetails = {
...TESTNET_NETWORK_DETAILS,
network: CUSTOM_NETWORK,
};
const result = await internalApi.simulateTokenTransfer({
address: contract,
publicKey: payer,
params: { publicKey: payer, destination: muxed, amount },
networkDetails,
transactionFee: "0.00001",
});
expect(result.ok).toBe(true);
expect(simulate).toHaveBeenCalledTimes(1);
expectTransfer(
simulate.mock.calls[0][0] as Transaction,
muxed,
String(amount),
);
const prepared = TransactionBuilder.fromXDR(
result.response.preparedTransaction,
networkDetails.networkPassphrase,
);
expectTransfer(prepared as Transaction, muxed, String(amount));
expect(prepared.fee).toBe("200");
expect(fetchSpy).not.toHaveBeenCalled();
},
);

it("rejects an exact transfer without RPC configuration before network access", async () => {
const fetchSpy = jest.spyOn(global, "fetch").mockResolvedValue({
ok: true,
json: async () => ({}),
} as Response);
await expect(
internalApi.simulateTokenTransfer({
address: contract,
publicKey: payer,
params: {
publicKey: payer,
destination: recipient,
amount: "9007199254740993",
},
networkDetails: {
...TESTNET_NETWORK_DETAILS,
sorobanRpcUrl: undefined,
},
transactionFee: "0.00001",
}),
).rejects.toThrow();
expect(fetchSpy).not.toHaveBeenCalled();
});
});

describe("getTokenPrices request payload filtering", () => {
Expand Down
36 changes: 33 additions & 3 deletions @shared/api/internal.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { SoranPaymentName, SoranPaymentReference } from "./types/soran";
import { captureException } from "@sentry/browser";
import {
Address,
Expand Down Expand Up @@ -2457,7 +2458,7 @@ export const simulateTokenTransfer = async (args: {
params: {
publicKey: string;
destination: string;
amount: number;
amount: number | string;
};
networkDetails: NetworkDetails;
transactionFee: string;
Expand All @@ -2471,7 +2472,8 @@ export const simulateTokenTransfer = async (args: {
const { address, publicKey, memo, params, networkDetails, transactionFee } =
args;

if (isCustomNetwork(networkDetails)) {
const isCustom = isCustomNetwork(networkDetails);
if (isCustom || typeof params.amount === "string") {
if (!networkDetails.sorobanRpcUrl) {
throw new SorobanRpcNotSupportedError();
}
Expand All @@ -2492,6 +2494,14 @@ export const simulateTokenTransfer = async (args: {
new XdrLargeInt("i128", params.amount).toI128(), // amount
];
const transaction = transfer(address, transferParams, memo, builder);
if (!isCustom) {
// Carry exact integer amounts in XDR instead of the numeric token endpoint.
// This reuses the same simulation API as collectible transfers.
return simulateTransaction({
xdr: transaction.toXdr(),
networkDetails,
});
}
// TODO: type narrow instead of cast
const simulationResponse = (await server.simulateTransaction(
transaction,
Expand Down Expand Up @@ -2530,7 +2540,7 @@ export const simulateTokenTransfer = async (args: {
pub_key: publicKey,
memo: memo || "", // Backend requires memo as string, use empty string if undefined
fee: xlmToStroop(transactionFee).toFixed(),
params,
params: { ...params, amount: params.amount },
network_passphrase: networkDetails.networkPassphrase,
};

Expand Down Expand Up @@ -2875,3 +2885,23 @@ export const cacheSwapTopTokens = async (
throw new Error(error);
}
};

export const saveSoranPaymentName = (
activePublicKey: string,
payment: SoranPaymentName,
): Promise<{ saved: boolean }> =>
sendMessageToBackground({
activePublicKey,
payment,
type: SERVICE_TYPES.SAVE_SORAN_PAYMENT_NAME,
});

export const getSoranPaymentName = (
activePublicKey: string,
payment: SoranPaymentReference,
): Promise<{ name: string | null }> =>
sendMessageToBackground({
activePublicKey,
payment,
type: SERVICE_TYPES.GET_SORAN_PAYMENT_NAME,
});
13 changes: 13 additions & 0 deletions @shared/api/types/message-request.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { SoranPaymentName, SoranPaymentReference } from "./soran";
import { Transaction } from "stellar-sdk";
import browser from "webextension-polyfill";

Expand Down Expand Up @@ -261,6 +262,16 @@ export interface SignFreighterSorobanTransactionMessage extends BaseMessage {
transactionXDR: string;
}

export interface SaveSoranPaymentNameMessage extends BaseMessage {
type: SERVICE_TYPES.SAVE_SORAN_PAYMENT_NAME;
payment: SoranPaymentName;
}

export interface GetSoranPaymentNameMessage extends BaseMessage {
type: SERVICE_TYPES.GET_SORAN_PAYMENT_NAME;
payment: SoranPaymentReference;
}

export interface AddRecentAddressMessage extends BaseMessage {
type: SERVICE_TYPES.ADD_RECENT_ADDRESS;
address: string;
Expand Down Expand Up @@ -543,6 +554,8 @@ export type ServiceMessageRequest =
| RejectTransactionMessage
| SignFreighterTransactionMessage
| SignFreighterSorobanTransactionMessage
| SaveSoranPaymentNameMessage
| GetSoranPaymentNameMessage
| AddRecentAddressMessage
| LoadRecentAddressesMessage
| LoadLastAccountUsedMessage
Expand Down
25 changes: 25 additions & 0 deletions @shared/api/types/soran.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/** Local annotation of the exact route used by a successful Freighter send. */
export interface SoranPaymentReference {
networkPassphrase: string;
transactionHash: string;
destination: string;
memo: string;
memoType: string;
}

export interface SoranPaymentName extends SoranPaymentReference {
name: string;
}

export const soranPaymentKey = (
publicKey: string,
payment: SoranPaymentReference,
) =>
JSON.stringify([
publicKey,
payment.networkPassphrase,
payment.transactionHash,
payment.destination,
payment.memoType || "none",
payment.memo || "",
]);
1 change: 1 addition & 0 deletions @shared/api/types/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,7 @@ export type HorizonOperation = Horizon.ServerApi.OperationRecord & {
[key: string]: any;
};
};
from_muxed?: string;
to_muxed?: string;
to?: string;
from?: string;
Expand Down
2 changes: 2 additions & 0 deletions @shared/constants/services.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
export enum SERVICE_TYPES {
SAVE_SORAN_PAYMENT_NAME = "SAVE_SORAN_PAYMENT_NAME",
GET_SORAN_PAYMENT_NAME = "GET_SORAN_PAYMENT_NAME",
CREATE_ACCOUNT = "CREATE_ACCOUNT",
FUND_ACCOUNT = "FUND_ACCOUNT",
ADD_ACCOUNT = "ADD_ACCOUNT",
Expand Down
4 changes: 2 additions & 2 deletions extension/e2e-tests/accountHistory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -436,7 +436,7 @@ test("History row displays muxed address extracted from XDR for payment", async
await page.getByTestId("history-item").nth(0).click();

// Verify muxed address is displayed (from to_muxed field in API response)
const dstAmount = page.getByTestId("KeyIdenticonKey");
const dstAmount = page.getByTestId("AssetDiff__to-from-address");
await expect(dstAmount).toBeVisible({ timeout: 10000 });
expect(await dstAmount.textContent()).toContain(TEST_M_ADDRESS.slice(0, 4));

Expand Down Expand Up @@ -618,7 +618,7 @@ test("History row displays regular G address when no muxed address in XDR", asyn
await page.getByTestId("history-item").first().click();

// Verify G address is displayed
const dstAmount = page.getByTestId("KeyIdenticonKey");
const dstAmount = page.getByTestId("AssetDiff__to-from-address");
await expect(dstAmount).toBeVisible({ timeout: 10000 });
expect(await dstAmount.textContent()).toContain(G_ADDRESS.slice(0, 4));

Expand Down
Loading