UNPKG

@ledgerhq/coin-hedera

Version:
1,175 lines (1,051 loc) 42.3 kB
import { AccountId, Hbar, HbarUnit, Long, TokenAssociateTransaction, TransferTransaction, AccountUpdateTransaction, ContractExecuteTransaction, ContractFunctionParameters, } from "@hashgraph/sdk"; import type { FeeEstimation } from "@ledgerhq/coin-module-framework/api/types"; import { setupCalClientStore } from "@ledgerhq/cryptoassets/cal-client/test-helpers"; import { getEnv } from "@ledgerhq/live-env"; import invariant from "invariant"; import { createApi } from "../api"; import { HEDERA_TRANSACTION_MODES, STAKING_REWARD_HASH_SUFFIX, TINYBAR_SCALE } from "../constants"; import { getSyntheticBlock, toEVMAddress } from "../logic/utils"; import { rpcClient } from "../network/rpc"; import { MAINNET_TEST_ACCOUNTS } from "../test/fixtures/account.fixture"; describe("createApi", () => { const api = createApi({ useHgraphForErc20: true, useNetworkTimestamp: true }, "hedera"); beforeAll(() => { // Setup CAL client store (automatically set as global store) setupCalClientStore(); }); afterAll(async () => { await rpcClient._resetInstance(); }); describe("craftTransaction", () => { it("returns serialized native coin TransferTransaction", async () => { const { transaction: hex } = await api.craftTransaction({ intentType: "transaction", asset: { type: "native", }, type: HEDERA_TRANSACTION_MODES.Send, amount: BigInt(1 * 10 ** TINYBAR_SCALE), sender: MAINNET_TEST_ACCOUNTS.withoutTokens.accountId, senderPublicKey: MAINNET_TEST_ACCOUNTS.withoutTokens.publicKey, recipient: MAINNET_TEST_ACCOUNTS.withTokens.accountId, memo: { kind: "text", type: "string", value: "native transfer", }, }); const rawTx = TransferTransaction.fromBytes(Buffer.from(hex, "hex")); expect(rawTx).toBeInstanceOf(TransferTransaction); invariant(rawTx instanceof TransferTransaction, "TransferTransaction type guard"); const sendTransfer = rawTx.hbarTransfers.get(MAINNET_TEST_ACCOUNTS.withoutTokens.accountId); const receiveTransfer = rawTx.hbarTransfers.get(MAINNET_TEST_ACCOUNTS.withTokens.accountId); expect(rawTx.hbarTransfers.size).toBe(2); expect(sendTransfer).toEqual(Hbar.from(-1, HbarUnit.Hbar)); expect(receiveTransfer).toEqual(Hbar.from(1, HbarUnit.Hbar)); expect(rawTx.transactionMemo).toBe("native transfer"); }); it("returns serialized HTS token TransferTransaction", async () => { const { transaction: hex } = await api.craftTransaction({ intentType: "transaction", asset: { type: "hts", assetReference: "0.0.5022567", }, type: HEDERA_TRANSACTION_MODES.Send, amount: BigInt(1), sender: MAINNET_TEST_ACCOUNTS.withoutTokens.accountId, senderPublicKey: MAINNET_TEST_ACCOUNTS.withoutTokens.publicKey, recipient: MAINNET_TEST_ACCOUNTS.withTokens.accountId, memo: { kind: "text", type: "string", value: "hts token transfer", }, }); const rawTx = TransferTransaction.fromBytes(Buffer.from(hex, "hex")); expect(rawTx).toBeInstanceOf(TransferTransaction); invariant(rawTx instanceof TransferTransaction, "TransferTransaction type guard"); const tokenTransfers = rawTx.tokenTransfers.get("0.0.5022567"); const senderTransfer = tokenTransfers?.get(MAINNET_TEST_ACCOUNTS.withoutTokens.accountId); const recipientTransfer = tokenTransfers?.get(MAINNET_TEST_ACCOUNTS.withTokens.accountId); expect(senderTransfer).toEqual(Long.fromNumber(-1)); expect(recipientTransfer).toEqual(Long.fromNumber(1)); expect(tokenTransfers).not.toBeNull(); expect(rawTx.transactionMemo).toBe("hts token transfer"); }); it("returns serialized ERC20 token ContractExecuteTransaction", async () => { const { transaction: hex } = await api.craftTransaction({ intentType: "transaction", asset: { type: "erc20", assetReference: "0xca367694cdac8f152e33683bb36cc9d6a73f1ef2", }, type: HEDERA_TRANSACTION_MODES.Send, amount: 1n, sender: MAINNET_TEST_ACCOUNTS.withoutTokens.accountId, senderPublicKey: MAINNET_TEST_ACCOUNTS.withoutTokens.publicKey, recipient: MAINNET_TEST_ACCOUNTS.withTokens.accountId, memo: { kind: "text", type: "string", value: "erc20 token transfer", }, data: { type: "erc20", gasLimit: 100n, }, } as any); const rawTx = ContractExecuteTransaction.fromBytes(Buffer.from(hex, "hex")); expect(rawTx).toBeInstanceOf(ContractExecuteTransaction); invariant( rawTx instanceof ContractExecuteTransaction, "ContractExecuteTransaction type guard", ); const recipientEvmAddress = await toEVMAddress(MAINNET_TEST_ACCOUNTS.withTokens.accountId); invariant(recipientEvmAddress, "hedera: missing recipient EVM address"); const expectedFunctionParameters = new ContractFunctionParameters() .addAddress(recipientEvmAddress) .addUint256(1); expect(rawTx.gas).toEqual(Long.fromNumber(100)); expect(rawTx.transactionMemo).toBe("erc20 token transfer"); expect(rawTx.functionParameters).toEqual( Buffer.concat([ Buffer.from([0xa9, 0x05, 0x9c, 0xbb]), // transfer(address,uint256) selector Buffer.from(expectedFunctionParameters._build()), // address + amount parameters ]), ); }); it("returns serialized HTS token association transaction", async () => { const { transaction: hex } = await api.craftTransaction({ intentType: "transaction", asset: { type: "hts", assetReference: "0.0.5022567", }, amount: BigInt(0), sender: MAINNET_TEST_ACCOUNTS.withoutTokens.accountId, senderPublicKey: MAINNET_TEST_ACCOUNTS.withoutTokens.publicKey, recipient: MAINNET_TEST_ACCOUNTS.withoutTokens.accountId, type: HEDERA_TRANSACTION_MODES.TokenAssociate, memo: { kind: "text", type: "string", value: "token association", }, }); const rawTx = TokenAssociateTransaction.fromBytes(Buffer.from(hex, "hex")); expect(rawTx).toBeInstanceOf(TokenAssociateTransaction); invariant(rawTx instanceof TokenAssociateTransaction, "TokenAssociateTransaction type guard"); expect(rawTx.accountId).toEqual( AccountId.fromString(MAINNET_TEST_ACCOUNTS.withoutTokens.accountId), ); // .toString() is used because sdk.TokenId.fromString() sets `_checksum` to undefined, // where tokenIds elements from TokenAssociateTransaction.fromBytes have it set to null expect(rawTx.tokenIds?.[0]?.toString()).toEqual("0.0.5022567"); expect(rawTx.transactionMemo).toBe("token association"); }); it.each([HEDERA_TRANSACTION_MODES.Delegate, HEDERA_TRANSACTION_MODES.Undelegate])( "returns serialized %s transaction", async type => { const { transaction: hex } = await api.craftTransaction({ intentType: "transaction", asset: { type: "native", }, type, amount: BigInt(0), sender: MAINNET_TEST_ACCOUNTS.withoutTokens.accountId, senderPublicKey: MAINNET_TEST_ACCOUNTS.withoutTokens.publicKey, recipient: MAINNET_TEST_ACCOUNTS.withoutTokens.accountId, memo: { kind: "text", type: "string", value: type, }, }); const rawTx = AccountUpdateTransaction.fromBytes(Buffer.from(hex, "hex")); expect(rawTx).toBeInstanceOf(AccountUpdateTransaction); invariant(rawTx instanceof AccountUpdateTransaction, "AccountUpdateTransaction type guard"); expect(rawTx.accountId).toEqual( AccountId.fromString(MAINNET_TEST_ACCOUNTS.withoutTokens.accountId), ); expect(rawTx.transactionMemo).toBe(type); }, ); it("applies customFees properly", async () => { const customFees: FeeEstimation = { value: BigInt(1000), }; const { transaction: hex } = await api.craftTransaction( { intentType: "transaction", asset: { type: "native", }, amount: BigInt(1 * 10 ** TINYBAR_SCALE), sender: MAINNET_TEST_ACCOUNTS.withoutTokens.accountId, senderPublicKey: MAINNET_TEST_ACCOUNTS.withoutTokens.publicKey, recipient: MAINNET_TEST_ACCOUNTS.withTokens.accountId, type: HEDERA_TRANSACTION_MODES.Send, memo: { kind: "text", type: "string", value: "", }, }, customFees, ); const rawTx = TransferTransaction.fromBytes(Buffer.from(hex, "hex")); const expectedMaxFee = Hbar.from(customFees.value.toString(), HbarUnit.Tinybar); expect(rawTx).toBeInstanceOf(TransferTransaction); invariant(rawTx instanceof TransferTransaction, "TransferTransaction type guard"); expect(rawTx.maxTransactionFee).toEqual(expectedMaxFee); }); it("throws if useAllAmount is true", async () => { await expect( api.craftTransaction({ intentType: "transaction", asset: { type: "native", }, amount: BigInt(100), useAllAmount: true, sender: MAINNET_TEST_ACCOUNTS.withoutTokens.accountId, senderPublicKey: MAINNET_TEST_ACCOUNTS.withoutTokens.publicKey, recipient: MAINNET_TEST_ACCOUNTS.withoutTokens.accountId, type: HEDERA_TRANSACTION_MODES.TokenAssociate, memo: { kind: "text", type: "string", value: "token association", }, }), ).rejects.toThrow("useAllAmount is not supported"); }); }); describe("estimateFees", () => { it("returns fee for coin transfer transaction", async () => { const fees = await api.estimateFees({ intentType: "transaction", asset: { type: "native", }, type: HEDERA_TRANSACTION_MODES.Send, sender: MAINNET_TEST_ACCOUNTS.withoutTokens.accountId, senderPublicKey: MAINNET_TEST_ACCOUNTS.withoutTokens.publicKey, amount: BigInt(100), recipient: MAINNET_TEST_ACCOUNTS.withTokens.accountId, memo: { kind: "text", type: "string", value: "", }, }); expect(fees.value).toBeGreaterThanOrEqual(0n); }); it("returns fee for HTS token transfer transaction", async () => { const fees = await api.estimateFees({ intentType: "transaction", asset: { type: "hts", assetReference: "0.0.5022567", }, type: HEDERA_TRANSACTION_MODES.Send, sender: MAINNET_TEST_ACCOUNTS.withoutTokens.accountId, senderPublicKey: MAINNET_TEST_ACCOUNTS.withoutTokens.publicKey, amount: BigInt(100), recipient: MAINNET_TEST_ACCOUNTS.withTokens.accountId, memo: { kind: "text", type: "string", value: "", }, }); expect(fees.value).toBeGreaterThanOrEqual(0n); }); it("returns fee for ERC20 token transfer transaction", async () => { const fees = await api.estimateFees({ intentType: "transaction", asset: { type: "erc20", assetReference: "0xca367694cdac8f152e33683bb36cc9d6a73f1ef2", }, type: HEDERA_TRANSACTION_MODES.Send, sender: MAINNET_TEST_ACCOUNTS.withoutTokens.accountId, senderPublicKey: MAINNET_TEST_ACCOUNTS.withoutTokens.publicKey, amount: 100n, recipient: MAINNET_TEST_ACCOUNTS.withTokens.accountId, memo: { kind: "text", type: "string", value: "", }, }); expect(fees.value).toBeGreaterThanOrEqual(0n); }); it("returns fee for token association transaction", async () => { const fees = await api.estimateFees({ intentType: "transaction", asset: { type: "hts", assetReference: "0.0.5022567", }, type: HEDERA_TRANSACTION_MODES.TokenAssociate, sender: MAINNET_TEST_ACCOUNTS.withoutTokens.accountId, senderPublicKey: MAINNET_TEST_ACCOUNTS.withoutTokens.publicKey, amount: BigInt(100), recipient: MAINNET_TEST_ACCOUNTS.withTokens.accountId, memo: { kind: "text", type: "string", value: "", }, }); expect(fees.value).toBeGreaterThanOrEqual(0n); }); it.each([ HEDERA_TRANSACTION_MODES.Delegate, HEDERA_TRANSACTION_MODES.Undelegate, HEDERA_TRANSACTION_MODES.ClaimRewards, HEDERA_TRANSACTION_MODES.Redelegate, ])("returns fee for %s transaction", async type => { const fees = await api.estimateFees({ intentType: "transaction", asset: { type: "native", }, type, sender: MAINNET_TEST_ACCOUNTS.withoutTokens.accountId, senderPublicKey: MAINNET_TEST_ACCOUNTS.withoutTokens.publicKey, amount: BigInt(100), recipient: MAINNET_TEST_ACCOUNTS.withTokens.accountId, memo: { kind: "text", type: "string", value: type, }, }); expect(fees.value).toBeGreaterThanOrEqual(0n); }); }); describe("getBalance", () => { it("returns zero balance for pristine account", async () => { const balances = await api.getBalance(MAINNET_TEST_ACCOUNTS.pristine.accountId); expect(balances.length).toBe(1); expect(balances[0].value).toBe(0n); }); it("returns empty result for non-existent account", async () => { const balances = await api.getBalance("0.0.0"); expect(balances).toEqual([]); }); it("returns native asset for account without tokens", async () => { const balances = await api.getBalance(MAINNET_TEST_ACCOUNTS.withoutTokens.accountId); const nativeBalance = balances.filter(b => b.asset.type === "native"); expect(nativeBalance.length).toBe(1); expect(nativeBalance[0].value).toBeGreaterThan(0n); }); it("returns native and token assets for account with tokens", async () => { const balances = await api.getBalance(MAINNET_TEST_ACCOUNTS.withTokens.accountId); const tokenBalances = balances.filter(b => b.asset.type !== "native"); const associatedTokenWithBalance = balances.find(b => { return ( "assetReference" in b.asset && b.asset.assetReference === MAINNET_TEST_ACCOUNTS.withTokens.associatedTokenWithBalance ); }); const associatedTokenWithoutBalance = balances.find(b => { return ( "assetReference" in b.asset && b.asset.assetReference === MAINNET_TEST_ACCOUNTS.withTokens.associatedTokenWithoutBalance ); }); const notAssociatedToken = balances.find(b => { return ( "assetReference" in b.asset && b.asset.assetReference === MAINNET_TEST_ACCOUNTS.withTokens.notAssociatedToken ); }); const erc20TokenBalance = balances.find(b => { return ( "assetReference" in b.asset && b.asset.assetReference === MAINNET_TEST_ACCOUNTS.withTokens.erc20Token ); }); expect(tokenBalances.length).toBeGreaterThan(0); expect(associatedTokenWithBalance?.value).toBeGreaterThan(0n); expect(associatedTokenWithoutBalance?.value).toBe(0n); expect(notAssociatedToken?.value).toBe(undefined); expect(erc20TokenBalance?.value).toBeGreaterThan(0n); }); it("returns stake information for delegated account", async () => { const balances = await api.getBalance(MAINNET_TEST_ACCOUNTS.activeStaking.accountId); const nativeBalance = balances.find(b => b.asset.type === "native"); expect(nativeBalance?.stake).toMatchObject({ uid: MAINNET_TEST_ACCOUNTS.activeStaking.accountId, address: MAINNET_TEST_ACCOUNTS.activeStaking.accountId, asset: { type: "native" }, state: "active", amount: expect.any(BigInt), amountDeposited: expect.any(BigInt), amountRewarded: expect.any(BigInt), delegate: expect.any(String), }); }); it("returns no stake information for non-delegated account", async () => { const balances = await api.getBalance(MAINNET_TEST_ACCOUNTS.inactiveStaking.accountId); const nativeBalance = balances.find(b => b.asset.type === "native"); expect(nativeBalance?.stake).toBe(undefined); }); }); describe("getBlock", () => { it("returns block with proper multi-transfer data", async () => { const blockHeight = 176051087; const multiTransferTxHash = "OoaJ/10qHN/97Zaxj8vxGIJfL9UhrGKaJBwclsL4wUeqbegBAXhdmw+/6/dB6mow"; const expectedCoinTransferTx = { hash: multiTransferTxHash, failed: false, fees: 1176695n, feesPayer: "0.0.8835924", operations: [ { type: "transfer", address: "0.0.15", asset: { type: "native", }, amount: 55631n, }, { type: "transfer", address: "0.0.801", asset: { type: "native", }, amount: 1121064n, }, { type: "transfer", address: "0.0.8835924", asset: { type: "native", }, amount: -2000000n, // -3176695n + 1176695n fee }, { type: "transfer", address: "0.0.9124531", asset: { type: "native", }, amount: 1000000n, }, { type: "transfer", address: "0.0.9169746", asset: { type: "native", }, amount: 1000000n, }, { type: "transfer", address: "0.0.8835924", asset: { type: "hts", assetReference: "0.0.456858", }, amount: -10000n, }, { type: "transfer", address: "0.0.9124531", asset: { type: "hts", assetReference: "0.0.456858", }, amount: 10000n, }, { type: "transfer", address: "0.0.8835924", asset: { type: "hts", assetReference: "0.0.5022567", }, amount: -2n, }, { type: "transfer", address: "0.0.9124531", asset: { type: "hts", assetReference: "0.0.5022567", }, amount: 1n, }, { type: "transfer", address: "0.0.9169746", asset: { type: "hts", assetReference: "0.0.5022567", }, amount: 1n, }, ], }; const block = await api.getBlock(blockHeight); const resultCoinTransferTx = block.transactions.find(tx => tx.hash === multiTransferTxHash); expect(block.info.height).toBe(blockHeight); expect(block.info.hash?.length).toBe(64); expect(block.info.time).toBeInstanceOf(Date); expect(block.info.time?.getTime()).toBeGreaterThan(0); expect(resultCoinTransferTx).toMatchObject(expectedCoinTransferTx); expect(block.transactions).toBeInstanceOf(Array); expect(block.transactions.length).toEqual(48); block.transactions.forEach(tx => { expect(tx.hash.length).toBe(64); expect(tx.fees).toBeGreaterThanOrEqual(0n); }); }); it("returns block with transaction memo", async () => { const blockHeight = 176180671; const txHash = "4Ksb7RTwtvvk9r6vvK0Gwxb38kwPqVbJjP6bL4bu2gTvdwrIGZGk6TWntlgRsjvU"; const block = await api.getBlock(blockHeight); const transaction = block.transactions.find(tx => tx.hash === txHash); expect(transaction?.details?.memo).toBe("test"); }); it("derives fees payer from transfers for failed transactions", async () => { const blockHeight = 176175512; const txPaidBySender = "zlE5fX0N44XgMzi9jxr9G4gcCwuAQ4v75wYVXmqBqE808wLKhc/aS+3ZZFl1XOzp"; const txNotPaidBySender = "su9qFNvTpteObMCdqJZ8UxKmgB0UFafqPbwjpawBKzAzJOPwCgpQz6TLCL80oZXd"; const block = await api.getBlock(blockHeight); const firstTx = block.transactions.find(tx => tx.hash === txPaidBySender); const secondTx = block.transactions.find(tx => tx.hash === txNotPaidBySender); expect(firstTx?.failed).toBe(true); expect(firstTx?.feesPayer).toBe("0.0.10067173"); expect(secondTx?.failed).toBe(true); expect(secondTx?.feesPayer).toBe("0.0.23"); }); it("correctly identifies erc20 operations in blocks", async () => { const blockHeight = 176814261; const txHash = "dN7BMus6+8ISOwNPVt7l4KpQT9VaSM9LG6qLPXBqpRVw83ZPMO6Bzyt63305lLXu"; const block = await api.getBlock(blockHeight); const transaction = block.transactions.find(tx => tx.hash === txHash); expect(transaction?.fees).toBe(BigInt(3741416)); expect(transaction?.operations).toEqual( expect.arrayContaining([ { type: "transfer", address: "0.0.801", asset: { type: "native", }, amount: 3741416n, }, { type: "transfer", address: "0.0.8835924", asset: { type: "native", }, amount: 0n, }, { type: "transfer", address: "0.0.9124531", asset: { type: "erc20", assetReference: "0xca367694cdac8f152e33683bb36cc9d6a73f1ef2", }, amount: 7770000000000n, }, { type: "transfer", address: "0.0.8835924", asset: { type: "erc20", assetReference: "0xca367694cdac8f152e33683bb36cc9d6a73f1ef2", }, amount: -7770000000000n, }, ]), ); }); it("correctly identifies staking operations in blocks", async () => { const [delegateBlock, undelegateBlock, redelegateBlock, rewardsBlock] = await Promise.all([ api.getBlock(176220207), api.getBlock(176220201), api.getBlock(176220211), api.getBlock(176777078), ]); const delegateOperations = delegateBlock.transactions .flatMap(tx => tx.operations) .filter(op => op.type === "other"); const undelegateOperations = undelegateBlock.transactions .flatMap(tx => tx.operations) .filter(op => op.type === "other"); const redelegateOperations = redelegateBlock.transactions .flatMap(tx => tx.operations) .filter(op => op.type === "other"); const rewardsTransaction = rewardsBlock.transactions.find( tx => tx.hash === "dwKzBC5qV79SxlRufB6yfXIVOrNh9Nswt36zDoxRgwOQaKmjDHJlM5ImKxSnnRgs", ); expect(delegateOperations).toEqual([ { type: "other", operationType: "DELEGATE", stakedNodeId: 34, previousStakedNodeId: null, stakedAmount: BigInt(21083322293), }, ]); expect(undelegateOperations).toEqual([ { type: "other", operationType: "UNDELEGATE", stakedNodeId: null, previousStakedNodeId: 22, stakedAmount: BigInt(21083441623), }, ]); expect(redelegateOperations).toEqual([ { type: "other", operationType: "REDELEGATE", stakedNodeId: 6, previousStakedNodeId: 34, stakedAmount: BigInt(21083202902), }, ]); expect(rewardsTransaction?.operations).toEqual( expect.arrayContaining([ { type: "transfer", address: "0.0.35", asset: { type: "native", }, amount: 3235n, }, { type: "transfer", address: "0.0.800", asset: { type: "native", }, amount: -30505446n, }, { type: "transfer", address: "0.0.801", asset: { type: "native", }, amount: 76639n, }, { type: "transfer", address: "0.0.8835924", asset: { type: "native", }, amount: -1000000n, // excluded fee and staking reward }, { type: "transfer", address: "0.0.9124531", asset: { type: "native", }, amount: 1000000n, // excluded staking reward }, { type: "transfer", address: "0.0.8835924", asset: { type: "native", }, amount: 30313674n, }, { type: "transfer", address: "0.0.9124531", asset: { type: "native", }, amount: 191772n, }, ]), ); }); it("returns block for latest finalized height from lastBlock", async () => { const latestBlockInfo = await api.lastBlock(); const block = await api.getBlock(latestBlockInfo.height); expect(block.info.height).toBe(latestBlockInfo.height); expect(block.info.hash).toBe(latestBlockInfo.hash); // Note: lastBlock().time is the transaction timestamp, while getBlock().info.time is the block start time expect(block.info.time).toBeInstanceOf(Date); expect(block.transactions).toBeInstanceOf(Array); }); it("returns single transaction for multiple erc20 transfers", async () => { const data = await api.getBlock(177314999); const erc20Asset = { type: "erc20", assetReference: "0xb7687538c7f4cad022d5e97cc778d0b46457c5db", }; const erc20TxHash = "givnas3WAL3fiGeap+oSRIYOqUbqE0Ig2XIMTWgTDQzTMc8g7aOC1vxc8hQy7wZX"; const filteredTransactions = data.transactions.filter(tx => tx.hash === erc20TxHash); expect(filteredTransactions).toEqual([ expect.objectContaining({ operations: [ { type: "transfer", address: "0.0.802", asset: { type: "native", }, amount: 26596592n, }, { type: "transfer", address: "0.0.10067136", asset: { type: "native", }, amount: 0n, }, { type: "transfer", address: "0.0.6145236", asset: erc20Asset, amount: 2863838n, }, { type: "transfer", address: "0x0000000000000000000000000000000000000000", asset: erc20Asset, amount: -2863838n, }, { type: "transfer", address: "0.0.10067136", asset: erc20Asset, amount: 148440n, }, { type: "transfer", address: "0x0000000000000000000000000000000000000000", asset: erc20Asset, amount: -148440n, }, ], fees: 26596592n, feesPayer: "0.0.10067136", }), ]); }); it("filters out ERC20 operations with null sender or recipient address", async () => { const blockHeight = 177564534; const txHashWithNullAddress = "tSFV6McHlh0v6tZEZVGlwavk/QRoMabPIOtVbyJ1/j3gvTHMZP97URu4Vw6JbMmC"; const block = await api.getBlock(blockHeight); const transaction = block.transactions.find(tx => tx.hash === txHashWithNullAddress); const operationAddresses = transaction?.operations.map(op => op.address); expect(transaction).not.toBeUndefined(); expect(transaction?.operations.length).toBeGreaterThan(0); expect(operationAddresses).not.toContain(null); }); }); describe("lastBlock", () => { it("returns the last block information", async () => { const lastBlock = await api.lastBlock(); expect(lastBlock.height).toBeGreaterThan(0); expect(lastBlock.hash?.length).toBe(64); expect(lastBlock.time?.getTime()).toBeGreaterThan(0); }); }); describe("listOperations", () => { const rewardPayerAddress = getEnv("HEDERA_STAKING_REWARD_ACCOUNT_ID"); it("returns empty array for pristine account", async () => { const { items: operations } = await api.listOperations( MAINNET_TEST_ACCOUNTS.pristine.accountId, { minHeight: 0, order: "desc" }, ); expect(operations).toBeInstanceOf(Array); expect(operations.length).toBe(0); }); it("returns operations with valid synthetic block info", async () => { const cursor = "1753099264.927988000"; const { items: ops } = await api.listOperations(MAINNET_TEST_ACCOUNTS.withTokens.accountId, { minHeight: 0, cursor, limit: 4, order: "asc", }); const expectedSyntheticBlock = getSyntheticBlock(cursor); const blockHeights = ops.map(o => o.tx.block.height); expect(blockHeights.every(h => h >= expectedSyntheticBlock.blockHeight)).toBe(true); }); it("returns operations for real account with tokens", async () => { const cursor = "1753099264.927988000"; const { items: ops } = await api.listOperations(MAINNET_TEST_ACCOUNTS.withTokens.accountId, { minHeight: 0, cursor, limit: 100, order: "desc", }); const memoTxHash = "WvMcFERtxRsGJqxqGVDYa6JR5PqLgFeJxiSVoimayaWra/AMEJMzC09LhdRLTZ/M"; const operationWithMemo = ops.find(op => op.tx.hash === memoTxHash); const firstTokenAssociateOperations = ops.find(op => op.type === "ASSOCIATE_TOKEN"); const firstSendTokenOperation = ops.find(o => o.type === "OUT" && o.asset.type !== "native"); const hasReceiveHbarOperations = ops.some(o => o.type === "IN" && o.asset.type === "native"); const hasSendHbarOperations = ops.some(op => op.type === "OUT" && op.asset.type === "native"); const hasReceiveTokenOperations = ops.some(o => o.type === "IN" && o.asset.type !== "native"); const hasSendTokenOperations = !!firstSendTokenOperation; const hasTokenAssociateOperations = !!firstTokenAssociateOperations; const hasFeesOperationForSendToken = ops.some( o => o.type === "FEES" && o.asset.type === "native" && o.tx.hash === firstSendTokenOperation?.tx.hash, ); expect(ops).toBeInstanceOf(Array); expect(ops.length).toBeGreaterThanOrEqual(2); expect(hasReceiveHbarOperations).toBe(true); expect(hasSendHbarOperations).toBe(true); expect(hasReceiveTokenOperations).toBe(true); expect(hasSendTokenOperations).toBe(true); expect(hasFeesOperationForSendToken).toBe(false); expect(hasTokenAssociateOperations).toBe(true); expect(operationWithMemo?.details).toMatchObject({ pagingToken: expect.any(String), consensusTimestamp: expect.any(String), ledgerOpType: expect.any(String), memo: expect.any(String), }); expect(firstTokenAssociateOperations?.details).toMatchObject({ pagingToken: expect.any(String), consensusTimestamp: expect.any(String), ledgerOpType: expect.any(String), associatedTokenId: expect.any(String), }); // every transfer operation should have a fees payer expect(ops.every(op => /^0\.0\.\d+$/.test(op.tx.feesPayer ?? ""))).toBe(true); }); it("returns IN/OUT operations for mint and burn of amUSDC", async () => { const ownerAccountId = MAINNET_TEST_ACCOUNTS.withTokens.accountIdWithErc20; const { items: ops } = await api.listOperations(ownerAccountId, { minHeight: 0, limit: 10, cursor: "1749584382.000000000", order: "asc", }); const zeroAddress = "0x0000000000000000000000000000000000000000"; const mintTxHash = "1Ed3RfhFN0VQIyFfUrkljtsV9CzbzYNt3LJqqQyHsbiyKoVbJFhGkZwvqr3k0rYJ"; const burnTxHash = "45Y5pSeY7ULMqJObvAtOow8AjamVNlG3XGbGLt5UrCP2HOdrQ4PzQfXqFlY4GDwd"; const mintOperation = ops.find(op => op.tx.hash === mintTxHash); const burnOperation = ops.find(op => op.tx.hash === burnTxHash); const expectedAsset = { type: "erc20", assetReference: "0xb7687538c7f4cad022d5e97cc778d0b46457c5db", assetOwner: ownerAccountId, }; expect(mintOperation).toMatchObject({ type: "IN", recipients: [ownerAccountId], senders: [zeroAddress], asset: expectedAsset, tx: { fees: 30080000n, feesPayer: ownerAccountId, }, }); expect(burnOperation).toMatchObject({ type: "OUT", recipients: [zeroAddress], senders: [ownerAccountId], asset: expectedAsset, tx: { fees: 52800000n, feesPayer: ownerAccountId, }, }); }); it("returns staking operations with correct metadata", async () => { const cursor = "1762202113.000000000"; const { items: ops } = await api.listOperations( MAINNET_TEST_ACCOUNTS.activeStaking.accountId, { minHeight: 0, cursor, limit: 30, order: "desc" }, ); const rewardOp = ops.find(op => op.type === "REWARD"); const delegateOp = ops.find(op => op.type === "DELEGATE"); const undelegateOp = ops.find(op => op.type === "UNDELEGATE"); const redelegateOp = ops.find(op => op.type === "REDELEGATE"); expect(delegateOp?.value).toBe(BigInt(0)); expect(delegateOp?.tx.fees).toBeGreaterThan(BigInt(0)); expect(delegateOp?.details).toMatchObject({ previousStakingNodeId: null, targetStakingNodeId: expect.any(Number), stakedAmount: expect.any(BigInt), }); expect(undelegateOp?.value).toBe(BigInt(0)); expect(undelegateOp?.tx.fees).toBeGreaterThan(BigInt(0)); expect(undelegateOp?.details).toMatchObject({ previousStakingNodeId: expect.any(Number), targetStakingNodeId: null, stakedAmount: expect.any(BigInt), }); expect(redelegateOp?.value).toBe(BigInt(0)); expect(redelegateOp?.tx.fees).toBeGreaterThan(BigInt(0)); expect(redelegateOp?.details).toMatchObject({ previousStakingNodeId: expect.any(Number), targetStakingNodeId: expect.any(Number), stakedAmount: expect.any(BigInt), }); expect(rewardOp?.value).toBeGreaterThan(BigInt(0)); expect(rewardOp?.tx.fees).toBe(BigInt(0)); expect(rewardOp?.tx.hash).not.toContain(STAKING_REWARD_HASH_SUFFIX); // every staking operation should have a fees payer expect(ops.every(op => /^0\.0\.\d+$/.test(op.tx.feesPayer ?? ""))).toBe(true); }); it("returns valid senders and recipients for staking operations", async () => { const cursor = "1772617523.000000000"; const { items: ops } = await api.listOperations( MAINNET_TEST_ACCOUNTS.withStakingHistory.accountId, { minHeight: 0, cursor, limit: 30, order: "desc" }, ); const delegateHash = "+07jwNyyEDuwngDgoW3sVgfTfDE5qn+HgPsbltlrUIW/n/LYpFSEwSQNOTu/8GLQ"; const undelegateHash = "v0jXJwjKaypunqz91EuQDU2mz/ejSb3AvEJ5fgYkftl+DDT2mBlwB5bSRqXWyoth"; const redelegateHash = "pm8vFWlcBEEPbB+pkZTUUxs0FfO2KyDtg0KNfOYnnba+rpHT63OIMhFKKNpfDokk"; const delegateOp = ops.find(o => o.type !== "REWARD" && o.tx.hash === delegateHash); const undelegateOp = ops.find(o => o.type !== "REWARD" && o.tx.hash === undelegateHash); const redelegateOp = ops.find(o => o.type !== "REWARD" && o.tx.hash === redelegateHash); const rewardOp = ops.find(o => o.type === "REWARD"); expect(delegateOp?.senders).toEqual([MAINNET_TEST_ACCOUNTS.withStakingHistory.accountId]); expect(delegateOp?.recipients).toEqual(["0.0.14"]); expect(undelegateOp?.senders).toEqual([MAINNET_TEST_ACCOUNTS.withStakingHistory.accountId]); expect(undelegateOp?.recipients).toEqual(["0.0.31"]); expect(redelegateOp?.senders).toEqual([MAINNET_TEST_ACCOUNTS.withStakingHistory.accountId]); expect(redelegateOp?.recipients).toEqual(["0.0.23"]); expect(rewardOp?.senders).toEqual([rewardPayerAddress]); expect(rewardOp?.recipients).toEqual([MAINNET_TEST_ACCOUNTS.withStakingHistory.accountId]); }); it("returns valid stakedAmount, respecting uncommitted balance changes", async () => { const { items: ops } = await api.listOperations( MAINNET_TEST_ACCOUNTS.withQuickBalanceChanges.accountId, { minHeight: 0, limit: 10, order: "asc" }, ); const opDelegate1 = ops[2]; const opOut1 = ops[3]; const opUndelegate = ops[4]; const opOut2 = ops[5]; const opDelegate2 = ops[6]; // starting point has known, hardcoded balance const expectedBalanceDelegate1 = BigInt(999834971); // after undelegate1 we expect stakedAmount to be initial balance reduced by: // 1. first DELEGATE fee // 2. first OUT value + fee const expectedBalanceUndelegate = expectedBalanceDelegate1 - opDelegate1.tx.fees - opOut1.value - opOut1.tx.fees; // after delegate2 we expect stakedAmount to be undelegate1 balance reduced by: // 1. first UNDELEGATE fee // 2. second OUT value + fee const expectedBalanceDelegate2 = expectedBalanceUndelegate - opUndelegate.tx.fees - opOut2.value - opOut2.tx.fees; expect(opOut1.type).toBe("OUT"); expect(opOut2.type).toBe("OUT"); expect(opDelegate1.type).toBe("DELEGATE"); expect(opDelegate1.details?.stakedAmount).toBe(expectedBalanceDelegate1); expect(opUndelegate.type).toBe("UNDELEGATE"); expect(opUndelegate.details?.stakedAmount).toBe(expectedBalanceUndelegate); expect(opDelegate2.type).toBe("DELEGATE"); expect(opDelegate2.details?.stakedAmount).toBe(expectedBalanceDelegate2); }); it.each(["desc", "asc"] as const)( "returns paginated operations for account with high activity (%s)", async order => { const minHeight = 0; const limit = 10; const initialCursor = order === "desc" ? "1762168437.643463899" : undefined; const { items: page1, next: pagingToken1 } = await api.listOperations( MAINNET_TEST_ACCOUNTS.withTokens.accountId, { minHeight, limit, order, ...(initialCursor ? { cursor: initialCursor } : {}) }, ); const { items: page2, next: pagingToken2 } = await api.listOperations( MAINNET_TEST_ACCOUNTS.withTokens.accountId, { minHeight, limit, order, ...(pagingToken1 ? { cursor: pagingToken1 } : {}) }, ); const firstPage1Timestamp = page1[0]?.tx?.date; const firstPage2Timestamp = page2[0]?.tx?.date; const lastPage1Timestamp = page1[page1.length - 1]?.tx?.date; const lastPage2Timestamp = page2[page2.length - 1]?.tx?.date; const page1Hashes = new Set(page1.map(op => op.tx.hash)); const page2Hashes = new Set(page2.map(op => op.tx.hash)); const hasOverlap = [...page2Hashes].some(hash => page1Hashes.has(hash)); // NOTE: this won't be equal to limit, because single Hedera transaction can generate multiple operations expect(page1.length).toBeGreaterThanOrEqual(limit); expect(page2.length).toBeGreaterThanOrEqual(limit); expect(pagingToken1).not.toBeNull(); expect(pagingToken2).not.toBeNull(); expect(hasOverlap).toBe(false); expect(firstPage1Timestamp).toBeInstanceOf(Date); expect(firstPage2Timestamp).toBeInstanceOf(Date); expect(lastPage1Timestamp).toBeInstanceOf(Date); expect(lastPage2Timestamp).toBeInstanceOf(Date); expect(lastPage1Timestamp > firstPage2Timestamp).toBe(order === "desc"); expect(firstPage1Timestamp < lastPage2Timestamp).toBe(order === "asc"); }, ); }); describe("getValidators", () => { it("returns validators with APY information", async () => { const result = await api.getValidators(); expect(result.items.length).toBeGreaterThan(0); result.items.forEach(item => { expect(item).toMatchObject({ address: expect.any(String), nodeId: expect.any(String), name: expect.any(String), description: expect.any(String), balance: expect.any(BigInt), apy: expect.any(Number), }); expect(item.apy).toBeGreaterThanOrEqual(0); expect(item.apy).toBeLessThanOrEqual(1); }); }); }); describe("getStakes", () => { it("returns empty stakes for pristine account", async () => { const stakes = await api.getStakes(MAINNET_TEST_ACCOUNTS.pristine.accountId); expect(stakes.items.length).toBe(0); }); it("returns stake for delegated account", async () => { const stakes = await api.getStakes(MAINNET_TEST_ACCOUNTS.activeStaking.accountId); expect(stakes.items.length).toBeGreaterThan(0); }); }); describe("getRewards", () => { it("returns empty rewards for pristine account", async () => { const rewards = await api.getRewards(MAINNET_TEST_ACCOUNTS.pristine.accountId); expect(rewards.items.length).toBe(0); }); it("returns rewards for delegated account", async () => { const rewards = await api.getRewards(MAINNET_TEST_ACCOUNTS.activeStaking.accountId); expect(rewards.items.length).toBeGreaterThan(0); }); }); });