@ledgerhq/coin-hedera
Version:
Ledger Hedera Coin integration
1,455 lines (1,329 loc) • 55.2 kB
text/typescript
import { setupMockCryptoAssetsStore } from "@ledgerhq/cryptoassets/cal-client/test-helpers";
import { encodeTokenAccountId } from "@ledgerhq/ledger-wallet-framework/account/accountId";
import { encodeOperationId } from "@ledgerhq/ledger-wallet-framework/operation";
import { getEnv } from "@ledgerhq/live-env";
import BigNumber from "bignumber.js";
import { apiClient } from "../network/api";
import { hgraphClient } from "../network/hgraph";
import * as networkUtils from "../network/utils";
import { getMockedEnrichedERC20Transfer } from "../test/fixtures/common.fixture";
import {
getMockedCurrency,
getMockedERC20TokenCurrency,
getMockedHTSTokenCurrency,
} from "../test/fixtures/currency.fixture";
import { getMockedERC20TokenTransfer } from "../test/fixtures/hgraph.fixture";
import {
getMockedMirrorAccount,
getMockedMirrorContractCallResult,
getMockedMirrorToken,
getMockedMirrorTransaction,
} from "../test/fixtures/mirror.fixture";
import type { StakingAnalysis, SyntheticBlock } from "../types";
import { listOperationsV2 as listOperations } from "./listOperations.v2";
import * as utils from "./utils";
setupMockCryptoAssetsStore();
jest.mock("@ledgerhq/ledger-wallet-framework/account/accountId", () => ({
...jest.requireActual("@ledgerhq/ledger-wallet-framework/account/accountId"),
encodeTokenAccountId: jest.fn(),
}));
jest.mock("@ledgerhq/ledger-wallet-framework/operation");
jest.mock("../network/api");
jest.mock("../network/hgraph");
jest.mock("../network/utils", () => ({
...jest.requireActual("../network/utils"),
enrichERC20Transfers: jest.fn(),
}));
jest.mock("./utils", () => ({
...jest.requireActual("./utils"),
base64ToUrlSafeBase64: jest.fn().mockImplementation(hash => `encoded-${hash}`),
getMemoFromBase64: jest.fn().mockImplementation(memo => (memo ? `decoded-${memo}` : null)),
getSyntheticBlock: jest.fn(),
extractFeesPayer: jest.fn(),
analyzeStakingOperation: jest.fn(),
}));
describe("listOperationsV2", () => {
const mockCurrency = getMockedCurrency();
const mockMirrorAccount = getMockedMirrorAccount({ account: "0.0.12345" });
const mockSyntheticBlock: SyntheticBlock = {
blockHeight: 1000000,
blockHash: "0x100000",
blockTime: new Date(),
};
const mockLimit = 10;
const mockOrder = "desc";
beforeEach(() => {
jest.clearAllMocks();
(apiClient.getAccountTransactions as jest.Mock).mockResolvedValue({
transactions: [],
nextCursor: null,
});
(hgraphClient.getERC20Transfers as jest.Mock).mockResolvedValue([]);
(hgraphClient.getLatestIndexedConsensusTimestamp as jest.Mock).mockResolvedValue(
new BigNumber(0),
);
(encodeOperationId as jest.Mock).mockImplementation(
(accountId, hash, type) => `${accountId}-${hash}-${type}`,
);
(encodeTokenAccountId as jest.Mock).mockImplementation(
(accountId, token) => `${accountId}-${token.id}`,
);
(utils.getSyntheticBlock as jest.Mock).mockReturnValue(mockSyntheticBlock);
(utils.extractFeesPayer as jest.Mock).mockImplementation(input =>
typeof input === "string"
? input.split("-")[0]
: (input.transaction_id?.split("-")[0] ?? "0.0.0"),
);
(utils.analyzeStakingOperation as jest.Mock).mockResolvedValue(null);
(networkUtils.enrichERC20Transfers as jest.Mock).mockReturnValue([]);
});
it("should return empty arrays when no transactions are found", async () => {
(apiClient.getAccountTransactions as jest.Mock).mockResolvedValue({
transactions: [],
nextCursor: null,
});
const result = await listOperations({
limit: mockLimit,
order: mockOrder,
currency: mockCurrency,
address: mockMirrorAccount.account,
evmAddress: mockMirrorAccount.evm_address,
mirrorTokens: [],
erc20Tokens: [],
fetchAllPages: true,
skipFeesForTokenOperations: false,
useEncodedHash: false,
useSyntheticBlocks: false,
});
expect(apiClient.getAccountTransactions).toHaveBeenCalledTimes(1);
expect(apiClient.getAccountTransactions).toHaveBeenCalledWith({
address: mockMirrorAccount.account,
fetchAllPages: true,
pagingToken: null,
order: mockOrder,
limit: mockLimit,
});
expect(hgraphClient.getERC20Transfers).toHaveBeenCalledTimes(1);
expect(hgraphClient.getERC20Transfers).toHaveBeenCalledWith({
address: mockMirrorAccount.account,
fetchAllPages: true,
order: mockOrder,
limit: mockLimit,
tokenEvmAddresses: [],
});
expect(hgraphClient.getLatestIndexedConsensusTimestamp).toHaveBeenCalledTimes(1);
expect(result.coinOperations).toEqual([]);
expect(result.tokenOperations).toEqual([]);
});
it("should parse HBAR transfer transactions correctly", async () => {
const mockTransaction = getMockedMirrorTransaction({
consensus_timestamp: "1625097600.000000000",
transaction_hash: "hash1",
charged_tx_fee: 500000,
result: "SUCCESS",
memo_base64: "test-memo",
token_transfers: [],
staking_reward_transfers: [],
transfers: [
{ account: mockMirrorAccount.account, amount: -1000000 },
{ account: "0.0.67890", amount: 1000000 },
],
name: "CRYPTOTRANSFER",
});
(apiClient.getAccountTransactions as jest.Mock).mockResolvedValue({
transactions: [mockTransaction],
nextCursor: null,
});
const result = await listOperations({
limit: mockLimit,
order: mockOrder,
currency: mockCurrency,
address: mockMirrorAccount.account,
evmAddress: mockMirrorAccount.evm_address,
mirrorTokens: [],
erc20Tokens: [],
fetchAllPages: true,
skipFeesForTokenOperations: false,
useEncodedHash: false,
useSyntheticBlocks: false,
});
expect(result.tokenOperations).toEqual([]);
expect(result.coinOperations).toHaveLength(1);
expect(result.coinOperations).toMatchObject([
{
type: "OUT",
value: expect.any(Object),
hash: "hash1",
fee: expect.any(Object),
date: expect.any(Date),
senders: [mockMirrorAccount.account],
recipients: ["0.0.67890"],
extra: {
pagingToken: "1625097600.000000000",
consensusTimestamp: "1625097600.000000000",
memo: "decoded-test-memo",
},
},
]);
});
it("should parse HTS token transfer transactions correctly", async () => {
const mockTokenHTS = getMockedHTSTokenCurrency();
const mockTransaction = getMockedMirrorTransaction({
consensus_timestamp: "1625097600.000000000",
transaction_hash: "hash1",
charged_tx_fee: 500000,
result: "SUCCESS",
token_transfers: [
{
token_id: mockTokenHTS.contractAddress,
account: mockMirrorAccount.account,
amount: -1000,
},
{ token_id: mockTokenHTS.contractAddress, account: "0.0.67890", amount: 1000 },
],
staking_reward_transfers: [],
transfers: [],
name: "CRYPTOTRANSFER",
});
(apiClient.getAccountTransactions as jest.Mock).mockResolvedValue({
transactions: [mockTransaction],
nextCursor: null,
});
setupMockCryptoAssetsStore({
findTokenByAddressInCurrency: jest.fn().mockResolvedValue(mockTokenHTS),
});
const result = await listOperations({
limit: mockLimit,
order: mockOrder,
currency: mockCurrency,
address: mockMirrorAccount.account,
evmAddress: mockMirrorAccount.evm_address,
mirrorTokens: [],
erc20Tokens: [],
fetchAllPages: true,
skipFeesForTokenOperations: false,
useEncodedHash: false,
useSyntheticBlocks: false,
});
expect(result.coinOperations).toMatchObject([
{
type: "FEES",
fee: expect.any(Object),
},
]);
expect(result.tokenOperations).toMatchObject([
{
type: "OUT",
value: expect.any(Object),
hash: "hash1",
contract: mockTokenHTS.contractAddress,
standard: "hts",
senders: [mockMirrorAccount.account],
recipients: ["0.0.67890"],
extra: {
pagingToken: "1625097600.000000000",
consensusTimestamp: "1625097600.000000000",
},
},
]);
});
it("should parse ERC20 token transfer transactions correctly", async () => {
const mockTokenERC20 = getMockedERC20TokenCurrency();
const sharedHash = "erc20-transfer-hash";
const sharedTimestamp = "1625097600.000000000";
const memo = "xyz";
const mockMirrorTransaction = getMockedMirrorTransaction({
consensus_timestamp: sharedTimestamp,
transaction_hash: sharedHash,
charged_tx_fee: 300000,
result: "SUCCESS",
name: "CONTRACTCALL",
memo_base64: memo,
staking_reward_transfers: [],
token_transfers: [],
transfers: [{ account: mockMirrorAccount.account, amount: -300000 }],
});
const mockERC20Transfer = getMockedERC20TokenTransfer({
transaction_hash: sharedHash,
consensus_timestamp: Number(sharedTimestamp.split(".")[0]) * 10 ** 9,
sender_account_id: 12345,
receiver_account_id: 67890,
sender_evm_address: mockMirrorAccount.evm_address,
receiver_evm_address: "0xrecipient",
payer_account_id: 12345,
amount: 5000000,
});
const mockContractCallResult = getMockedMirrorContractCallResult({
block_hash: "0xblockhash123",
gas_consumed: 75000,
gas_limit: 100000,
gas_used: 75000,
});
const mockEnrichedERC20Transfer = getMockedEnrichedERC20Transfer({
mirrorTransaction: mockMirrorTransaction,
contractCallResult: mockContractCallResult,
transfers: [mockERC20Transfer],
});
jest.spyOn(networkUtils, "enrichERC20Transfers").mockResolvedValue([mockEnrichedERC20Transfer]);
(apiClient.getAccountTransactions as jest.Mock).mockResolvedValue({
transactions: [],
nextCursor: null,
});
(hgraphClient.getERC20Transfers as jest.Mock).mockResolvedValue([mockERC20Transfer]);
(hgraphClient.getLatestIndexedConsensusTimestamp as jest.Mock).mockResolvedValue(
new BigNumber(sharedTimestamp),
);
setupMockCryptoAssetsStore({
findTokenByAddressInCurrency: jest.fn().mockResolvedValue(mockTokenERC20),
});
const result = await listOperations({
limit: mockLimit,
order: mockOrder,
currency: mockCurrency,
address: mockMirrorAccount.account,
evmAddress: mockMirrorAccount.evm_address,
mirrorTokens: [],
erc20Tokens: [{ token: mockTokenERC20, balance: new BigNumber(10000000) }],
fetchAllPages: true,
skipFeesForTokenOperations: false,
useEncodedHash: false,
useSyntheticBlocks: false,
});
expect(result.tokenOperations).toEqual([
expect.objectContaining({
type: "OUT",
standard: "erc20",
contract: mockTokenERC20.contractAddress,
hash: mockMirrorTransaction.transaction_hash,
value: new BigNumber(5000000),
fee: new BigNumber(300000),
senders: ["0.0.12345"],
recipients: ["0.0.67890"],
blockHash: null,
extra: expect.objectContaining({
pagingToken: mockMirrorTransaction.consensus_timestamp,
consensusTimestamp: mockMirrorTransaction.consensus_timestamp,
memo: `decoded-${mockMirrorTransaction.memo_base64}`,
gasConsumed: mockContractCallResult.gas_consumed,
gasLimit: mockContractCallResult.gas_limit,
gasUsed: mockContractCallResult.gas_used,
}),
}),
]);
expect(result.coinOperations).toEqual([
expect.objectContaining({
type: "FEES",
value: new BigNumber(300000),
hash: sharedHash,
}),
]);
});
it("should use EVM address for sender/recipient when account_id is null in ERC20 transfer", async () => {
const mockTokenERC20 = getMockedERC20TokenCurrency();
const sharedHash = "erc20-transfer-hash";
const sharedTimestamp = "1625097600.000000000";
const mockMirrorTransaction = getMockedMirrorTransaction({
consensus_timestamp: sharedTimestamp,
transaction_hash: sharedHash,
charged_tx_fee: 300000,
result: "SUCCESS",
name: "CONTRACTCALL",
staking_reward_transfers: [],
token_transfers: [],
transfers: [{ account: mockMirrorAccount.account, amount: -300000 }],
});
const mockERC20Transfer = getMockedERC20TokenTransfer({
transaction_hash: sharedHash,
consensus_timestamp: Number(sharedTimestamp.split(".")[0]) * 10 ** 9,
sender_account_id: null,
receiver_account_id: null,
sender_evm_address: mockMirrorAccount.evm_address,
receiver_evm_address: "0xrecipient123",
payer_account_id: 12345,
amount: 5000000,
});
const mockContractCallResult = getMockedMirrorContractCallResult();
const mockEnrichedERC20Transfer = getMockedEnrichedERC20Transfer({
mirrorTransaction: mockMirrorTransaction,
contractCallResult: mockContractCallResult,
transfers: [mockERC20Transfer],
});
jest.spyOn(networkUtils, "enrichERC20Transfers").mockResolvedValue([mockEnrichedERC20Transfer]);
(apiClient.getAccountTransactions as jest.Mock).mockResolvedValue({
transactions: [],
nextCursor: null,
});
(hgraphClient.getERC20Transfers as jest.Mock).mockResolvedValue([mockERC20Transfer]);
(hgraphClient.getLatestIndexedConsensusTimestamp as jest.Mock).mockResolvedValue(
new BigNumber(sharedTimestamp),
);
setupMockCryptoAssetsStore({
findTokenByAddressInCurrency: jest.fn().mockResolvedValue(mockTokenERC20),
});
const result = await listOperations({
limit: mockLimit,
order: mockOrder,
currency: mockCurrency,
address: mockMirrorAccount.account,
evmAddress: mockMirrorAccount.evm_address,
mirrorTokens: [],
erc20Tokens: [{ token: mockTokenERC20, balance: new BigNumber(10000000) }],
fetchAllPages: true,
skipFeesForTokenOperations: false,
useEncodedHash: false,
useSyntheticBlocks: false,
});
expect(result.tokenOperations).toEqual([
expect.objectContaining({
senders: [mockMirrorAccount.evm_address],
recipients: [mockERC20Transfer.receiver_evm_address],
}),
]);
});
it("should skip ERC20 operations when sender evm address is null", async () => {
const mockTokenERC20 = getMockedERC20TokenCurrency();
const sharedHash = "erc20-null-sender-hash";
const sharedTimestamp = "1625097600.000000000";
const mockMirrorTransaction = getMockedMirrorTransaction({
consensus_timestamp: sharedTimestamp,
transaction_hash: sharedHash,
name: "CONTRACTCALL",
transfers: [{ account: mockMirrorAccount.account, amount: -300000 }],
});
const mockERC20Transfer = getMockedERC20TokenTransfer({
transaction_hash: sharedHash,
consensus_timestamp: Number(sharedTimestamp.split(".")[0]) * 10 ** 9,
sender_account_id: null,
receiver_account_id: 67890,
sender_evm_address: null,
receiver_evm_address: "0xrecipient",
payer_account_id: 12345,
amount: 5000000,
});
const mockContractCallResult = getMockedMirrorContractCallResult();
const mockEnrichedERC20Transfer = getMockedEnrichedERC20Transfer({
mirrorTransaction: mockMirrorTransaction,
contractCallResult: mockContractCallResult,
transfers: [mockERC20Transfer],
});
jest.spyOn(networkUtils, "enrichERC20Transfers").mockResolvedValue([mockEnrichedERC20Transfer]);
(apiClient.getAccountTransactions as jest.Mock).mockResolvedValue({
transactions: [],
nextCursor: null,
});
(hgraphClient.getERC20Transfers as jest.Mock).mockResolvedValue([mockERC20Transfer]);
(hgraphClient.getLatestIndexedConsensusTimestamp as jest.Mock).mockResolvedValue(
new BigNumber(sharedTimestamp),
);
setupMockCryptoAssetsStore({
findTokenByAddressInCurrency: jest.fn().mockResolvedValue(mockTokenERC20),
});
const result = await listOperations({
limit: mockLimit,
order: mockOrder,
currency: mockCurrency,
address: mockMirrorAccount.account,
evmAddress: mockMirrorAccount.evm_address,
mirrorTokens: [],
erc20Tokens: [{ token: mockTokenERC20, balance: new BigNumber(10000000) }],
fetchAllPages: true,
skipFeesForTokenOperations: false,
useEncodedHash: false,
useSyntheticBlocks: false,
});
expect(result.tokenOperations).toEqual([]);
expect(result.coinOperations).toEqual([]);
});
it("should parse token associate transactions correctly", async () => {
const mockTransaction = getMockedMirrorTransaction({
consensus_timestamp: "1625097600.000000000",
transaction_hash: "hash1",
charged_tx_fee: 500000,
result: "SUCCESS",
token_transfers: [],
staking_reward_transfers: [],
transfers: [{ account: mockMirrorAccount.account, amount: -500000 }],
name: "TOKENASSOCIATE",
});
(apiClient.getAccountTransactions as jest.Mock).mockResolvedValue({
transactions: [mockTransaction],
nextCursor: null,
});
const result = await listOperations({
limit: mockLimit,
order: mockOrder,
currency: mockCurrency,
address: mockMirrorAccount.account,
evmAddress: mockMirrorAccount.evm_address,
mirrorTokens: [],
erc20Tokens: [],
fetchAllPages: true,
skipFeesForTokenOperations: false,
useEncodedHash: false,
useSyntheticBlocks: false,
});
expect(result.tokenOperations).toEqual([]);
expect(result.coinOperations).toMatchObject([
{
type: "ASSOCIATE_TOKEN",
value: expect.any(Object),
hash: "hash1",
fee: expect.any(Object),
senders: [mockMirrorAccount.account],
recipients: ["0.0.3"],
extra: {
pagingToken: "1625097600.000000000",
consensusTimestamp: "1625097600.000000000",
},
},
]);
});
it("should include associatedTokenId in extra when ASSOCIATE_TOKEN creates a token", async () => {
const mockTokenHTS = getMockedHTSTokenCurrency();
const mockMirrorTransaction = getMockedMirrorTransaction({
consensus_timestamp: "1625097600.000000000",
transaction_hash: "hash1",
charged_tx_fee: 500000,
result: "SUCCESS",
token_transfers: [],
staking_reward_transfers: [],
transfers: [{ account: mockMirrorAccount.account, amount: -500000 }],
name: "TOKENASSOCIATE",
});
const mockMirrorToken = getMockedMirrorToken({
token_id: mockTokenHTS.contractAddress,
created_timestamp: mockMirrorTransaction.consensus_timestamp,
});
(apiClient.getAccountTransactions as jest.Mock).mockResolvedValue({
transactions: [mockMirrorTransaction],
nextCursor: null,
});
const result = await listOperations({
limit: mockLimit,
order: mockOrder,
currency: mockCurrency,
address: mockMirrorAccount.account,
evmAddress: mockMirrorAccount.evm_address,
mirrorTokens: [mockMirrorToken],
erc20Tokens: [],
fetchAllPages: true,
skipFeesForTokenOperations: false,
useEncodedHash: false,
useSyntheticBlocks: false,
});
expect(result.tokenOperations).toEqual([]);
expect(result.coinOperations).toEqual([
expect.objectContaining({
type: "ASSOCIATE_TOKEN",
hash: "hash1",
extra: expect.objectContaining({
associatedTokenId: mockTokenHTS.contractAddress,
}),
}),
]);
});
it("should skip token operations when token is not found in cryptoassets", async () => {
const tokenId = "0.0.7890";
const mockTransaction = getMockedMirrorTransaction({
consensus_timestamp: "1625097600.000000000",
transaction_hash: "hash1",
charged_tx_fee: 500000,
result: "SUCCESS",
token_transfers: [
{ token_id: tokenId, account: mockMirrorAccount.account, amount: -1000 },
{ token_id: tokenId, account: "0.0.67890", amount: 1000 },
],
staking_reward_transfers: [],
transfers: [],
name: "CRYPTOTRANSFER",
});
(apiClient.getAccountTransactions as jest.Mock).mockResolvedValue({
transactions: [mockTransaction],
nextCursor: null,
});
setupMockCryptoAssetsStore({
findTokenByAddressInCurrency: jest.fn().mockResolvedValue(null),
});
const result = await listOperations({
limit: mockLimit,
order: mockOrder,
currency: mockCurrency,
address: mockMirrorAccount.account,
evmAddress: mockMirrorAccount.evm_address,
mirrorTokens: [],
erc20Tokens: [],
fetchAllPages: true,
skipFeesForTokenOperations: false,
useEncodedHash: false,
useSyntheticBlocks: false,
});
expect(result.coinOperations).toEqual([]);
expect(result.tokenOperations).toEqual([]);
});
it("should use pagination parameters correctly", async () => {
const customOrder = "asc";
const customLimit = 20;
const lastPagingToken = "1625097500.000000000";
(apiClient.getAccountTransactions as jest.Mock).mockResolvedValue({
transactions: [],
nextCursor: null,
});
await listOperations({
limit: customLimit,
order: customOrder,
cursor: lastPagingToken,
currency: mockCurrency,
address: mockMirrorAccount.account,
evmAddress: mockMirrorAccount.evm_address,
mirrorTokens: [],
erc20Tokens: [],
fetchAllPages: true,
skipFeesForTokenOperations: false,
useEncodedHash: false,
useSyntheticBlocks: false,
});
expect(apiClient.getAccountTransactions).toHaveBeenCalledTimes(1);
expect(apiClient.getAccountTransactions).toHaveBeenCalledWith({
address: mockMirrorAccount.account,
fetchAllPages: true,
pagingToken: lastPagingToken,
order: customOrder,
limit: customLimit,
});
});
it("should handle failed transactions", async () => {
const mockTransaction = getMockedMirrorTransaction({
consensus_timestamp: "1625097600.000000000",
transaction_hash: "hash1",
charged_tx_fee: 500000,
result: "INVALID_SIGNATURE",
memo_base64: "",
token_transfers: [],
staking_reward_transfers: [],
transfers: [
{ account: mockMirrorAccount.account, amount: -1000000 },
{ account: "0.0.67890", amount: 1000000 },
],
name: "CRYPTOTRANSFER",
});
(apiClient.getAccountTransactions as jest.Mock).mockResolvedValue({
transactions: [mockTransaction],
nextCursor: null,
});
const result = await listOperations({
limit: mockLimit,
order: mockOrder,
address: mockMirrorAccount.account,
evmAddress: mockMirrorAccount.evm_address,
currency: mockCurrency,
mirrorTokens: [],
erc20Tokens: [],
fetchAllPages: true,
skipFeesForTokenOperations: false,
useEncodedHash: false,
useSyntheticBlocks: false,
});
expect(result.coinOperations).toMatchObject([{ hasFailed: true }]);
});
it("should include inferred fees payer in operation extra", async () => {
(utils.extractFeesPayer as jest.Mock).mockReturnValue("0.0.23");
const mockTransaction = getMockedMirrorTransaction({
consensus_timestamp: "1625097600.000000000",
transaction_hash: "hash1",
transaction_id: "0.0.10067173-1761755118-730000493",
charged_tx_fee: 40743,
result: "INSUFFICIENT_PAYER_BALANCE",
token_transfers: [],
staking_reward_transfers: [],
transfers: [
{ account: "0.0.23", amount: -40743 },
{ account: "0.0.801", amount: 40743 },
],
name: "CRYPTOTRANSFER",
});
(apiClient.getAccountTransactions as jest.Mock).mockResolvedValue({
transactions: [mockTransaction],
nextCursor: null,
});
const result = await listOperations({
limit: mockLimit,
order: mockOrder,
address: mockMirrorAccount.account,
evmAddress: mockMirrorAccount.evm_address,
currency: mockCurrency,
mirrorTokens: [],
erc20Tokens: [],
fetchAllPages: true,
skipFeesForTokenOperations: false,
useEncodedHash: false,
useSyntheticBlocks: false,
});
expect(result.coinOperations).toMatchObject([
{
extra: {
transactionId: "0.0.10067173-1761755118-730000493",
feesPayer: "0.0.23",
},
},
]);
});
it("should create REWARD operation when staking rewards are present", async () => {
const mockTransaction = getMockedMirrorTransaction({
consensus_timestamp: "1625097600.000000000",
transaction_hash: "hash1",
charged_tx_fee: 500000,
result: "SUCCESS",
memo_base64: "",
token_transfers: [],
staking_reward_transfers: [{ account: mockMirrorAccount.account, amount: 1000000 }],
transfers: [{ account: mockMirrorAccount.account, amount: -500000 }],
name: "CRYPTOTRANSFER",
});
(apiClient.getAccountTransactions as jest.Mock).mockResolvedValue({
transactions: [mockTransaction],
nextCursor: null,
});
const result = await listOperations({
limit: mockLimit,
order: mockOrder,
address: mockMirrorAccount.account,
evmAddress: mockMirrorAccount.evm_address,
currency: mockCurrency,
mirrorTokens: [],
erc20Tokens: [],
fetchAllPages: true,
skipFeesForTokenOperations: false,
useEncodedHash: false,
useSyntheticBlocks: false,
});
const rewardTimestamp = result.coinOperations[0].date.getTime();
const mainTimestamp = result.coinOperations[1].date.getTime();
expect(result.tokenOperations).toEqual([]);
expect(rewardTimestamp).toBe(mainTimestamp + 1);
expect(result.coinOperations).toMatchObject([
{
type: "REWARD",
hash: utils.createStakingRewardOperationHash(mockTransaction.transaction_hash ?? ""),
value: new BigNumber(1000000),
fee: new BigNumber(0),
senders: [getEnv("HEDERA_STAKING_REWARD_ACCOUNT_ID")],
recipients: [mockMirrorAccount.account],
},
{
type: "OUT",
hash: mockTransaction.transaction_hash,
},
]);
});
it("should create REWARD operation for ERC20 transfers with staking rewards", async () => {
const mockTokenERC20 = getMockedERC20TokenCurrency();
const mockRewardAmount = 10000000;
const mockMirrorTransaction = getMockedMirrorTransaction({
consensus_timestamp: "1625097600.000000000",
transaction_hash: "erc20-transfer-hash",
charged_tx_fee: 300000,
result: "SUCCESS",
name: "CONTRACTCALL",
staking_reward_transfers: [{ account: mockMirrorAccount.account, amount: mockRewardAmount }],
token_transfers: [],
transfers: [{ account: mockMirrorAccount.account, amount: -300000 }],
});
const mockERC20Transfer = getMockedERC20TokenTransfer({
transaction_hash: mockMirrorTransaction.transaction_hash,
consensus_timestamp:
Number(mockMirrorTransaction.consensus_timestamp.split(".")[0]) * 10 ** 9,
sender_account_id: 12345,
receiver_account_id: 67890,
sender_evm_address: mockMirrorAccount.evm_address,
receiver_evm_address: "0xrecipient",
payer_account_id: 12345,
amount: 5000000,
});
const mockContractCallResult = getMockedMirrorContractCallResult();
const mockEnrichedERC20Transfer = getMockedEnrichedERC20Transfer({
mirrorTransaction: mockMirrorTransaction,
contractCallResult: mockContractCallResult,
transfers: [mockERC20Transfer],
});
jest.spyOn(networkUtils, "enrichERC20Transfers").mockResolvedValue([mockEnrichedERC20Transfer]);
(apiClient.getAccountTransactions as jest.Mock).mockResolvedValue({
transactions: [],
nextCursor: null,
});
(hgraphClient.getERC20Transfers as jest.Mock).mockResolvedValue([mockERC20Transfer]);
(hgraphClient.getLatestIndexedConsensusTimestamp as jest.Mock).mockResolvedValue(
new BigNumber(mockMirrorTransaction.consensus_timestamp),
);
setupMockCryptoAssetsStore({
findTokenByAddressInCurrency: jest.fn().mockResolvedValue(mockTokenERC20),
});
const result = await listOperations({
limit: mockLimit,
order: mockOrder,
currency: mockCurrency,
address: mockMirrorAccount.account,
evmAddress: mockMirrorAccount.evm_address,
mirrorTokens: [],
erc20Tokens: [{ token: mockTokenERC20, balance: new BigNumber(10000000) }],
fetchAllPages: true,
skipFeesForTokenOperations: false,
useEncodedHash: false,
useSyntheticBlocks: false,
});
expect(result.tokenOperations).toEqual([
expect.objectContaining({
type: "OUT",
standard: "erc20",
contract: mockTokenERC20.contractAddress,
hash: mockMirrorTransaction.transaction_hash,
value: new BigNumber(mockERC20Transfer.amount),
}),
]);
expect(result.coinOperations).toEqual([
expect.objectContaining({
type: "REWARD",
hash: utils.createStakingRewardOperationHash(mockMirrorTransaction.transaction_hash ?? ""),
value: new BigNumber(mockRewardAmount),
}),
expect.objectContaining({
type: "FEES",
hash: mockMirrorTransaction.transaction_hash,
value: new BigNumber(mockMirrorTransaction.charged_tx_fee),
}),
]);
});
it("should create STAKE operation when UPDATE_ACCOUNT transaction stakes to a node", async () => {
const mockTransaction = getMockedMirrorTransaction({
consensus_timestamp: "1625097600.000000000",
transaction_hash: "hash1",
charged_tx_fee: 500000,
result: "SUCCESS",
memo_base64: "",
token_transfers: [],
staking_reward_transfers: [],
transfers: [{ account: mockMirrorAccount.account, amount: -500000 }],
name: "CRYPTOUPDATEACCOUNT",
});
const mockStakingAnalysis: StakingAnalysis = {
operationType: "STAKE",
previousStakingNodeId: null,
targetStakingNodeId: 3,
stakedAmount: BigInt(1000000000),
};
(apiClient.getAccountTransactions as jest.Mock).mockResolvedValue({
transactions: [mockTransaction],
nextCursor: null,
});
(utils.analyzeStakingOperation as jest.Mock).mockResolvedValue(mockStakingAnalysis);
const result = await listOperations({
limit: mockLimit,
order: mockOrder,
currency: mockCurrency,
address: mockMirrorAccount.account,
evmAddress: mockMirrorAccount.evm_address,
mirrorTokens: [],
erc20Tokens: [],
fetchAllPages: true,
skipFeesForTokenOperations: false,
useEncodedHash: false,
useSyntheticBlocks: false,
});
expect(result.tokenOperations).toEqual([]);
expect(result.coinOperations).toEqual([
expect.objectContaining({
type: "STAKE",
hash: "hash1",
fee: new BigNumber(500000),
extra: expect.objectContaining({
previousStakingNodeId: null,
targetStakingNodeId: 3,
stakedAmount: new BigNumber(1000000000),
}),
}),
]);
});
it("should create UNSTAKE operation when UPDATE_ACCOUNT transaction removes staking", async () => {
const mockTransaction = getMockedMirrorTransaction({
consensus_timestamp: "1625097600.000000000",
transaction_hash: "hash1",
charged_tx_fee: 500000,
result: "SUCCESS",
memo_base64: "",
token_transfers: [],
staking_reward_transfers: [],
transfers: [{ account: mockMirrorAccount.account, amount: -500000 }],
name: "CRYPTOUPDATEACCOUNT",
});
const mockStakingAnalysis: StakingAnalysis = {
operationType: "UNSTAKE",
previousStakingNodeId: 3,
targetStakingNodeId: null,
stakedAmount: BigInt(0),
};
(apiClient.getAccountTransactions as jest.Mock).mockResolvedValue({
transactions: [mockTransaction],
nextCursor: null,
});
(utils.analyzeStakingOperation as jest.Mock).mockResolvedValue(mockStakingAnalysis);
const result = await listOperations({
limit: mockLimit,
order: mockOrder,
currency: mockCurrency,
address: mockMirrorAccount.account,
evmAddress: mockMirrorAccount.evm_address,
mirrorTokens: [],
erc20Tokens: [],
fetchAllPages: true,
skipFeesForTokenOperations: false,
useEncodedHash: false,
useSyntheticBlocks: false,
});
expect(result.tokenOperations).toEqual([]);
expect(result.coinOperations).toEqual([
expect.objectContaining({
type: "UNSTAKE",
hash: "hash1",
fee: new BigNumber(500000),
extra: expect.objectContaining({
previousStakingNodeId: 3,
targetStakingNodeId: null,
stakedAmount: new BigNumber(0),
}),
}),
]);
});
it("should skip FEES operations for HTS IN transfers", async () => {
const mockTokenHTS = getMockedHTSTokenCurrency();
const mockTransaction = getMockedMirrorTransaction({
consensus_timestamp: "1625097600.000000000",
transaction_hash: "hash1",
charged_tx_fee: 500000,
result: "SUCCESS",
token_transfers: [
{ token_id: mockTokenHTS.contractAddress, account: "0.0.67890", amount: -1000 },
{
token_id: mockTokenHTS.contractAddress,
account: mockMirrorAccount.account,
amount: 1000,
},
],
staking_reward_transfers: [],
transfers: [],
name: "CRYPTOTRANSFER",
});
(apiClient.getAccountTransactions as jest.Mock).mockResolvedValue({
transactions: [mockTransaction],
nextCursor: null,
});
setupMockCryptoAssetsStore({
findTokenByAddressInCurrency: jest.fn().mockResolvedValue(mockTokenHTS),
});
const result = await listOperations({
limit: mockLimit,
order: mockOrder,
currency: mockCurrency,
address: mockMirrorAccount.account,
evmAddress: mockMirrorAccount.evm_address,
mirrorTokens: [],
erc20Tokens: [],
fetchAllPages: true,
skipFeesForTokenOperations: false,
useEncodedHash: false,
useSyntheticBlocks: false,
});
expect(result.coinOperations).toEqual([]);
expect(result.tokenOperations).toEqual([expect.objectContaining({ type: "IN" })]);
});
it("should skip FEES operations for ERC20 IN transfers", async () => {
const mockTokenERC20 = getMockedERC20TokenCurrency();
const sharedHash = "erc20-in-transfer-hash";
const sharedTimestamp = "1625097600.000000000";
const mockMirrorTransaction = getMockedMirrorTransaction({
consensus_timestamp: sharedTimestamp,
transaction_hash: sharedHash,
charged_tx_fee: 300000,
result: "SUCCESS",
name: "CONTRACTCALL",
staking_reward_transfers: [],
token_transfers: [],
transfers: [],
});
const mockERC20Transfer = getMockedERC20TokenTransfer({
transaction_hash: sharedHash,
consensus_timestamp: Number(sharedTimestamp.split(".")[0]) * 10 ** 9,
sender_account_id: 67890,
receiver_account_id: 12345,
sender_evm_address: "0xsender",
receiver_evm_address: mockMirrorAccount.evm_address,
payer_account_id: 67890,
amount: 5000000,
});
const mockContractCallResult = getMockedMirrorContractCallResult();
const mockEnrichedERC20Transfer = getMockedEnrichedERC20Transfer({
mirrorTransaction: mockMirrorTransaction,
contractCallResult: mockContractCallResult,
transfers: [mockERC20Transfer],
});
jest.spyOn(networkUtils, "enrichERC20Transfers").mockResolvedValue([mockEnrichedERC20Transfer]);
(apiClient.getAccountTransactions as jest.Mock).mockResolvedValue({
transactions: [],
nextCursor: null,
});
(hgraphClient.getERC20Transfers as jest.Mock).mockResolvedValue([mockERC20Transfer]);
(hgraphClient.getLatestIndexedConsensusTimestamp as jest.Mock).mockResolvedValue(
new BigNumber(sharedTimestamp),
);
setupMockCryptoAssetsStore({
findTokenByAddressInCurrency: jest.fn().mockResolvedValue(mockTokenERC20),
});
const result = await listOperations({
limit: mockLimit,
order: mockOrder,
currency: mockCurrency,
address: mockMirrorAccount.account,
evmAddress: mockMirrorAccount.evm_address,
mirrorTokens: [],
erc20Tokens: [{ token: mockTokenERC20, balance: new BigNumber(10000000) }],
fetchAllPages: true,
skipFeesForTokenOperations: false,
useEncodedHash: false,
useSyntheticBlocks: false,
});
expect(result.coinOperations).toEqual([]);
expect(result.tokenOperations).toEqual([expect.objectContaining({ type: "IN" })]);
});
it("should produce two token operations for a swap with two different-token transfers", async () => {
const sharedHash = "erc20-in-transfer-hash";
const mockTokenA = getMockedERC20TokenCurrency({
id: "hedera/erc20/0xTokenA",
contractAddress: "0xTokenA",
});
const mockTokenB = getMockedERC20TokenCurrency({
id: "hedera/erc20/0xTokenB",
contractAddress: "0xTokenB",
});
const mockErc20TransferOut = getMockedERC20TokenTransfer({
token_evm_address: mockTokenA.contractAddress,
sender_evm_address: mockMirrorAccount.evm_address,
sender_account_id: 12345,
receiver_account_id: 99999,
transfer_type: "transfer",
amount: 1000,
transaction_hash: sharedHash,
});
const mockErc20TransferIn = getMockedERC20TokenTransfer({
token_evm_address: mockTokenB.contractAddress,
receiver_evm_address: mockMirrorAccount.evm_address,
sender_account_id: 99999,
receiver_account_id: 12345,
transfer_type: "transfer",
amount: 2000,
transaction_hash: sharedHash,
});
const mockEnrichedERC20Transfer = getMockedEnrichedERC20Transfer({
transfers: [mockErc20TransferOut, mockErc20TransferIn],
});
jest.spyOn(networkUtils, "enrichERC20Transfers").mockResolvedValue([mockEnrichedERC20Transfer]);
(apiClient.getAccountTransactions as jest.Mock).mockResolvedValue({
transactions: [],
nextCursor: null,
});
(hgraphClient.getERC20Transfers as jest.Mock).mockResolvedValue([]);
(hgraphClient.getLatestIndexedConsensusTimestamp as jest.Mock).mockResolvedValue(
new BigNumber("1625097600.000000000"),
);
setupMockCryptoAssetsStore({
findTokenByAddressInCurrency: jest
.fn()
.mockResolvedValueOnce(mockTokenA) // for transfer out
.mockResolvedValueOnce(mockTokenB), // for transfer in
});
const result = await listOperations({
limit: mockLimit,
order: mockOrder,
currency: mockCurrency,
address: mockMirrorAccount.account,
evmAddress: mockMirrorAccount.evm_address,
mirrorTokens: [],
erc20Tokens: [
{ token: mockTokenA, balance: new BigNumber(10000) },
{ token: mockTokenB, balance: new BigNumber(20000) },
],
fetchAllPages: true,
skipFeesForTokenOperations: false,
useEncodedHash: false,
useSyntheticBlocks: false,
});
expect(result.coinOperations).toEqual([expect.objectContaining({ type: "FEES" })]);
expect(result.tokenOperations).toEqual([
expect.objectContaining({ type: "OUT" }),
expect.objectContaining({ type: "IN" }),
]);
});
it("should skip FEES operations when skipFeesForTokenOperations is true", async () => {
const mockTokenHTS = getMockedHTSTokenCurrency();
const mockTransaction = getMockedMirrorTransaction({
consensus_timestamp: "1625097600.000000000",
transaction_hash: "hash1",
charged_tx_fee: 500000,
result: "SUCCESS",
token_transfers: [
{
token_id: mockTokenHTS.contractAddress,
account: mockMirrorAccount.account,
amount: -1000,
},
{ token_id: mockTokenHTS.contractAddress, account: "0.0.67890", amount: 1000 },
],
staking_reward_transfers: [],
transfers: [],
name: "CRYPTOTRANSFER",
});
(apiClient.getAccountTransactions as jest.Mock).mockResolvedValue({
transactions: [mockTransaction],
nextCursor: null,
});
setupMockCryptoAssetsStore({
findTokenByAddressInCurrency: jest.fn().mockResolvedValue(mockTokenHTS),
});
const result = await listOperations({
limit: mockLimit,
order: mockOrder,
currency: mockCurrency,
address: mockMirrorAccount.account,
evmAddress: mockMirrorAccount.evm_address,
mirrorTokens: [],
erc20Tokens: [],
fetchAllPages: true,
skipFeesForTokenOperations: true,
useEncodedHash: false,
useSyntheticBlocks: false,
});
expect(result.coinOperations).toHaveLength(0);
expect(result.tokenOperations).toHaveLength(1);
expect(result.tokenOperations[0].type).toBe("OUT");
});
it("should use encoded hash when useEncodedHash is true", async () => {
const mockTransaction = getMockedMirrorTransaction({
consensus_timestamp: "1625097600.000000000",
transaction_hash: "hash1",
charged_tx_fee: 500000,
result: "SUCCESS",
memo_base64: "",
token_transfers: [],
staking_reward_transfers: [],
transfers: [
{ account: mockMirrorAccount.account, amount: -1000000 },
{ account: "0.0.67890", amount: 1000000 },
],
name: "CRYPTOTRANSFER",
});
(apiClient.getAccountTransactions as jest.Mock).mockResolvedValue({
transactions: [mockTransaction],
nextCursor: null,
});
const result = await listOperations({
limit: mockLimit,
order: mockOrder,
currency: mockCurrency,
address: mockMirrorAccount.account,
evmAddress: mockMirrorAccount.evm_address,
mirrorTokens: [],
erc20Tokens: [],
fetchAllPages: true,
skipFeesForTokenOperations: false,
useEncodedHash: true,
useSyntheticBlocks: false,
});
expect(result.coinOperations).toHaveLength(1);
expect(result.coinOperations[0].hash).toBe("encoded-hash1");
});
it("should use synthetic blocks when useSyntheticBlocks is true", async () => {
const mockTransaction = getMockedMirrorTransaction({
consensus_timestamp: "1625097600.000000000",
transaction_hash: "hash1",
charged_tx_fee: 500000,
result: "SUCCESS",
memo_base64: "",
token_transfers: [],
staking_reward_transfers: [],
transfers: [
{ account: mockMirrorAccount.account, amount: -1000000 },
{ account: "0.0.67890", amount: 1000000 },
],
name: "CRYPTOTRANSFER",
});
(apiClient.getAccountTransactions as jest.Mock).mockResolvedValue({
transactions: [mockTransaction],
nextCursor: null,
});
const result = await listOperations({
limit: mockLimit,
order: mockOrder,
currency: mockCurrency,
address: mockMirrorAccount.account,
evmAddress: mockMirrorAccount.evm_address,
mirrorTokens: [],
erc20Tokens: [],
fetchAllPages: true,
skipFeesForTokenOperations: false,
useEncodedHash: false,
useSyntheticBlocks: true,
});
expect(utils.getSyntheticBlock).toHaveBeenCalledTimes(1);
expect(result.coinOperations).toEqual([
expect.objectContaining({
blockHeight: mockSyntheticBlock.blockHeight,
blockHash: mockSyntheticBlock.blockHash,
}),
]);
});
it("should use synthetic block hash for ERC20 transfers when useSyntheticBlocks is true", async () => {
const mockTokenERC20 = getMockedERC20TokenCurrency();
const sharedHash = "erc20-transfer-hash-synthetic";
const sharedTimestamp = "1625097600.000000000";
const mockMirrorTransaction = getMockedMirrorTransaction({
consensus_timestamp: sharedTimestamp,
transaction_hash: sharedHash,
charged_tx_fee: 300000,
result: "SUCCESS",
name: "CONTRACTCALL",
staking_reward_transfers: [],
token_transfers: [],
transfers: [{ account: mockMirrorAccount.account, amount: -300000 }],
});
const mockERC20Transfer = getMockedERC20TokenTransfer({
transaction_hash: sharedHash,
consensus_timestamp: Number(sharedTimestamp.split(".")[0]) * 10 ** 9,
sender_account_id: 12345,
receiver_account_id: 67890,
sender_evm_address: mockMirrorAccount.evm_address,
receiver_evm_address: "0xrecipient",
payer_account_id: 12345,
amount: 5000000,
});
const mockContractCallResult = getMockedMirrorContractCallResult({
block_hash: "0xevm-block-hash-should-not-be-used",
});
const mockEnrichedERC20Transfer = getMockedEnrichedERC20Transfer({
mirrorTransaction: mockMirrorTransaction,
contractCallResult: mockContractCallResult,
transfers: [mockERC20Transfer],
});
jest.spyOn(networkUtils, "enrichERC20Transfers").mockResolvedValue([mockEnrichedERC20Transfer]);
(apiClient.getAccountTransactions as jest.Mock).mockResolvedValue({
transactions: [],
nextCursor: null,
});
(hgraphClient.getERC20Transfers as jest.Mock).mockResolvedValue([mockERC20Transfer]);
(hgraphClient.getLatestIndexedConsensusTimestamp as jest.Mock).mockResolvedValue(
new BigNumber(sharedTimestamp),
);
setupMockCryptoAssetsStore({
findTokenByAddressInCurrency: jest.fn().mockResolvedValue(mockTokenERC20),
});
const result = await listOperations({
limit: mockLimit,
order: mockOrder,
currency: mockCurrency,
address: mockMirrorAccount.account,
evmAddress: mockMirrorAccount.evm_address,
mirrorTokens: [],
erc20Tokens: [{ token: mockTokenERC20, balance: new BigNumber(10000000) }],
fetchAllPages: true,
skipFeesForTokenOperations: false,
useEncodedHash: false,
useSyntheticBlocks: true,
});
expect(result.tokenOperations).toEqual([
expect.objectContaining({
type: "OUT",
standard: "erc20",
blockHeight: mockSyntheticBlock.blockHeight,
blockHash: mockSyntheticBlock.blockHash,
}),
]);
});
it("should deduplicate CONTRACT_CALL operations when ERC20 transfer exists for same hash", async () => {
const sharedHash = "contract-call-hash";
const sharedTimestamp = "1625097600.000000000";
const mockTokenERC20 = getMockedERC20TokenCurrency();
const mockMirrorTransaction = getMockedMirrorTransaction({
consensus_timestamp: sharedTimestamp,
transaction_hash: sharedHash,
charged_tx_fee: 200000,
result: "SUCCESS",
name: "CONTRACTCALL",
staking_reward_transfers: [],
transfers: [{ account: mockMirrorAccount.account, amount: -200000 }],
token_transfers: [],
});
const mockERC20Transfer = getMockedERC20TokenTransfer({
transaction_hash: sharedHash,
consensus_timestamp: Number(sharedTimestamp) * 10 ** 9,
sender_account_id: 1234,
sender_evm_address: mockMirrorAccount.evm_address,
payer_account_id: 1234,
amount: 1000000,
});
const mockContractCallResult = getMockedMirrorContractCallResult();
const mockEnrichedERC20Transfer = getMockedEnrichedERC20Transfer({
mirrorTransaction: mockMirrorTransaction,
contractCallResult: mockContractCallResult,
transfers: [mockERC20Transfer],
});
jest.spyOn(networkUtils, "enrichERC20Transfers").mockResolvedValue([mockEnrichedERC20Transfer]);
(apiClient.getAccountTransactions as jest.Mock).mockResolvedValue({
transactions: [mockMirrorTransaction],
nextCursor: null,
});
(hgraphClient.getERC20Transfers as jest.Mock).mockResolvedValue([mockERC20Transfer]);
(apiClient.getContractCallResult as jest.Mock).mockResolvedValue(mockContractCallResult);
(apiClient.findTransactionByContractCall as jest.Mock).mockResolvedValue(mockMirrorTransaction);
(hgraphClient.getLatestIndexedConsensusTimestamp as jest.Mock).mockResolvedValue(
new BigNumber(sharedTimestamp),
);
setupMockCryptoAssetsStore({
findTokenByAddressInCurrency: jest.fn().mockResolvedValue(mockTokenERC20),
});
const result = await listOperations({
limit: mockLimit,
order: mockOrder,
currency: mockCurrency,
address: mockMirrorAccount.account,
evmAddress: mockMirrorAccount.evm_address,
mirrorTokens: [],
erc20Tokens: [{ token: mockTokenERC20, balance: new BigNumber(5000000) }],
fetchAllPages: true,
skipFeesForTokenOperations: false,
useEncodedHash: false,
useSyntheticBlocks: false,
});
expect(result.coinOperations).toEqual([expect.objectContaining({ type: "FEES" })]);
expect(result.tokenOperations).toEqual([
expect.objectContaining({ type: "OUT", standard: "erc20" }),
]);
});
it("should sort with nanosecond precision", async () => {
const mockTransaction1 = getMockedMirrorTransaction({
consensus_timestamp: "1625097600.000000003",
transaction_hash: "hash3",
charged_tx_fee: 100000,
result: "SUCCESS",
name: "CRYPTOTRANSFER",
token_transfers: [],
staking_reward_transfers: [],
transfers: [{ account: mockMirrorAccount.account, amount: -300000 }],
});
const mockTransaction2 = getMockedMirrorTransaction({
consensus_timestamp: "1625097600.000000001",
transaction_hash: "hash1",
charged_tx_fee: 100000,
result: "SUCCESS",
name: "CRYPTOTRANSFER",
token_transfers: [],
staking_reward_transfers: [],
transfers: [{ account: mockMirrorAccount.account, amount: -100000 }],
});
const mockTransaction3 = getMockedMirrorTransaction({
consensus_timestam