UNPKG

@ledgerhq/coin-tron

Version:
706 lines (626 loc) 22.3 kB
import { assert } from "console"; import { InvalidTransactionError } from "@ledgerhq/errors"; import { Account, TokenAccount } from "@ledgerhq/types-live"; import { BigNumber } from "bignumber.js"; import { HttpResponse, http } from "msw"; import { setupServer, SetupServerApi } from "msw/node"; import coinConfig from "../config"; import { getBlock as getBlockLogic, getBlockInfo } from "../logic/getBlock"; import { Transaction } from "../types"; import { TRANSACTION_DETAIL_FIXTURE, TRANSACTION_FIXTURE, TRC20_FIXTURE } from "./types.fixture"; import { createTronTransaction, defaultFetchParams, fetchTronAccountTxs, getBlock, getBlockWithTransactions, getTransactionInfoByBlockNum, } from "."; const TRON_BASE_URL_TEST = "https://httpbin.org"; const defaultGetTransactionsH = http.get( `${TRON_BASE_URL_TEST}/v1/accounts/:addr/transactions`, () => HttpResponse.json(TRANSACTION_FIXTURE), ); const defaultGetTrc20TransactionsH = http.get( `${TRON_BASE_URL_TEST}/v1/accounts/:addr/transactions/trc20`, () => HttpResponse.json(TRC20_FIXTURE), ); const defaultGetTxInfo = http.get( `${TRON_BASE_URL_TEST}/wallet/gettransactioninfobyid`, ({ request }) => { const url = new URL(request.url); const value = url.searchParams.get("value") ?? "UNKNOWN"; return HttpResponse.json(TRANSACTION_DETAIL_FIXTURE(value)); }, ); function doBeforeAll(server: SetupServerApi): () => void { return () => { coinConfig.setCoinConfig(() => ({ status: { type: "active", }, explorer: { url: TRON_BASE_URL_TEST, }, })); server.listen(); }; } function doBeforeEach(server: SetupServerApi): () => void { return () => server.resetHandlers(); } function doAfterAll(server: SetupServerApi): () => void { return () => server.close(); } function buildTriggerSmartContractFixture(txId: string, contractRet: "REVERT" | "SUCCESS") { return { ret: [{ contractRet, fee: 0 }], signature: ["sig"], txID: txId, net_usage: 0, raw_data_hex: "", net_fee: 0, energy_usage: 0, block_timestamp: 1717419792000, blockNumber: 80285488, energy_fee: 0, energy_usage_total: 0, raw_data: { contract: [ { parameter: { value: { owner_address: "4105cc125604448afeb6867eb688efb7e80411d57a", contract_address: "41a614f803b6fd780986a42c78ec9c7f77e6ded13c", data: "", }, type_url: "type.googleapis.com/protocol.TriggerSmartContract", }, type: "TriggerSmartContract", }, ], ref_block_bytes: "00", ref_block_hash: "00", expiration: 1717419846000, timestamp: 1717419788444, }, internal_transactions: [], }; } function buildTrc20TransferFixture(txId: string) { return { transaction_id: txId, token_info: { symbol: "USDT", address: "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", decimals: 6, name: "Tether USD", }, block_timestamp: 1717419792000, from: "TQ7pF3NTDL2Tjz5rdJ6ECjQWjaWHpLZJMH", to: "TAVrrARNdnjHgCGMQYeQV7hv4PSu7mVsMj", detail: { ret: [{ contractRet: "SUCCESS", fee: 0 }], signature: ["sig"], txID: txId, net_usage: 345, raw_data_hex: "", net_fee: 0, energy_usage: 0, blockNumber: 80285488, block_timestamp: 1717419792000, energy_fee: 0, energy_usage_total: 0, raw_data: { contract: [ { parameter: { value: { owner_address: "4105cc125604448afeb6867eb688efb7e80411d57a", contract_address: "41a614f803b6fd780986a42c78ec9c7f77e6ded13c", data: "", }, type_url: "type.googleapis.com/protocol.TriggerSmartContract", }, type: "TriggerSmartContract", }, ], ref_block_bytes: "00", ref_block_hash: "00", expiration: 1717419846000, timestamp: 1717419788444, }, internal_transactions: [], }, type: "Transfer", value: "1000000", }; } describe("fetchTronAccountTxs", () => { const handlers = [defaultGetTransactionsH, defaultGetTrc20TransactionsH, defaultGetTxInfo]; const mockServer = setupServer(...handlers); beforeAll(doBeforeAll(mockServer)); beforeEach(doBeforeEach(mockServer)); afterAll(doAfterAll(mockServer)); it("convert correctly operations from the blockchain", async () => { // WHEN const results = await fetchTronAccountTxs( "ADDRESS", txs => txs.length < 100, {}, defaultFetchParams, ); // THEN expect(results).toContainEqual( expect.objectContaining({ blockHeight: 62258698, from: "TQ7pF3NTDL2Tjz5rdJ6ECjQWjaWHpLZJMH", to: "TAVrrARNdnjHgCGMQYeQV7hv4PSu7mVsMj", }), ); }, 10_000); }); describe("fetchTronAccountTxs with invalid TRC20 (see LIVE-18992)", () => { const tx1Hash = "1237889e91c0ebbe389436c341865df09921f8f0c029d9286102372cbaadc585"; const tx2Hash = "154164dd04482ae78f930033d0ad95730b8b19fde171a33c3920d18c228426ab"; let counterGetTrc20 = 0; const invalidTrc20Handler = http.get( `${TRON_BASE_URL_TEST}/v1/accounts/:addr/transactions/trc20`, () => { const ret: any = JSON.parse(JSON.stringify(TRC20_FIXTURE)); switch (counterGetTrc20) { case 0: { const tx1 = ret.data[0]; assert(tx1.transaction_id === tx1Hash); ret.data[0].detail.ret = undefined; break; } case 1: { const tx2 = ret.data[1]; assert(tx2.transaction_id === tx2Hash); ret.data[1].detail.ret = undefined; break; } default: // the 3rd call should not happen // because merging the 1st and 2nd results is enough to have a full set, perfectly well formed throw "results should be merged after 2 calls"; } counterGetTrc20++; return HttpResponse.json(ret); }, ); const handlers = [defaultGetTransactionsH, invalidTrc20Handler, defaultGetTxInfo]; const mockServer = setupServer(...handlers); beforeAll(doBeforeAll(mockServer)); beforeEach(doBeforeEach(mockServer)); afterAll(doAfterAll(mockServer)); it("retry several times until result is correct", async () => { // WHEN const results = await fetchTronAccountTxs("ADDRESS", () => true, {}, defaultFetchParams); // THEN expect(results).toContainEqual(expect.objectContaining({ txID: tx1Hash })); expect(results).toContainEqual(expect.objectContaining({ txID: tx2Hash })); }, 10_000); }); describe("Failed TRC20 txs", () => { const txId = "f8a52daf9a247f73432afa292b8063d5c5429c8fdb0f8c66f5e8b15b3767e14b"; const mockServer = setupServer(defaultGetTxInfo); const getTrc20 = (trc20Txs: any[]) => http.get(`${TRON_BASE_URL_TEST}/v1/accounts/:addr/transactions/trc20`, () => HttpResponse.json({ data: trc20Txs, success: true, meta: { at: 0, page_size: 0 }, }), ); const getEmptyTrc20 = getTrc20([]); const getNativeTx = (nativeTxs: any[]) => http.get(`${TRON_BASE_URL_TEST}/v1/accounts/:addr/transactions`, () => HttpResponse.json({ data: nativeTxs, success: true, meta: { at: 1717419792000, page_size: 1 }, }), ); const fetchTxs = (address: string) => fetchTronAccountTxs(address, () => true, {}, defaultFetchParams); beforeAll(doBeforeAll(mockServer)); beforeEach(doBeforeEach(mockServer)); afterAll(doAfterAll(mockServer)); // this scenario is to make sure that failed TRC20 tx are returned it("returns the failed TriggerSmartContract tx when tx not in TRC20 set", async () => { const failedTriggerSmartContractFixture = buildTriggerSmartContractFixture(txId, "REVERT"); mockServer.use(getNativeTx([failedTriggerSmartContractFixture]), getEmptyTrc20); const results = await fetchTxs("ADDRESS"); expect(results).toEqual([ expect.objectContaining({ txID: txId, hasFailed: true, type: "TriggerSmartContract", }), ]); }, 10_000); it("excludes successful TriggerSmartContract tx when tx not in TRC20 set", async () => { const successfulTriggerSmartContractFixture = buildTriggerSmartContractFixture(txId, "SUCCESS"); mockServer.use(getNativeTx([successfulTriggerSmartContractFixture]), getEmptyTrc20); const results = await fetchTxs("ADDRESS"); expect(results).not.toContainEqual(expect.objectContaining({ txID: txId })); }, 10_000); it("returns a single successful TriggerSmartContract from TRC20 (deduped with native) when tx is in TRC20 set", async () => { const successfulTriggerSmartContractFixture = buildTriggerSmartContractFixture(txId, "SUCCESS"); mockServer.use( getNativeTx([successfulTriggerSmartContractFixture]), getTrc20([buildTrc20TransferFixture(txId)]), ); const results = await fetchTxs("ADDRESS"); expect(results).toEqual([ expect.objectContaining({ txID: txId, hasFailed: false, type: "TriggerSmartContract", tokenType: "trc20", }), ]); }, 10_000); }); describe("Transactions with internal_transactions", () => { const txId = "2824c452c141c74fdd9cb13c4d4e5369145cd1ab02baeedcb42b6b440e95e435"; const mockServer = setupServer(defaultGetTxInfo); const getEmptyTrc20 = http.get(`${TRON_BASE_URL_TEST}/v1/accounts/:addr/transactions/trc20`, () => HttpResponse.json({ data: [], success: true, meta: { at: 0, page_size: 0 }, }), ); const getNativeTx = (nativeTxs: any[]) => http.get(`${TRON_BASE_URL_TEST}/v1/accounts/:addr/transactions`, () => HttpResponse.json({ data: nativeTxs, success: true, meta: { at: 1717419792000, page_size: 1 }, }), ); const fetchTxs = (address: string) => fetchTronAccountTxs(address, () => true, {}, defaultFetchParams); beforeAll(doBeforeAll(mockServer)); beforeEach(doBeforeEach(mockServer)); afterAll(doAfterAll(mockServer)); function buildTxWithInternalTransactions(txId: string, fee: number) { return { ret: [{ contractRet: "FAILED", fee }], signature: ["sig"], txID: txId, net_usage: 0, raw_data_hex: "", net_fee: 0, energy_usage: 0, block_timestamp: 1717419792000, blockNumber: 73343824, energy_fee: 0, energy_usage_total: 0, raw_data: { contract: [ { parameter: { value: { owner_address: "41a5c7c47bc8a62a90aece66734d2bacae16e1dde5", contract_address: "41cebde71077b830b958c8da17bcddeeb85d0bcf25", data: "", }, type_url: "type.googleapis.com/protocol.TriggerSmartContract", }, type: "TriggerSmartContract", }, ], ref_block_bytes: "00", ref_block_hash: "00", expiration: 1717419846000, timestamp: 1717419788444, }, internal_transactions: [ { internal_tx_id: "fbbd70a9c997cd7f60325dd5c967e94e106d6d2ee607560e5a98383e61cba48e", data: { note: "63616c6c", rejected: true }, to_address: "41cebde71077b830b958c8da17bcddeeb85d0bcf25", from_address: "41cebde71077b830b958c8da17bcddeeb85d0bcf25", }, ], }; } it("includes transactions with internal_transactions to track fees", async () => { const txWithInternalTxs = buildTxWithInternalTransactions(txId, 2341260); mockServer.use(getNativeTx([txWithInternalTxs]), getEmptyTrc20); const results = await fetchTxs("ADDRESS"); expect(results).toContainEqual( expect.objectContaining({ txID: txId, hasFailed: true, type: "TriggerSmartContract", fee: expect.any(Object), }), ); expect(results.find(tx => tx.txID === txId)?.fee?.toNumber()).toBe(2341260); }, 10_000); }); describe("fetchTronAccountTxs with invalid TRC20 (see LIVE-18992): after 3 tries it throws an exception", () => { const tx1Hash = "1237889e91c0ebbe389436c341865df09921f8f0c029d9286102372cbaadc585"; const alwaysInvalidTrc20Handler = http.get( `${TRON_BASE_URL_TEST}/v1/accounts/:addr/transactions/trc20`, () => { const ret: any = JSON.parse(JSON.stringify(TRC20_FIXTURE)); const tx1 = ret.data[0]; assert(tx1.transaction_id === tx1Hash); ret.data[0].detail.ret = undefined; return HttpResponse.json(ret); }, ); const handlers = [defaultGetTransactionsH, alwaysInvalidTrc20Handler, defaultGetTxInfo]; const mockServer = setupServer(...handlers); beforeAll(doBeforeAll(mockServer)); beforeEach(doBeforeEach(mockServer)); afterAll(doAfterAll(mockServer)); it("after several retry, it gives up on retry", async () => { await expect( fetchTronAccountTxs("ADDRESS", () => true, {}, defaultFetchParams), ).rejects.toThrow( "getTrc20TxsWithRetry: couldn't fetch trc20 transactions after several attempts", ); }, 10_000); }); describe("getBlock", () => { let capturedRequest: { method: string; url: string; body: unknown } | null = null; const getBlockHandler = http.post( `${TRON_BASE_URL_TEST}/wallet/getblock`, async ({ request }) => { capturedRequest = { method: request.method, url: request.url, body: await request.json(), }; return HttpResponse.json({ blockID: "000000000426763400000000000000000000000000000000000000000000000", block_header: { raw_data: { number: 69629492, timestamp: 1739540559000, parentHash: "00000000042676330000000000000000000000000000000000000000000000", }, }, }); }, ); const mockServer = setupServer(getBlockHandler); beforeAll(doBeforeAll(mockServer)); beforeEach(() => { capturedRequest = null; mockServer.resetHandlers(); }); afterAll(doAfterAll(mockServer)); it("sends POST request with detail: false", async () => { const result = await getBlock(69629492); expect(capturedRequest).not.toBeNull(); expect(capturedRequest!.method).toBe("POST"); expect(capturedRequest!.url).toContain("/wallet/getblock"); expect(capturedRequest!.body).toEqual({ id_or_num: "69629492", detail: false, }); expect(result.height).toBe(69629492); expect(result.hash).toBe("000000000426763400000000000000000000000000000000000000000000000"); }); }); describe("getBlockWithTransactions", () => { let capturedRequest: { method: string; url: string; body: unknown } | null = null; const getBlockHandler = http.post( `${TRON_BASE_URL_TEST}/wallet/getblock`, async ({ request }) => { capturedRequest = { method: request.method, url: request.url, body: await request.json(), }; return HttpResponse.json({ blockID: "000000000426763400000000000000000000000000000000000000000000000", block_header: { raw_data: { number: 69629492, timestamp: 1739540559000, parentHash: "00000000042676330000000000000000000000000000000000000000000000", }, }, transactions: [], }); }, ); const mockServer = setupServer(getBlockHandler); beforeAll(doBeforeAll(mockServer)); beforeEach(() => { capturedRequest = null; mockServer.resetHandlers(); }); afterAll(doAfterAll(mockServer)); it("sends POST request with detail: true", async () => { const result = await getBlockWithTransactions(69629492); expect(capturedRequest).not.toBeNull(); expect(capturedRequest!.method).toBe("POST"); expect(capturedRequest!.url).toContain("/wallet/getblock"); expect(capturedRequest!.body).toEqual({ id_or_num: "69629492", detail: true, }); expect(result.block_header.raw_data.number).toBe(69629492); expect(result.blockID).toBe("000000000426763400000000000000000000000000000000000000000000000"); }); }); describe("getTransactionInfoByBlockNum", () => { let capturedRequest: { method: string; url: string; body: unknown } | null = null; const txInfoFixture = [ { id: "abc123", fee: 1000 }, { id: "def456", fee: 2000 }, ]; const getTxInfoHandler = http.post( `${TRON_BASE_URL_TEST}/wallet/gettransactioninfobyblocknum`, async ({ request }) => { capturedRequest = { method: request.method, url: request.url, body: await request.json(), }; return HttpResponse.json(txInfoFixture); }, ); const mockServer = setupServer(getTxInfoHandler); beforeAll(doBeforeAll(mockServer)); beforeEach(() => { capturedRequest = null; mockServer.resetHandlers(); }); afterAll(doAfterAll(mockServer)); it("sends POST request with num in body", async () => { const result = await getTransactionInfoByBlockNum(69629492); expect(capturedRequest).not.toBeNull(); expect(capturedRequest!.method).toBe("POST"); expect(capturedRequest!.url).toContain("/wallet/gettransactioninfobyblocknum"); expect(capturedRequest!.body).toEqual({ num: 69629492 }); expect(result).toEqual(txInfoFixture); }); }); describe("createTronTransaction", () => { const mockServer = setupServer( http.post(`${TRON_BASE_URL_TEST}/wallet/createtransaction`, () => HttpResponse.json({ raw_data: { expiration: Date.now() - 3_600_000 } }), ), http.post(`${TRON_BASE_URL_TEST}/wallet/transferasset`, () => HttpResponse.json({ raw_data: { expiration: Date.now() - 3_600_000 } }), ), http.post(`${TRON_BASE_URL_TEST}/wallet/triggersmartcontract`, () => HttpResponse.json({ transaction: { raw_data: { expiration: Date.now() - 3_600_000 } } }), ), ); beforeAll(doBeforeAll(mockServer)); beforeEach(doBeforeEach(mockServer)); afterAll(doAfterAll(mockServer)); it.each([ ["native", null], [ "trc10", { type: "TokenAccount", token: { id: "tron/trc10/1000001" }, } as unknown as TokenAccount, ], [ "trc20", { type: "TokenAccount", token: { id: "tron/trc20/TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", contractAddress: "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", }, } as unknown as TokenAccount, ], ])( "throws InvalidTransactionError for %s asset when the node returns an expired transaction", async (_, subAccount) => { await expect( createTronTransaction( { freshAddress: "TQ7pF3NTDL2Tjz5rdJ6ECjQWjaWHpLZJMH" } as Account, { recipient: "TAVrrARNdnjHgCGMQYeQV7hv4PSu7mVsMj", amount: new BigNumber(1000000), } as Transaction, subAccount, ), ).rejects.toThrow(InvalidTransactionError); }, ); }); describe("getBlock API integration", () => { const blockFixture = { blockID: "0000000004267634abc123def456789000000000000000000000000000000000", block_header: { raw_data: { number: 69629492, timestamp: 1739540559000, parentHash: "0000000004267633def456789abc123000000000000000000000000000000000", txTrieRoot: "0000000000000000000000000000000000000000000000000000000000000000", witness_address: "41ffffffffffffffffffffffffffffffffffffffff", }, witness_signature: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789", }, transactions: [ { txID: "abc123def456789", raw_data: { contract: [ { type: "TransferContract", parameter: { value: { owner_address: "41a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", to_address: "41f6e5d4c3b2a1f6e5d4c3b2a1f6e5d4c3b2a1f6e5", amount: 1000000, }, }, }, ], }, ret: [{ contractRet: "SUCCESS", fee: 1000 }], }, ], }; const txInfoFixture = [ { id: "abc123def456789", fee: 1000, blockNumber: 69629492, blockTimeStamp: 1739540559000, }, ]; const getBlockHandler = http.post(`${TRON_BASE_URL_TEST}/wallet/getblock`, async () => HttpResponse.json(blockFixture), ); const getTxInfoByBlockNumHandler = http.post( `${TRON_BASE_URL_TEST}/wallet/gettransactioninfobyblocknum`, async () => HttpResponse.json(txInfoFixture), ); const mockServer = setupServer(getBlockHandler, getTxInfoByBlockNumHandler); beforeAll(doBeforeAll(mockServer)); beforeEach(() => mockServer.resetHandlers()); afterAll(doAfterAll(mockServer)); it("getBlockInfo returns correct block info from API through logic layer", async () => { const result = await getBlockInfo(69629492); expect(result.height).toBe(69629492); expect(result.hash).toBe("0000000004267634abc123def456789000000000000000000000000000000000"); expect(result.time).toEqual(new Date(1739540559000)); }); it("getBlock returns block with transactions and operations from API through logic layer", async () => { const result = await getBlockLogic(69629492); expect(result.info.height).toBe(69629492); expect(result.info.hash).toBe( "0000000004267634abc123def456789000000000000000000000000000000000", ); expect(result.info.time).toEqual(new Date(1739540559000)); expect(result.info.parent).toEqual({ height: 69629491, hash: "0000000004267633def456789abc123000000000000000000000000000000000", }); expect(result.transactions).toHaveLength(1); expect(result.transactions[0].hash).toBe("abc123def456789"); expect(result.transactions[0].failed).toBe(false); expect(result.transactions[0].fees).toBe(BigInt(1000)); expect(result.transactions[0].operations).toHaveLength(2); expect(result.transactions[0].operations[0]).toMatchObject({ type: "transfer", asset: { type: "native" }, amount: BigInt(-1000000), }); expect(result.transactions[0].operations[1]).toMatchObject({ type: "transfer", asset: { type: "native" }, amount: BigInt(1000000), }); }); });