import { describe, it, expect, vi, beforeEach } from 'vitest' import { TronTransactionService } from './tron-transaction-service' import type SignClient from '@walletconnect/sign-client' import type { SessionTypes } from '@walletconnect/types' import type { TronNativeTransferRequest, TronTRC20TransferRequest, TronContractCallRequest } from '@meshconnect/uwc-types' // --------------------------------------------------------------------------- // Mock tron-utils — keeps tests fast and isolated from network / address conversion // --------------------------------------------------------------------------- const mockBuildNativeTronTx = vi.fn() const mockBuildTrc20TronTx = vi.fn() const mockBuildContractCallTronTx = vi.fn() const mockBroadcastTronTx = vi.fn() const mockNodeHasTransaction = vi.fn() const mockTronAddressToHex = vi.fn() vi.mock('../../utils/tron-utils', () => ({ buildNativeTronTx: (...args: unknown[]) => mockBuildNativeTronTx(...args), buildTrc20TronTx: (...args: unknown[]) => mockBuildTrc20TronTx(...args), buildContractCallTronTx: (...args: unknown[]) => mockBuildContractCallTronTx(...args), broadcastTronTx: (...args: unknown[]) => mockBroadcastTronTx(...args), nodeHasTransaction: (...args: unknown[]) => mockNodeHasTransaction(...args), tronAddressToHex: (addr: string) => mockTronAddressToHex(addr) })) // --------------------------------------------------------------------------- // Fixtures // --------------------------------------------------------------------------- const TRON_MAINNET = 'tron:0x2b6653dc' const MOCK_TOPIC = 'mock-topic' const ACTIVE_ADDRESS = '41aaaaaa0000000000000000000000000000000001' const OTHER_ADDRESS = '41bbbbbb0000000000000000000000000000000002' const UNSIGNED_TX = { txID: 'unsigned', raw_data: {} } const SIGNED_TX = { txID: 'unsigned', signature: ['0xsig'] } // What the node must receive: our unsigned transaction carrying the wallet's // signature, normalized to an array of unprefixed hex. const BROADCAST_BODY = { ...UNSIGNED_TX, signature: ['sig'] } const BROADCAST_TXID = 'broadcast_txid_abc' const mockSession = { topic: MOCK_TOPIC } as SessionTypes.Struct function makeSignClient(wcResponse: unknown): SignClient { return { request: vi.fn().mockResolvedValue(wcResponse) } as unknown as SignClient } const nativeRequest: TronNativeTransferRequest = { from: ACTIVE_ADDRESS, to: OTHER_ADDRESS, amount: 1_000_000, type: 'tron_native' } const trc20Request: TronTRC20TransferRequest = { from: ACTIVE_ADDRESS, to: OTHER_ADDRESS, amount: 1_000_000n, contractAddress: '41cccccc0000000000000000000000000000000003', type: 'tron_trc20' } const contractCallRequest: TronContractCallRequest = { from: ACTIVE_ADDRESS, contractAddress: '41cccccc0000000000000000000000000000000003', data: 'a9059cbb' + '0'.repeat(128), type: 'tron_contract_call' } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- describe('TronTransactionService.sendTransaction', () => { let service: TronTransactionService beforeEach(() => { vi.clearAllMocks() service = new TronTransactionService() // Default: addresses compare equal (same hex form) mockTronAddressToHex.mockImplementation((addr: string) => addr) mockBuildNativeTronTx.mockResolvedValue(UNSIGNED_TX) mockBuildTrc20TronTx.mockResolvedValue(UNSIGNED_TX) mockBuildContractCallTronTx.mockResolvedValue(UNSIGNED_TX) mockBroadcastTronTx.mockResolvedValue(BROADCAST_TXID) // Default: nothing landed, so the recovery probe never masks a real failure. mockNodeHasTransaction.mockResolvedValue(false) }) it('sends a native transfer end-to-end', async () => { const client = makeSignClient(SIGNED_TX) const txid = await service.sendTransaction( client, mockSession, nativeRequest, TRON_MAINNET, ACTIVE_ADDRESS ) expect(mockBuildNativeTronTx).toHaveBeenCalledWith( TRON_MAINNET, nativeRequest ) expect(client.request).toHaveBeenCalledWith( expect.objectContaining({ topic: MOCK_TOPIC, chainId: TRON_MAINNET, request: expect.objectContaining({ method: 'tron_signTransaction' }) }) ) expect(mockBroadcastTronTx).toHaveBeenCalledWith( TRON_MAINNET, BROADCAST_BODY ) expect(txid).toBe(BROADCAST_TXID) }) it('sends a TRC20 transfer end-to-end', async () => { const client = makeSignClient(SIGNED_TX) const txid = await service.sendTransaction( client, mockSession, trc20Request, TRON_MAINNET, ACTIVE_ADDRESS ) expect(mockBuildTrc20TronTx).toHaveBeenCalledWith( TRON_MAINNET, trc20Request ) expect(txid).toBe(BROADCAST_TXID) }) it('sends a contract-call transfer end-to-end', async () => { const client = makeSignClient(SIGNED_TX) const txid = await service.sendTransaction( client, mockSession, contractCallRequest, TRON_MAINNET, ACTIVE_ADDRESS ) expect(mockBuildContractCallTronTx).toHaveBeenCalledWith( TRON_MAINNET, contractCallRequest ) expect(txid).toBe(BROADCAST_TXID) }) it('unwraps Trust Wallet { result: {...} } response before broadcasting', async () => { const trustWalletResponse = { result: SIGNED_TX } const client = makeSignClient(trustWalletResponse) await service.sendTransaction( client, mockSession, nativeRequest, TRON_MAINNET, ACTIVE_ADDRESS ) // broadcastTronTx must receive the inner object, not the wrapper expect(mockBroadcastTronTx).toHaveBeenCalledWith( TRON_MAINNET, BROADCAST_BODY ) }) it('broadcasts an unwrapped reply merged into the unsigned transaction', async () => { const client = makeSignClient(SIGNED_TX) await service.sendTransaction( client, mockSession, nativeRequest, TRON_MAINNET, ACTIVE_ADDRESS ) expect(mockBroadcastTronTx).toHaveBeenCalledWith( TRON_MAINNET, BROADCAST_BODY ) }) it('throws when the wallet returns null', async () => { const client = makeSignClient(null) await expect( service.sendTransaction( client, mockSession, nativeRequest, TRON_MAINNET, ACTIVE_ADDRESS ) ).rejects.toThrow('unexpected result') }) it('throws when the wallet returns a primitive', async () => { const client = makeSignClient('not-an-object') await expect( service.sendTransaction( client, mockSession, nativeRequest, TRON_MAINNET, ACTIVE_ADDRESS ) ).rejects.toThrow('unexpected result') }) it('rejects native transfer when from address does not match active address', async () => { // Simulate mismatch: same value passed but tronAddressToHex returns different results mockTronAddressToHex.mockImplementation((addr: string) => addr === ACTIVE_ADDRESS ? ACTIVE_ADDRESS : OTHER_ADDRESS ) const client = makeSignClient(SIGNED_TX) const mismatchedRequest: TronNativeTransferRequest = { ...nativeRequest, from: OTHER_ADDRESS } await expect( service.sendTransaction( client, mockSession, mismatchedRequest, TRON_MAINNET, ACTIVE_ADDRESS ) ).rejects.toThrow('From address does not match') }) it('rejects TRC20 transfer when from address does not match active address', async () => { mockTronAddressToHex.mockImplementation((addr: string) => addr === ACTIVE_ADDRESS ? ACTIVE_ADDRESS : OTHER_ADDRESS ) const client = makeSignClient(SIGNED_TX) const mismatchedRequest: TronTRC20TransferRequest = { ...trc20Request, from: OTHER_ADDRESS } await expect( service.sendTransaction( client, mockSession, mismatchedRequest, TRON_MAINNET, ACTIVE_ADDRESS ) ).rejects.toThrow('From address does not match') }) it('sends the legacy nested payload when the session advertises no version', async () => { const client = makeSignClient(SIGNED_TX) await service.sendTransaction( client, mockSession, nativeRequest, TRON_MAINNET, ACTIVE_ADDRESS ) expect(client.request).toHaveBeenCalledWith( expect.objectContaining({ request: expect.objectContaining({ params: { address: ACTIVE_ADDRESS, transaction: { transaction: UNSIGNED_TX } } }) }) ) }) it('sends the flat payload when the session advertises tron_method_version v1', async () => { const client = makeSignClient(SIGNED_TX) const v1Session = { topic: MOCK_TOPIC, sessionProperties: { tron_method_version: 'v1' } } as unknown as SessionTypes.Struct await service.sendTransaction( client, v1Session, nativeRequest, TRON_MAINNET, ACTIVE_ADDRESS ) expect(client.request).toHaveBeenCalledWith( expect.objectContaining({ request: expect.objectContaining({ params: { address: ACTIVE_ADDRESS, transaction: UNSIGNED_TX } }) }) ) }) it('merges a signature-only reply into the unsigned transaction', async () => { // Trust Wallet answers with the signature alone — a shape the spec allows. // Relaying it verbatim gives the node a body with no raw_data at all. const client = makeSignClient({ signature: '0xdeadbeef' }) await service.sendTransaction( client, mockSession, nativeRequest, TRON_MAINNET, ACTIVE_ADDRESS ) expect(mockBroadcastTronTx).toHaveBeenCalledWith(TRON_MAINNET, { ...UNSIGNED_TX, signature: ['deadbeef'] }) }) it('strips the 0x prefix and wraps a bare string signature in an array', async () => { const client = makeSignClient({ ...UNSIGNED_TX, signature: '0xABCDEF' }) await service.sendTransaction( client, mockSession, nativeRequest, TRON_MAINNET, ACTIVE_ADDRESS ) expect(mockBroadcastTronTx).toHaveBeenCalledWith(TRON_MAINNET, { ...UNSIGNED_TX, signature: ['ABCDEF'] }) }) it('never broadcasts raw_data the wallet returned — only what we built', async () => { // The txID guard only bites when a wallet volunteers one. A wallet that // omits it while returning raw_data for a different recipient must not get // that raw_data broadcast: the node validates the signature against // whatever raw_data it is handed. const client = makeSignClient({ signature: ['0xsig'], raw_data: { contract: [{ parameter: { value: { to_address: 'attacker' } } }] }, raw_data_hex: 'deadbeef', visible: true }) await service.sendTransaction( client, mockSession, nativeRequest, TRON_MAINNET, ACTIVE_ADDRESS ) expect(mockBroadcastTronTx).toHaveBeenCalledWith( TRON_MAINNET, BROADCAST_BODY ) }) it('ignores a wallet-echoed matching txID rather than trusting it', async () => { const client = makeSignClient({ ...UNSIGNED_TX, txID: UNSIGNED_TX.txID, raw_data_hex: 'tampered-but-same-claimed-id', signature: ['0xsig'] }) await service.sendTransaction( client, mockSession, nativeRequest, TRON_MAINNET, ACTIVE_ADDRESS ) expect(mockBroadcastTronTx).toHaveBeenCalledWith( TRON_MAINNET, BROADCAST_BODY ) }) it('throws when the wallet returns no usable signature', async () => { const client = makeSignClient({ ...UNSIGNED_TX, signature: [] }) await expect( service.sendTransaction( client, mockSession, nativeRequest, TRON_MAINNET, ACTIVE_ADDRESS ) ).rejects.toThrow('no usable signature') expect(mockBroadcastTronTx).not.toHaveBeenCalled() }) it('refuses to broadcast when the wallet signed a different transaction', async () => { const client = makeSignClient({ txID: 'something-else', signature: ['0xsig'] }) await expect( service.sendTransaction( client, mockSession, nativeRequest, TRON_MAINNET, ACTIVE_ADDRESS ) ).rejects.toThrow('signed a different transaction') expect(mockBroadcastTronTx).not.toHaveBeenCalled() }) // ------------------------------------------------------------------------- // Recovery probe — a wallet that broadcasts before it answers can leave the // transfer on-chain while we never receive a signature at all. // ------------------------------------------------------------------------- describe('recovery after a failed sign', () => { function failingSignClient(error: unknown): SignClient { return { request: vi.fn().mockRejectedValue(error) } as unknown as SignClient } it('returns the built txID when the wallet never answered but the tx landed', async () => { mockNodeHasTransaction.mockResolvedValue(true) const client = failingSignClient(new Error('Request expired')) const txid = await service.sendTransaction( client, mockSession, nativeRequest, TRON_MAINNET, ACTIVE_ADDRESS ) expect(txid).toBe(UNSIGNED_TX.txID) expect(mockNodeHasTransaction).toHaveBeenCalledWith( TRON_MAINNET, UNSIGNED_TX.txID ) // Nothing to broadcast — we never got a signature. expect(mockBroadcastTronTx).not.toHaveBeenCalled() }) it('rethrows when the wallet never answered and nothing landed', async () => { mockNodeHasTransaction.mockResolvedValue(false) const client = failingSignClient(new Error('Request expired')) await expect( service.sendTransaction( client, mockSession, nativeRequest, TRON_MAINNET, ACTIVE_ADDRESS ) ).rejects.toThrow('Request expired') expect(mockNodeHasTransaction).toHaveBeenCalledOnce() }) it('still probes on a user rejection — a decline cannot prove nothing was sent', async () => { // Trust Wallet broadcasts on the first confirmation and asks again // afterwards, so declining the second prompt can arrive after the funds // moved. Reporting failure there loses the exact transfer the probe exists // for. mockNodeHasTransaction.mockResolvedValue(true) const client = failingSignClient({ code: 4001, message: 'User rejected' }) const txid = await service.sendTransaction( client, mockSession, nativeRequest, TRON_MAINNET, ACTIVE_ADDRESS ) expect(txid).toBe(UNSIGNED_TX.txID) }) it('gives a rejection the same lookup budget as any other failure', async () => { // No shortcut for declines: the transaction is readable only once it is in // a block, and the wallet may have broadcast it elsewhere, so an immediate // single lookup would call a landed transfer failed. mockNodeHasTransaction.mockResolvedValue(false) const client = failingSignClient({ code: 4001, message: 'User rejected' }) await expect( service.sendTransaction( client, mockSession, nativeRequest, TRON_MAINNET, ACTIVE_ADDRESS ) ).rejects.toThrow('User rejected') expect(mockNodeHasTransaction).toHaveBeenCalledWith( TRON_MAINNET, UNSIGNED_TX.txID ) }) it('preserves the rejection type when the user declines and nothing landed', async () => { mockNodeHasTransaction.mockResolvedValue(false) const client = failingSignClient({ code: 4001, message: 'User rejected' }) const { WalletConnectorError } = await import('@meshconnect/uwc-types') const failure = await service .sendTransaction( client, mockSession, nativeRequest, TRON_MAINNET, ACTIVE_ADDRESS ) .catch((e: unknown) => e) expect(failure).toBeInstanceOf(WalletConnectorError) expect((failure as { type: string }).type).toBe('rejected') }) it('recovers a transfer the node accepted even though the broadcast threw', async () => { mockNodeHasTransaction.mockResolvedValue(true) mockBroadcastTronTx.mockRejectedValue(new Error('Tapos check error.')) const client = makeSignClient(SIGNED_TX) const txid = await service.sendTransaction( client, mockSession, nativeRequest, TRON_MAINNET, ACTIVE_ADDRESS ) expect(txid).toBe(UNSIGNED_TX.txID) }) it('keeps the broadcast error when the node does not have the transaction', async () => { mockNodeHasTransaction.mockResolvedValue(false) mockBroadcastTronTx.mockRejectedValue(new Error('Tapos check error.')) const client = makeSignClient(SIGNED_TX) await expect( service.sendTransaction( client, mockSession, nativeRequest, TRON_MAINNET, ACTIVE_ADDRESS ) ).rejects.toThrow('Tapos check error.') }) it('recovers a TRC20 transfer, not just a native one', async () => { mockNodeHasTransaction.mockResolvedValue(true) const client = failingSignClient(new Error('Request expired')) const txid = await service.sendTransaction( client, mockSession, trc20Request, TRON_MAINNET, ACTIVE_ADDRESS ) expect(txid).toBe(UNSIGNED_TX.txID) }) it('does not probe when the build failed — there is no txID to look up', async () => { mockBuildNativeTronTx.mockRejectedValue( new Error('createtransaction failed') ) const client = makeSignClient(SIGNED_TX) await expect( service.sendTransaction( client, mockSession, nativeRequest, TRON_MAINNET, ACTIVE_ADDRESS ) ).rejects.toThrow('createtransaction failed') expect(mockNodeHasTransaction).not.toHaveBeenCalled() }) it('does not probe when the built transaction carries no txID', async () => { mockBuildNativeTronTx.mockResolvedValue({ raw_data: {} }) const client = failingSignClient(new Error('Request expired')) await expect( service.sendTransaction( client, mockSession, nativeRequest, TRON_MAINNET, ACTIVE_ADDRESS ) ).rejects.toThrow('Request expired') expect(mockNodeHasTransaction).not.toHaveBeenCalled() }) it('does not probe on an address mismatch — nothing was ever built or sent', async () => { mockTronAddressToHex.mockImplementation((addr: string) => addr === ACTIVE_ADDRESS ? ACTIVE_ADDRESS : OTHER_ADDRESS ) const client = makeSignClient(SIGNED_TX) await expect( service.sendTransaction( client, mockSession, { ...nativeRequest, from: OTHER_ADDRESS }, TRON_MAINNET, ACTIVE_ADDRESS ) ).rejects.toThrow('From address does not match') expect(mockNodeHasTransaction).not.toHaveBeenCalled() }) it('rethrows the original error when the probe itself fails', async () => { // An unanswerable probe is inconclusive, not a new failure — the caller // must still see why signing failed, not why the lookup did. mockNodeHasTransaction.mockRejectedValue(new Error('node unreachable')) const client = failingSignClient(new Error('Request expired')) await expect( service.sendTransaction( client, mockSession, nativeRequest, TRON_MAINNET, ACTIVE_ADDRESS ) ).rejects.toThrow('Request expired') }) it('recovers our own transaction, never the mismatched one the wallet returned', async () => { // The wallet answered with a different txID, so its reply is refused and // nothing of the wallet's is broadcast. The probe then reports OUR // transaction on-chain, and that — and only that — is what we return. mockNodeHasTransaction.mockResolvedValue(true) const client = makeSignClient({ txID: 'a-different-transaction', signature: ['0xsig'] }) const txid = await service.sendTransaction( client, mockSession, nativeRequest, TRON_MAINNET, ACTIVE_ADDRESS ) // Our own transaction did land, so returning its id is correct — but the // wallet's mismatched transaction was never broadcast. expect(txid).toBe(UNSIGNED_TX.txID) expect(mockBroadcastTronTx).not.toHaveBeenCalled() }) }) it('normalises broadcast errors through parseError', async () => { mockBroadcastTronTx.mockRejectedValue( new Error('user rejected the request') ) const client = makeSignClient(SIGNED_TX) const { WalletConnectorError } = await import('@meshconnect/uwc-types') await expect( service.sendTransaction( client, mockSession, nativeRequest, TRON_MAINNET, ACTIVE_ADDRESS ) ).rejects.toBeInstanceOf(WalletConnectorError) }) })