@ledgerhq/live-common
Version:
Common ground for the Ledger Live apps
477 lines (416 loc) • 15.2 kB
text/typescript
import { handlers } from "./server";
import { Account } from "@ledgerhq/types-live";
import { CryptoCurrency, TokenCurrency } from "@ledgerhq/types-cryptoassets";
import BigNumber from "bignumber.js";
import { AppPlatform, AppBranch, Visibility } from "../types";
import { getCryptoAssetsStore } from "@ledgerhq/cryptoassets/state";
// Mock dependencies
jest.mock("@ledgerhq/wallet-api-server", () => ({
RPCHandler: jest.fn(),
customWrapper: jest.fn(handler => handler),
}));
jest.mock("@ledgerhq/cryptoassets", () => ({
getCryptoCurrencyById: jest.fn(),
}));
jest.mock("@ledgerhq/cryptoassets/state", () => ({
getCryptoAssetsStore: jest.fn(),
}));
jest.mock("../converters", () => ({
getAccountIdFromWalletAccountId: jest.fn(),
getWalletAPITransactionSignFlowInfos: jest.fn(),
}));
jest.mock("../../bridge", () => ({
getAccountBridge: jest.fn(),
}));
jest.mock("@ledgerhq/live-env", () => ({
getEnv: jest.fn(),
changes: { subscribe: jest.fn() },
}));
// Mock types
const mockTracking = {
signMessageRequested: jest.fn(),
signMessageSuccess: jest.fn(),
signMessageFail: jest.fn(),
signMessageNoParams: jest.fn(),
signMessageUserRefused: jest.fn(),
signTransactionRequested: jest.fn(),
signTransactionSuccess: jest.fn(),
signTransactionFail: jest.fn(),
signTransactionNoParams: jest.fn(),
signTransactionAndBroadcastNoParams: jest.fn(),
broadcastSuccess: jest.fn(),
broadcastFail: jest.fn(),
broadcastOperationDetailsClick: jest.fn(),
};
const mockManifest = {
id: "test-manifest",
name: "Test App",
url: "https://test.app",
homepageUrl: "https://test.app",
icon: "data:image/png;base64,test",
apiVersion: "1.0.0",
permissions: [],
domains: [],
categories: [],
platforms: ["desktop", "ios"] as AppPlatform[],
manifestVersion: "1.0.0",
branch: "stable" as AppBranch,
currencies: [],
visibility: "complete" as Visibility,
content: {
shortDescription: { en: "Test app" },
description: { en: "Test app description" },
},
};
const mockEthereumCurrency: CryptoCurrency = {
type: "CryptoCurrency",
id: "ethereum",
coinType: 60,
name: "Ethereum",
managerAppName: "Ethereum",
ticker: "ETH",
scheme: "ethereum",
color: "#0ebdcd",
symbol: "Ξ",
family: "evm",
blockAvgTime: 15,
units: [
{
name: "ether",
code: "ETH",
magnitude: 18,
},
],
keywords: ["eth", "ethereum"],
explorerViews: [],
explorerId: "eth",
};
const mockTokenCurrency: TokenCurrency = {
type: "TokenCurrency",
id: "ethereum/erc20/acre_btc",
contractAddress: "0x1234567890123456789012345678901234567890",
parentCurrency: mockEthereumCurrency,
tokenType: "erc20",
name: "ACRE Bitcoin",
ticker: "acreBTC",
units: [
{
name: "ACRE Bitcoin",
code: "acreBTC",
magnitude: 8,
},
],
};
const mockAccount: Account = {
type: "Account",
id: "js:2:ethereum:0x1234567890123456789012345678901234567890:ethereum",
seedIdentifier: "0x1234567890123456789012345678901234567890",
derivationMode: "",
index: 0,
freshAddress: "0x1234567890123456789012345678901234567890",
freshAddressPath: "44'/60'/0'/0/0",
used: false,
blockHeight: 0,
creationDate: new Date(),
balance: new BigNumber(0),
spendableBalance: new BigNumber(0),
operationsCount: 0,
operations: [],
pendingOperations: [],
currency: mockEthereumCurrency,
lastSyncDate: new Date(),
swapHistory: [],
balanceHistoryCache: {
HOUR: { latestDate: null, balances: [] },
DAY: { latestDate: null, balances: [] },
WEEK: { latestDate: null, balances: [] },
},
syncHash: "0x00000000",
subAccounts: [],
nfts: [],
};
jest.mock("@ledgerhq/ledger-wallet-framework/account/index", () => ({
getParentAccount: jest.fn(),
getMainAccount: jest.fn(),
makeEmptyTokenAccount: jest.fn(),
isTokenAccount: jest.fn(),
}));
describe("ACRE Server Handlers", () => {
let mockUiHooks: any;
let serverHandlers: any;
beforeEach(() => {
jest.clearAllMocks();
(getCryptoAssetsStore as jest.Mock).mockReturnValue({
findTokenByAddressInCurrency: jest.fn().mockResolvedValue(mockTokenCurrency),
findTokenById: jest.fn().mockResolvedValue(mockTokenCurrency),
});
mockUiHooks = {
"custom.acre.messageSign": jest.fn().mockImplementation(({ onSuccess }) => {
onSuccess("0x1234567890abcdef");
}),
"custom.acre.transactionSign": jest.fn().mockImplementation(({ onSuccess }) => {
onSuccess({
operation: { hash: "0x1234567890abcdef" },
signature: "0xabcdef1234567890",
});
}),
"custom.acre.transactionBroadcast": jest.fn(),
"custom.acre.registerAccount": jest.fn().mockImplementation(({ onSuccess }) => {
onSuccess();
}),
};
// Mock the account functions
const { makeEmptyTokenAccount, getMainAccount, getParentAccount } = jest.requireMock(
"@ledgerhq/ledger-wallet-framework/account/index",
);
makeEmptyTokenAccount.mockReturnValue({
type: "TokenAccount",
id: "mock-token-account-id",
parentId: "mock-parent-id",
token: mockTokenCurrency,
balance: new BigNumber(0),
spendableBalance: new BigNumber(0),
creationDate: new Date(),
operationsCount: 0,
operations: [],
pendingOperations: [],
balanceHistoryCache: {
HOUR: { latestDate: null, balances: [] },
DAY: { latestDate: null, balances: [] },
WEEK: { latestDate: null, balances: [] },
},
swapHistory: [],
});
getMainAccount.mockReturnValue(mockAccount);
getParentAccount.mockReturnValue(undefined);
// Mock the converter functions
const { getAccountIdFromWalletAccountId, getWalletAPITransactionSignFlowInfos } =
jest.requireMock("../converters");
getAccountIdFromWalletAccountId.mockReturnValue(
"js:2:ethereum:0x1234567890123456789012345678901234567890:ethereum",
);
getWalletAPITransactionSignFlowInfos.mockReturnValue({
canEditFees: true,
liveTx: { family: "evm" },
hasFeesProvided: true,
});
// Mock the bridge
const { getAccountBridge } = jest.requireMock("../../bridge");
getAccountBridge.mockReturnValue({
broadcast: jest.fn().mockResolvedValue({ hash: "0x1234567890abcdef" }),
});
serverHandlers = handlers({
accounts: [mockAccount],
tracking: mockTracking,
manifest: mockManifest,
uiHooks: mockUiHooks,
});
});
describe("custom.acre.messageSign", () => {
it("should handle message signing request successfully", async () => {
const mockParams = {
accountId: "test-account-id",
message: { type: "SignIn", message: "test" },
derivationPath: "0/0",
options: { hwAppId: "test-app" },
meta: { test: "data" },
};
const mockSignature = "0x1234567890abcdef";
mockUiHooks["custom.acre.messageSign"].mockImplementation(({ onSuccess }) => {
onSuccess(mockSignature);
});
const result = await serverHandlers["custom.acre.messageSign"](mockParams);
expect(result).toEqual({
hexSignedMessage: "1234567890abcdef",
});
expect(mockTracking.signMessageRequested).toHaveBeenCalledWith(mockManifest);
expect(mockTracking.signMessageSuccess).toHaveBeenCalledWith(mockManifest);
});
it("should handle missing parameters", async () => {
const result = await serverHandlers["custom.acre.messageSign"](null);
expect(result).toEqual({
hexSignedMessage: "",
});
expect(mockTracking.signMessageNoParams).toHaveBeenCalledWith(mockManifest);
});
it("should handle account not found", async () => {
const mockParams = {
accountId: "non-existent-account",
message: { type: "SignIn", message: "test" },
derivationPath: "0/0",
options: {},
meta: {},
};
// Mock getAccountIdFromWalletAccountId to return null for non-existent account
const { getAccountIdFromWalletAccountId } = jest.requireMock("../converters");
getAccountIdFromWalletAccountId.mockReturnValueOnce(null);
await expect(serverHandlers["custom.acre.messageSign"](mockParams)).rejects.toThrow(
"accountId non-existent-account unknown",
);
});
});
describe("custom.acre.transactionSign", () => {
it("should handle transaction signing request successfully", async () => {
const mockParams = {
accountId: "test-account-id",
rawTransaction: "0x1234567890abcdef",
options: { hwAppId: "test-app" },
meta: { test: "data" },
tokenCurrency: "ethereum/erc20/test",
};
const mockSignedOperation = {
operation: { hash: "0x1234567890abcdef" },
signature: "0xabcdef1234567890",
};
mockUiHooks["custom.acre.transactionSign"].mockImplementation(({ onSuccess }) => {
onSuccess(mockSignedOperation);
});
const result = await serverHandlers["custom.acre.transactionSign"](mockParams);
expect(result).toEqual({
signedTransactionHex: "307861626364656631323334353637383930",
});
expect(mockTracking.signTransactionRequested).toHaveBeenCalledWith(mockManifest);
expect(mockTracking.signTransactionSuccess).toHaveBeenCalledWith(mockManifest);
});
it("should handle missing parameters", async () => {
const result = await serverHandlers["custom.acre.transactionSign"](null);
expect(result).toEqual({
signedTransactionHex: "",
});
expect(mockTracking.signTransactionNoParams).toHaveBeenCalledWith(mockManifest);
});
});
describe("custom.acre.transactionSignAndBroadcast", () => {
it("should handle transaction sign and broadcast successfully", async () => {
const mockParams = {
accountId: "test-account-id",
rawTransaction: "0x1234567890abcdef",
options: { hwAppId: "test-app" },
meta: { test: "data" },
tokenCurrency: "ethereum/erc20/test",
};
const mockSignedOperation = {
operation: {
hash: "0x1234567890abcdef",
recipients: ["0x1234567890123456789012345678901234567890"],
},
signature: "0xabcdef1234567890",
};
mockUiHooks["custom.acre.transactionSign"].mockImplementation(({ onSuccess }) => {
onSuccess(mockSignedOperation);
});
const result = await serverHandlers["custom.acre.transactionSignAndBroadcast"](mockParams);
expect(result).toEqual({
transactionHash: "0x1234567890abcdef",
});
});
it("should handle missing parameters", async () => {
const result = await serverHandlers["custom.acre.transactionSignAndBroadcast"](null);
expect(result).toEqual({
transactionHash: "",
});
expect(mockTracking.signTransactionAndBroadcastNoParams).toHaveBeenCalledWith(mockManifest);
});
});
describe("custom.acre.registerYieldBearingEthereumAddress", () => {
const mockParams = {
ethereumAddress: "0x9876543210987654321098765432109876543210",
tokenContractAddress: "0x1234567890123456789012345678901234567890",
meta: { test: "data" },
};
it("should register new yield-bearing Ethereum address successfully on mobile", async () => {
const result =
await serverHandlers["custom.acre.registerYieldBearingEthereumAddress"](mockParams);
expect(result.success).toBe(true);
expect(result.accountName).toBe("Yield-bearing BTC on ACRE");
expect(result.ethereumAddress).toBe(mockParams.ethereumAddress);
expect(result.tokenContractAddress).toBe(mockParams.tokenContractAddress);
// Verify UI hook was called with correct parameters
expect(mockUiHooks["custom.acre.registerAccount"]).toHaveBeenCalledWith({
parentAccount: expect.objectContaining({
type: "Account",
id: expect.stringContaining("0x9876543210987654321098765432109876543210"),
}),
accountName: "Yield-bearing BTC on ACRE",
existingAccounts: [mockAccount],
onSuccess: expect.any(Function),
onError: expect.any(Function),
});
});
it("should register new yield-bearing Ethereum address successfully on desktop", async () => {
const desktopServerHandlers = handlers({
accounts: [mockAccount],
tracking: mockTracking,
manifest: mockManifest,
uiHooks: mockUiHooks,
});
const result = await (
desktopServerHandlers["custom.acre.registerYieldBearingEthereumAddress"] as any
)(mockParams);
expect(result.success).toBe(true);
expect(mockUiHooks["custom.acre.registerAccount"]).toHaveBeenCalledWith({
parentAccount: expect.objectContaining({
type: "Account",
id: expect.stringContaining("0x9876543210987654321098765432109876543210"),
}),
accountName: "Yield-bearing BTC on ACRE",
existingAccounts: [mockAccount],
onSuccess: expect.any(Function),
onError: expect.any(Function),
});
});
it("should handle existing account gracefully", async () => {
const existingAccount = {
...mockAccount,
freshAddress: mockParams.ethereumAddress,
};
const serverHandlersWithExisting = handlers({
accounts: [existingAccount],
tracking: mockTracking,
manifest: mockManifest,
uiHooks: mockUiHooks,
});
const result = await (
serverHandlersWithExisting["custom.acre.registerYieldBearingEthereumAddress"] as any
)(mockParams);
expect(result.success).toBe(true);
expect(result.parentAccountId).toBe(existingAccount.id);
expect(result.tokenAccountId).toBe(existingAccount.id);
expect(mockUiHooks["custom.acre.registerAccount"]).not.toHaveBeenCalled();
});
it("should handle missing UI hook", async () => {
const serverHandlersWithoutHook = handlers({
accounts: [mockAccount],
tracking: mockTracking,
manifest: mockManifest,
uiHooks: {
...mockUiHooks,
"custom.acre.registerAccount": undefined,
},
});
await expect(
(serverHandlersWithoutHook["custom.acre.registerYieldBearingEthereumAddress"] as any)(
mockParams,
),
).rejects.toThrow("No account registration UI hook available");
});
it("should validate Ethereum address format", async () => {
const invalidParams = {
...mockParams,
ethereumAddress: "invalid-address",
};
await expect(
serverHandlers["custom.acre.registerYieldBearingEthereumAddress"](invalidParams),
).rejects.toThrow("Invalid Ethereum address format");
});
it("should require either tokenContractAddress or tokenTicker", async () => {
const invalidParams = {
ethereumAddress: mockParams.ethereumAddress,
meta: mockParams.meta,
};
await expect(
serverHandlers["custom.acre.registerYieldBearingEthereumAddress"](invalidParams),
).rejects.toThrow("Either tokenContractAddress or tokenTicker must be provided");
});
});
});