@ledgerhq/coin-hedera
Version:
Ledger Hedera Coin integration
213 lines (182 loc) • 7.97 kB
text/typescript
import * as coinFrameworkAccount from "@ledgerhq/ledger-wallet-framework/account";
import type { AccountShapeInfo } from "@ledgerhq/ledger-wallet-framework/bridge/jsHelpers";
import type { Account } from "@ledgerhq/types-live";
import hederaCoinConfig from "../config";
import * as logic from "../logic";
import * as logicUtils from "../logic/utils";
import { apiClient } from "../network/api";
import * as networkUtils from "../network/utils";
import { getMockedAccount, getMockedTokenAccount } from "../test/fixtures/account.fixture";
import { getMockedConfig } from "../test/fixtures/config.fixture";
import { getMockedCurrency, getMockedHTSTokenCurrency } from "../test/fixtures/currency.fixture";
import { getMockedMirrorAccount } from "../test/fixtures/mirror.fixture";
import { getMockedOperation } from "../test/fixtures/operation.fixture";
import type { HederaAccount } from "../types";
import { getAccountShape, postSync } from "./synchronisation";
jest.mock("../config");
jest.mock("../network/api");
jest.mock("../network/utils");
jest.mock("../logic");
jest.mock("../logic/utils", () => ({
...jest.requireActual("../logic/utils"),
toEVMAddress: jest.fn(),
}));
jest.mock("@ledgerhq/ledger-wallet-framework/account", () => ({
...jest.requireActual("@ledgerhq/ledger-wallet-framework/account"),
getSyncHash: jest.fn(),
encodeAccountId: jest.fn(),
}));
const mockEncodeAccountId = jest.mocked(coinFrameworkAccount.encodeAccountId);
const mockGetSyncHash = jest.mocked(coinFrameworkAccount.getSyncHash);
const mockHederaConfig = jest.mocked(hederaCoinConfig);
const mockToEVMAddress = jest.mocked(logicUtils.toEVMAddress);
const mockGetAccount = jest.mocked(apiClient.getAccount);
const mockGetAccountTokens = jest.mocked(apiClient.getAccountTokens);
const mockListOperationsV2 = jest.mocked(logic.listOperationsV2);
const mockGetERC20BalancesForAccountV2 = jest.mocked(networkUtils.getERC20BalancesForAccountV2);
const mockConfig = getMockedConfig();
const mockCurrency = getMockedCurrency();
const mockMirrorAccount = getMockedMirrorAccount();
const mockAddress = mockMirrorAccount.account;
const mockEvmAddress = mockMirrorAccount.evm_address;
const mockLiveAccountId = `js:2:hedera:${mockAddress}:`;
const mockSyncHash = "synchash";
const mockInfo: AccountShapeInfo<HederaAccount> = {
currency: mockCurrency,
derivationMode: "" as const,
address: mockAddress,
initialAccount: undefined,
index: 0,
derivationPath: "44/3030",
};
describe("getAccountShape", () => {
beforeEach(() => {
jest.clearAllMocks();
mockHederaConfig.getCoinConfig.mockReturnValue({ ...mockConfig, useHgraphForErc20: true });
mockEncodeAccountId.mockReturnValue(mockLiveAccountId);
mockGetSyncHash.mockResolvedValue(mockSyncHash);
mockToEVMAddress.mockResolvedValue(mockEvmAddress);
mockGetAccount.mockResolvedValue(mockMirrorAccount);
mockGetAccountTokens.mockResolvedValue([]);
mockGetERC20BalancesForAccountV2.mockResolvedValue([]);
mockListOperationsV2.mockResolvedValue({
coinOperations: [],
tokenOperations: [],
nextCursor: null,
});
});
it("should call listOperationsV2 instead of listOperations", async () => {
await getAccountShape(mockInfo, { paginationConfig: {} });
expect(logic.listOperations).not.toHaveBeenCalled();
expect(logic.listOperationsV2).toHaveBeenCalledTimes(1);
expect(logic.listOperationsV2).toHaveBeenCalledWith(
expect.objectContaining({ address: mockAddress, evmAddress: mockEvmAddress }),
);
});
it("should call getERC20BalancesForAccountV2 instead of getERC20BalancesForAccount", async () => {
await getAccountShape(mockInfo, { paginationConfig: {} });
expect(mockGetERC20BalancesForAccountV2).toHaveBeenCalledTimes(1);
expect(mockGetERC20BalancesForAccountV2).toHaveBeenCalledWith(mockAddress);
expect(networkUtils.getERC20BalancesForAccount).not.toHaveBeenCalled();
});
it("should return a valid account shape", async () => {
const result = await getAccountShape(mockInfo, { paginationConfig: {} });
expect(result).toMatchObject({
id: mockLiveAccountId,
freshAddress: mockAddress,
operations: [],
operationsCount: 0,
});
});
it("should pass cursor when initial account has existing operations (incremental sync)", async () => {
const existingOp = { date: new Date("2024-01-01T00:00:00Z") };
// @ts-expect-error - no other fields are needed for this test
const initialAccount = {
syncHash: mockSyncHash,
operations: [existingOp],
pendingOperations: [],
} as Account;
await getAccountShape({ ...mockInfo, initialAccount }, { paginationConfig: {} });
expect(logic.listOperationsV2).toHaveBeenCalledTimes(1);
expect(logic.listOperationsV2).toHaveBeenCalledWith(
expect.objectContaining({ cursor: expect.any(String) }),
);
});
it("should NOT pass cursor on fresh sync", async () => {
await getAccountShape(mockInfo, { paginationConfig: {} });
expect(logic.listOperationsV2).toHaveBeenCalledTimes(1);
expect(logic.listOperationsV2).toHaveBeenCalledWith(
expect.not.objectContaining({ cursor: expect.any(String) }),
);
});
it("should NOT pass cursor when syncHash has changed", async () => {
mockGetSyncHash.mockResolvedValue("new-synchash");
// @ts-expect-error - no other fields are needed for this test
const initialAccount = {
syncHash: "old-synchash",
operations: [{ date: new Date() }],
pendingOperations: [],
} as Account;
await getAccountShape({ ...mockInfo, initialAccount }, { paginationConfig: {} });
expect(logic.listOperationsV2).toHaveBeenCalledTimes(1);
expect(logic.listOperationsV2).toHaveBeenCalledWith(
expect.not.objectContaining({ cursor: expect.any(String) }),
);
});
});
describe("postSync", () => {
beforeEach(() => {
jest.clearAllMocks();
mockHederaConfig.getCoinConfig.mockReturnValue(mockConfig);
});
it("should remove pending operations that match confirmed ERC20 operations", () => {
const confirmedERC20Ops = [
getMockedOperation({ hash: "hash1", standard: "erc20" }),
getMockedOperation({ hash: "hash2", standard: "erc20" }),
];
const initialAccount = {} as Account;
const syncedAccount = getMockedAccount({
operations: [...confirmedERC20Ops, getMockedOperation({ hash: "otherHash" })],
pendingOperations: [
getMockedOperation({ hash: "hash1" }),
getMockedOperation({ hash: "hash2" }),
getMockedOperation({ hash: "hash3" }),
],
});
const result = postSync(initialAccount, syncedAccount);
expect(result.pendingOperations).toHaveLength(1);
expect(result.pendingOperations).toMatchObject([{ hash: "hash3" }]);
});
it("should filter pending operations from subaccounts", () => {
const mockToken1 = getMockedHTSTokenCurrency();
const confirmedERC20Ops = [
getMockedOperation({ hash: "hash1", standard: "erc20" }),
getMockedOperation({ hash: "hash2", standard: "erc20" }),
];
const subAccounts = [
getMockedTokenAccount(mockToken1, {
pendingOperations: [
getMockedOperation({ hash: "hash1" }),
getMockedOperation({ hash: "hash4" }),
],
}),
getMockedTokenAccount(mockToken1, {
pendingOperations: [
getMockedOperation({ hash: "hash2" }),
getMockedOperation({ hash: "hash5" }),
],
}),
];
const initialAccount = {} as Account;
const syncedAccount = getMockedAccount({
operations: [...confirmedERC20Ops, getMockedOperation({ hash: "otherHash" })],
subAccounts,
});
const result = postSync(initialAccount, syncedAccount);
expect(result.subAccounts).toHaveLength(2);
expect(result.subAccounts).toMatchObject([
{ pendingOperations: [{ hash: "hash4" }] },
{ pendingOperations: [{ hash: "hash5" }] },
]);
});
});