UNPKG

@ledgerhq/live-common

Version:
1,390 lines (1,178 loc) • 46.1 kB
import { bitcoinFamilyAccountGetAddressLogic, bitcoinFamilyAccountGetAddressesLogic, bitcoinFamilyAccountGetPublicKeyLogic, bitcoinFamilyAccountGetXPubLogic, broadcastTransactionLogic, completeExchangeLogic, protectStorageLogic, receiveOnAccountLogic, signMessageLogic, WalletAPIContext, } from "./logic"; import { AppManifest, WalletAPITransaction } from "./types"; import { createFixtureAccount, createFixtureCryptoCurrency, createFixtureTokenAccount, } from "../mock/fixtures/cryptoCurrencies"; import { Transaction as EvmTransaction } from "@ledgerhq/coin-evm/types/index"; import { OperationType, SignedOperation, TokenAccount } from "@ledgerhq/types-live"; import { getWalletAccount } from "@ledgerhq/coin-bitcoin/wallet-btc/index"; import BigNumber from "bignumber.js"; import * as converters from "./converters"; import * as signMessage from "../hw/signMessage/index"; jest.mock("./converters", () => ({ ...jest.requireActual("./converters"), getAccountIdFromWalletAccountId: jest.fn(), accountToWalletAPIAccount: jest.fn(), })); jest.mock("../hw/signMessage/index", () => ({ ...jest.requireActual("../hw/signMessage/index"), prepareMessageToSign: jest.fn(), })); import { CryptoCurrency, TokenCurrency } from "@ledgerhq/types-cryptoassets"; import { TrackingAPI } from "./tracking"; import { cryptocurrenciesById } from "@ledgerhq/cryptoassets/currencies"; import { setSupportedCurrencies } from "../currencies"; import { initialState as walletState } from "@ledgerhq/live-wallet/store"; import { setupMockCryptoAssetsStore } from "@ledgerhq/cryptoassets/cal-client/test-helpers"; // Setup mock store for unit tests setupMockCryptoAssetsStore(); // Global mocked functions const mockedGetAccountIdFromWalletAccountId = jest.mocked( converters.getAccountIdFromWalletAccountId, ); const mockedAccountToWalletAPIAccount = jest.mocked(converters.accountToWalletAPIAccount); const mockedPrepareMessageToSign = jest.mocked(signMessage.prepareMessageToSign); const mockedGetWalletAccount = jest.mocked(getWalletAccount); describe("receiveOnAccountLogic", () => { // Given const mockWalletAPIReceiveRequested = jest.fn(); const mockWalletAPIReceiveFail = jest.fn(); const context = createContextContainingAccountId({ tracking: { receiveRequested: mockWalletAPIReceiveRequested, receiveFail: mockWalletAPIReceiveFail, }, accountsParams: [{ id: "11" }, { id: "12" }], }); const uiNavigation = jest.fn(); beforeEach(() => { mockWalletAPIReceiveRequested.mockClear(); mockWalletAPIReceiveFail.mockClear(); uiNavigation.mockClear(); mockedGetAccountIdFromWalletAccountId.mockClear(); mockedAccountToWalletAPIAccount.mockClear(); // Default implementation for accountToWalletAPIAccount mockedAccountToWalletAPIAccount.mockImplementation((_walletState, _account, _parentAccount) => { return createWalletAPIAccount(); }); }); describe("when nominal case", () => { // Given const accountId = "js:2:ethereum:0x012:"; const walletAccountId = "806ea21d-f5f0-425a-add3-39d4b78209f1"; const expectedResult = "Function called"; beforeEach(() => { uiNavigation.mockResolvedValueOnce(expectedResult); mockedGetAccountIdFromWalletAccountId.mockReturnValueOnce(accountId); }); it("calls uiNavigation callback with an accountAddress", async () => { // Given const convertedAccount = { ...createWalletAPIAccount(), address: "Converted address", }; mockedAccountToWalletAPIAccount.mockReturnValueOnce(convertedAccount); // When const result = await receiveOnAccountLogic( walletState, context, walletAccountId, 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 () => { // When await receiveOnAccountLogic(walletState, context, walletAccountId, uiNavigation); // Then expect(mockWalletAPIReceiveRequested).toHaveBeenCalledTimes(1); expect(mockWalletAPIReceiveFail).toHaveBeenCalledTimes(0); }); }); describe("when account cannot be found", () => { // Given const walletAccountId = "806ea21d-f5f0-425a-add3-39d4b78209f1"; beforeEach(() => { mockedGetAccountIdFromWalletAccountId.mockReturnValueOnce(undefined); }); it("returns an error", async () => { // When await expect(async () => { await receiveOnAccountLogic(walletState, context, walletAccountId, uiNavigation); }).rejects.toThrow(`accountId ${walletAccountId} unknown`); // Then expect(uiNavigation).toHaveBeenCalledTimes(0); }); it("calls the tracking for error", async () => { // When await expect(async () => { await receiveOnAccountLogic(walletState, context, walletAccountId, uiNavigation); }).rejects.toThrow(); // Then expect(mockWalletAPIReceiveRequested).toHaveBeenCalledTimes(1); expect(mockWalletAPIReceiveFail).toHaveBeenCalledTimes(1); }); }); }); function createWalletAPIEtherumTransaction(): WalletAPITransaction { return { family: "ethereum", amount: BigNumber(1000000000), recipient: "0x0123456", nonce: 8, data: Buffer.from("Some data..."), gasPrice: BigNumber(700000), gasLimit: BigNumber(1200000), }; } function createWalletAPIBitcoinTransaction(): WalletAPITransaction { return { family: "bitcoin", amount: BigNumber(1000000000), recipient: "0x0123456", feePerByte: BigNumber(900000), }; } describe("completeExchangeLogic", () => { // Given const mockWalletAPICompleteExchangeRequested = jest.fn(); const context = createContextContainingAccountId({ tracking: { completeExchangeRequested: mockWalletAPICompleteExchangeRequested, }, accountsParams: [{ id: "11" }, { id: "12" }], }); const uiNavigation = jest.fn(); beforeAll(() => { setSupportedCurrencies(["bitcoin", "ethereum"]); }); afterAll(() => { setSupportedCurrencies([]); }); beforeEach(() => { mockWalletAPICompleteExchangeRequested.mockClear(); uiNavigation.mockClear(); mockedGetAccountIdFromWalletAccountId.mockClear(); }); describe("when nominal case", () => { // Given const expectedResult = "Function called"; beforeEach(() => uiNavigation.mockResolvedValueOnce(expectedResult)); it("calls uiNavigation callback (token)", async () => { // Given const fromAccountId = "js:2:ethereum:0x16:+ethereum%2Ferc20%2Fusd_tether__erc20_"; const toAccountId = "js:2:ethereum:0x042:"; const fromAccount = createFixtureTokenAccount("16"); const fromParentAccount = createFixtureAccount("16"); context.accounts = [...context.accounts, fromAccount, fromParentAccount]; const transaction = createWalletAPIEtherumTransaction(); const completeExchangeRequest = { provider: "provider", fromAccountId: "806ea21d-f5f0-425a-add3-39d4b78209f1", toAccountId: "806ea21d-f5f0-425a-add3-39d4b78209f2", transaction, binaryPayload: "binaryPayload", signature: "signature", feesStrategy: "medium", exchangeType: 8, }; const expectedTransaction: EvmTransaction = { family: "evm", amount: new BigNumber("1000000000"), subAccountId: fromAccountId, recipient: "0x0123456", nonce: 8, data: Buffer.from("Some data..."), 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, }; mockedGetAccountIdFromWalletAccountId .mockReturnValueOnce(fromAccountId) .mockReturnValueOnce(toAccountId); // 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, swapId: undefined, rate: undefined, }); expect(result).toEqual(expectedResult); }); it("calls uiNavigation callback (coin)", async () => { // Given const fromAccountId = "js:2:ethereum:0x017:"; const toAccountId = "js:2:ethereum:0x042:"; const fromAccount = createFixtureAccount("17"); context.accounts = [...context.accounts, fromAccount]; const transaction = createWalletAPIEtherumTransaction(); const completeExchangeRequest = { provider: "provider", fromAccountId: "806ea21d-f5f0-425a-add3-39d4b78209f1", toAccountId: "806ea21d-f5f0-425a-add3-39d4b78209f2", transaction, 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..."), 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, }; mockedGetAccountIdFromWalletAccountId .mockReturnValueOnce(fromAccountId) .mockReturnValueOnce(toAccountId); // 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, swapId: undefined, rate: undefined, }); expect(result).toEqual(expectedResult); }); it.each(["slow", "medium", "fast", "custom"])( "calls uiNavigation with a transaction that has the %s feeStrategy", async expectedFeeStrategy => { // Given const fromAccountId = "js:2:ethereum:0x017:"; const toAccountId = "js:2:ethereum:0x042:"; const fromAccount = createFixtureAccount("17"); context.accounts = [...context.accounts, fromAccount]; const transaction = createWalletAPIEtherumTransaction(); const completeExchangeRequest = { provider: "provider", fromAccountId: "806ea21d-f5f0-425a-add3-39d4b78209f1", toAccountId: "806ea21d-f5f0-425a-add3-39d4b78209f2", transaction, binaryPayload: "binaryPayload", signature: "signature", feesStrategy: expectedFeeStrategy, exchangeType: 8, swapId: "1234", rate: 1, }; mockedGetAccountIdFromWalletAccountId .mockReturnValueOnce(fromAccountId) .mockReturnValueOnce(toAccountId); // 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 fromAccountId = "js:2:ethereum:0x012:"; const toAccountId = "js:2:ethereum:0x042:"; const completeExchangeRequest = { provider: "provider", fromAccountId: "806ea21d-f5f0-425a-add3-39d4b78209f1", toAccountId: "806ea21d-f5f0-425a-add3-39d4b78209f2", transaction: createWalletAPIEtherumTransaction(), binaryPayload: "binaryPayload", signature: "signature", feesStrategy: "medium", exchangeType: 8, swapId: "1234", rate: 1, }; mockedGetAccountIdFromWalletAccountId .mockReturnValueOnce(fromAccountId) .mockReturnValueOnce(toAccountId); // When await completeExchangeLogic(context, completeExchangeRequest, uiNavigation); // Then expect(mockWalletAPICompleteExchangeRequested).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 fromAccountId = "js:2:ethereum:0x012:"; const toAccountId = "js:2:ethereum:0x042:"; const fromAccount = createFixtureAccount("17"); context.accounts = [...context.accounts, fromAccount]; const transaction = createWalletAPIBitcoinTransaction(); const completeExchangeRequest = { provider: "provider", fromAccountId: "806ea21d-f5f0-425a-add3-39d4b78209f1", toAccountId: "806ea21d-f5f0-425a-add3-39d4b78209f2", transaction, binaryPayload: "binaryPayload", signature: "signature", feesStrategy: "medium", exchangeType: 8, swapId: "1234", rate: 1, }; mockedGetAccountIdFromWalletAccountId .mockReturnValueOnce(fromAccountId) .mockReturnValueOnce(toAccountId); // 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 mockWalletAPIBroadcastFail = jest.fn(); const context = createContextContainingAccountId({ tracking: { broadcastFail: mockWalletAPIBroadcastFail, }, accountsParams: [{ id: "11" }, { id: "12" }], }); const uiNavigation = jest.fn(); beforeEach(() => { mockWalletAPIBroadcastFail.mockClear(); uiNavigation.mockClear(); mockedGetAccountIdFromWalletAccountId.mockClear(); }); describe("when nominal case", () => { // Given const accountId = "js:2:ethereum:0x012:"; const walletAccountId = "806ea21d-f5f0-425a-add3-39d4b78209f1"; const signedTransaction = createSignedOperation(); beforeEach(() => { mockedGetAccountIdFromWalletAccountId.mockReturnValueOnce(accountId); }); it("calls uiNavigation callback with a signedOperation", async () => { // Given const expectedResult = "Function called"; // const signedOperation = createSignedOperation(); // jest // .spyOn(serializers, "deserializeWalletAPISignedTransaction") // .mockReturnValueOnce(signedOperation); uiNavigation.mockResolvedValueOnce(expectedResult); // When const result = await broadcastTransactionLogic( context, walletAccountId, signedTransaction, 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, walletAccountId, signedTransaction, uiNavigation); // Then expect(mockWalletAPIBroadcastFail).toHaveBeenCalledTimes(0); }); }); describe("when account cannot be found", () => { // Given const nonFoundAccountId = "js:2:ethereum:0x010:"; const walletAccountId = "806ea21d-f5f0-425a-add3-39d4b78209f1"; const signedTransaction = createSignedOperation(); beforeEach(() => { mockedGetAccountIdFromWalletAccountId.mockReturnValueOnce(nonFoundAccountId); }); it("returns an error", async () => { // Given const expectedResult = "Function called"; // const signedOperation = createSignedOperation(); // jest // .spyOn(serializers, "deserializeWalletAPISignedTransaction") // .mockReturnValueOnce(signedOperation); uiNavigation.mockResolvedValueOnce(expectedResult); // When await expect(async () => { await broadcastTransactionLogic(context, walletAccountId, signedTransaction, uiNavigation); }).rejects.toThrow("Account required"); // Then expect(uiNavigation).toHaveBeenCalledTimes(0); }); it("calls the tracking for error", async () => { // When await expect(async () => { await broadcastTransactionLogic(context, walletAccountId, signedTransaction, uiNavigation); }).rejects.toThrow(); // Then expect(mockWalletAPIBroadcastFail).toHaveBeenCalledTimes(1); }); }); }); describe("signMessageLogic", () => { // Given const mockWalletAPISignMessageRequested = jest.fn(); const mockWalletAPISignMessageFail = jest.fn(); const context = createContextContainingAccountId({ tracking: { signMessageRequested: mockWalletAPISignMessageRequested, signMessageFail: mockWalletAPISignMessageFail, }, accountsParams: [{ id: "11" }, { id: "12" }], }); const uiNavigation = jest.fn(); beforeEach(() => { mockWalletAPISignMessageRequested.mockClear(); mockWalletAPISignMessageFail.mockClear(); uiNavigation.mockClear(); mockedGetAccountIdFromWalletAccountId.mockClear(); }); describe("when nominal case", () => { // Given const accountId = "js:2:ethereum:0x012:"; const messageToSign = "Message to sign"; const walletAccountId = "806ea21d-f5f0-425a-add3-39d4b78209f1"; beforeEach(() => { mockedPrepareMessageToSign.mockClear(); mockedGetAccountIdFromWalletAccountId.mockReturnValueOnce(accountId); }); 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, walletAccountId, 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(mockWalletAPISignMessageRequested).toHaveBeenCalledTimes(1); expect(mockWalletAPISignMessageFail).toHaveBeenCalledTimes(0); }); }); describe("when account cannot be found", () => { // Given const nonFoundAccountId = "js:2:ethereum:0x010:"; const messageToSign = "Message to sign"; const walletAccountId = "806ea21d-f5f0-425a-add3-39d4b78209f1"; beforeEach(() => { mockedGetAccountIdFromWalletAccountId.mockReturnValueOnce(nonFoundAccountId); }); it("returns an error", async () => { // When await expect(async () => { await signMessageLogic(context, walletAccountId, messageToSign, uiNavigation); }).rejects.toThrow("account not found"); // Then expect(uiNavigation).toHaveBeenCalledTimes(0); }); it("calls the tracking for error", async () => { // When await expect(async () => { await signMessageLogic(context, walletAccountId, messageToSign, uiNavigation); }).rejects.toThrow(); // Then expect(mockWalletAPISignMessageRequested).toHaveBeenCalledTimes(1); expect(mockWalletAPISignMessageFail).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]; const walletAccountId = "806ea21d-f5f0-425a-add3-39d4b78209f1"; beforeEach(() => { mockedGetAccountIdFromWalletAccountId.mockReturnValueOnce(tokenAccountId); }); it("returns an error", async () => { // When await expect(async () => { await signMessageLogic(context, walletAccountId, 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, walletAccountId, messageToSign, uiNavigation); }).rejects.toThrow(); // Then expect(mockWalletAPISignMessageRequested).toHaveBeenCalledTimes(1); expect(mockWalletAPISignMessageFail).toHaveBeenCalledTimes(1); }); }); describe("when inner call prepareMessageToSign raise an error", () => { // Given const accountId = "js:2:ethereum:0x012:"; const messageToSign = "Message to sign"; const walletAccountId = "806ea21d-f5f0-425a-add3-39d4b78209f1"; beforeEach(() => { mockedPrepareMessageToSign.mockClear(); mockedGetAccountIdFromWalletAccountId.mockReturnValueOnce(accountId); }); it("returns an error", async () => { // Given mockedPrepareMessageToSign.mockImplementationOnce(() => { throw new Error("Some error"); }); // When await expect(async () => { await signMessageLogic(context, walletAccountId, 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, walletAccountId, messageToSign, uiNavigation); }).rejects.toThrow(); // Then expect(mockWalletAPISignMessageRequested).toHaveBeenCalledTimes(1); expect(mockWalletAPISignMessageFail).toHaveBeenCalledTimes(1); }); }); }); jest.mock("@ledgerhq/coin-bitcoin/lib/wallet-btc/index", () => ({ ...jest.requireActual("@ledgerhq/coin-bitcoin/lib/wallet-btc/index"), getWalletAccount: jest.fn().mockReturnValue({ params: { path: "84'/0'", index: 0 }, xpub: { derivationMode: "native_segwit", xpub: "xpub", crypto: { getAddress: jest .fn() .mockImplementation((_mode, _xpub, account, index) => Promise.resolve(account === 0 && index === 1 ? "0x01" : `addr_${account}_${index}`), ), getPubkeyAt: jest.fn().mockReturnValue(Buffer.from("testPubkey")), }, getXpubAddresses: jest.fn().mockResolvedValue([ { account: 0, index: 0, address: "bc1qfirst" }, { account: 0, index: 1, address: "bc1qsecond" }, { account: 1, index: 0, address: "bc1qchange0" }, ]), storage: { getAddressUnspentUtxos: jest.fn().mockReturnValue([]), }, }, }), })); describe("bitcoinFamilyAccountGetAddressLogic", () => { // Given const mockBitcoinFamilyAccountAddressRequested = jest.fn(); const mockBitcoinFamilyAccountAddressFail = jest.fn(); const mockBitcoinFamilyAccountAddressSuccess = jest.fn(); const bitcoinCrypto = cryptocurrenciesById["bitcoin"]; const context = createContextContainingAccountId({ tracking: { bitcoinFamilyAccountAddressRequested: mockBitcoinFamilyAccountAddressRequested, bitcoinFamilyAccountAddressFail: mockBitcoinFamilyAccountAddressFail, bitcoinFamilyAccountAddressSuccess: mockBitcoinFamilyAccountAddressSuccess, }, accountsParams: [{ id: "11" }, { id: "12" }, { id: "13", currency: bitcoinCrypto }], }); beforeEach(() => { mockBitcoinFamilyAccountAddressRequested.mockClear(); mockBitcoinFamilyAccountAddressFail.mockClear(); mockBitcoinFamilyAccountAddressSuccess.mockClear(); mockedGetAccountIdFromWalletAccountId.mockClear(); }); const walletAccountId = "806ea21d-f5f0-425a-add3-39d4b78209f1"; it.each([ { desc: "receive unkown accountId", accountId: undefined, errorMessage: `accountId ${walletAccountId} unknown`, }, { desc: "account not found", accountId: "js:2:ethereum:0x010:", errorMessage: "account not found", }, { desc: "account is not a bitcoin family account", accountId: "js:2:ethereum:0x012:", errorMessage: "not a bitcoin family account", }, ])("returns an error when $desc", async ({ accountId, errorMessage }) => { // Given mockedGetAccountIdFromWalletAccountId.mockReturnValueOnce(accountId); // When await expect(async () => { await bitcoinFamilyAccountGetAddressLogic(context, walletAccountId); }).rejects.toThrow(errorMessage); // Then expect(mockBitcoinFamilyAccountAddressRequested).toHaveBeenCalledTimes(1); expect(mockBitcoinFamilyAccountAddressFail).toHaveBeenCalledTimes(1); expect(mockBitcoinFamilyAccountAddressSuccess).toHaveBeenCalledTimes(0); }); it("should return the address", async () => { // Given const accountId = "js:2:bitcoin:0x013:"; mockedGetAccountIdFromWalletAccountId.mockReturnValueOnce(accountId); // When const result = await bitcoinFamilyAccountGetAddressLogic(context, walletAccountId); // Then expect(result).toEqual("0x01"); expect(mockBitcoinFamilyAccountAddressRequested).toHaveBeenCalledTimes(1); expect(mockBitcoinFamilyAccountAddressFail).toHaveBeenCalledTimes(0); expect(mockBitcoinFamilyAccountAddressSuccess).toHaveBeenCalledTimes(1); }); it("should return the address with a derivationPath", async () => { // Given const accountId = "js:2:bitcoin:0x013:"; mockedGetAccountIdFromWalletAccountId.mockReturnValueOnce(accountId); // When const result = await bitcoinFamilyAccountGetAddressLogic(context, walletAccountId, "0/1"); // Then expect(result).toEqual("0x01"); expect(mockBitcoinFamilyAccountAddressRequested).toHaveBeenCalledTimes(1); expect(mockBitcoinFamilyAccountAddressFail).toHaveBeenCalledTimes(0); expect(mockBitcoinFamilyAccountAddressSuccess).toHaveBeenCalledTimes(1); }); }); describe("bitcoinFamilyAccountGetPublicKeyLogic", () => { // Given const mockBitcoinFamilyAccountPublicKeyRequested = jest.fn(); const mockBitcoinFamilyAccountPublicKeyFail = jest.fn(); const mockBitcoinFamilyAccountPublicKeySuccess = jest.fn(); const bitcoinCrypto = cryptocurrenciesById["bitcoin"]; const context = createContextContainingAccountId({ tracking: { bitcoinFamilyAccountPublicKeyRequested: mockBitcoinFamilyAccountPublicKeyRequested, bitcoinFamilyAccountPublicKeyFail: mockBitcoinFamilyAccountPublicKeyFail, bitcoinFamilyAccountPublicKeySuccess: mockBitcoinFamilyAccountPublicKeySuccess, }, accountsParams: [{ id: "11" }, { id: "12" }, { id: "13", currency: bitcoinCrypto }], }); beforeEach(() => { mockBitcoinFamilyAccountPublicKeyRequested.mockClear(); mockBitcoinFamilyAccountPublicKeyFail.mockClear(); mockBitcoinFamilyAccountPublicKeySuccess.mockClear(); mockedGetAccountIdFromWalletAccountId.mockClear(); }); const walletAccountId = "806ea21d-f5f0-425a-add3-39d4b78209f1"; it.each([ { desc: "receive unkown accountId", accountId: undefined, errorMessage: `accountId ${walletAccountId} unknown`, }, { desc: "account not found", accountId: "js:2:ethereum:0x010:", errorMessage: "account not found", }, { desc: "account is not a bitcoin family account", accountId: "js:2:ethereum:0x012:", errorMessage: "not a bitcoin family account", }, ])("returns an error when $desc", async ({ accountId, errorMessage }) => { // Given mockedGetAccountIdFromWalletAccountId.mockReturnValueOnce(accountId); // When await expect(async () => { await bitcoinFamilyAccountGetPublicKeyLogic(context, walletAccountId); }).rejects.toThrow(errorMessage); // Then expect(mockBitcoinFamilyAccountPublicKeyRequested).toHaveBeenCalledTimes(1); expect(mockBitcoinFamilyAccountPublicKeyFail).toHaveBeenCalledTimes(1); expect(mockBitcoinFamilyAccountPublicKeySuccess).toHaveBeenCalledTimes(0); }); it("should return the PublicKey", async () => { // Given const accountId = "js:2:bitcoin:0x013:"; mockedGetAccountIdFromWalletAccountId.mockReturnValueOnce(accountId); // When const result = await bitcoinFamilyAccountGetPublicKeyLogic(context, walletAccountId); // Then expect(result).toEqual(Buffer.from("testPubkey").toString("hex")); expect(mockBitcoinFamilyAccountPublicKeyRequested).toHaveBeenCalledTimes(1); expect(mockBitcoinFamilyAccountPublicKeyFail).toHaveBeenCalledTimes(0); expect(mockBitcoinFamilyAccountPublicKeySuccess).toHaveBeenCalledTimes(1); }); it("should return the PublicKey with a derivationPath", async () => { // Given const accountId = "js:2:bitcoin:0x013:"; mockedGetAccountIdFromWalletAccountId.mockReturnValueOnce(accountId); // When const result = await bitcoinFamilyAccountGetPublicKeyLogic(context, walletAccountId, "0/1"); // Then expect(result).toEqual(Buffer.from("testPubkey").toString("hex")); expect(mockBitcoinFamilyAccountPublicKeyRequested).toHaveBeenCalledTimes(1); expect(mockBitcoinFamilyAccountPublicKeyFail).toHaveBeenCalledTimes(0); expect(mockBitcoinFamilyAccountPublicKeySuccess).toHaveBeenCalledTimes(1); }); }); describe("bitcoinFamilyAccountGetAddressesLogic", () => { const mockBitcoinFamilyAccountAddressesRequested = jest.fn(); const mockBitcoinFamilyAccountAddressesFail = jest.fn(); const mockBitcoinFamilyAccountAddressesSuccess = jest.fn(); const bitcoinCrypto = cryptocurrenciesById["bitcoin"]; const context = createContextContainingAccountId({ tracking: { bitcoinFamilyAccountAddressesRequested: mockBitcoinFamilyAccountAddressesRequested, bitcoinFamilyAccountAddressesFail: mockBitcoinFamilyAccountAddressesFail, bitcoinFamilyAccountAddressesSuccess: mockBitcoinFamilyAccountAddressesSuccess, }, accountsParams: [{ id: "11" }, { id: "12" }, { id: "13", currency: bitcoinCrypto }], }); const walletAccountId = "806ea21d-f5f0-425a-add3-39d4b78209f1"; beforeEach(() => { mockBitcoinFamilyAccountAddressesRequested.mockClear(); mockBitcoinFamilyAccountAddressesFail.mockClear(); mockBitcoinFamilyAccountAddressesSuccess.mockClear(); mockedGetAccountIdFromWalletAccountId.mockClear(); }); it("returns empty array when intentions does not include payment", async () => { const accountId = "js:2:bitcoin:0x013:"; mockedGetAccountIdFromWalletAccountId.mockReturnValueOnce(accountId); const result = await bitcoinFamilyAccountGetAddressesLogic(context, walletAccountId, [ "ordinal", ]); expect(result).toEqual([]); expect(mockBitcoinFamilyAccountAddressesRequested).toHaveBeenCalledTimes(1); expect(mockBitcoinFamilyAccountAddressesSuccess).toHaveBeenCalledTimes(1); expect(mockBitcoinFamilyAccountAddressesFail).toHaveBeenCalledTimes(0); }); it.each([ { desc: "unknown accountId", accountId: undefined, errorMessage: `accountId ${walletAccountId} unknown`, }, { desc: "account not found", accountId: "js:2:ethereum:0x010:", errorMessage: "account not found", }, { desc: "account is not a bitcoin family account", accountId: "js:2:ethereum:0x012:", errorMessage: "account requested is not a bitcoin family account", }, ])("rejects when $desc", async ({ accountId, errorMessage }) => { mockedGetAccountIdFromWalletAccountId.mockReturnValueOnce(accountId); await expect(bitcoinFamilyAccountGetAddressesLogic(context, walletAccountId)).rejects.toThrow( errorMessage, ); expect(mockBitcoinFamilyAccountAddressesRequested).toHaveBeenCalledTimes(1); expect(mockBitcoinFamilyAccountAddressesFail).toHaveBeenCalledTimes(1); expect(mockBitcoinFamilyAccountAddressesSuccess).toHaveBeenCalledTimes(0); }); it("returns addresses with first external address and unused receive and change addresses", async () => { const accountId = "js:2:bitcoin:0x013:"; mockedGetAccountIdFromWalletAccountId.mockReturnValueOnce(accountId); const result = await bitcoinFamilyAccountGetAddressesLogic(context, walletAccountId); expect(mockBitcoinFamilyAccountAddressesRequested).toHaveBeenCalledTimes(1); expect(mockBitcoinFamilyAccountAddressesFail).toHaveBeenCalledTimes(0); expect(mockBitcoinFamilyAccountAddressesSuccess).toHaveBeenCalledTimes(1); expect(result).toBeInstanceOf(Array); expect(result.length).toBeGreaterThan(0); const firstExternal = result.find((r: { path?: string }) => r.path === "m/84'/0'/0'/0/0"); expect(firstExternal).toEqual({ address: "bc1qfirst", publicKey: Buffer.from("testPubkey").toString("hex"), path: "m/84'/0'/0'/0/0", intention: "payment", }); result.forEach( (item: { address: string; publicKey?: string; path?: string; intention?: string }) => { expect(item).toHaveProperty("address"); expect(item).toHaveProperty("publicKey"); expect(item).toHaveProperty("path"); expect(item.intention).toBe("payment"); }, ); }); it("includes at least 2 unused receive and 2 unused change addresses", async () => { const accountId = "js:2:bitcoin:0x013:"; mockedGetAccountIdFromWalletAccountId.mockReturnValueOnce(accountId); const result = await bitcoinFamilyAccountGetAddressesLogic(context, walletAccountId); const receiveAddresses = result.filter((r: { path?: string }) => /\/0\/\d+$/.test(r.path ?? ""), ); const changeAddresses = result.filter((r: { path?: string }) => /\/1\/\d+$/.test(r.path ?? "")); expect(receiveAddresses.length).toBeGreaterThanOrEqual(2); expect(changeAddresses.length).toBeGreaterThanOrEqual(2); }); it("includes addresses that have UTXOs", async () => { const accountId = "js:2:bitcoin:0x013:"; mockedGetAccountIdFromWalletAccountId.mockReturnValueOnce(accountId); const mockGetAddressUnspentUtxos = jest.fn().mockImplementation((addr: { address: string }) => { // bc1qsecond (account 0, index 1) has a UTXO if (addr.address === "bc1qsecond") return [{ value: "1000" }]; return []; }); mockedGetWalletAccount.mockReturnValueOnce({ params: { path: "84'/0'", index: 0 }, xpub: { derivationMode: "native_segwit", xpub: "xpub", crypto: { getAddress: jest .fn() .mockImplementation((_mode: string, _xpub: string, account: number, index: number) => Promise.resolve(`addr_${account}_${index}`), ), getPubkeyAt: jest.fn().mockReturnValue(Buffer.from("testPubkey")), }, getXpubAddresses: jest.fn().mockResolvedValue([ { account: 0, index: 0, address: "bc1qfirst" }, { account: 0, index: 1, address: "bc1qsecond" }, { account: 1, index: 0, address: "bc1qchange0" }, ]), storage: { getAddressUnspentUtxos: mockGetAddressUnspentUtxos, }, }, } as unknown as ReturnType<typeof getWalletAccount>); const result = await bitcoinFamilyAccountGetAddressesLogic(context, walletAccountId); // Address at index 1 (which has a UTXO) should be included const addrWithUtxo = result.find((r: { path?: string }) => r.path === "m/84'/0'/0'/0/1"); expect(addrWithUtxo).toBeDefined(); expect(addrWithUtxo?.address).toBe("bc1qsecond"); // getAddressUnspentUtxos should have been called for each known address expect(mockGetAddressUnspentUtxos).toHaveBeenCalledTimes(3); expect(mockBitcoinFamilyAccountAddressesRequested).toHaveBeenCalledTimes(1); expect(mockBitcoinFamilyAccountAddressesFail).toHaveBeenCalledTimes(0); expect(mockBitcoinFamilyAccountAddressesSuccess).toHaveBeenCalledTimes(1); }); }); describe("bitcoinFamilyAccountGetXPubLogic", () => { // Given const mockBitcoinFamilyAccountXpubRequested = jest.fn(); const mockBitcoinFamilyAccountXpubFail = jest.fn(); const mockBitcoinFamilyAccountXpubSuccess = jest.fn(); const bitcoinCrypto = cryptocurrenciesById["bitcoin"]; const context = createContextContainingAccountId({ tracking: { bitcoinFamilyAccountXpubRequested: mockBitcoinFamilyAccountXpubRequested, bitcoinFamilyAccountXpubFail: mockBitcoinFamilyAccountXpubFail, bitcoinFamilyAccountXpubSuccess: mockBitcoinFamilyAccountXpubSuccess, }, accountsParams: [{ id: "11" }, { id: "12" }, { id: "13", currency: bitcoinCrypto }], }); beforeEach(() => { mockBitcoinFamilyAccountXpubRequested.mockClear(); mockBitcoinFamilyAccountXpubFail.mockClear(); mockBitcoinFamilyAccountXpubSuccess.mockClear(); mockedGetAccountIdFromWalletAccountId.mockClear(); }); const walletAccountId = "806ea21d-f5f0-425a-add3-39d4b78209f1"; it.each([ { desc: "receive unkown accountId", accountId: undefined, errorMessage: `accountId ${walletAccountId} unknown`, }, { desc: "account not found", accountId: "js:2:ethereum:0x010:", errorMessage: "account not found", }, { desc: "account is not a bitcoin family account", accountId: "js:2:ethereum:0x012:", errorMessage: "not a bitcoin family account", }, ])("returns an error when $desc", async ({ accountId, errorMessage }) => { // Given mockedGetAccountIdFromWalletAccountId.mockReturnValueOnce(accountId); // When await expect(async () => { await bitcoinFamilyAccountGetXPubLogic(context, walletAccountId); }).rejects.toThrow(errorMessage); // Then expect(mockBitcoinFamilyAccountXpubRequested).toHaveBeenCalledTimes(1); expect(mockBitcoinFamilyAccountXpubFail).toHaveBeenCalledTimes(1); expect(mockBitcoinFamilyAccountXpubSuccess).toHaveBeenCalledTimes(0); }); it("should return the xpub", async () => { // Given const accountId = "js:2:bitcoin:0x013:"; mockedGetAccountIdFromWalletAccountId.mockReturnValueOnce(accountId); // When const result = await bitcoinFamilyAccountGetXPubLogic(context, walletAccountId); // Then expect(result).toEqual("testxpub"); expect(mockBitcoinFamilyAccountXpubRequested).toHaveBeenCalledTimes(1); expect(mockBitcoinFamilyAccountXpubFail).toHaveBeenCalledTimes(0); expect(mockBitcoinFamilyAccountXpubSuccess).toHaveBeenCalledTimes(1); }); }); describe("protectStorageLogic", () => { const manifestBase = { id: "my-live-app", name: "Test App", } as AppManifest; beforeEach(() => { jest.clearAllMocks(); }); it("allows access when storeId === manifest.id", () => { const manifest = { ...manifestBase }; const handler = jest.fn().mockReturnValue("ok"); const wrapped = protectStorageLogic(manifest, handler); const args = { key: "k", storeId: "my-live-app" }; const result = wrapped(args); expect(handler).toHaveBeenCalledTimes(1); expect(handler).toHaveBeenCalledWith(args); expect(result).toBe("ok"); }); it("allows access when storeId is in manifest.storage", () => { const manifest = { ...manifestBase, storage: ["allowed-store"] }; const handler = jest.fn().mockReturnValue("done"); const wrapped = protectStorageLogic(manifest, handler); const args = { key: "k", storeId: "allowed-store" }; const result = wrapped(args); expect(handler).toHaveBeenCalledTimes(1); expect(handler).toHaveBeenCalledWith(args); expect(result).toBe("done"); }); it("throws when storeId is not permitted", () => { const manifest = { ...manifestBase, storage: ["allowed-store"] }; const handler = jest.fn(); const wrapped = protectStorageLogic(manifest, handler); const args = { key: "k", storeId: "forbidden-store" }; expect(() => wrapped(args)).toThrow( `Live App "my-live-app" is not permitted to access storage "forbidden-store".`, ); expect(handler).not.toHaveBeenCalled(); }); it("throws when storeId is not permitted and manifest.storage is missing", () => { const manifest = { ...manifestBase, storage: undefined }; const handler = jest.fn(); const wrapped = protectStorageLogic(manifest, handler); const args = { key: "k", storeId: "another-store" }; expect(() => wrapped(args)).toThrow( `Live App "my-live-app" is not permitted to access storage "another-store".`, ); expect(handler).not.toHaveBeenCalled(); }); it("propagates errors thrown by the handler", () => { const manifest = { ...manifestBase, storage: ["allowed-store"] }; const handler = jest.fn(() => { throw new Error("Handler failed"); }); const wrapped = protectStorageLogic(manifest, handler); const args = { key: "k", storeId: "allowed-store" }; expect(() => wrapped(args)).toThrow("Handler failed"); expect(handler).toHaveBeenCalledTimes(1); }); it("supports async handler returning Promise", async () => { const manifest = { ...manifestBase, storage: ["allowed-store"] }; const handler = jest.fn(async () => "async-ok"); const wrapped = protectStorageLogic(manifest, handler); const args = { key: "k", storeId: "allowed-store" }; const result = await wrapped(args); expect(handler).toHaveBeenCalledTimes(1); expect(result).toBe("async-ok"); }); it("rejects Promise when async handler throws", async () => { const manifest = { ...manifestBase, storage: ["allowed-store"] }; const handler = jest.fn(async () => { throw new Error("Async boom"); }); const wrapped = protectStorageLogic(manifest, handler); const args = { key: "k", storeId: "allowed-store" }; await expect(wrapped(args)).rejects.toThrow("Async boom"); expect(handler).toHaveBeenCalledTimes(1); }); }); function createAppManifest(id = "1"): AppManifest { 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, accountsParams, }: { tracking: Partial<TrackingAPI>; accountsParams: Array<{ id: string; currency?: CryptoCurrency }>; }): WalletAPIContext { return { manifest: createAppManifest(), accounts: accountsParams .map(({ id, currency }) => createFixtureAccount(id, currency)) .concat([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 createWalletAPIAccount() { 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: [], }; }