@ledgerhq/coin-tron
Version:
Ledger Tron Coin integration
706 lines (626 loc) • 22.3 kB
text/typescript
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),
});
});
});