@ledgerhq/live-common
Version:
Common ground for the Ledger Live apps
1,390 lines (1,178 loc) • 46.1 kB
text/typescript
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: [],
};
}