diff --git a/packages/currency/src/chains/declarative/data/nile.ts b/packages/currency/src/chains/declarative/data/nile.ts deleted file mode 100644 index e80c5f179..000000000 --- a/packages/currency/src/chains/declarative/data/nile.ts +++ /dev/null @@ -1,10 +0,0 @@ -export const chainId = 'nile'; - -// Nile is Tron's test network -export const testnet = true; - -// Test tokens on Nile testnet -// Note: These are testnet token addresses, not mainnet -export const currencies = { - // Add testnet token addresses as needed -}; diff --git a/packages/currency/src/chains/declarative/data/tron.ts b/packages/currency/src/chains/declarative/data/tron.ts deleted file mode 100644 index 3ad0a105f..000000000 --- a/packages/currency/src/chains/declarative/data/tron.ts +++ /dev/null @@ -1,20 +0,0 @@ -export const chainId = 'tron'; - -// Tron mainnet configuration -export const testnet = false; - -// Common TRC20 tokens on Tron -export const currencies = { - // USDT-TRC20 - the most widely used stablecoin on Tron - TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t: { - name: 'Tether USD', - symbol: 'USDT', - decimals: 6, - }, - // USDC on Tron - TEkxiTehnzSmSe2XqrBj4w32RUN966rdz8: { - name: 'USD Coin', - symbol: 'USDC', - decimals: 6, - }, -}; diff --git a/packages/payment-detection/README.md b/packages/payment-detection/README.md index 4544df8ff..0bb5f8a06 100644 --- a/packages/payment-detection/README.md +++ b/packages/payment-detection/README.md @@ -66,6 +66,99 @@ The code generation is included in the pre-build script and can be run manually: yarn codegen ``` +## TRON Payment Detection (Hasura-based) + +TRON payment detection uses a Hasura GraphQL API backed by a PostgreSQL database that is populated by a Substreams-based indexer. This approach was chosen because The Graph does not support subgraphs for native TRON (only TRON EVM). + +### Architecture + +``` +TRON Blockchain → Substreams → PostgreSQL → Hasura GraphQL → SDK +``` + +The payment data flows through: + +1. **Substreams**: Indexes ERC20FeeProxy payment events from the TRON blockchain +2. **PostgreSQL**: Stores payment data via `substreams-sink-sql` +3. **Hasura**: Exposes the PostgreSQL data as a GraphQL API +4. **SDK**: Queries Hasura via `TronInfoRetriever` and `HasuraClient` + +### Components + +- **`TronFeeProxyPaymentDetector`**: Payment detector for TRON ERC20 Fee Proxy payments +- **`TronInfoRetriever`**: Retrieves payment events from Hasura, implements `ITheGraphBaseInfoRetriever` +- **`HasuraClient`**: GraphQL client for querying the Hasura endpoint + +### Usage + +The `TronFeeProxyPaymentDetector` is automatically registered in the `PaymentNetworkFactory` for TRON networks (`tron` and `nile`). + +```typescript +import { PaymentNetworkFactory } from '@requestnetwork/payment-detection'; + +// The factory automatically uses TronFeeProxyPaymentDetector for TRON +const paymentNetwork = PaymentNetworkFactory.getPaymentNetworkFromRequest({ + request, + advancedLogic, +}); + +const balance = await paymentNetwork.getBalance(request); +``` + +### Custom Hasura Endpoint + +By default, the `HasuraClient` connects to the production Hasura endpoint. To use a custom endpoint: + +```typescript +import { + HasuraClient, + TronInfoRetriever, + TronFeeProxyPaymentDetector, +} from '@requestnetwork/payment-detection'; + +// Create a custom Hasura client +const customClient = new HasuraClient({ + baseUrl: 'https://your-hasura-instance.com/v1/graphql', +}); + +// Use it with TronInfoRetriever +const retriever = new TronInfoRetriever(customClient); + +// Or use getHasuraClient with custom options +import { getHasuraClient } from '@requestnetwork/payment-detection'; + +const client = getHasuraClient('tron', { + baseUrl: 'https://your-hasura-instance.com/v1/graphql', +}); +``` + +### TRON-specific Event Fields + +TRON payment events include additional fields specific to the TRON blockchain: + +```typescript +interface TronPaymentEvent { + txHash: string; + feeAmount: string; + block: number; + to: string; + from: string; + feeAddress?: string; + tokenAddress?: string; + // TRON-specific resource consumption + energyUsed?: string; // Total energy consumed + energyFee?: string; // Energy fee in SUN + netFee?: string; // Network/bandwidth fee in SUN +} +``` + +### Supported Networks + +| Network | Chain Identifier | Description | +| ------- | ---------------- | ----------------- | +| `tron` | `tron` | TRON Mainnet | +| `nile` | `tron-nile` | TRON Nile Testnet | + # Test ```sh diff --git a/packages/payment-detection/codegen.yml b/packages/payment-detection/codegen.yml index f67dfd436..455351978 100644 --- a/packages/payment-detection/codegen.yml +++ b/packages/payment-detection/codegen.yml @@ -1,5 +1,5 @@ overwrite: true -schema: 'https://api.studio.thegraph.com/query/67444/request-payments-sepolia/version/latest' +schema: 'https://api.studio.thegraph.com/query/67444/request-payments-base/version/latest' documents: src/thegraph/queries/*.graphql generates: src/thegraph/generated/graphql.ts: diff --git a/packages/payment-detection/src/index.ts b/packages/payment-detection/src/index.ts index d8950d80b..ff2c07923 100644 --- a/packages/payment-detection/src/index.ts +++ b/packages/payment-detection/src/index.ts @@ -31,7 +31,13 @@ import { unpadAmountFromChainlink, } from './utils'; import { NearConversionNativeTokenPaymentDetector, NearNativeTokenPaymentDetector } from './near'; -import { TronERC20FeeProxyPaymentDetector, TronInfoRetriever } from './tron'; +import { + TronFeeProxyPaymentDetector, + getHasuraClient, + HasuraClient, + TronInfoRetriever, +} from './tron'; +export type { TronPaymentEvent } from './tron/retrievers/tron-info-retriever'; import { FeeReferenceBasedDetector } from './fee-reference-based-detector'; import { SuperFluidPaymentDetector } from './erc777/superfluid-detector'; import { EscrowERC20InfoRetriever } from './erc20/escrow-info-retriever'; @@ -57,8 +63,11 @@ export { SuperFluidPaymentDetector, NearNativeTokenPaymentDetector, NearConversionNativeTokenPaymentDetector, - TronERC20FeeProxyPaymentDetector, + // TRON + TronFeeProxyPaymentDetector, TronInfoRetriever, + getHasuraClient, + HasuraClient, EscrowERC20InfoRetriever, SuperFluidInfoRetriever, MetaDetector, diff --git a/packages/payment-detection/src/payment-network-factory.ts b/packages/payment-detection/src/payment-network-factory.ts index 7e5e48de7..6aa3a46a2 100644 --- a/packages/payment-detection/src/payment-network-factory.ts +++ b/packages/payment-detection/src/payment-network-factory.ts @@ -24,7 +24,7 @@ import { SuperFluidPaymentDetector } from './erc777/superfluid-detector'; import { EthFeeProxyPaymentDetector, EthInputDataPaymentDetector } from './eth'; import { AnyToERC20PaymentDetector, AnyToEthFeeProxyPaymentDetector } from './any'; import { NearConversionNativeTokenPaymentDetector, NearNativeTokenPaymentDetector } from './near'; -import { TronERC20FeeProxyPaymentDetector } from './tron'; +import { TronFeeProxyPaymentDetector } from './tron'; import { getPaymentNetworkExtension } from './utils'; import { getTheGraphClient } from './thegraph'; import { getDefaultProvider } from 'ethers'; @@ -56,12 +56,12 @@ const supportedPaymentNetwork: ISupportedPaymentNetworkByCurrency = { 'near-testnet': { [PN_ID.ERC20_FEE_PROXY_CONTRACT]: ERC20FeeProxyPaymentDetector, }, - // TRON chains + // TRON networks tron: { - [PN_ID.ERC20_FEE_PROXY_CONTRACT]: TronERC20FeeProxyPaymentDetector, + [PN_ID.ERC20_FEE_PROXY_CONTRACT]: TronFeeProxyPaymentDetector, }, nile: { - [PN_ID.ERC20_FEE_PROXY_CONTRACT]: TronERC20FeeProxyPaymentDetector, + [PN_ID.ERC20_FEE_PROXY_CONTRACT]: TronFeeProxyPaymentDetector, }, '*': { diff --git a/packages/payment-detection/src/thegraph/queries/graphql.config.yml b/packages/payment-detection/src/thegraph/queries/graphql.config.yml index 6d35f9a0a..19b1d64ca 100644 --- a/packages/payment-detection/src/thegraph/queries/graphql.config.yml +++ b/packages/payment-detection/src/thegraph/queries/graphql.config.yml @@ -1 +1 @@ -schema: https://api.studio.thegraph.com/query/67444/request-payments-sepolia/version/latest +schema: https://api.studio.thegraph.com/query/67444/request-payments-base/version/latest diff --git a/packages/payment-detection/src/thegraph/queries/tron/graphql.config.yml b/packages/payment-detection/src/thegraph/queries/tron/graphql.config.yml index f731d57f3..205b553d3 100644 --- a/packages/payment-detection/src/thegraph/queries/tron/graphql.config.yml +++ b/packages/payment-detection/src/thegraph/queries/tron/graphql.config.yml @@ -1,2 +1,2 @@ -# Using local schema until the subgraph is deployed to The Graph Studio -schema: ../../../../../substreams-tron/schema.graphql +# Local schema for TRON payment queries +schema: ./schema.graphql diff --git a/packages/payment-detection/src/tron/index.ts b/packages/payment-detection/src/tron/index.ts index 7356bcf3b..aefbadfd6 100644 --- a/packages/payment-detection/src/tron/index.ts +++ b/packages/payment-detection/src/tron/index.ts @@ -1,3 +1,4 @@ -export { TronERC20FeeProxyPaymentDetector } from './tron-fee-proxy-detector'; -export { TronInfoRetriever } from './tron-info-retriever'; -export type { TronPaymentEvent } from './tron-info-retriever'; +export { TronERC20FeeProxyPaymentDetector as TronFeeProxyPaymentDetector } from './tron-fee-proxy-detector'; +export { TronInfoRetriever } from './retrievers/tron-info-retriever'; +export type { TronPaymentEvent } from './retrievers/tron-info-retriever'; +export { HasuraClient, getHasuraClient } from './retrievers/hasura-client'; diff --git a/packages/payment-detection/src/tron/retrievers/hasura-client.ts b/packages/payment-detection/src/tron/retrievers/hasura-client.ts new file mode 100644 index 000000000..fc4cf59e8 --- /dev/null +++ b/packages/payment-detection/src/tron/retrievers/hasura-client.ts @@ -0,0 +1,148 @@ +import { CurrencyTypes } from '@requestnetwork/types'; + +/** + * Hasura payment response type matching the database schema + */ +export interface HasuraPayment { + id: string; + chain: string; + tx_hash: string; + block_number: number; + timestamp: number; + contract_address: string; + token_address: string; + from_address: string; + to_address: string; + amount: string; + fee_amount: string; + fee_address: string; + payment_reference: string; + // TRON-specific resource fields + energy_used?: string; + energy_fee?: string; + net_fee?: string; +} + +export interface HasuraPaymentsResponse { + payments: HasuraPayment[]; +} + +export interface HasuraClientOptions { + /** Hasura GraphQL endpoint URL */ + url: string; + /** Optional admin secret for authentication */ + adminSecret?: string; + /** Optional custom headers */ + headers?: Record; +} + +/** + * Client for querying payment data from Hasura GraphQL API + */ +export class HasuraClient { + private readonly url: string; + private readonly headers: Record; + + constructor(options: HasuraClientOptions) { + this.url = options.url; + this.headers = { + 'Content-Type': 'application/json', + ...(options.adminSecret && { 'x-hasura-admin-secret': options.adminSecret }), + ...options.headers, + }; + } + + /** + * Query payments by payment reference + */ + async getPaymentsByReference(params: { + paymentReference: string; + toAddress: string; + chain?: string; + tokenAddress?: string; + contractAddress?: string; + }): Promise { + const whereConditions: string[] = [ + `payment_reference: { _eq: "${params.paymentReference}" }`, + `to_address: { _ilike: "${params.toAddress}" }`, + ]; + + if (params.chain) { + whereConditions.push(`chain: { _eq: "${params.chain}" }`); + } + + if (params.tokenAddress) { + whereConditions.push(`token_address: { _ilike: "${params.tokenAddress}" }`); + } + + if (params.contractAddress) { + whereConditions.push(`contract_address: { _ilike: "${params.contractAddress}" }`); + } + + const query = ` + query GetPayments { + payments( + where: { ${whereConditions.join(', ')} } + order_by: { block_number: asc } + ) { + id + chain + tx_hash + block_number + timestamp + contract_address + token_address + from_address + to_address + amount + fee_amount + fee_address + payment_reference + energy_used + energy_fee + net_fee + } + } + `; + + const response = await fetch(this.url, { + method: 'POST', + headers: this.headers, + body: JSON.stringify({ query }), + }); + + if (!response.ok) { + throw new Error(`Hasura request failed: ${response.statusText}`); + } + + const result = await response.json(); + + if (result.errors) { + throw new Error(`Hasura query error: ${JSON.stringify(result.errors)}`); + } + + return result.data.payments; + } +} + +/** + * Factory function to create a Hasura client + */ +export function getHasuraClient( + network: CurrencyTypes.ChainName, + options?: Partial, +): HasuraClient | undefined { + // Only return a client for TRON networks + if (!network.toLowerCase().includes('tron')) { + return undefined; + } + + const defaultUrl = process.env.HASURA_GRAPHQL_URL || 'https://graphql.request.network/v1/graphql'; + const adminSecret = process.env.HASURA_ADMIN_SECRET; + + return new HasuraClient({ + url: options?.url || defaultUrl, + adminSecret: options?.adminSecret || adminSecret, + headers: options?.headers, + }); +} diff --git a/packages/payment-detection/src/tron/retrievers/index.ts b/packages/payment-detection/src/tron/retrievers/index.ts new file mode 100644 index 000000000..dbb392725 --- /dev/null +++ b/packages/payment-detection/src/tron/retrievers/index.ts @@ -0,0 +1,2 @@ +export * from './tron-info-retriever'; +export * from './hasura-client'; diff --git a/packages/payment-detection/src/tron/retrievers/tron-info-retriever.ts b/packages/payment-detection/src/tron/retrievers/tron-info-retriever.ts new file mode 100644 index 000000000..95b72d78b --- /dev/null +++ b/packages/payment-detection/src/tron/retrievers/tron-info-retriever.ts @@ -0,0 +1,83 @@ +import { CurrencyTypes, PaymentTypes } from '@requestnetwork/types'; +import { ITheGraphBaseInfoRetriever, TransferEventsParams } from '../../types'; +import { HasuraClient, HasuraPayment } from './hasura-client'; + +/** + * TRON-specific payment event parameters + */ +export interface TronPaymentEvent extends PaymentTypes.IERC20FeePaymentEventParameters { + txHash: string; + // TRON-specific resource consumption fields + energyUsed?: string; + energyFee?: string; + netFee?: string; +} + +/** + * Gets a list of transfer events for TRON payments via Hasura + * Retriever for ERC20 Fee Proxy payments on TRON blockchain. + */ +export class TronInfoRetriever implements ITheGraphBaseInfoRetriever { + constructor(protected readonly client: HasuraClient) {} + + public async getTransferEvents( + params: TransferEventsParams, + ): Promise> { + const { paymentReference, toAddress, contractAddress, acceptedTokens, paymentChain } = params; + + if (acceptedTokens && acceptedTokens.length > 1) { + throw new Error(`TronInfoRetriever does not support multiple accepted tokens.`); + } + + // Map chain name to the chain identifier used in the database + const chainIdentifier = this.getChainIdentifier(paymentChain); + + const payments = await this.client.getPaymentsByReference({ + paymentReference: `0x${paymentReference}`, + toAddress, + chain: chainIdentifier, + tokenAddress: acceptedTokens?.[0], + contractAddress, + }); + + return { + paymentEvents: payments.map((p) => this.mapPaymentEvent(p, params)), + }; + } + + private getChainIdentifier(paymentChain: CurrencyTypes.VMChainName): string { + // Map SDK chain names to the chain identifiers stored in Hasura + const chainMap: Record = { + tron: 'tron', + nile: 'tron-nile', + }; + + return chainMap[paymentChain.toLowerCase()] || paymentChain.toLowerCase(); + } + + private mapPaymentEvent( + payment: HasuraPayment, + params: TransferEventsParams, + ): PaymentTypes.IPaymentNetworkEvent { + // Note: TRON addresses use Base58 format, not Ethereum checksum format + // So we don't use formatAddress which expects EVM addresses + return { + amount: payment.amount, + name: params.eventName, + timestamp: payment.timestamp, + parameters: { + txHash: payment.tx_hash, + feeAmount: payment.fee_amount, + block: payment.block_number, + to: payment.to_address, + from: payment.from_address, + feeAddress: payment.fee_address || undefined, + tokenAddress: payment.token_address || undefined, + // TRON-specific resource fields + energyUsed: payment.energy_used || undefined, + energyFee: payment.energy_fee || undefined, + netFee: payment.net_fee || undefined, + }, + }; + } +} diff --git a/packages/payment-detection/src/tron/tron-fee-proxy-detector.ts b/packages/payment-detection/src/tron/tron-fee-proxy-detector.ts index c96be94a6..cca2d0852 100644 --- a/packages/payment-detection/src/tron/tron-fee-proxy-detector.ts +++ b/packages/payment-detection/src/tron/tron-fee-proxy-detector.ts @@ -9,9 +9,9 @@ import { TronChains, isSameChain } from '@requestnetwork/currency'; import { ERC20FeeProxyPaymentDetectorBase } from '../erc20/fee-proxy-contract'; import { NetworkNotSupported } from '../balance-error'; -import { ReferenceBasedDetectorOptions, TGetSubGraphClient } from '../types'; -import { TronInfoRetriever, TronPaymentEvent } from './tron-info-retriever'; -import { TheGraphClient } from '../thegraph'; +import { ReferenceBasedDetectorOptions } from '../types'; +import { TronInfoRetriever, TronPaymentEvent } from './retrievers/tron-info-retriever'; +import { getHasuraClient, HasuraClientOptions } from './retrievers/hasura-client'; /** * Handle payment networks with ERC20 fee proxy contract extension on TRON chains @@ -20,17 +20,17 @@ export class TronERC20FeeProxyPaymentDetector extends ERC20FeeProxyPaymentDetect ExtensionTypes.PnFeeReferenceBased.IFeeReferenceBased, TronPaymentEvent > { - private readonly getSubgraphClient: TGetSubGraphClient; + private readonly hasuraClientOptions?: Partial; protected readonly network: CurrencyTypes.TronChainName | undefined; constructor({ advancedLogic, currencyManager, - getSubgraphClient, network, + hasuraClientOptions, }: ReferenceBasedDetectorOptions & { network?: CurrencyTypes.TronChainName; - getSubgraphClient: TGetSubGraphClient; + hasuraClientOptions?: Partial; }) { super( ExtensionTypes.PAYMENT_NETWORK_ID.ERC20_FEE_PROXY_CONTRACT, @@ -38,7 +38,7 @@ export class TronERC20FeeProxyPaymentDetector extends ERC20FeeProxyPaymentDetect advancedLogic.extensions.feeProxyContractErc20, currencyManager, ); - this.getSubgraphClient = getSubgraphClient; + this.hasuraClientOptions = hasuraClientOptions; this.network = network; } @@ -103,18 +103,16 @@ export class TronERC20FeeProxyPaymentDetector extends ERC20FeeProxyPaymentDetect const { address: proxyContractAddress } = TronERC20FeeProxyPaymentDetector.getDeploymentInformation(paymentChain); - const subgraphClient = this.getSubgraphClient( - paymentChain, - ) as TheGraphClient; + const hasuraClient = getHasuraClient(paymentChain, this.hasuraClientOptions); - if (!subgraphClient) { + if (!hasuraClient) { throw new Error( - `Could not get a TheGraph-based info retriever for TRON chain ${paymentChain}. ` + - `Ensure the TRON Substreams-powered subgraph is deployed and accessible.`, + `Could not get a Hasura client for TRON chain ${paymentChain}. ` + + `Ensure HASURA_GRAPHQL_URL is configured or the network is supported.`, ); } - const infoRetriever = new TronInfoRetriever(subgraphClient); + const infoRetriever = new TronInfoRetriever(hasuraClient); return infoRetriever.getTransferEvents({ eventName, diff --git a/packages/payment-detection/test/tron/hasura-client.test.ts b/packages/payment-detection/test/tron/hasura-client.test.ts new file mode 100644 index 000000000..79e04f8c8 --- /dev/null +++ b/packages/payment-detection/test/tron/hasura-client.test.ts @@ -0,0 +1,259 @@ +import { HasuraClient, getHasuraClient } from '../../src/tron/retrievers/hasura-client'; + +// Mock fetch globally +const mockFetch = jest.fn(); +global.fetch = mockFetch; + +describe('HasuraClient', () => { + const defaultUrl = 'https://graphql.request.network/v1/graphql'; + let client: HasuraClient; + + beforeEach(() => { + jest.clearAllMocks(); + client = new HasuraClient({ url: defaultUrl }); + }); + + describe('constructor', () => { + it('should create a client with the provided URL', () => { + const client = new HasuraClient({ url: 'https://custom.url/graphql' }); + expect(client).toBeDefined(); + }); + + it('should include admin secret in headers when provided', async () => { + const clientWithSecret = new HasuraClient({ + url: defaultUrl, + adminSecret: 'test-secret', + }); + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ data: { payments: [] } }), + }); + + await clientWithSecret.getPaymentsByReference({ + paymentReference: '0xtest', + toAddress: 'TAddress123', + }); + + expect(mockFetch).toHaveBeenCalledWith( + defaultUrl, + expect.objectContaining({ + headers: expect.objectContaining({ + 'x-hasura-admin-secret': 'test-secret', + }), + }), + ); + }); + + it('should merge custom headers', async () => { + const clientWithHeaders = new HasuraClient({ + url: defaultUrl, + headers: { 'X-Custom-Header': 'custom-value' }, + }); + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ data: { payments: [] } }), + }); + + await clientWithHeaders.getPaymentsByReference({ + paymentReference: '0xtest', + toAddress: 'TAddress123', + }); + + expect(mockFetch).toHaveBeenCalledWith( + defaultUrl, + expect.objectContaining({ + headers: expect.objectContaining({ + 'X-Custom-Header': 'custom-value', + }), + }), + ); + }); + }); + + describe('getPaymentsByReference', () => { + const mockPayments = [ + { + id: 'tron-0x123-0', + chain: 'tron', + tx_hash: '0x123abc', + block_number: 79238121, + timestamp: 1738742584, + contract_address: 'TCUDPYnS9dH3WvFEaE7wN7vnDa51J4R4fd', + token_address: 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t', + from_address: 'TFromAddress123', + to_address: 'TToAddress456', + amount: '100000000', + fee_amount: '1000000', + fee_address: 'TFeeAddress789', + payment_reference: '0xabc123', + }, + ]; + + it('should query payments by reference and toAddress', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ data: { payments: mockPayments } }), + }); + + const result = await client.getPaymentsByReference({ + paymentReference: '0xabc123', + toAddress: 'TToAddress456', + }); + + expect(result).toEqual(mockPayments); + expect(mockFetch).toHaveBeenCalledWith( + defaultUrl, + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + 'Content-Type': 'application/json', + }), + body: expect.stringContaining('payment_reference'), + }), + ); + }); + + it('should include chain filter when provided', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ data: { payments: mockPayments } }), + }); + + await client.getPaymentsByReference({ + paymentReference: '0xabc123', + toAddress: 'TToAddress456', + chain: 'tron', + }); + + const callBody = mockFetch.mock.calls[0][1].body; + expect(callBody).toContain('chain'); + expect(callBody).toContain('tron'); + }); + + it('should include tokenAddress filter when provided', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ data: { payments: mockPayments } }), + }); + + await client.getPaymentsByReference({ + paymentReference: '0xabc123', + toAddress: 'TToAddress456', + tokenAddress: 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t', + }); + + const callBody = mockFetch.mock.calls[0][1].body; + expect(callBody).toContain('token_address: { _ilike:'); + }); + + it('should include contractAddress filter when provided', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ data: { payments: mockPayments } }), + }); + + await client.getPaymentsByReference({ + paymentReference: '0xabc123', + toAddress: 'TToAddress456', + contractAddress: 'TCUDPYnS9dH3WvFEaE7wN7vnDa51J4R4fd', + }); + + const callBody = mockFetch.mock.calls[0][1].body; + expect(callBody).toContain('contract_address: { _ilike:'); + }); + + it('should throw error when fetch fails', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + statusText: 'Internal Server Error', + }); + + await expect( + client.getPaymentsByReference({ + paymentReference: '0xabc123', + toAddress: 'TToAddress456', + }), + ).rejects.toThrow('Hasura request failed: Internal Server Error'); + }); + + it('should throw error when GraphQL returns errors', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + errors: [{ message: 'Field not found' }], + }), + }); + + await expect( + client.getPaymentsByReference({ + paymentReference: '0xabc123', + toAddress: 'TToAddress456', + }), + ).rejects.toThrow('Hasura query error:'); + }); + + it('should return empty array when no payments found', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ data: { payments: [] } }), + }); + + const result = await client.getPaymentsByReference({ + paymentReference: '0xnonexistent', + toAddress: 'TToAddress456', + }); + + expect(result).toEqual([]); + }); + }); +}); + +describe('getHasuraClient', () => { + const originalEnv = process.env; + + beforeEach(() => { + jest.clearAllMocks(); + process.env = { ...originalEnv }; + }); + + afterAll(() => { + process.env = originalEnv; + }); + + it('should return a client for tron network', () => { + const client = getHasuraClient('tron'); + expect(client).toBeInstanceOf(HasuraClient); + }); + + it('should return a client for nile (tron testnet) network', () => { + const client = getHasuraClient('nile' as any); + expect(client).toBeUndefined(); // 'nile' doesn't contain 'tron' + }); + + it('should return undefined for non-TRON networks', () => { + const client = getHasuraClient('mainnet'); + expect(client).toBeUndefined(); + }); + + it('should return undefined for ethereum network', () => { + const client = getHasuraClient('sepolia'); + expect(client).toBeUndefined(); + }); + + it('should use environment variable for URL when not provided', () => { + process.env.HASURA_GRAPHQL_URL = 'https://env.graphql.url/v1/graphql'; + const client = getHasuraClient('tron'); + expect(client).toBeInstanceOf(HasuraClient); + }); + + it('should use provided options over environment variables', () => { + process.env.HASURA_GRAPHQL_URL = 'https://env.graphql.url/v1/graphql'; + const client = getHasuraClient('tron', { + url: 'https://custom.graphql.url/v1/graphql', + }); + expect(client).toBeInstanceOf(HasuraClient); + }); +}); diff --git a/packages/payment-detection/test/tron/payment-network-factory.test.ts b/packages/payment-detection/test/tron/payment-network-factory.test.ts new file mode 100644 index 000000000..c16419cf0 --- /dev/null +++ b/packages/payment-detection/test/tron/payment-network-factory.test.ts @@ -0,0 +1,93 @@ +import { ExtensionTypes, RequestLogicTypes } from '@requestnetwork/types'; +import { CurrencyManager } from '@requestnetwork/currency'; +import { AdvancedLogic } from '@requestnetwork/advanced-logic'; +import { PaymentNetworkFactory } from '../../src'; +import { TronFeeProxyPaymentDetector } from '../../src/tron'; + +const currencyManager = CurrencyManager.getDefault(); +const advancedLogic = new AdvancedLogic(currencyManager); + +const salt = 'a6475e4c3d45feb6'; +const paymentAddress = 'TToAddress456'; +const tokenAddress = 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t'; + +const createRequest = (network: string): any => ({ + requestId: '01c9190b6d015b3a0b2bbd0e492b9474b0734ca19a16f2fda8f7adec10d0fa3e7a', + currency: { + network, + type: RequestLogicTypes.CURRENCY.ERC20, + value: tokenAddress, + }, + extensions: { + [ExtensionTypes.PAYMENT_NETWORK_ID.ERC20_FEE_PROXY_CONTRACT as string]: { + id: ExtensionTypes.PAYMENT_NETWORK_ID.ERC20_FEE_PROXY_CONTRACT, + type: ExtensionTypes.TYPE.PAYMENT_NETWORK, + values: { + paymentAddress, + salt, + feeAddress: 'TFeeAddress789', + feeAmount: '1000000', + network, + }, + version: '0.2.0', + }, + }, +}); + +describe('PaymentNetworkFactory with TRON', () => { + const paymentNetworkFactory = new PaymentNetworkFactory(advancedLogic, currencyManager); + + describe('createPaymentNetwork', () => { + it('should create TronFeeProxyPaymentDetector for tron network', () => { + const detector = paymentNetworkFactory.createPaymentNetwork( + ExtensionTypes.PAYMENT_NETWORK_ID.ERC20_FEE_PROXY_CONTRACT, + RequestLogicTypes.CURRENCY.ERC20, + 'tron', + ); + + expect(detector).toBeInstanceOf(TronFeeProxyPaymentDetector); + }); + + it('should create TronFeeProxyPaymentDetector for nile testnet', () => { + const detector = paymentNetworkFactory.createPaymentNetwork( + ExtensionTypes.PAYMENT_NETWORK_ID.ERC20_FEE_PROXY_CONTRACT, + RequestLogicTypes.CURRENCY.ERC20, + 'nile', + ); + + expect(detector).toBeInstanceOf(TronFeeProxyPaymentDetector); + }); + }); + + describe('getPaymentNetworkFromRequest', () => { + it('should return TronFeeProxyPaymentDetector for TRON ERC20 request', () => { + const request = createRequest('tron'); + const detector = paymentNetworkFactory.getPaymentNetworkFromRequest(request); + + expect(detector).toBeInstanceOf(TronFeeProxyPaymentDetector); + }); + + it('should return TronFeeProxyPaymentDetector for nile testnet request', () => { + const request = createRequest('nile'); + const detector = paymentNetworkFactory.getPaymentNetworkFromRequest(request); + + expect(detector).toBeInstanceOf(TronFeeProxyPaymentDetector); + }); + + it('should return null when no payment network extension', () => { + const request = { + requestId: '01c9190b6d015b3a0b2bbd0e492b9474b0734ca19a16f2fda8f7adec10d0fa3e7a', + currency: { + network: 'tron', + type: RequestLogicTypes.CURRENCY.ERC20, + value: tokenAddress, + }, + extensions: {}, + }; + + const detector = paymentNetworkFactory.getPaymentNetworkFromRequest(request); + + expect(detector).toBeNull(); + }); + }); +}); diff --git a/packages/payment-processor/src/index.ts b/packages/payment-processor/src/index.ts index 4e92546b5..68d2d772e 100644 --- a/packages/payment-processor/src/index.ts +++ b/packages/payment-processor/src/index.ts @@ -29,7 +29,7 @@ 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/trc20-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/trc20-fee-proxy.ts similarity index 100% rename from packages/payment-processor/src/payment/tron-fee-proxy.ts rename to packages/payment-processor/src/payment/trc20-fee-proxy.ts diff --git a/packages/payment-processor/src/payment/utils-tron.ts b/packages/payment-processor/src/payment/utils-tron.ts index f5fa56c19..c35e143c3 100644 --- a/packages/payment-processor/src/payment/utils-tron.ts +++ b/packages/payment-processor/src/payment/utils-tron.ts @@ -14,6 +14,7 @@ export interface TronWeb { getBalance: (address: string) => Promise; sign: (transaction: unknown, privateKey?: string) => Promise; sendRawTransaction: (signedTransaction: unknown) => Promise; + getTransactionInfo: (txHash: string) => Promise; }; contract: ( abi: T, @@ -36,6 +37,24 @@ export interface TronWeb { fromSun: (amount: number) => number; } +/** + * Transaction info returned by getTransactionInfo + */ +export interface TronTransactionInfo { + id?: string; + blockNumber?: number; + blockTimeStamp?: number; + contractResult?: string[]; + receipt?: { + result?: string; + energy_usage?: number; + energy_usage_total?: number; + net_usage?: number; + }; + result?: string; + resMessage?: string; +} + // Generic contract instance type that provides method typing based on ABI export type TronContractInstance = { [K in ExtractFunctionNames]: (...args: unknown[]) => TronContractMethod; @@ -137,8 +156,81 @@ export const getTronAllowance = async ( } }; +/** Default fee limit for TRC20 approval (100 TRX in SUN) */ +export const DEFAULT_APPROVAL_FEE_LIMIT = 100_000_000; + +/** Default fee limit for TRC20 fee proxy payment (150 TRX in SUN) */ +export const DEFAULT_PAYMENT_FEE_LIMIT = 150_000_000; + +/** Maximum retries when waiting for transaction confirmation */ +const MAX_CONFIRMATION_RETRIES = 10; + +/** Delay between retries in milliseconds */ +const CONFIRMATION_RETRY_DELAY = 3000; + +/** + * Waits for a transaction to be confirmed and validates its success + * @param tronWeb - TronWeb instance + * @param txHash - Transaction hash to validate + * @returns The transaction info if successful + * @throws Error if transaction failed or couldn't be confirmed + */ +export const waitForTransactionConfirmation = async ( + tronWeb: TronWeb, + txHash: string, +): Promise => { + for (let i = 0; i < MAX_CONFIRMATION_RETRIES; i++) { + try { + const txInfo = await tronWeb.trx.getTransactionInfo(txHash); + + // If we have receipt info, the transaction is confirmed + if (txInfo.receipt) { + // Check if the transaction was successful + if (txInfo.receipt.result && txInfo.receipt.result !== 'SUCCESS') { + const errorMsg = txInfo.resMessage + ? Buffer.from(txInfo.resMessage, 'hex').toString('utf8') + : `Transaction failed with result: ${txInfo.receipt.result}`; + throw new Error(errorMsg); + } + + // Check contractResult for revert + if (txInfo.contractResult && txInfo.contractResult.length > 0) { + const result = txInfo.contractResult[0]; + // Empty result or success + if ( + result === '' || + result === '0000000000000000000000000000000000000000000000000000000000000001' + ) { + return txInfo; + } + // Non-empty result that's not success could indicate an error + // But some contracts return data, so we check receipt.result primarily + } + + return txInfo; + } + + // Transaction not yet confirmed, wait and retry + await new Promise((resolve) => setTimeout(resolve, CONFIRMATION_RETRY_DELAY)); + } catch (error) { + // If it's our own error (from failed transaction), rethrow + if ((error as Error).message.includes('Transaction failed')) { + throw error; + } + // Otherwise, wait and retry (network error, tx not found yet, etc.) + await new Promise((resolve) => setTimeout(resolve, CONFIRMATION_RETRY_DELAY)); + } + } + + throw new Error( + `Transaction ${txHash} confirmation timeout after ${MAX_CONFIRMATION_RETRIES} retries`, + ); +}; + /** * Approves the ERC20FeeProxy contract to spend TRC20 tokens + * @param feeLimit - Optional fee limit in SUN (1 TRX = 1,000,000 SUN). Defaults to 100 TRX. + * @param waitForConfirmation - If true, waits for transaction confirmation and validates success. Defaults to false. */ export const approveTrc20 = async ( tronWeb: TronWeb, @@ -146,19 +238,26 @@ export const approveTrc20 = async ( network: CurrencyTypes.TronChainName, amount: BigNumberish, callback?: ITronTransactionCallback, + feeLimit: number = DEFAULT_APPROVAL_FEE_LIMIT, + waitForConfirmation = false, ): 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 + feeLimit, shouldPollResponse: true, }); const txHash = result.txid || result.transaction?.txID || ''; callback?.onHash?.(txHash); + if (waitForConfirmation && txHash) { + const txInfo = await waitForTransactionConfirmation(tronWeb, txHash); + callback?.onConfirmation?.(txInfo); + } + return txHash; } catch (error) { callback?.onError?.(error as Error); @@ -168,6 +267,8 @@ export const approveTrc20 = async ( /** * Processes a TRC20 fee proxy payment on Tron + * @param feeLimit - Optional fee limit in SUN (1 TRX = 1,000,000 SUN). Defaults to 150 TRX. + * @param waitForConfirmation - If true, waits for transaction confirmation and validates success. Defaults to false. */ export const processTronFeeProxyPayment = async ( tronWeb: TronWeb, @@ -179,6 +280,8 @@ export const processTronFeeProxyPayment = async ( feeAmount: BigNumberish, feeAddress: string, callback?: ITronTransactionCallback, + feeLimit: number = DEFAULT_PAYMENT_FEE_LIMIT, + waitForConfirmation = false, ): Promise => { // Validate addresses if (!isValidTronAddress(to)) { @@ -213,13 +316,18 @@ export const processTronFeeProxyPayment = async ( feeAddress, ) .send({ - feeLimit: 150000000, // 150 TRX fee limit for proxy call + feeLimit, shouldPollResponse: true, }); const txHash = result.txid || result.transaction?.txID || ''; callback?.onHash?.(txHash); + if (waitForConfirmation && txHash) { + const txInfo = await waitForTransactionConfirmation(tronWeb, txHash); + callback?.onConfirmation?.(txInfo); + } + return txHash; } catch (error) { callback?.onError?.(error as Error); diff --git a/packages/payment-processor/test/payment/tron-fee-proxy.test.ts b/packages/payment-processor/test/payment/trc20-fee-proxy.test.ts similarity index 99% rename from packages/payment-processor/test/payment/tron-fee-proxy.test.ts rename to packages/payment-processor/test/payment/trc20-fee-proxy.test.ts index a3a3e3d70..1e4b2914b 100644 --- a/packages/payment-processor/test/payment/tron-fee-proxy.test.ts +++ b/packages/payment-processor/test/payment/trc20-fee-proxy.test.ts @@ -9,7 +9,7 @@ import { hasSufficientTronAllowance, hasSufficientTronBalance, getTronPaymentInfo, -} from '../../src/payment/tron-fee-proxy'; +} from '../../src/payment/trc20-fee-proxy'; import { BigNumber } from 'ethers'; /* eslint-disable @typescript-eslint/no-unused-expressions */ diff --git a/packages/smart-contracts/deployments/tron/mainnet.json b/packages/smart-contracts/deployments/tron/mainnet.json index b77db46af..f1755f518 100644 --- a/packages/smart-contracts/deployments/tron/mainnet.json +++ b/packages/smart-contracts/deployments/tron/mainnet.json @@ -7,6 +7,7 @@ "contracts": { "ERC20FeeProxy": { "address": "TCUDPYnS9dH3WvFEaE7wN7vnDa51J4R4fd", + "hexAddress": "411b6ca35d39842cf8fbe49000653a1505412da659", "creationBlockNumber": 79216121 } } diff --git a/packages/smart-contracts/deployments/tron/nile.json b/packages/smart-contracts/deployments/tron/nile.json index 6107d03c1..77257b8bc 100644 --- a/packages/smart-contracts/deployments/tron/nile.json +++ b/packages/smart-contracts/deployments/tron/nile.json @@ -7,6 +7,7 @@ "contracts": { "ERC20FeeProxy": { "address": "THK5rNmrvCujhmrXa5DB1dASepwXTr9cJs", + "hexAddress": "41508b3b4059c40bb3aac5da5ac006ccdd9c4dc957", "creationBlockNumber": 63208782 } }