// @vitest-environment node import { describe, it, expect, vi, afterEach } from 'vitest' import { tronAddressToHex, buildNativeTronTx, buildTrc20TronTx, buildContractCallTronTx, broadcastTronTx, nodeHasTransaction } from './tron-utils' afterEach(() => vi.unstubAllGlobals()) const TRON_MAINNET = 'tron:0x2b6653dc' // A real Tron address (USDT TRC20 contract) and its known hex form const VALID_BASE58 = 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t' const VALID_HEX = '41a614f803b6fd780986a42c78ec9c7f77e6ded13c' const VALID_HEX_WITH_0X = '0x41a614f803b6fd780986a42c78ec9c7f77e6ded13c' // --------------------------------------------------------------------------- // tronAddressToHex // --------------------------------------------------------------------------- describe('tronAddressToHex', () => { it('returns hex input unchanged (lowercase)', () => { expect(tronAddressToHex(VALID_HEX)).toBe(VALID_HEX) }) it('strips 0x prefix from hex input', () => { expect(tronAddressToHex(VALID_HEX_WITH_0X)).toBe(VALID_HEX) }) it('normalises upper-case hex to lowercase', () => { expect(tronAddressToHex(VALID_HEX.toUpperCase())).toBe(VALID_HEX) }) it('converts a known base58 address to its hex form', () => { expect(tronAddressToHex(VALID_BASE58)).toBe(VALID_HEX) }) it('throws on an empty string', () => { expect(() => tronAddressToHex('')).toThrow() }) it('throws on a string with invalid base58 characters', () => { // '0' is not in the base58 alphabet expect(() => tronAddressToHex('T0invalid')).toThrow() }) it('throws when decoded bytes lack the 0x41 Tron prefix', () => { // A valid-length base58 string that decodes without the 0x41 prefix // We pass the raw hex of a non-Tron address (EVM 0x00-prefixed) expect(() => tronAddressToHex('1111111111111111111111111')).toThrow() }) }) // --------------------------------------------------------------------------- // TronGrid wrappers — mock fetch // --------------------------------------------------------------------------- function mockFetch(body: unknown, ok = true, status = 200) { return vi.fn().mockResolvedValue({ ok, status, statusText: ok ? 'OK' : 'Bad Request', json: async () => body }) } describe('buildNativeTronTx', () => { afterEach(() => vi.restoreAllMocks()) it('rejects non-safe-integer amounts', async () => { await expect( buildNativeTronTx(TRON_MAINNET, { from: VALID_HEX, to: VALID_HEX, amount: 1.5 }) ).rejects.toThrow('non-negative safe integer') }) it('rejects negative amounts', async () => { await expect( buildNativeTronTx(TRON_MAINNET, { from: VALID_HEX, to: VALID_HEX, amount: -1 }) ).rejects.toThrow('non-negative safe integer') }) it('rejects unknown network IDs', async () => { await expect( buildNativeTronTx('tron:0xdeadbeef', { from: VALID_HEX, to: VALID_HEX, amount: 1 }) ).rejects.toThrow('no full-node endpoint') }) it('builds a native transfer and returns the tx', async () => { const fakeTx = { txID: 'abc123', raw_data: {} } vi.stubGlobal('fetch', mockFetch(fakeTx)) const result = await buildNativeTronTx(TRON_MAINNET, { from: VALID_HEX, to: VALID_HEX, amount: 1_000_000 }) expect(result).toMatchObject({ txID: 'abc123' }) const [url, opts] = (globalThis.fetch as ReturnType).mock .calls[0]! expect(url).toContain('/wallet/createtransaction') const body = JSON.parse(opts.body as string) expect(body.visible).toBe(false) expect(body.amount).toBe(1_000_000) }) it('throws when the node returns an Error field', async () => { vi.stubGlobal('fetch', mockFetch({ Error: 'BANDWITH_ERROR' })) await expect( buildNativeTronTx(TRON_MAINNET, { from: VALID_HEX, to: VALID_HEX, amount: 1 }) ).rejects.toThrow('BANDWITH_ERROR') }) it('throws when the node returns no txID', async () => { vi.stubGlobal('fetch', mockFetch({})) await expect( buildNativeTronTx(TRON_MAINNET, { from: VALID_HEX, to: VALID_HEX, amount: 1 }) ).rejects.toThrow('no txID') }) it('rejects when fetch returns a non-ok status', async () => { vi.stubGlobal('fetch', mockFetch({}, false, 503)) await expect( buildNativeTronTx(TRON_MAINNET, { from: VALID_HEX, to: VALID_HEX, amount: 1 }) ).rejects.toThrow('503') }) }) describe('buildTrc20TronTx', () => { afterEach(() => vi.restoreAllMocks()) const params = { from: VALID_HEX, to: VALID_HEX, amount: 1_000_000n, contractAddress: VALID_HEX } it('builds a TRC20 transfer and returns the tx', async () => { const fakeTx = { txID: 'trc20tx' } vi.stubGlobal( 'fetch', mockFetch({ result: { result: true }, transaction: fakeTx }) ) const result = await buildTrc20TronTx(TRON_MAINNET, params) expect(result).toEqual(fakeTx) const [url, opts] = (globalThis.fetch as ReturnType).mock .calls[0]! expect(url).toContain('/wallet/triggersmartcontract') const body = JSON.parse(opts.body as string) expect(body.function_selector).toBe('transfer(address,uint256)') expect(body.visible).toBe(false) }) it('throws when TronGrid signals failure', async () => { vi.stubGlobal( 'fetch', mockFetch({ result: { result: false, code: 'CONTRACT_VALIDATE_ERROR', message: '' } }) ) await expect(buildTrc20TronTx(TRON_MAINNET, params)).rejects.toThrow( 'CONTRACT_VALIDATE_ERROR' ) }) }) describe('buildContractCallTronTx', () => { afterEach(() => vi.restoreAllMocks()) const selector = 'a9059cbb' // transfer(address,uint256) const validData = selector + '0'.repeat(128) it('throws on data shorter than 4-byte selector', async () => { await expect( buildContractCallTronTx(TRON_MAINNET, { from: VALID_HEX, contractAddress: VALID_HEX, data: 'abc' }) ).rejects.toThrow('too short') }) it('throws on odd-length hex data', async () => { await expect( buildContractCallTronTx(TRON_MAINNET, { from: VALID_HEX, contractAddress: VALID_HEX, data: selector + '0' }) ).rejects.toThrow('not valid hex') }) it('throws on non-hex characters in data', async () => { await expect( buildContractCallTronTx(TRON_MAINNET, { from: VALID_HEX, contractAddress: VALID_HEX, data: 'zzzzzzzz' }) ).rejects.toThrow('not valid hex') }) it('strips 0x prefix before sending', async () => { const fakeTx = { txID: 'contract_tx' } vi.stubGlobal( 'fetch', mockFetch({ result: { result: true }, transaction: fakeTx }) ) const result = await buildContractCallTronTx(TRON_MAINNET, { from: VALID_HEX, contractAddress: VALID_HEX, data: '0x' + validData }) expect(result).toEqual(fakeTx) const body = JSON.parse( (globalThis.fetch as ReturnType).mock.calls[0]![1].body ) expect(body.data).toBe(validData) expect(body.data.startsWith('0x')).toBe(false) }) // ONC-379. A payable call (a router's `depositNative`) carries its amount here, // not in the calldata — hardcoding 0 produced a transaction that succeeded // on-chain and deposited nothing. it('forwards callValue for a payable call', async () => { const fakeTx = { txID: 'payable_tx' } vi.stubGlobal( 'fetch', mockFetch({ result: { result: true }, transaction: fakeTx }) ) await buildContractCallTronTx(TRON_MAINNET, { from: VALID_HEX, contractAddress: VALID_HEX, data: validData, callValue: 120_119 }) const body = JSON.parse( (globalThis.fetch as ReturnType).mock.calls[0]![1].body ) expect(body.call_value).toBe(120_119) }) it.each([-1, 1.5, Number.MAX_SAFE_INTEGER + 2, 2n ** 60n])( 'rejects an out-of-range callValue (%s) instead of losing precision', async invalid => { vi.stubGlobal('fetch', mockFetch({ result: { result: true } })) await expect( buildContractCallTronTx(TRON_MAINNET, { from: VALID_HEX, contractAddress: VALID_HEX, data: validData, callValue: invalid }) ).rejects.toThrow(/callValue must be a non-negative safe integer/) expect(globalThis.fetch).not.toHaveBeenCalled() } ) it('sends call_value 0 when callValue is omitted (non-payable unchanged)', async () => { const fakeTx = { txID: 'free_tx' } vi.stubGlobal( 'fetch', mockFetch({ result: { result: true }, transaction: fakeTx }) ) await buildContractCallTronTx(TRON_MAINNET, { from: VALID_HEX, contractAddress: VALID_HEX, data: validData }) const body = JSON.parse( (globalThis.fetch as ReturnType).mock.calls[0]![1].body ) expect(body.call_value).toBe(0) }) }) describe('broadcastTronTx', () => { afterEach(() => vi.restoreAllMocks()) const signedTx = { txID: 'tx123', signature: ['sig'] } it('returns our own txID, not the one the node echoed', async () => { // Reconciliation key is the hash of the raw_data we posted — the echo // cannot be influenced by the response, and a divergent one would mean the // body and the id disagree rather than something to adopt. vi.stubGlobal('fetch', mockFetch({ result: true, txid: 'on_chain_tx' })) const txid = await broadcastTronTx(TRON_MAINNET, signedTx) expect(txid).toBe('tx123') }) it('falls back to the echoed txid when we posted no txID', async () => { vi.stubGlobal('fetch', mockFetch({ result: true, txid: 'on_chain_tx' })) const txid = await broadcastTronTx(TRON_MAINNET, { signature: ['sig'] }) expect(txid).toBe('on_chain_tx') }) it('falls back to the input txID when txid is absent from response', async () => { vi.stubGlobal('fetch', mockFetch({ result: true })) const txid = await broadcastTronTx(TRON_MAINNET, signedTx) expect(txid).toBe('tx123') }) it('throws on broadcast rejection', async () => { vi.stubGlobal( 'fetch', mockFetch({ result: false, code: 'TRANSACTION_EXPIRATION_ERROR' }) ) await expect(broadcastTronTx(TRON_MAINNET, signedTx)).rejects.toThrow( 'TRANSACTION_EXPIRATION_ERROR' ) }) it('throws on a duplicate instead of reconciling it here', async () => { // One reconciliation point, in the caller: probing here as well would spend // the lookup budget twice and double the traffic for a single decision. vi.stubGlobal( 'fetch', mockFetch({ code: 'DUP_TRANSACTION_ERROR', message: '447570207472616e73616374696f6e2e', txid: 'tx123' }) ) await expect(broadcastTronTx(TRON_MAINNET, signedTx)).rejects.toThrow( 'Dup transaction.' ) }) it('bounds the broadcast request too', async () => { // Waiting forever is not the safe option: a node can accept the // transaction and lose the response, leaving the call pending while the // funds move and the caller's probe never runs. const fetchMock = mockFetch({ result: true, txid: 'tx123' }) vi.stubGlobal('fetch', fetchMock) await broadcastTronTx(TRON_MAINNET, signedTx) const init = fetchMock.mock.calls[0]?.[1] as { signal?: AbortSignal } expect(init.signal).toBeInstanceOf(AbortSignal) }) it("surfaces a rejection carried only in the node's Error field", async () => { // The branch that motivated reading `Error` at all: a malformed body is // reported there, with no `message` and no `code`, and used to surface as an // unattributable "rejected by node". vi.stubGlobal( 'fetch', mockFetch({ Error: 'class java.lang.NullPointerException : null' }) ) await expect(broadcastTronTx(TRON_MAINNET, signedTx)).rejects.toThrow( 'class java.lang.NullPointerException : null' ) }) it('decodes hex-encoded error messages', async () => { // 'REVERT' in hex ASCII const hexMsg = Array.from('REVERT') .map(c => c.charCodeAt(0).toString(16).padStart(2, '0')) .join('') vi.stubGlobal( 'fetch', mockFetch({ result: false, message: hexMsg, code: '' }) ) await expect(broadcastTronTx(TRON_MAINNET, signedTx)).rejects.toThrow( 'REVERT' ) }) }) describe('nodeHasTransaction', () => { afterEach(() => { vi.restoreAllMocks() vi.useRealTimers() }) it('accepts a transaction that only becomes readable late in the window', async () => { // The observed window was 16.3s — the wallet reported the transaction out // and it stayed invisible until its block closed. A miss answers in // milliseconds, so only an elapsed-time budget covers that. vi.useFakeTimers() let lookups = 0 vi.stubGlobal( 'fetch', vi.fn(async () => { lookups++ const body = lookups < 8 ? {} : { txID: 'tx123' } return { ok: true, status: 200, json: async () => body } }) ) const pending = nodeHasTransaction(TRON_MAINNET, 'tx123') await vi.advanceTimersByTimeAsync(20_000) await expect(pending).resolves.toBe(true) expect(lookups).toBeGreaterThanOrEqual(8) }) it('gives up once the budget is spent', async () => { vi.useFakeTimers() vi.stubGlobal( 'fetch', vi.fn(async () => ({ ok: true, status: 200, json: async () => ({}) })) ) const pending = nodeHasTransaction(TRON_MAINNET, 'tx123') await vi.advanceTimersByTimeAsync(25_000) await expect(pending).resolves.toBe(false) }) it('treats a failing lookup as inconclusive and keeps asking', async () => { vi.useFakeTimers() let calls = 0 vi.stubGlobal( 'fetch', vi.fn(async () => { calls++ if (calls < 3) { throw new DOMException('The operation was aborted.', 'TimeoutError') } return { ok: true, status: 200, json: async () => ({ txID: 'tx123' }) } }) ) const pending = nodeHasTransaction(TRON_MAINNET, 'tx123') await vi.advanceTimersByTimeAsync(20_000) await expect(pending).resolves.toBe(true) expect(calls).toBeGreaterThanOrEqual(3) }) it('bounds each lookup so a hung node cannot eat the whole budget', async () => { vi.useFakeTimers() const fetchMock = vi.fn(async () => ({ ok: true, status: 200, json: async () => ({ txID: 'tx123' }) })) vi.stubGlobal('fetch', fetchMock) await nodeHasTransaction(TRON_MAINNET, 'tx123') const init = fetchMock.mock.calls[0]?.[1] as { signal?: AbortSignal } expect(init.signal).toBeInstanceOf(AbortSignal) }) })