@ledgerhq/live-common
Version:
Common ground for the Ledger Live apps
773 lines (680 loc) • 22.8 kB
text/typescript
import { Transaction as EvmTransaction } from "@ledgerhq/coin-evm/types/index";
import { TokenCurrency } from "@ledgerhq/types-cryptoassets";
import {
OperationType,
SignedOperation,
SignedOperationRaw,
TokenAccount,
} from "@ledgerhq/types-live";
import BigNumber from "bignumber.js";
import { setSupportedCurrencies } from "../currencies";
import * as signMessage from "../hw/signMessage/index";
import {
createFixtureAccount,
createFixtureCryptoCurrency,
createFixtureTokenAccount,
} from "../mock/fixtures/cryptoCurrencies";
import * as converters from "./converters";
jest.mock("../hw/signMessage/index", () => ({
...jest.requireActual("../hw/signMessage/index"),
prepareMessageToSign: jest.fn(),
}));
jest.mock("./converters", () => ({
...jest.requireActual("./converters"),
accountToPlatformAccount: jest.fn(),
}));
jest.mock("./serializers", () => ({
...jest.requireActual("./serializers"),
deserializePlatformSignedTransaction: jest.fn(),
}));
import {
broadcastTransactionLogic,
completeExchangeLogic,
receiveOnAccountLogic,
signMessageLogic,
WebPlatformContext,
} from "./logic";
import { RawPlatformTransaction } from "./rawTypes";
import * as serializers from "./serializers";
import { LiveAppManifest } from "./types";
import { initialState } from "@ledgerhq/live-wallet/store";
import { TrackingAPI } from "./tracking";
describe("receiveOnAccountLogic", () => {
const walletState = initialState;
// Given
const mockPlatformReceiveRequested = jest.fn();
const mockPlatformReceiveFail = jest.fn();
const context = createContextContainingAccountId(
{
platformReceiveRequested: mockPlatformReceiveRequested,
platformReceiveFail: mockPlatformReceiveFail,
},
"11",
"12",
);
const uiNavigation = jest.fn();
beforeEach(() => {
mockPlatformReceiveRequested.mockClear();
mockPlatformReceiveFail.mockClear();
uiNavigation.mockClear();
mockedAccountToPlatformAccount.mockClear();
});
describe("when nominal case", () => {
// Given
const accountId = "js:2:ethereum:0x012:";
const expectedResult = "Function called";
beforeEach(() => uiNavigation.mockResolvedValueOnce(expectedResult));
it("calls uiNavigation callback with an accountAddress", async () => {
// Given
const convertedAccount = {
...createPlatformAccount(),
address: "Converted address",
};
mockedAccountToPlatformAccount.mockReturnValueOnce(convertedAccount);
// When
const result = await receiveOnAccountLogic(walletState, context, accountId, uiNavigation);
// Then
expect(uiNavigation).toHaveBeenCalledTimes(1);
expect(uiNavigation.mock.calls[0][2]).toEqual("Converted address");
expect(result).toEqual(expectedResult);
});
it("calls the tracking for success", async () => {
// Given
const convertedAccount = {
...createPlatformAccount(),
address: "Converted address",
};
mockedAccountToPlatformAccount.mockReturnValueOnce(convertedAccount);
// When
await receiveOnAccountLogic(walletState, context, accountId, uiNavigation);
// Then
expect(mockPlatformReceiveRequested).toHaveBeenCalledTimes(1);
expect(mockPlatformReceiveFail).toHaveBeenCalledTimes(0);
});
});
describe("when account cannot be found", () => {
// Given
const nonFoundAccountId = "js:2:ethereum:0x010:";
it("returns an error", async () => {
// When
await expect(async () => {
await receiveOnAccountLogic(walletState, context, nonFoundAccountId, uiNavigation);
}).rejects.toThrow("Account required");
// Then
expect(uiNavigation).toHaveBeenCalledTimes(0);
});
it("calls the tracking for error", async () => {
// When
await expect(async () => {
await receiveOnAccountLogic(walletState, context, nonFoundAccountId, uiNavigation);
}).rejects.toThrow();
// Then
expect(mockPlatformReceiveRequested).toHaveBeenCalledTimes(1);
expect(mockPlatformReceiveFail).toHaveBeenCalledTimes(1);
});
});
});
describe("completeExchangeLogic", () => {
// Given
const mockPlatformCompleteExchangeRequested = jest.fn();
const context = createContextContainingAccountId(
{
platformCompleteExchangeRequested: mockPlatformCompleteExchangeRequested,
},
"11",
"12",
);
const uiNavigation = jest.fn();
beforeAll(() => {
setSupportedCurrencies(["bitcoin", "ethereum"]);
});
afterAll(() => {
setSupportedCurrencies([]);
});
beforeEach(() => {
mockPlatformCompleteExchangeRequested.mockClear();
uiNavigation.mockClear();
});
describe("when nominal case", () => {
// Given
const expectedResult = "Function called";
beforeEach(() => uiNavigation.mockResolvedValueOnce(expectedResult));
it("calls uiNavigation callback (token)", async () => {
// Given
const fromAccount = createFixtureTokenAccount("16");
const fromParentAccount = createFixtureAccount("16");
context.accounts = [...context.accounts, fromAccount, fromParentAccount];
const rawPlatformTransaction = createRawEtherumTransaction();
const completeExchangeRequest = {
provider: "provider",
fromAccountId: "js:2:ethereum:0x16:+ethereum%2Ferc20%2Fusd_tether__erc20_",
toAccountId: "js:2:ethereum:0x042:",
transaction: rawPlatformTransaction,
binaryPayload: "binaryPayload",
signature: "signature",
feesStrategy: "medium",
exchangeType: 8,
};
const expectedTransaction: EvmTransaction = {
family: "evm",
amount: new BigNumber("1000000000"),
subAccountId: "js:2:ethereum:0x16:+ethereum%2Ferc20%2Fusd_tether__erc20_",
recipient: "0x0123456",
nonce: 8,
data: Buffer.from("Some data...", "hex"),
type: 0,
gasPrice: new BigNumber("700000"),
maxFeePerGas: undefined,
maxPriorityFeePerGas: undefined,
gasLimit: new BigNumber("1200000"),
customGasLimit: new BigNumber("1200000"),
feesStrategy: "medium",
mode: "send",
useAllAmount: false,
chainId: 1,
};
// When
const result = await completeExchangeLogic(context, completeExchangeRequest, uiNavigation);
// Then
expect(uiNavigation).toHaveBeenCalledTimes(1);
expect(uiNavigation.mock.calls[0][0]).toEqual({
provider: "provider",
exchange: {
fromAccount,
fromParentAccount,
fromCurrency: fromAccount.token,
toAccount: undefined,
toParentAccount: undefined,
toCurrency: undefined,
},
transaction: expectedTransaction,
binaryPayload: "binaryPayload",
signature: "signature",
feesStrategy: "medium",
exchangeType: 8,
});
expect(result).toEqual(expectedResult);
});
it("calls uiNavigation callback (coin)", async () => {
// Given
const fromAccount = createFixtureAccount("17");
context.accounts = [...context.accounts, fromAccount];
const rawPlatformTransaction = createRawEtherumTransaction();
const completeExchangeRequest = {
provider: "provider",
fromAccountId: "js:2:ethereum:0x017:",
toAccountId: "js:2:ethereum:0x042:",
transaction: rawPlatformTransaction,
binaryPayload: "binaryPayload",
signature: "signature",
feesStrategy: "medium",
exchangeType: 8,
};
const expectedTransaction: EvmTransaction = {
family: "evm",
amount: new BigNumber("1000000000"),
recipient: "0x0123456",
nonce: 8,
data: Buffer.from("Some data...", "hex"),
gasPrice: new BigNumber("700000"),
gasLimit: new BigNumber("1200000"),
customGasLimit: new BigNumber("1200000"),
feesStrategy: "medium",
mode: "send",
useAllAmount: false,
chainId: 1,
subAccountId: undefined,
type: 0,
maxFeePerGas: undefined,
maxPriorityFeePerGas: undefined,
};
// When
const result = await completeExchangeLogic(context, completeExchangeRequest, uiNavigation);
// Then
expect(uiNavigation).toHaveBeenCalledTimes(1);
expect(uiNavigation.mock.calls[0][0]).toEqual({
provider: "provider",
exchange: {
fromAccount,
fromParentAccount: undefined,
fromCurrency: fromAccount.currency,
toAccount: undefined,
toParentAccount: undefined,
toCurrency: undefined,
},
transaction: expectedTransaction,
binaryPayload: "binaryPayload",
signature: "signature",
feesStrategy: "medium",
exchangeType: 8,
});
expect(result).toEqual(expectedResult);
});
it.each(["slow", "medium", "fast", "custom"])(
"calls uiNavigation with a transaction that has the %s feeStrategy",
async expectedFeeStrategy => {
// Given
const fromAccount = createFixtureAccount("17");
context.accounts = [...context.accounts, fromAccount];
const rawPlatformTransaction = createRawEtherumTransaction();
const completeExchangeRequest = {
provider: "provider",
fromAccountId: "js:2:ethereum:0x017:",
toAccountId: "js:2:ethereum:0x042:",
transaction: rawPlatformTransaction,
binaryPayload: "binaryPayload",
signature: "signature",
feesStrategy: expectedFeeStrategy,
exchangeType: 8,
};
// When
await completeExchangeLogic(context, completeExchangeRequest, uiNavigation);
// Then
expect(uiNavigation).toHaveBeenCalledTimes(1);
expect(uiNavigation.mock.calls[0][0]["transaction"].feesStrategy).toEqual(
expectedFeeStrategy,
);
},
);
it("calls the tracking for success", async () => {
// Given
const completeExchangeRequest = {
provider: "provider",
fromAccountId: "js:2:ethereum:0x012:",
toAccountId: "js:2:ethereum:0x042:",
transaction: createRawEtherumTransaction(),
binaryPayload: "binaryPayload",
signature: "signature",
feesStrategy: "feeStrategy",
exchangeType: 8,
};
// When
await completeExchangeLogic(context, completeExchangeRequest, uiNavigation);
// Then
expect(mockPlatformCompleteExchangeRequested).toHaveBeenCalledTimes(1);
});
});
describe("when Account is from a different family than the transaction", () => {
// Given
const expectedResult = "Function called";
beforeEach(() => uiNavigation.mockResolvedValueOnce(expectedResult));
it("returns an error", async () => {
// Given
const fromAccount = createFixtureAccount("17");
context.accounts = [...context.accounts, fromAccount];
const rawPlatformTransaction = createRawBitcoinTransaction();
const completeExchangeRequest = {
provider: "provider",
fromAccountId: "js:2:ethereum:0x017:",
toAccountId: "js:2:ethereum:0x042:",
transaction: rawPlatformTransaction,
binaryPayload: "binaryPayload",
signature: "signature",
feesStrategy: "feeStrategy",
exchangeType: 8,
};
// When
await expect(async () => {
await completeExchangeLogic(context, completeExchangeRequest, uiNavigation);
}).rejects.toThrow("Account and transaction must be from the same family");
// Then
expect(uiNavigation).toHaveBeenCalledTimes(0);
});
});
});
describe("broadcastTransactionLogic", () => {
// Given
const mockplatformBroadcastFail = jest.fn();
const context = createContextContainingAccountId(
{
platformBroadcastFail: mockplatformBroadcastFail,
},
"11",
"12",
);
const uiNavigation = jest.fn();
beforeEach(() => {
mockplatformBroadcastFail.mockClear();
uiNavigation.mockClear();
mockedDeserializePlatformSignedTransaction.mockClear();
});
describe("when nominal case", () => {
// Given
const accountId = "js:2:ethereum:0x012:";
const rawSignedTransaction = createSignedOperationRaw();
it("calls uiNavigation callback with a signedOperation", async () => {
// Given
const expectedResult = "Function called";
const signedOperation = createSignedOperation();
mockedDeserializePlatformSignedTransaction.mockReturnValueOnce(signedOperation);
uiNavigation.mockResolvedValueOnce(expectedResult);
// When
const result = await broadcastTransactionLogic(
context,
accountId,
rawSignedTransaction,
uiNavigation,
);
// Then
expect(uiNavigation).toHaveBeenCalledTimes(1);
expect(uiNavigation.mock.calls[0][2]).toEqual(signedOperation);
expect(result).toEqual(expectedResult);
});
it("calls the tracking for success", async () => {
// When
await broadcastTransactionLogic(context, accountId, rawSignedTransaction, uiNavigation);
// Then
expect(mockplatformBroadcastFail).toHaveBeenCalledTimes(0);
});
});
describe("when account cannot be found", () => {
// Given
const nonFoundAccountId = "js:2:ethereum:0x010:";
const rawSignedTransaction = createSignedOperationRaw();
it("returns an error", async () => {
// Given
const expectedResult = "Function called";
const signedOperation = createSignedOperation();
mockedDeserializePlatformSignedTransaction.mockReturnValueOnce(signedOperation);
uiNavigation.mockResolvedValueOnce(expectedResult);
// When
await expect(async () => {
await broadcastTransactionLogic(
context,
nonFoundAccountId,
rawSignedTransaction,
uiNavigation,
);
}).rejects.toThrow("Account required");
// Then
expect(uiNavigation).toHaveBeenCalledTimes(0);
});
it("calls the tracking for error", async () => {
// When
await expect(async () => {
await broadcastTransactionLogic(
context,
nonFoundAccountId,
rawSignedTransaction,
uiNavigation,
);
}).rejects.toThrow();
// Then
expect(mockplatformBroadcastFail).toHaveBeenCalledTimes(1);
});
});
});
const mockedPrepareMessageToSign = jest.mocked(signMessage.prepareMessageToSign);
const mockedAccountToPlatformAccount = jest.mocked(converters.accountToPlatformAccount);
const mockedDeserializePlatformSignedTransaction = jest.mocked(
serializers.deserializePlatformSignedTransaction,
);
describe("signMessageLogic", () => {
// Given
const mockPlatformSignMessageRequested = jest.fn();
const mockPlatformSignMessageFail = jest.fn();
const context = createContextContainingAccountId(
{
platformSignMessageRequested: mockPlatformSignMessageRequested,
platformSignMessageFail: mockPlatformSignMessageFail,
},
"11",
"12",
);
const uiNavigation = jest.fn();
beforeEach(() => {
mockPlatformSignMessageRequested.mockClear();
mockPlatformSignMessageFail.mockClear();
uiNavigation.mockClear();
mockedPrepareMessageToSign.mockClear();
});
describe("when nominal case", () => {
// Given
const accountId = "js:2:ethereum:0x012:";
const messageToSign = "Message to sign";
it("calls uiNavigation callback with a signedOperation", async () => {
// Given
const expectedResult = "Function called";
const formattedMessage = createMessageData();
mockedPrepareMessageToSign.mockReturnValueOnce(formattedMessage);
uiNavigation.mockResolvedValueOnce(expectedResult);
// When
const result = await signMessageLogic(context, accountId, messageToSign, uiNavigation);
// Then
expect(uiNavigation).toHaveBeenCalledTimes(1);
expect(uiNavigation.mock.calls[0][1]).toEqual(formattedMessage);
expect(result).toEqual(expectedResult);
});
it("calls the tracking for success", async () => {
// When
await signMessageLogic(context, accountId, messageToSign, uiNavigation);
// Then
expect(mockPlatformSignMessageRequested).toHaveBeenCalledTimes(1);
expect(mockPlatformSignMessageFail).toHaveBeenCalledTimes(0);
});
});
describe("when account cannot be found", () => {
// Given
const nonFoundAccountId = "js:2:ethereum:0x010:";
const messageToSign = "Message to sign";
it("returns an error", async () => {
// When
await expect(async () => {
await signMessageLogic(context, nonFoundAccountId, messageToSign, uiNavigation);
}).rejects.toThrow(`account with id "${nonFoundAccountId}" not found`);
// Then
expect(uiNavigation).toHaveBeenCalledTimes(0);
});
it("calls the tracking for error", async () => {
// When
await expect(async () => {
await signMessageLogic(context, nonFoundAccountId, messageToSign, uiNavigation);
}).rejects.toThrow();
// Then
expect(mockPlatformSignMessageRequested).toHaveBeenCalledTimes(1);
expect(mockPlatformSignMessageFail).toHaveBeenCalledTimes(1);
});
});
describe("when account found is not of type 'Account'", () => {
// Given
const tokenAccountId = "15";
const messageToSign = "Message to sign";
context.accounts = [createTokenAccount(tokenAccountId), ...context.accounts];
it("returns an error", async () => {
// When
await expect(async () => {
await signMessageLogic(context, tokenAccountId, messageToSign, uiNavigation);
}).rejects.toThrow("account provided should be the main one");
// Then
expect(uiNavigation).toHaveBeenCalledTimes(0);
});
it("calls the tracking for error", async () => {
// When
await expect(async () => {
await signMessageLogic(context, tokenAccountId, messageToSign, uiNavigation);
}).rejects.toThrow();
// Then
expect(mockPlatformSignMessageRequested).toHaveBeenCalledTimes(1);
expect(mockPlatformSignMessageFail).toHaveBeenCalledTimes(1);
});
});
describe("when inner call prepareMessageToSign raise an error", () => {
// Given
const accountId = "js:2:ethereum:0x012:";
const messageToSign = "Message to sign";
it("returns an error", async () => {
// Given
mockedPrepareMessageToSign.mockImplementationOnce(() => {
throw new Error("Some error");
});
// When
await expect(async () => {
await signMessageLogic(context, accountId, messageToSign, uiNavigation);
}).rejects.toThrow("Some error");
// Then
expect(uiNavigation).toHaveBeenCalledTimes(0);
});
it("calls the tracking for error", async () => {
// Given
mockedPrepareMessageToSign.mockImplementationOnce(() => {
throw new Error("Some error");
});
// When
await expect(async () => {
await signMessageLogic(context, accountId, messageToSign, uiNavigation);
}).rejects.toThrow();
// Then
expect(mockPlatformSignMessageRequested).toHaveBeenCalledTimes(1);
expect(mockPlatformSignMessageFail).toHaveBeenCalledTimes(1);
});
});
});
function createAppManifest(id = "1"): LiveAppManifest {
return {
id,
private: false,
name: "New App Manifest",
url: "https://www.ledger.com",
homepageUrl: "https://www.ledger.com",
supportUrl: "https://www.ledger.com",
icon: null,
platforms: ["ios", "android", "desktop"],
apiVersion: "1.0.0",
manifestVersion: "1.0.0",
branch: "debug",
params: undefined,
categories: [],
currencies: "*",
content: {
shortDescription: {
en: "short description",
},
description: {
en: "description",
},
},
permissions: [],
domains: [],
visibility: "complete",
};
}
function createContextContainingAccountId(
tracking: Partial<TrackingAPI>,
...accountIds: string[]
): WebPlatformContext {
return {
manifest: createAppManifest(),
accounts: [...accountIds.map(val => createFixtureAccount(val)), createFixtureAccount()],
tracking: tracking as TrackingAPI,
};
}
function createSignedOperation(): SignedOperation {
const operation = {
id: "42",
hash: "hashed",
type: "IN" as OperationType,
value: new BigNumber(0),
fee: new BigNumber(0),
senders: [],
recipients: [],
blockHeight: null,
blockHash: null,
accountId: "14",
date: new Date(),
extra: {},
};
return {
operation,
signature: "Signature",
};
}
function createSignedOperationRaw(): SignedOperationRaw {
const rawOperation = {
id: "12",
hash: "123456",
type: "CREATE" as OperationType,
value: "0",
fee: "0",
senders: [],
recipients: [],
blockHeight: null,
blockHash: null,
accountId: "12",
date: "01/01/1970",
extra: {},
};
return {
operation: rawOperation,
signature: "Signature",
};
}
function createPlatformAccount() {
return {
id: "12",
name: "",
address: "",
currency: "",
balance: new BigNumber(0),
spendableBalance: new BigNumber(0),
blockHeight: 0,
lastSyncDate: new Date(),
};
}
function createMessageData() {
return {
account: createFixtureAccount("17"),
message: "default message",
};
}
function createTokenAccount(id = "32"): TokenAccount {
return {
type: "TokenAccount",
id,
parentId: "whatever",
token: createTokenCurrency(),
balance: new BigNumber(0),
spendableBalance: new BigNumber(0),
creationDate: new Date(),
operationsCount: 0,
operations: [],
pendingOperations: [],
balanceHistoryCache: {
WEEK: { latestDate: null, balances: [] },
HOUR: { latestDate: null, balances: [] },
DAY: { latestDate: null, balances: [] },
},
swapHistory: [],
};
}
function createTokenCurrency(): TokenCurrency {
return {
type: "TokenCurrency",
id: "3",
contractAddress: "",
parentCurrency: createFixtureCryptoCurrency("eth"),
tokenType: "",
//-- CurrencyCommon
name: "",
ticker: "",
units: [],
};
}
function createRawEtherumTransaction(): RawPlatformTransaction {
return {
family: "ethereum" as any,
amount: "1000000000",
recipient: "0x0123456",
nonce: 8,
data: "Some data...",
gasPrice: "700000",
gasLimit: "1200000",
};
}
function createRawBitcoinTransaction(): RawPlatformTransaction {
return {
family: "bitcoin" as any,
amount: "1000000000",
recipient: "0x0123456",
feePerByte: "900000",
};
}