diff --git a/.circleci/config.yml b/.circleci/config.yml index 30270210b..59a9cf22d 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -43,6 +43,15 @@ references: name: 'Subgraph deployment and configuration' working_directory: ~/ command: | + # Wait for Graph node to be ready + for i in $(seq 1 30); do + if curl -s http://localhost:8020 > /dev/null 2>&1; then + echo "Graph node is ready" + break + fi + echo "Waiting for Graph node... ($i/30)" + sleep 2 + done git clone https://github.com/RequestNetwork/storage-subgraph cd storage-subgraph yarn diff --git a/packages/payment-processor/src/index.ts b/packages/payment-processor/src/index.ts index b0a4ef854..4e92546b5 100644 --- a/packages/payment-processor/src/index.ts +++ b/packages/payment-processor/src/index.ts @@ -28,6 +28,8 @@ export * from './payment/encoder-approval'; export * as Escrow from './payment/erc20-escrow-payment'; export * from './payment/prepared-transaction'; export * from './payment/utils-near'; +export * from './payment/utils-tron'; +export * from './payment/tron-fee-proxy'; export * from './payment/single-request-forwarder'; export * from './payment/erc20-recurring-payment-proxy'; export * from './payment/erc20-commerce-escrow-wrapper'; diff --git a/packages/payment-processor/src/payment/tron-fee-proxy.ts b/packages/payment-processor/src/payment/tron-fee-proxy.ts new file mode 100644 index 000000000..5f58bd802 --- /dev/null +++ b/packages/payment-processor/src/payment/tron-fee-proxy.ts @@ -0,0 +1,191 @@ +import { BigNumber, BigNumberish } from 'ethers'; +import { ClientTypes, ExtensionTypes } from '@requestnetwork/types'; +import { TronChains } from '@requestnetwork/currency'; + +import { getAmountToPay, getRequestPaymentValues, validateRequest } from './utils'; +import { + TronWeb, + ITronTransactionCallback, + processTronFeeProxyPayment, + approveTrc20, + getTronAllowance, + isTronAccountSolvent, + isValidTronAddress, + getERC20FeeProxyAddress, +} from './utils-tron'; +import { validatePaymentReference } from '../utils/validation'; + +/** + * Checks if the TronWeb instance has sufficient allowance for the payment + */ +export async function hasSufficientTronAllowance( + request: ClientTypes.IRequestData, + tronWeb: TronWeb, + amount?: BigNumberish, +): Promise { + const network = request.currencyInfo.network; + if (!network || !TronChains.isChainSupported(network)) { + throw new Error('Request currency network is not a supported Tron network'); + } + TronChains.assertChainSupported(network); + + const tokenAddress = request.currencyInfo.value; + const { feeAmount } = getRequestPaymentValues(request); + const amountToPay = getAmountToPay(request, amount); + const totalAmount = BigNumber.from(amountToPay).add(feeAmount || 0); + + const allowance = await getTronAllowance(tronWeb, tokenAddress, network); + return allowance.gte(totalAmount); +} + +/** + * Checks if the payer has sufficient TRC20 token balance + */ +export async function hasSufficientTronBalance( + request: ClientTypes.IRequestData, + tronWeb: TronWeb, + amount?: BigNumberish, +): Promise { + const tokenAddress = request.currencyInfo.value; + const { feeAmount } = getRequestPaymentValues(request); + const amountToPay = getAmountToPay(request, amount); + const totalAmount = BigNumber.from(amountToPay).add(feeAmount || 0); + + return isTronAccountSolvent(tronWeb, tokenAddress, totalAmount); +} + +/** + * Approves the ERC20FeeProxy contract to spend TRC20 tokens for a request payment + */ +export async function approveTronFeeProxyRequest( + request: ClientTypes.IRequestData, + tronWeb: TronWeb, + amount?: BigNumberish, + callback?: ITronTransactionCallback, +): Promise { + const network = request.currencyInfo.network; + if (!network || !TronChains.isChainSupported(network)) { + throw new Error('Request currency network is not a supported Tron network'); + } + TronChains.assertChainSupported(network); + + validateRequest(request, ExtensionTypes.PAYMENT_NETWORK_ID.ERC20_FEE_PROXY_CONTRACT); + + const tokenAddress = request.currencyInfo.value; + const { feeAmount } = getRequestPaymentValues(request); + const amountToPay = getAmountToPay(request, amount); + const totalAmount = BigNumber.from(amountToPay).add(feeAmount || 0); + + return approveTrc20(tronWeb, tokenAddress, network, totalAmount, callback); +} + +/** + * Processes a TRC20 fee proxy payment for a Request. + * + * @param request The request to pay + * @param tronWeb The TronWeb instance connected to the payer's wallet + * @param amount Optionally, the amount to pay. Defaults to remaining amount of the request. + * @param feeAmount Optionally, the fee amount to pay. Defaults to the fee amount from the request. + * @param callback Optional callbacks for transaction events + * @returns The transaction hash + */ +export async function payTronFeeProxyRequest( + request: ClientTypes.IRequestData, + tronWeb: TronWeb, + amount?: BigNumberish, + feeAmount?: BigNumberish, + callback?: ITronTransactionCallback, +): Promise { + const network = request.currencyInfo.network; + if (!network || !TronChains.isChainSupported(network)) { + throw new Error('Request currency network is not a supported Tron network'); + } + TronChains.assertChainSupported(network); + + validateRequest(request, ExtensionTypes.PAYMENT_NETWORK_ID.ERC20_FEE_PROXY_CONTRACT); + + const { + paymentReference, + paymentAddress, + feeAddress, + feeAmount: requestFeeAmount, + } = getRequestPaymentValues(request); + + validatePaymentReference(paymentReference); + + if (!isValidTronAddress(paymentAddress)) { + throw new Error(`Invalid Tron payment address: ${paymentAddress}`); + } + + const tokenAddress = request.currencyInfo.value; + const amountToPay = getAmountToPay(request, amount); + const feeToPay = feeAmount ?? requestFeeAmount ?? '0'; + + // Check allowance + const totalAmount = BigNumber.from(amountToPay).add(feeToPay); + const allowance = await getTronAllowance(tronWeb, tokenAddress, network); + + if (allowance.lt(totalAmount)) { + throw new Error( + `Insufficient TRC20 allowance. Required: ${totalAmount.toString()}, Available: ${allowance.toString()}. ` + + `Please call approveTronFeeProxyRequest first.`, + ); + } + + // Check balance + const hasSufficientBalance = await isTronAccountSolvent(tronWeb, tokenAddress, totalAmount); + if (!hasSufficientBalance) { + throw new Error('Insufficient TRC20 token balance for payment'); + } + + return processTronFeeProxyPayment( + tronWeb, + network, + tokenAddress, + paymentAddress, + amountToPay, + paymentReference, + feeToPay, + feeAddress || tronWeb.defaultAddress.base58, + callback, + ); +} + +/** + * Gets information needed to pay a Tron request + */ +export function getTronPaymentInfo( + request: ClientTypes.IRequestData, + amount?: BigNumberish, +): { + proxyAddress: string; + tokenAddress: string; + paymentAddress: string; + amount: string; + paymentReference: string; + feeAmount: string; + feeAddress: string; +} { + const network = request.currencyInfo.network; + if (!network || !TronChains.isChainSupported(network)) { + throw new Error('Request currency network is not a supported Tron network'); + } + TronChains.assertChainSupported(network); + + const { paymentReference, paymentAddress, feeAddress, feeAmount } = + getRequestPaymentValues(request); + + const tokenAddress = request.currencyInfo.value; + const amountToPay = getAmountToPay(request, amount); + const proxyAddress = getERC20FeeProxyAddress(network); + + return { + proxyAddress, + tokenAddress, + paymentAddress, + amount: amountToPay.toString(), + paymentReference: paymentReference ?? '', + feeAmount: (feeAmount || '0').toString(), + feeAddress: feeAddress ?? '', + }; +} diff --git a/packages/payment-processor/src/payment/utils-tron.ts b/packages/payment-processor/src/payment/utils-tron.ts new file mode 100644 index 000000000..f5fa56c19 --- /dev/null +++ b/packages/payment-processor/src/payment/utils-tron.ts @@ -0,0 +1,311 @@ +import { BigNumber, BigNumberish } from 'ethers'; +import { CurrencyTypes } from '@requestnetwork/types'; +import { erc20FeeProxyArtifact } from '@requestnetwork/smart-contracts'; + +// TronWeb types for v6+ +// Using interface that matches TronWeb's actual API +export interface TronWeb { + address: { + fromPrivateKey: (privateKey: string) => string; + toHex: (address: string) => string; + fromHex: (address: string) => string; + }; + trx: { + getBalance: (address: string) => Promise; + sign: (transaction: unknown, privateKey?: string) => Promise; + sendRawTransaction: (signedTransaction: unknown) => Promise; + }; + contract: ( + abi: T, + address: string, + ) => Promise>; + transactionBuilder: { + triggerSmartContract: ( + contractAddress: string, + functionSelector: string, + options: TronTriggerOptions, + parameters: unknown[], + issuerAddress: string, + ) => Promise<{ transaction: unknown; result: { result: boolean } }>; + }; + defaultAddress: { + base58: string; + hex: string; + }; + toSun: (amount: number) => number; + fromSun: (amount: number) => number; +} + +// Generic contract instance type that provides method typing based on ABI +export type TronContractInstance = { + [K in ExtractFunctionNames]: (...args: unknown[]) => TronContractMethod; +}; + +// Helper type to extract function names from ABI +type ExtractFunctionNames = T extends readonly (infer U)[] + ? U extends { name: string; type: 'function' } + ? U['name'] + : never + : never; + +export interface TronContractMethod { + call: () => Promise; + send: (options?: TronSendOptions) => Promise; +} + +export interface TronTriggerOptions { + feeLimit?: number; + callValue?: number; +} + +export interface TronSendOptions { + feeLimit?: number; + callValue?: number; + shouldPollResponse?: boolean; +} + +export interface TronTransactionResult { + result?: boolean; + txid?: string; + transaction?: { + txID: string; + }; +} + +/** + * Callback arguments for Tron transactions + */ +export interface ITronTransactionCallback { + onHash?: (txHash: string) => void; + onConfirmation?: (receipt: unknown) => void; + onError?: (error: Error) => void; +} + +/** + * Validates a Tron address (Base58 format starting with T) + */ +export const isValidTronAddress = (address: string): boolean => { + if (!address) return false; + // Tron addresses start with 'T' and are 34 characters in Base58Check encoding + // Base58 alphabet: 123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz + // (excludes 0, O, I, l to avoid confusion) + const tronAddressRegex = /^T[123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz]{33}$/; + return tronAddressRegex.test(address); +}; + +/** + * Gets the ERC20FeeProxy contract address for a Tron network + */ +export const getERC20FeeProxyAddress = (network: CurrencyTypes.TronChainName): string => { + return erc20FeeProxyArtifact.getAddress(network, 'tron'); +}; + +/** + * Checks if a Tron account has sufficient TRC20 token balance + */ +export const isTronAccountSolvent = async ( + tronWeb: TronWeb, + tokenAddress: string, + amount: BigNumberish, +): Promise => { + try { + const contract = await tronWeb.contract(TRC20_ABI, tokenAddress); + const balance = await contract.balanceOf(tronWeb.defaultAddress.base58).call(); + return BigNumber.from(String(balance)).gte(amount); + } catch (error) { + console.error('Error checking Tron account solvency:', error); + return false; + } +}; + +/** + * Checks the TRC20 token allowance for the ERC20FeeProxy contract + */ +export const getTronAllowance = async ( + tronWeb: TronWeb, + tokenAddress: string, + network: CurrencyTypes.TronChainName, +): Promise => { + try { + const proxyAddress = getERC20FeeProxyAddress(network); + const contract = await tronWeb.contract(TRC20_ABI, tokenAddress); + const allowance = await contract.allowance(tronWeb.defaultAddress.base58, proxyAddress).call(); + return BigNumber.from(String(allowance)); + } catch (error) { + console.error('Error getting Tron allowance:', error); + return BigNumber.from(0); + } +}; + +/** + * Approves the ERC20FeeProxy contract to spend TRC20 tokens + */ +export const approveTrc20 = async ( + tronWeb: TronWeb, + tokenAddress: string, + network: CurrencyTypes.TronChainName, + amount: BigNumberish, + callback?: ITronTransactionCallback, +): Promise => { + const proxyAddress = getERC20FeeProxyAddress(network); + const contract = await tronWeb.contract(TRC20_ABI, tokenAddress); + + try { + const result = await contract.approve(proxyAddress, amount.toString()).send({ + feeLimit: 100000000, // 100 TRX fee limit + shouldPollResponse: true, + }); + + const txHash = result.txid || result.transaction?.txID || ''; + callback?.onHash?.(txHash); + + return txHash; + } catch (error) { + callback?.onError?.(error as Error); + throw new Error(`TRC20 approval failed: ${(error as Error).message}`); + } +}; + +/** + * Processes a TRC20 fee proxy payment on Tron + */ +export const processTronFeeProxyPayment = async ( + tronWeb: TronWeb, + network: CurrencyTypes.TronChainName, + tokenAddress: string, + to: string, + amount: BigNumberish, + paymentReference: string, + feeAmount: BigNumberish, + feeAddress: string, + callback?: ITronTransactionCallback, +): Promise => { + // Validate addresses + if (!isValidTronAddress(to)) { + throw new Error(`Invalid Tron payment address: ${to}`); + } + if (feeAmount.toString() !== '0' && !isValidTronAddress(feeAddress)) { + throw new Error(`Invalid Tron fee address: ${feeAddress}`); + } + if (!isValidTronAddress(tokenAddress)) { + throw new Error(`Invalid TRC20 token address: ${tokenAddress}`); + } + + const proxyAddress = getERC20FeeProxyAddress(network); + + // Get the proxy contract + const proxyContract = await tronWeb.contract(ERC20_FEE_PROXY_ABI, proxyAddress); + + // Format payment reference - ensure it's bytes format + const formattedReference = paymentReference.startsWith('0x') + ? paymentReference + : `0x${paymentReference}`; + + try { + // Call transferFromWithReferenceAndFee + const result = await proxyContract + .transferFromWithReferenceAndFee( + tokenAddress, + to, + amount.toString(), + formattedReference, + feeAmount.toString(), + feeAddress, + ) + .send({ + feeLimit: 150000000, // 150 TRX fee limit for proxy call + shouldPollResponse: true, + }); + + const txHash = result.txid || result.transaction?.txID || ''; + callback?.onHash?.(txHash); + + return txHash; + } catch (error) { + callback?.onError?.(error as Error); + throw new Error(`Tron fee proxy payment failed: ${(error as Error).message}`); + } +}; + +/** + * Encodes a TRC20 fee proxy payment for use with multi-sig or batching + */ +export const encodeTronFeeProxyPayment = ( + tokenAddress: string, + to: string, + amount: BigNumberish, + paymentReference: string, + feeAmount: BigNumberish, + feeAddress: string, +): { + functionSelector: string; + parameters: unknown[]; +} => { + const formattedReference = paymentReference.startsWith('0x') + ? paymentReference + : `0x${paymentReference}`; + + return { + functionSelector: + 'transferFromWithReferenceAndFee(address,address,uint256,bytes,uint256,address)', + parameters: [ + { type: 'address', value: tokenAddress }, + { type: 'address', value: to }, + { type: 'uint256', value: amount.toString() }, + { type: 'bytes', value: formattedReference }, + { type: 'uint256', value: feeAmount.toString() }, + { type: 'address', value: feeAddress }, + ], + }; +}; + +// Minimal TRC20 ABI for balance, allowance, and approve +// Using `as const` for proper type inference in TronWeb v6+ +const TRC20_ABI = [ + { + constant: true, + inputs: [{ name: 'owner', type: 'address' }], + name: 'balanceOf', + outputs: [{ name: '', type: 'uint256' }], + type: 'function', + }, + { + constant: true, + inputs: [ + { name: 'owner', type: 'address' }, + { name: 'spender', type: 'address' }, + ], + name: 'allowance', + outputs: [{ name: '', type: 'uint256' }], + type: 'function', + }, + { + constant: false, + inputs: [ + { name: 'spender', type: 'address' }, + { name: 'amount', type: 'uint256' }, + ], + name: 'approve', + outputs: [{ name: '', type: 'bool' }], + type: 'function', + }, +] as const; + +// ERC20FeeProxy ABI (minimal, only what we need) +// Using `as const` for proper type inference in TronWeb v6+ +const ERC20_FEE_PROXY_ABI = [ + { + inputs: [ + { name: '_tokenAddress', type: 'address' }, + { name: '_to', type: 'address' }, + { name: '_amount', type: 'uint256' }, + { name: '_paymentReference', type: 'bytes' }, + { name: '_feeAmount', type: 'uint256' }, + { name: '_feeAddress', type: 'address' }, + ], + name: 'transferFromWithReferenceAndFee', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, +] as const; diff --git a/packages/payment-processor/test/payment/tron-fee-proxy.test.ts b/packages/payment-processor/test/payment/tron-fee-proxy.test.ts new file mode 100644 index 000000000..a3a3e3d70 --- /dev/null +++ b/packages/payment-processor/test/payment/tron-fee-proxy.test.ts @@ -0,0 +1,349 @@ +import { ExtensionTypes, RequestLogicTypes } from '@requestnetwork/types'; +import { deepCopy } from '@requestnetwork/utils'; +import { PaymentReferenceCalculator } from '@requestnetwork/payment-detection'; + +import * as tronUtils from '../../src/payment/utils-tron'; +import { + payTronFeeProxyRequest, + approveTronFeeProxyRequest, + hasSufficientTronAllowance, + hasSufficientTronBalance, + getTronPaymentInfo, +} from '../../src/payment/tron-fee-proxy'; +import { BigNumber } from 'ethers'; + +/* eslint-disable @typescript-eslint/no-unused-expressions */ + +const usdt = { + type: RequestLogicTypes.CURRENCY.ERC20, + value: 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t', // USDT on Tron + network: 'tron', +}; + +const salt = 'a6475e4c3d45feb6'; +const paymentAddress = 'TQn9Y2khEsLJW1ChVWFMSMeRDow5KcbLSE'; +const feeAddress = 'TG3XXyExBkPp9nzdajDZsozEu4BkaSJozs'; +const network = 'tron'; +const feeAmount = '5'; + +const request: any = { + requestId: '0x123', + expectedAmount: '100', + currencyInfo: usdt, + extensions: { + [ExtensionTypes.PAYMENT_NETWORK_ID.ERC20_FEE_PROXY_CONTRACT]: { + events: [], + id: ExtensionTypes.PAYMENT_NETWORK_ID.ERC20_FEE_PROXY_CONTRACT, + type: ExtensionTypes.TYPE.PAYMENT_NETWORK, + values: { + salt, + paymentAddress, + feeAddress, + network, + feeAmount, + }, + version: '0.1.0', + }, + }, +}; + +// Mock TronWeb instance +const createMockTronWeb = (overrides: Partial = {}): tronUtils.TronWeb => ({ + address: { + fromPrivateKey: jest.fn().mockReturnValue('TTestAddress123'), + toHex: jest.fn().mockReturnValue('41...'), + fromHex: jest.fn().mockReturnValue('T...'), + }, + trx: { + getBalance: jest.fn().mockResolvedValue(1000000000), + sign: jest.fn().mockResolvedValue({}), + sendRawTransaction: jest.fn().mockResolvedValue({ result: true, txid: 'mock-tx-hash' }), + }, + contract: jest.fn().mockResolvedValue({ + balanceOf: jest.fn().mockReturnValue({ + call: jest.fn().mockResolvedValue('1000000000000000000'), + }), + allowance: jest.fn().mockReturnValue({ + call: jest.fn().mockResolvedValue('1000000000000000000'), + }), + approve: jest.fn().mockReturnValue({ + send: jest.fn().mockResolvedValue({ txid: 'approve-tx-hash' }), + }), + transferFromWithReferenceAndFee: jest.fn().mockReturnValue({ + send: jest.fn().mockResolvedValue({ txid: 'payment-tx-hash' }), + }), + }), + transactionBuilder: { + triggerSmartContract: jest.fn().mockResolvedValue({ + transaction: {}, + result: { result: true }, + }), + }, + defaultAddress: { + base58: 'TDTFFJuVQCxEixEmhLQJhcqdYnRiKrNCDv', + hex: '41...', + }, + toSun: jest.fn((amount) => amount * 1000000), + fromSun: jest.fn((amount) => amount / 1000000), + ...overrides, +}); + +describe('Tron Fee Proxy Payment', () => { + let mockTronWeb: tronUtils.TronWeb; + + beforeEach(() => { + mockTronWeb = createMockTronWeb(); + jest.clearAllMocks(); + jest.restoreAllMocks(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('payTronFeeProxyRequest', () => { + it('should pay a TRC20 request successfully', async () => { + const paymentSpy = jest + .spyOn(tronUtils, 'processTronFeeProxyPayment') + .mockResolvedValue('mock-payment-tx'); + + jest.spyOn(tronUtils, 'getTronAllowance').mockResolvedValue(BigNumber.from('1000000')); + jest.spyOn(tronUtils, 'isTronAccountSolvent').mockResolvedValue(true); + + const result = await payTronFeeProxyRequest(request, mockTronWeb); + + expect(result).toBe('mock-payment-tx'); + expect(paymentSpy).toHaveBeenCalledWith( + mockTronWeb, + 'tron', + usdt.value, + paymentAddress, + expect.anything(), // amount + expect.any(String), // payment reference + feeAmount, + feeAddress, + undefined, // callback + ); + }); + + it('should throw if allowance is insufficient', async () => { + jest.spyOn(tronUtils, 'getTronAllowance').mockResolvedValue(BigNumber.from('0')); + + await expect(payTronFeeProxyRequest(request, mockTronWeb)).rejects.toThrow( + /Insufficient TRC20 allowance/, + ); + }); + + it('should throw if balance is insufficient', async () => { + jest.spyOn(tronUtils, 'getTronAllowance').mockResolvedValue(BigNumber.from('1000000')); + jest.spyOn(tronUtils, 'isTronAccountSolvent').mockResolvedValue(false); + + await expect(payTronFeeProxyRequest(request, mockTronWeb)).rejects.toThrow( + /Insufficient TRC20 token balance/, + ); + }); + + it('should throw if network is not Tron', async () => { + const invalidRequest = deepCopy(request); + invalidRequest.currencyInfo.network = 'mainnet'; // Ethereum network + + await expect(payTronFeeProxyRequest(invalidRequest, mockTronWeb)).rejects.toThrow( + /not a supported Tron network/, + ); + }); + + it('should throw if payment address is invalid', async () => { + jest.spyOn(tronUtils, 'getTronAllowance').mockResolvedValue(BigNumber.from('1000000')); + jest.spyOn(tronUtils, 'isTronAccountSolvent').mockResolvedValue(true); + jest.spyOn(tronUtils, 'isValidTronAddress').mockReturnValue(false); + + await expect(payTronFeeProxyRequest(request, mockTronWeb)).rejects.toThrow( + /Invalid Tron payment address/, + ); + }); + + it('should throw for wrong payment network extension', async () => { + const invalidRequest = deepCopy(request); + invalidRequest.extensions = { + [ExtensionTypes.PAYMENT_NETWORK_ID.ETH_INPUT_DATA]: { + ...invalidRequest.extensions[ExtensionTypes.PAYMENT_NETWORK_ID.ERC20_FEE_PROXY_CONTRACT], + }, + }; + + await expect(payTronFeeProxyRequest(invalidRequest, mockTronWeb)).rejects.toThrow(); + }); + }); + + describe('approveTronFeeProxyRequest', () => { + it('should approve TRC20 tokens for the proxy', async () => { + const approveSpy = jest.spyOn(tronUtils, 'approveTrc20').mockResolvedValue('approve-tx-hash'); + + const result = await approveTronFeeProxyRequest(request, mockTronWeb); + + expect(result).toBe('approve-tx-hash'); + expect(approveSpy).toHaveBeenCalledWith( + mockTronWeb, + usdt.value, + 'tron', + expect.anything(), // total amount (payment + fee) + undefined, // callback + ); + }); + + it('should throw if network is not Tron', async () => { + const invalidRequest = deepCopy(request); + invalidRequest.currencyInfo.network = 'matic'; + + await expect(approveTronFeeProxyRequest(invalidRequest, mockTronWeb)).rejects.toThrow( + /not a supported Tron network/, + ); + }); + }); + + describe('hasSufficientTronAllowance', () => { + it('should return true if allowance is sufficient', async () => { + jest.spyOn(tronUtils, 'getTronAllowance').mockResolvedValue(BigNumber.from('1000')); + + const result = await hasSufficientTronAllowance(request, mockTronWeb); + + expect(result).toBe(true); + }); + + it('should return false if allowance is insufficient', async () => { + jest.spyOn(tronUtils, 'getTronAllowance').mockResolvedValue(BigNumber.from('0')); + + const result = await hasSufficientTronAllowance(request, mockTronWeb); + + expect(result).toBe(false); + }); + }); + + describe('hasSufficientTronBalance', () => { + it('should return true if balance is sufficient', async () => { + jest.spyOn(tronUtils, 'isTronAccountSolvent').mockResolvedValue(true); + + const result = await hasSufficientTronBalance(request, mockTronWeb); + + expect(result).toBe(true); + }); + + it('should return false if balance is insufficient', async () => { + jest.spyOn(tronUtils, 'isTronAccountSolvent').mockResolvedValue(false); + + const result = await hasSufficientTronBalance(request, mockTronWeb); + + expect(result).toBe(false); + }); + }); + + describe('getTronPaymentInfo', () => { + it('should return correct payment information', () => { + const paymentInfo = getTronPaymentInfo(request); + + expect(paymentInfo.tokenAddress).toBe(usdt.value); + expect(paymentInfo.paymentAddress).toBe(paymentAddress); + expect(paymentInfo.amount).toBe('100'); + expect(paymentInfo.feeAmount).toBe('5'); + expect(paymentInfo.feeAddress).toBe(feeAddress); + expect(paymentInfo.proxyAddress).toBeDefined(); + expect(paymentInfo.paymentReference).toBeDefined(); + }); + + it('should throw if network is not Tron', () => { + const invalidRequest = deepCopy(request); + invalidRequest.currencyInfo.network = 'mainnet'; + + expect(() => getTronPaymentInfo(invalidRequest)).toThrow(/not a supported Tron network/); + }); + }); +}); + +describe('Tron Utils', () => { + beforeEach(() => { + jest.restoreAllMocks(); + }); + + describe('isValidTronAddress', () => { + it('should return true for valid Tron addresses', () => { + expect(tronUtils.isValidTronAddress('TQn9Y2khEsLJW1ChVWFMSMeRDow5KcbLSE')).toBe(true); + expect(tronUtils.isValidTronAddress('TG3XXyExBkPp9nzdajDZsozEu4BkaSJozs')).toBe(true); + expect(tronUtils.isValidTronAddress('TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t')).toBe(true); + }); + + it('should return false for invalid addresses', () => { + expect(tronUtils.isValidTronAddress('')).toBe(false); + expect(tronUtils.isValidTronAddress('0x123')).toBe(false); + expect(tronUtils.isValidTronAddress('invalid')).toBe(false); + expect(tronUtils.isValidTronAddress('Aqn9Y2khEsLJW1ChVWFMSMeRDow5KcbLSE')).toBe(false); // Wrong prefix + }); + }); + + describe('getERC20FeeProxyAddress', () => { + it('should return the proxy address for tron mainnet', () => { + const address = tronUtils.getERC20FeeProxyAddress('tron'); + expect(address).toBe('TCUDPYnS9dH3WvFEaE7wN7vnDa51J4R4fd'); + }); + + it('should return the proxy address for nile testnet', () => { + const address = tronUtils.getERC20FeeProxyAddress('nile'); + expect(address).toBe('THK5rNmrvCujhmrXa5DB1dASepwXTr9cJs'); + }); + }); + + describe('encodeTronFeeProxyPayment', () => { + it('should encode payment parameters correctly', () => { + const encoded = tronUtils.encodeTronFeeProxyPayment( + 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t', + 'TQn9Y2khEsLJW1ChVWFMSMeRDow5KcbLSE', + '100', + '0xaabb', + '5', + 'TG3XXyExBkPp9nzdajDZsozEu4BkaSJozs', + ); + + expect(encoded.functionSelector).toBe( + 'transferFromWithReferenceAndFee(address,address,uint256,bytes,uint256,address)', + ); + expect(encoded.parameters).toHaveLength(6); + }); + + it('should format payment reference with 0x prefix', () => { + const encoded = tronUtils.encodeTronFeeProxyPayment( + 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t', + 'TQn9Y2khEsLJW1ChVWFMSMeRDow5KcbLSE', + '100', + 'aabb', // Without 0x + '5', + 'TG3XXyExBkPp9nzdajDZsozEu4BkaSJozs', + ); + + const refParam = encoded.parameters[3] as { type: string; value: string }; + expect(refParam.value).toBe('0xaabb'); + }); + }); +}); + +describe('Tron Payment with Nile Testnet', () => { + const nileRequest: any = { + ...request, + currencyInfo: { + ...usdt, + network: 'nile', + }, + extensions: { + [ExtensionTypes.PAYMENT_NETWORK_ID.ERC20_FEE_PROXY_CONTRACT]: { + ...request.extensions[ExtensionTypes.PAYMENT_NETWORK_ID.ERC20_FEE_PROXY_CONTRACT], + values: { + ...request.extensions[ExtensionTypes.PAYMENT_NETWORK_ID.ERC20_FEE_PROXY_CONTRACT].values, + network: 'nile', + }, + }, + }, + }; + + it('should get payment info for Nile testnet', () => { + const paymentInfo = getTronPaymentInfo(nileRequest); + + expect(paymentInfo.proxyAddress).toBe('THK5rNmrvCujhmrXa5DB1dASepwXTr9cJs'); + }); +}); diff --git a/yarn.lock b/yarn.lock index 55cf67da1..abeadb72a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -15,6 +15,11 @@ "@gql.tada/internal" "^1.0.0" graphql "^15.5.0 || ^16.0.0 || ^17.0.0" +"@adraffy/ens-normalize@1.10.1": + version "1.10.1" + resolved "https://registry.yarnpkg.com/@adraffy/ens-normalize/-/ens-normalize-1.10.1.tgz#63430d04bd8c5e74f8d7d049338f1cd9d4f02069" + integrity sha512-96Z2IP3mYmF1Xg2cDm8f1gWGf/HUVedQ3FMifV4kG/PQ4yEP51xDtRAEfhVNt5f/uzpNkZHwWQuUcu6D6K+Ekw== + "@adraffy/ens-normalize@^1.10.1": version "1.11.1" resolved "https://registry.yarnpkg.com/@adraffy/ens-normalize/-/ens-normalize-1.11.1.tgz#6c2d657d4b2dfb37f8ea811dcb3e60843d4ac24a" @@ -5746,6 +5751,11 @@ dependencies: elliptic "^6.5.4" +"@tronweb3/google-protobuf@^3.21.2": + version "3.21.4" + resolved "https://registry.yarnpkg.com/@tronweb3/google-protobuf/-/google-protobuf-3.21.4.tgz#611e5b4f6d084c24301e1ee583fe09dc7fcfe41a" + integrity sha512-joxgV4esCdyZ921AprMIG1T7HjkypquhbJ5qJti/priCBJhRE1z9GOxIEMvayxSVSRbMGIoJNE0Knrg3vpwM1w== + "@truffle/abi-utils@^0.2.11": version "0.2.11" resolved "https://registry.npmjs.org/@truffle/abi-utils/-/abi-utils-0.2.11.tgz" @@ -6216,6 +6226,13 @@ resolved "https://registry.npmjs.org/@types/node/-/node-18.11.9.tgz" integrity sha512-CRpX21/kGdzjOpFsZSkcrXMGIBWMGNIHXXBVFSH+ggkftxg+XYP20TESbh+zFvFj3EQOl5byk0HTRn1IL6hbqg== +"@types/node@22.7.5": + version "22.7.5" + resolved "https://registry.yarnpkg.com/@types/node/-/node-22.7.5.tgz#cfde981727a7ab3611a481510b473ae54442b92b" + integrity sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ== + dependencies: + undici-types "~6.19.2" + "@types/node@^12.12.6": version "12.20.6" resolved "https://registry.npmjs.org/@types/node/-/node-12.20.6.tgz" @@ -7077,6 +7094,11 @@ aes-js@3.0.0: resolved "https://registry.npmjs.org/aes-js/-/aes-js-3.0.0.tgz" integrity sha1-4h3xCtbCBTKVvLuNq0Cwnb6ofk0= +aes-js@4.0.0-beta.5: + version "4.0.0-beta.5" + resolved "https://registry.yarnpkg.com/aes-js/-/aes-js-4.0.0-beta.5.tgz#8d2452c52adedebc3a3e28465d858c11ca315873" + integrity sha512-G965FqalsNyrPqgEGON7nIx1e/OVENSgiEIzyC63haUMuvNnwIgIjMs52hlTCKhkBny7A2ORNlfY9Zu+jmGk1Q== + aes-js@^3.1.1: version "3.1.2" resolved "https://registry.npmjs.org/aes-js/-/aes-js-3.1.2.tgz" @@ -7768,6 +7790,15 @@ axios@^1.0.0, axios@^1.4.0, axios@^1.6.8: form-data "^4.0.0" proxy-from-env "^1.1.0" +axios@^1.6.2: + version "1.13.2" + resolved "https://registry.yarnpkg.com/axios/-/axios-1.13.2.tgz#9ada120b7b5ab24509553ec3e40123521117f687" + integrity sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA== + dependencies: + follow-redirects "^1.15.6" + form-data "^4.0.4" + proxy-from-env "^1.1.0" + b4a@^1.0.1: version "1.6.7" resolved "https://registry.npmjs.org/b4a/-/b4a-1.6.7.tgz" @@ -12835,6 +12866,19 @@ ethers@^5.4.2: "@ethersproject/web" "5.5.1" "@ethersproject/wordlists" "5.5.0" +ethers@^6.6.0: + version "6.16.0" + resolved "https://registry.yarnpkg.com/ethers/-/ethers-6.16.0.tgz#fff9b4f05d7a359c774ad6e91085a800f7fccf65" + integrity sha512-U1wulmetNymijEhpSEQ7Ct/P/Jw9/e7R1j5XIbPRydgV2DjLVMsULDlNksq3RQnFgKoLlZf88ijYtWEXcPa07A== + dependencies: + "@adraffy/ens-normalize" "1.10.1" + "@noble/curves" "1.2.0" + "@noble/hashes" "1.3.2" + "@types/node" "22.7.5" + aes-js "4.0.0-beta.5" + tslib "2.7.0" + ws "8.17.1" + ethjs-unit@0.1.6: version "0.1.6" resolved "https://registry.npmjs.org/ethjs-unit/-/ethjs-unit-0.1.6.tgz" @@ -12882,6 +12926,11 @@ eventemitter3@5.0.1: resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-5.0.1.tgz#53f5ffd0a492ac800721bb42c66b841de96423c4" integrity sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA== +eventemitter3@^3.1.0: + version "3.1.2" + resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-3.1.2.tgz#2d3d48f9c346698fce83a85d7d664e98535df6e7" + integrity sha512-tvtQIeLVHjDkJYnzf2dgVMxfuSGJeM/7UCG17TT4EumTfNtF+0nebF/4zWOIkCreAbtNqhGEboB6BWrwqNaw4Q== + eventemitter3@^4.0.4: version "4.0.7" resolved "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz" @@ -13707,6 +13756,17 @@ form-data@^4.0.0: combined-stream "^1.0.8" mime-types "^2.1.12" +form-data@^4.0.4: + version "4.0.5" + resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.5.tgz#b49e48858045ff4cbf6b03e1805cebcad3679053" + integrity sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w== + dependencies: + asynckit "^0.4.0" + combined-stream "^1.0.8" + es-set-tostringtag "^2.1.0" + hasown "^2.0.2" + mime-types "^2.1.12" + form-data@~2.3.2: version "2.3.3" resolved "https://registry.npmjs.org/form-data/-/form-data-2.3.3.tgz" @@ -15476,6 +15536,11 @@ init-package-json@3.0.2, init-package-json@^3.0.2: validate-npm-package-license "^3.0.4" validate-npm-package-name "^4.0.0" +injectpromise@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/injectpromise/-/injectpromise-1.0.0.tgz#c621f7df2bbfc1164d714f1fb229adec2079da39" + integrity sha512-qNq5wy4qX4uWHcVFOEU+RqZkoVG65FhvGkyDWbuBxILMjK6A1LFf5A1mgXZkD4nRx5FCorD81X/XvPKp/zVfPA== + inline-source-map@~0.6.0: version "0.6.2" resolved "https://registry.npmjs.org/inline-source-map/-/inline-source-map-0.6.2.tgz" @@ -21113,7 +21178,7 @@ query-string@^5.0.1: object-assign "^4.1.0" strict-uri-encode "^1.0.0" -querystring-es3@~0.2.0: +querystring-es3@^0.2.1, querystring-es3@~0.2.0: version "0.2.1" resolved "https://registry.npmjs.org/querystring-es3/-/querystring-es3-0.2.1.tgz" integrity sha1-nsYfeQSYdXB9aUFFlv2Qek1xHnM= @@ -24046,6 +24111,25 @@ trim-right@^1.0.1: resolved "https://registry.npmjs.org/trim-right/-/trim-right-1.0.1.tgz" integrity sha512-WZGXGstmCWgeevgTL54hrCuw1dyMQIzWy7ZfqRJfSmJZBwklI15egmQytFP6bPidmw3M8d5yEowl1niq4vmqZw== +tronweb@5.3.2: + version "5.3.2" + resolved "https://registry.yarnpkg.com/tronweb/-/tronweb-5.3.2.tgz#393b0fa0290e2c5aa7a3b3b82956f53ca65a764f" + integrity sha512-iPcIjMCxb6H7FXMntAj47F3L+7jSideyQ7ZOvRj9MeZBh46SHevMrDDR57HzakUa/tT8VvlPFHtqFK4hzTLkXw== + dependencies: + "@babel/runtime" "^7.0.0" + "@ethersproject/abi" "^5.7.0" + "@tronweb3/google-protobuf" "^3.21.2" + axios "^1.6.2" + bignumber.js "^9.0.1" + ethereum-cryptography "^2.0.0" + ethers "^6.6.0" + eventemitter3 "^3.1.0" + injectpromise "^1.0.0" + lodash "^4.17.21" + querystring-es3 "^0.2.1" + semver "^5.6.0" + validator "^13.7.0" + try-to-catch@^3.0.0: version "3.0.0" resolved "https://registry.npmjs.org/try-to-catch/-/try-to-catch-3.0.0.tgz" @@ -24260,6 +24344,11 @@ tslib@1.14.1, tslib@^1.8.1, tslib@^1.9.0, tslib@^1.9.3: resolved "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz" integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== +tslib@2.7.0: + version "2.7.0" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.7.0.tgz#d9b40c5c40ab59e8738f297df3087bf1a2690c01" + integrity sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA== + tslib@2.8.1: version "2.8.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" @@ -24725,6 +24814,11 @@ underscore@1.12.1, underscore@1.9.1, underscore@^1.12.1: resolved "https://registry.yarnpkg.com/underscore/-/underscore-1.13.7.tgz#970e33963af9a7dda228f17ebe8399e5fbe63a10" integrity sha512-GMXzWtsc57XAtguZgaQViUOzs0KTkk8ojr3/xAxXLITqf/3EMwxC0inyETfDFjH/Krbhuep0HNbbjI9i/q3F3g== +undici-types@~6.19.2: + version "6.19.8" + resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.19.8.tgz#35111c9d1437ab83a7cdc0abae2f26d88eda0a02" + integrity sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw== + undici@^5.14.0: version "5.29.0" resolved "https://registry.yarnpkg.com/undici/-/undici-5.29.0.tgz#419595449ae3f2cdcba3580a2e8903399bd1f5a3" @@ -25177,6 +25271,11 @@ validate-npm-package-name@^5.0.0: dependencies: builtins "^5.0.0" +validator@^13.7.0: + version "13.15.26" + resolved "https://registry.yarnpkg.com/validator/-/validator-13.15.26.tgz#36c3deeab30e97806a658728a155c66fcaa5b944" + integrity sha512-spH26xU080ydGggxRyR1Yhcbgx+j3y5jbNXk/8L+iRvdIEQ4uTRH2Sgf2dokud6Q4oAtsbNvJ1Ft+9xmm6IZcA== + valtio@1.11.2: version "1.11.2" resolved "https://registry.npmjs.org/valtio/-/valtio-1.11.2.tgz" @@ -26685,6 +26784,11 @@ ws@7.4.6: resolved "https://registry.npmjs.org/ws/-/ws-7.4.6.tgz" integrity sha512-YmhHDO4MzaDLB+M9ym/mDA5z0naX8j7SIlT8f8z+I0VtzsRbekxEutHSme7NPS2qE8StCYQNUnfWdXta/Yu85A== +ws@8.17.1: + version "8.17.1" + resolved "https://registry.yarnpkg.com/ws/-/ws-8.17.1.tgz#9293da530bb548febc95371d90f9c878727d919b" + integrity sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ== + ws@^3.0.0: version "3.3.3" resolved "https://registry.npmjs.org/ws/-/ws-3.3.3.tgz"