From 31236ce5709b92fded40a37ef8a9a7b073e30290 Mon Sep 17 00:00:00 2001 From: Jintu Das Date: Thu, 26 Mar 2026 17:34:27 +0530 Subject: [PATCH] feat: add USDT0 Legacy Mesh as fallback bridge for TAC --- .../adapters/rebalance/src/adapters/index.ts | 3 + .../rebalance/src/adapters/usdt0/index.ts | 2 + .../rebalance/src/adapters/usdt0/types.ts | 56 ++ .../rebalance/src/adapters/usdt0/usdt0.ts | 437 ++++++++++++++++ .../test/adapters/usdt0/usdt0.spec.ts | 485 ++++++++++++++++++ packages/core/src/types/config.ts | 1 + packages/poller/src/rebalance/tacUsdt.ts | 384 ++++++++------ 7 files changed, 1215 insertions(+), 153 deletions(-) create mode 100644 packages/adapters/rebalance/src/adapters/usdt0/index.ts create mode 100644 packages/adapters/rebalance/src/adapters/usdt0/types.ts create mode 100644 packages/adapters/rebalance/src/adapters/usdt0/usdt0.ts create mode 100644 packages/adapters/rebalance/test/adapters/usdt0/usdt0.spec.ts diff --git a/packages/adapters/rebalance/src/adapters/index.ts b/packages/adapters/rebalance/src/adapters/index.ts index dd66ec9c..a4ebfbf9 100644 --- a/packages/adapters/rebalance/src/adapters/index.ts +++ b/packages/adapters/rebalance/src/adapters/index.ts @@ -17,6 +17,7 @@ import { CCIPBridgeAdapter } from './ccip'; import { ZKSyncNativeBridgeAdapter } from './zksync'; import { LineaNativeBridgeAdapter } from './linea'; import { ZircuitNativeBridgeAdapter } from './zircuit'; +import { Usdt0BridgeAdapter } from './usdt0'; export class RebalanceAdapter { constructor( @@ -103,6 +104,8 @@ export class RebalanceAdapter { return new LineaNativeBridgeAdapter(this.config.chains, this.logger); case SupportedBridge.Zircuit: return new ZircuitNativeBridgeAdapter(this.config.chains, this.logger); + case SupportedBridge.Usdt0: + return new Usdt0BridgeAdapter(this.config.chains, this.logger); default: throw new Error(`Unsupported adapter type: ${type}`); } diff --git a/packages/adapters/rebalance/src/adapters/usdt0/index.ts b/packages/adapters/rebalance/src/adapters/usdt0/index.ts new file mode 100644 index 00000000..8a775939 --- /dev/null +++ b/packages/adapters/rebalance/src/adapters/usdt0/index.ts @@ -0,0 +1,2 @@ +export { Usdt0BridgeAdapter } from './usdt0'; +export * from './types'; diff --git a/packages/adapters/rebalance/src/adapters/usdt0/types.ts b/packages/adapters/rebalance/src/adapters/usdt0/types.ts new file mode 100644 index 00000000..8921e811 --- /dev/null +++ b/packages/adapters/rebalance/src/adapters/usdt0/types.ts @@ -0,0 +1,56 @@ +/** + * USDT0 Legacy Mesh contract addresses and constants + * + * USDT0 is Tether's official omnichain USDT built on LayerZero OFT standard. + * For TON, it uses the "Legacy Mesh" — a credit/debit pool mechanism that + * releases canonical USDT (same jetton as EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs) + * on TON. The pool currently holds ~9.2B USDT. + * + * Reference: + * - USDT0 Docs: https://docs.usdt0.to/ + * - Deployments: https://docs.usdt0.to/api/deployments + * - Legacy Mesh: https://docs.usdt0.to/overview/the-legacy-mesh + */ + +// ============================================================================ +// Contract Addresses +// ============================================================================ + +/** + * USDT0 Legacy Mesh OFT contract on Ethereum mainnet + * Used for sending USDT to legacy chains (TON, Tron) + * Uses credit/debit pool mechanism (not mint/burn) + */ +export const USDT0_LEGACY_MESH_ETH = '0x1F748c76dE468e9D11bd340fA9D5CBADf315dFB0' as `0x${string}`; + +/** + * USDT ERC-20 on Ethereum mainnet + * Must be approved to the Legacy Mesh OFT contract before sending + */ +export const USDT_ETH = '0xdAC17F958D2ee523a2206206994597C13D831ec7' as `0x${string}`; + +// ============================================================================ +// LayerZero Endpoint IDs (USDT0-specific) +// ============================================================================ + +/** + * TON LayerZero Endpoint ID for USDT0 Legacy Mesh + * Note: This differs from Stargate's TON endpoint (30826). + * USDT0 uses its own endpoint for the Legacy Mesh routing. + */ +export const USDT0_LZ_ENDPOINT_TON = 30343; + +/** + * Ethereum LayerZero Endpoint ID + */ +export const USDT0_LZ_ENDPOINT_ETH = 30101; + +// ============================================================================ +// Fee Constants +// ============================================================================ + +/** + * USDT0 Legacy Mesh transfer fee: 0.03% (3 basis points) + * Used for fee estimation when on-chain quoteOFT is unavailable + */ +export const USDT0_LEGACY_MESH_FEE_BPS = 3n; diff --git a/packages/adapters/rebalance/src/adapters/usdt0/usdt0.ts b/packages/adapters/rebalance/src/adapters/usdt0/usdt0.ts new file mode 100644 index 00000000..0dfbf9d5 --- /dev/null +++ b/packages/adapters/rebalance/src/adapters/usdt0/usdt0.ts @@ -0,0 +1,437 @@ +import { + TransactionReceipt, + createPublicClient, + encodeFunctionData, + http, + erc20Abi, + fallback, + type PublicClient, + pad, + decodeEventLog, +} from 'viem'; +import { ChainConfiguration, SupportedBridge, RebalanceRoute, axiosGet, MAINNET_CHAIN_ID } from '@mark/core'; +import { jsonifyError, Logger } from '@mark/logger'; +import { BridgeAdapter, MemoizedTransactionRequest, RebalanceTransactionMemo } from '../../types'; +import { STARGATE_OFT_ABI } from '../stargate/abi'; +import { + StargateSendParam, + StargateMessagingFee, + LzMessageStatus, + LzScanMessageResponse, + tonAddressToBytes32, +} from '../stargate/types'; +import { USDT0_LEGACY_MESH_ETH, USDT_ETH, USDT0_LZ_ENDPOINT_TON, USDT0_LEGACY_MESH_FEE_BPS } from './types'; + +// LayerZero Scan API base URL (same as Stargate — both use LayerZero) +const LZ_SCAN_API_URL = 'https://scan.layerzero-api.com'; + +/** + * USDT0 Bridge Adapter for bridging USDT via Tether's USDT0 Legacy Mesh + * + * This adapter serves as a fallback for the Stargate adapter when Stargate + * has no liquidity for the ETH → TON USDT route. It uses the same LayerZero + * OFT interface (identical ABI) and delivery tracking (LayerZero Scan API). + * + * Reference: + * - USDT0 Docs: https://docs.usdt0.to/ + * - Legacy Mesh: https://docs.usdt0.to/overview/the-legacy-mesh + */ +export class Usdt0BridgeAdapter implements BridgeAdapter { + private readonly publicClients = new Map(); + + constructor( + private readonly chains: Record, + private readonly logger: Logger, + ) { + this.logger.debug('Initializing Usdt0BridgeAdapter (Legacy Mesh)', { + contract: USDT0_LEGACY_MESH_ETH, + tonEndpointId: USDT0_LZ_ENDPOINT_TON, + }); + } + + type(): SupportedBridge { + return SupportedBridge.Usdt0; + } + + /** + * Get the expected amount received after bridging via USDT0 Legacy Mesh + * + * Uses the known fixed Legacy Mesh fee of 0.03% (3 basis points). + * Note: The fixed fee is reliable and documented at https://docs.usdt0.to/overview/the-legacy-mesh. + * + */ + async getReceivedAmount(amount: string, route: RebalanceRoute): Promise { + const logContext = { amount, origin: route.origin, destination: route.destination }; + + const estimatedReceived = BigInt(amount) - (BigInt(amount) * USDT0_LEGACY_MESH_FEE_BPS) / 10000n; + + this.logger.debug('USDT0 received amount (fixed 0.03% Legacy Mesh fee)', { + ...logContext, + estimatedReceived: estimatedReceived.toString(), + feeBps: USDT0_LEGACY_MESH_FEE_BPS.toString(), + }); + + return estimatedReceived.toString(); + } + + /** + * Returns null — defer minimum amount to the caller's config + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async getMinimumAmount(route: RebalanceRoute): Promise { + return null; + } + + /** + * Build transactions to bridge USDT from Ethereum to TON via USDT0 Legacy Mesh + * + * Flow: + * 1. Approve USDT to Legacy Mesh OFT contract (with USDT zero-allowance workaround) + * 2. Call send() on the Legacy Mesh OFT contract + * + * The OFT contract locks USDT on Ethereum, routes through Arbitrum hub, + * and the TON Legacy Mesh pool releases canonical USDT to the recipient. + */ + async send( + sender: string, + recipient: string, + amount: string, + route: RebalanceRoute, + ): Promise { + const logContext = { sender, recipient, amount, origin: route.origin, destination: route.destination }; + + try { + const client = this.getPublicClient(route.origin); + + // Convert recipient to bytes32 (handles TON address formats) + let recipientBytes32: `0x${string}`; + if (recipient.startsWith('0x')) { + recipientBytes32 = pad(recipient as `0x${string}`, { size: 32 }); + } else { + recipientBytes32 = tonAddressToBytes32(recipient); + } + + this.logger.debug('USDT0 encoding recipient address', { + ...logContext, + recipientBytes32, + isTonAddress: !recipient.startsWith('0x'), + }); + + // Calculate minimum amount with slippage (0.5%) + const slippageBps = 50n; + const minAmount = (BigInt(amount) * (10000n - slippageBps)) / 10000n; + + // Build SendParam for the OFT contract + const sendParam: StargateSendParam = { + dstEid: USDT0_LZ_ENDPOINT_TON, + to: recipientBytes32, + amountLD: BigInt(amount), + minAmountLD: minAmount, + extraOptions: '0x' as `0x${string}`, + composeMsg: '0x' as `0x${string}`, + oftCmd: '0x' as `0x${string}`, + }; + + // Get messaging fee quote + const fee = (await client.readContract({ + address: USDT0_LEGACY_MESH_ETH, + abi: STARGATE_OFT_ABI, + functionName: 'quoteSend', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + args: [sendParam, false] as any, + })) as { nativeFee: bigint; lzTokenFee: bigint }; + + this.logger.debug('USDT0 messaging fee quoted', { + ...logContext, + nativeFee: fee.nativeFee.toString(), + lzTokenFee: fee.lzTokenFee.toString(), + }); + + const transactions: MemoizedTransactionRequest[] = []; + + // 1. Check and add USDT approval (with zero-allowance workaround for mainnet USDT) + const tokenAddress = route.asset as `0x${string}`; + const allowance = await client.readContract({ + address: tokenAddress, + abi: erc20Abi, + functionName: 'allowance', + args: [sender as `0x${string}`, USDT0_LEGACY_MESH_ETH], + }); + + this.logger.debug('USDT0 checking USDT allowance', { + ...logContext, + currentAllowance: allowance.toString(), + required: amount, + spender: USDT0_LEGACY_MESH_ETH, + }); + + if (allowance < BigInt(amount)) { + // Mainnet USDT requires setting allowance to 0 before setting a new non-zero value + if ( + route.origin === Number(MAINNET_CHAIN_ID) && + route.asset.toLowerCase() === USDT_ETH.toLowerCase() && + allowance > 0n + ) { + this.logger.info('USDT0: Adding zero-approval for mainnet USDT (non-standard ERC20)', { + ...logContext, + currentAllowance: allowance.toString(), + }); + transactions.push({ + memo: RebalanceTransactionMemo.Approval, + transaction: { + to: tokenAddress, + data: encodeFunctionData({ + abi: erc20Abi, + functionName: 'approve', + args: [USDT0_LEGACY_MESH_ETH, 0n], + }), + value: BigInt(0), + funcSig: 'approve(address,uint256)', + }, + }); + } + + transactions.push({ + memo: RebalanceTransactionMemo.Approval, + transaction: { + to: tokenAddress, + data: encodeFunctionData({ + abi: erc20Abi, + functionName: 'approve', + args: [USDT0_LEGACY_MESH_ETH, BigInt(amount)], + }), + value: BigInt(0), + funcSig: 'approve(address,uint256)', + }, + }); + + this.logger.debug('USDT0 approval transaction(s) added', { + ...logContext, + approvalCount: transactions.length, + }); + } + + // 2. Build the OFT send transaction + const messagingFee: StargateMessagingFee = { + nativeFee: fee.nativeFee, + lzTokenFee: BigInt(0), + }; + + transactions.push({ + memo: RebalanceTransactionMemo.Rebalance, + transaction: { + to: USDT0_LEGACY_MESH_ETH, + data: encodeFunctionData({ + abi: STARGATE_OFT_ABI, + functionName: 'send', + args: [sendParam, messagingFee, sender as `0x${string}`], + }), + value: fee.nativeFee, // Pay LayerZero messaging fee in ETH + funcSig: 'send((uint32,bytes32,uint256,uint256,bytes,bytes,bytes),(uint256,uint256),address)', + }, + }); + + this.logger.info('USDT0 bridge transactions prepared', { + ...logContext, + contract: USDT0_LEGACY_MESH_ETH, + dstEid: USDT0_LZ_ENDPOINT_TON, + minAmount: minAmount.toString(), + nativeFee: fee.nativeFee.toString(), + transactionCount: transactions.length, + }); + + return transactions; + } catch (error) { + this.logger.error('Failed to prepare USDT0 bridge transactions', { + ...logContext, + error: jsonifyError(error), + }); + throw new Error(`Failed to prepare USDT0 bridge: ${(error as Error)?.message ?? ''}`); + } + } + + /** + * USDT0 OFT auto-delivers on destination — no callback needed + */ + async destinationCallback( + route: RebalanceRoute, + originTransaction: TransactionReceipt, + ): Promise { + this.logger.debug('USDT0 destinationCallback invoked - no action required (auto-delivery)', { + transactionHash: originTransaction.transactionHash, + origin: route.origin, + destination: route.destination, + }); + return; + } + + /** + * Check if the LayerZero message has been delivered to TON + * Uses the same LayerZero Scan API as Stargate (both are LayerZero OFT) + */ + async readyOnDestination( + amount: string, + route: RebalanceRoute, + originTransaction: TransactionReceipt, + ): Promise { + const logContext = { + amount, + origin: route.origin, + destination: route.destination, + transactionHash: originTransaction.transactionHash, + }; + + this.logger.debug('USDT0 checking delivery status via LayerZero Scan', logContext); + + try { + // Extract GUID from OFTSent event (same event as Stargate — standard OFT) + const guid = this.extractGuidFromReceipt(originTransaction); + if (!guid) { + this.logger.warn('USDT0: Could not extract GUID from OFTSent event', logContext); + return false; + } + + // Query LayerZero Scan API for message status + const status = await this.getLayerZeroMessageStatus(originTransaction.transactionHash); + + if (!status) { + this.logger.debug('USDT0: LayerZero message status not found yet', { ...logContext, guid }); + return false; + } + + const isReady = status.status === LzMessageStatus.DELIVERED; + this.logger.debug('USDT0 LayerZero message status', { + ...logContext, + status: status.status, + isReady, + guid, + dstTxHash: status.dstTxHash, + }); + + if (status.status === LzMessageStatus.FAILED || status.status === LzMessageStatus.BLOCKED) { + this.logger.error('USDT0 LayerZero message failed or blocked', { + ...logContext, + status: status.status, + guid, + }); + } + + return isReady; + } catch (error) { + this.logger.error('Failed to check USDT0 transfer status', { + ...logContext, + error: jsonifyError(error), + }); + return false; + } + } + + /** + * Get the destination transaction hash after a successful USDT0 bridge + */ + async getDestinationTxHash(originTxHash: string): Promise { + try { + const status = await this.getLayerZeroMessageStatus(originTxHash); + return status?.dstTxHash; + } catch { + return undefined; + } + } + + /** + * Extract the GUID from OFTSent event in the transaction receipt + * The OFTSent event is part of the standard LayerZero OFT interface + */ + private extractGuidFromReceipt(receipt: TransactionReceipt): `0x${string}` | undefined { + for (const log of receipt.logs) { + try { + const decoded = decodeEventLog({ + abi: STARGATE_OFT_ABI, + eventName: 'OFTSent', + data: log.data as `0x${string}`, + topics: log.topics as [`0x${string}`, ...`0x${string}`[]], + }); + + if (decoded.eventName === 'OFTSent') { + return decoded.args.guid; + } + } catch { + continue; + } + } + return undefined; + } + + /** + * Query LayerZero Scan API for message status + */ + private async getLayerZeroMessageStatus(txHash: string): Promise { + try { + const url = `${LZ_SCAN_API_URL}/v1/messages/tx/${txHash}`; + + interface LzScanApiResponse { + data: Array<{ + pathway: { srcEid: number; dstEid: number }; + source: { tx: { txHash: string; blockNumber: string } }; + destination: { tx?: { txHash: string; blockNumber?: number } }; + status: { name: string; message?: string }; + }>; + } + + const { data: response } = await axiosGet(url); + + if (!response.data || response.data.length === 0) { + return undefined; + } + + const msg = response.data[0]; + + const result: LzScanMessageResponse = { + status: msg.status.name as LzMessageStatus, + srcTxHash: msg.source.tx.txHash, + dstTxHash: msg.destination.tx?.txHash, + srcChainId: msg.pathway.srcEid, + dstChainId: msg.pathway.dstEid, + srcBlockNumber: parseInt(msg.source.tx.blockNumber, 10), + dstBlockNumber: msg.destination.tx?.blockNumber, + }; + + this.logger.debug('USDT0 LayerZero message status retrieved', { + txHash, + status: result.status, + dstTxHash: result.dstTxHash, + srcEid: result.srcChainId, + dstEid: result.dstChainId, + }); + + return result; + } catch (error) { + this.logger.error('USDT0: Failed to query LayerZero Scan API', { + error: jsonifyError(error), + txHash, + }); + return undefined; + } + } + + /** + * Get or create a public client for a chain + */ + private getPublicClient(chainId: number): PublicClient { + if (this.publicClients.has(chainId)) { + return this.publicClients.get(chainId)!; + } + + const providers = this.chains[chainId.toString()]?.providers ?? []; + if (!providers.length) { + throw new Error(`No providers found for chain ${chainId}`); + } + + const client = createPublicClient({ + transport: fallback(providers.map((provider: string) => http(provider))), + }); + + this.publicClients.set(chainId, client); + return client; + } +} diff --git a/packages/adapters/rebalance/test/adapters/usdt0/usdt0.spec.ts b/packages/adapters/rebalance/test/adapters/usdt0/usdt0.spec.ts new file mode 100644 index 00000000..054f77e6 --- /dev/null +++ b/packages/adapters/rebalance/test/adapters/usdt0/usdt0.spec.ts @@ -0,0 +1,485 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { beforeEach, describe, expect, it, jest, afterEach } from '@jest/globals'; +import { ChainConfiguration, SupportedBridge, RebalanceRoute, axiosGet, cleanupHttpConnections } from '@mark/core'; +import { jsonifyError, Logger } from '@mark/logger'; +import { TransactionReceipt } from 'viem'; +import { Usdt0BridgeAdapter } from '../../../src/adapters/usdt0/usdt0'; +import { + USDT0_LEGACY_MESH_ETH, + USDT_ETH, + USDT0_LZ_ENDPOINT_TON, + USDT0_LEGACY_MESH_FEE_BPS, +} from '../../../src/adapters/usdt0/types'; +import { RebalanceTransactionMemo } from '../../../src/types'; + +// Mock viem functions +const mockReadContract = jest.fn(); +const mockDecodeEventLog = jest.fn(); + +jest.mock('viem', () => { + const actual = jest.requireActual('viem') as any; + return { + ...actual, + createPublicClient: jest.fn(() => ({ + getBalance: jest.fn().mockResolvedValue(1000000n as never), + readContract: mockReadContract, + getTransactionReceipt: jest.fn(), + })), + encodeFunctionData: jest.fn().mockReturnValue('0xmockEncodedData' as never), + pad: jest.fn().mockReturnValue(('0x' + '0'.repeat(64)) as never), + decodeEventLog: (...args: any[]) => mockDecodeEventLog(...args), + }; +}); + +jest.mock('@mark/core', () => { + const actual = jest.requireActual('@mark/core') as any; + return { + ...actual, + axiosGet: jest.fn(), + cleanupHttpConnections: jest.fn(), + }; +}); + +jest.mock('@mark/logger'); +(jsonifyError as jest.Mock).mockImplementation((err) => { + const error = err as { name?: string; message?: string; stack?: string }; + return { + name: error?.name ?? 'unknown', + message: error?.message ?? 'unknown', + stack: error?.stack ?? 'unknown', + context: {}, + }; +}); + +// Mock logger +const mockLogger = { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), +} as unknown as jest.Mocked; + +// Mock chain configurations +const USDT_TICKER_HASH = '0x8b1a1d9c2b109e527c9134b25b1a1833b16b6594f92daa9f6d9b7a6024bce9d0'; + +const mockChains: Record = { + '1': { + assets: [ + { + address: USDT_ETH, + symbol: 'USDT', + decimals: 6, + tickerHash: USDT_TICKER_HASH, + isNative: false, + balanceThreshold: '0', + }, + ], + providers: ['https://mock-eth-rpc.example.com'], + invoiceAge: 3600, + gasThreshold: '5000000000000000', + deployments: { + everclear: '0xMockEverclearAddress', + permit2: '0x000000000022D473030F116dDEE9F6B43aC78BA3', + multicall3: '0xcA11bde05977b3631167028862bE2a173976CA11', + }, + }, +}; + +// Standard test route: ETH USDT -> TON +const ethToTonRoute: RebalanceRoute = { + asset: USDT_ETH, + origin: 1, + destination: 30826, // TON chain ID used by the TAC rebalancer +}; + +const mockSender = '0x1234567890abcdef1234567890abcdef12345678'; +const mockTonRecipient = 'EQD4FPq-PRDieyQKkizFTRtSDyucUIqrj0v_zXJmqaDp6_0t'; + +describe('Usdt0BridgeAdapter', () => { + let adapter: Usdt0BridgeAdapter; + + beforeEach(() => { + jest.clearAllMocks(); + mockLogger.debug.mockReset(); + mockLogger.info.mockReset(); + mockLogger.warn.mockReset(); + mockLogger.error.mockReset(); + + adapter = new Usdt0BridgeAdapter(mockChains, mockLogger); + }); + + afterEach(() => { + cleanupHttpConnections(); + }); + + describe('constructor', () => { + it('should initialize and log contract details', () => { + expect(adapter).toBeDefined(); + expect(mockLogger.debug).toHaveBeenCalledWith( + 'Initializing Usdt0BridgeAdapter (Legacy Mesh)', + expect.objectContaining({ + contract: USDT0_LEGACY_MESH_ETH, + tonEndpointId: USDT0_LZ_ENDPOINT_TON, + }), + ); + }); + }); + + describe('type', () => { + it('should return SupportedBridge.Usdt0', () => { + expect(adapter.type()).toBe(SupportedBridge.Usdt0); + }); + + it('should return usdt0 string', () => { + expect(adapter.type()).toBe('usdt0'); + }); + }); + + describe('getMinimumAmount', () => { + it('should return null to defer to caller config', async () => { + const result = await adapter.getMinimumAmount(ethToTonRoute); + expect(result).toBeNull(); + }); + }); + + describe('getReceivedAmount', () => { + it('should apply fixed 0.03% Legacy Mesh fee', async () => { + const result = await adapter.getReceivedAmount('1000000', ethToTonRoute); + + // 1000000 - (1000000 * 3 / 10000) = 1000000 - 300 = 999700 + expect(result).toBe('999700'); + expect(mockLogger.debug).toHaveBeenCalledWith( + 'USDT0 received amount (fixed 0.03% Legacy Mesh fee)', + expect.objectContaining({ + estimatedReceived: '999700', + feeBps: '3', + }), + ); + }); + + it('should handle large amounts correctly', async () => { + const result = await adapter.getReceivedAmount('100000000000', ethToTonRoute); // 100k USDT + + // 100000000000 - (100000000000 * 3 / 10000) = 100000000000 - 30000000 = 99970000000 + expect(result).toBe('99970000000'); + }); + + it('should handle small amounts (1 USDT)', async () => { + const result = await adapter.getReceivedAmount('1000000', ethToTonRoute); + expect(Number(result)).toBeGreaterThan(0); + expect(Number(result)).toBeLessThanOrEqual(1000000); + }); + }); + + describe('send', () => { + beforeEach(() => { + // Mock quoteSend for messaging fee + mockReadContract.mockResolvedValue({ + nativeFee: 50000000000000n, // ~0.00005 ETH + lzTokenFee: 0n, + } as never); + }); + + it('should return approval + send transactions when allowance is insufficient', async () => { + // First call: quoteSend, Second call: allowance check + mockReadContract + .mockResolvedValueOnce({ nativeFee: 50000000000000n, lzTokenFee: 0n } as never) // quoteSend + .mockResolvedValueOnce(0n as never); // allowance = 0 + + const txs = await adapter.send(mockSender, mockTonRecipient, '1000000', ethToTonRoute); + + // Should have approval + send transactions + expect(txs.length).toBe(2); + expect(txs[0].memo).toBe(RebalanceTransactionMemo.Approval); + expect(txs[1].memo).toBe(RebalanceTransactionMemo.Rebalance); + + // Send tx should target USDT0 Legacy Mesh contract + expect(txs[1].transaction.to).toBe(USDT0_LEGACY_MESH_ETH); + // Should pay native fee as value + expect(txs[1].transaction.value).toBe(50000000000000n); + + expect(mockLogger.info).toHaveBeenCalledWith( + 'USDT0 bridge transactions prepared', + expect.objectContaining({ + contract: USDT0_LEGACY_MESH_ETH, + dstEid: USDT0_LZ_ENDPOINT_TON, + transactionCount: 2, + }), + ); + }); + + it('should add zero-approval for mainnet USDT when existing allowance is non-zero', async () => { + mockReadContract + .mockResolvedValueOnce({ nativeFee: 50000000000000n, lzTokenFee: 0n } as never) // quoteSend + .mockResolvedValueOnce(500000n as never); // existing non-zero allowance + + const txs = await adapter.send(mockSender, mockTonRecipient, '1000000', ethToTonRoute); + + // Should have zero-approval + approval + send = 3 transactions + expect(txs.length).toBe(3); + expect(txs[0].memo).toBe(RebalanceTransactionMemo.Approval); // zero approval + expect(txs[1].memo).toBe(RebalanceTransactionMemo.Approval); // actual approval + expect(txs[2].memo).toBe(RebalanceTransactionMemo.Rebalance); // send + + expect(mockLogger.info).toHaveBeenCalledWith( + 'USDT0: Adding zero-approval for mainnet USDT (non-standard ERC20)', + expect.any(Object), + ); + }); + + it('should skip approval when allowance is sufficient', async () => { + mockReadContract + .mockResolvedValueOnce({ nativeFee: 50000000000000n, lzTokenFee: 0n } as never) // quoteSend + .mockResolvedValueOnce(2000000n as never); // allowance > amount + + const txs = await adapter.send(mockSender, mockTonRecipient, '1000000', ethToTonRoute); + + // Should have only the send transaction + expect(txs.length).toBe(1); + expect(txs[0].memo).toBe(RebalanceTransactionMemo.Rebalance); + }); + + it('should handle EVM recipient address (0x-prefixed)', async () => { + const evmRecipient = '0xabcdef1234567890abcdef1234567890abcdef12'; + + mockReadContract + .mockResolvedValueOnce({ nativeFee: 50000000000000n, lzTokenFee: 0n } as never) + .mockResolvedValueOnce(2000000n as never); + + const txs = await adapter.send(mockSender, evmRecipient, '1000000', ethToTonRoute); + + expect(txs.length).toBe(1); + expect(mockLogger.debug).toHaveBeenCalledWith( + 'USDT0 encoding recipient address', + expect.objectContaining({ + isTonAddress: false, + }), + ); + }); + + it('should handle TON address recipient', async () => { + mockReadContract + .mockResolvedValueOnce({ nativeFee: 50000000000000n, lzTokenFee: 0n } as never) + .mockResolvedValueOnce(2000000n as never); + + const txs = await adapter.send(mockSender, mockTonRecipient, '1000000', ethToTonRoute); + + expect(txs.length).toBe(1); + expect(mockLogger.debug).toHaveBeenCalledWith( + 'USDT0 encoding recipient address', + expect.objectContaining({ + isTonAddress: true, + }), + ); + }); + + it('should throw when quoteSend fails', async () => { + mockReadContract.mockRejectedValueOnce(new Error('RPC error') as never); + + await expect(adapter.send(mockSender, mockTonRecipient, '1000000', ethToTonRoute)).rejects.toThrow( + 'Failed to prepare USDT0 bridge', + ); + + expect(mockLogger.error).toHaveBeenCalledWith( + 'Failed to prepare USDT0 bridge transactions', + expect.objectContaining({ + sender: mockSender, + recipient: mockTonRecipient, + }), + ); + }); + }); + + describe('destinationCallback', () => { + it('should be a no-op (auto-delivery)', async () => { + const mockReceipt = { transactionHash: '0xabc123' } as unknown as TransactionReceipt; + const result = await adapter.destinationCallback(ethToTonRoute, mockReceipt); + expect(result).toBeUndefined(); + expect(mockLogger.debug).toHaveBeenCalledWith( + 'USDT0 destinationCallback invoked - no action required (auto-delivery)', + expect.any(Object), + ); + }); + }); + + describe('readyOnDestination', () => { + const mockGuid = ('0x' + 'aa'.repeat(32)) as `0x${string}`; + + const mockReceipt = { + transactionHash: '0xabcd1234', + logs: [ + { + data: '0x' as `0x${string}`, + topics: [ + '0x85496b760a4b105f3571ae44ffdc7ea14dc10cafe07bb51a4cf783bd19f3a5d1', + mockGuid, + ('0x' + '00'.repeat(12) + mockSender.slice(2)) as `0x${string}`, + ] as [`0x${string}`, ...`0x${string}`[]], + }, + ], + } as unknown as TransactionReceipt; + + beforeEach(() => { + // Mock decodeEventLog to return a valid OFTSent event + mockDecodeEventLog.mockReturnValue({ + eventName: 'OFTSent', + args: { guid: mockGuid }, + }); + }); + + it('should return true when LayerZero status is DELIVERED', async () => { + const mockApiResponse = { + data: [ + { + pathway: { srcEid: 30101, dstEid: USDT0_LZ_ENDPOINT_TON }, + source: { tx: { txHash: '0xabcd1234', blockNumber: '12345678' } }, + destination: { tx: { txHash: '0xdest4567', blockNumber: 9876543 } }, + status: { name: 'DELIVERED' }, + }, + ], + }; + + (axiosGet as jest.Mock).mockResolvedValue({ data: mockApiResponse } as never); + + const result = await adapter.readyOnDestination('1000000', ethToTonRoute, mockReceipt); + + expect(result).toBe(true); + expect(axiosGet).toHaveBeenCalledWith('https://scan.layerzero-api.com/v1/messages/tx/0xabcd1234'); + }); + + it('should return false when LayerZero status is INFLIGHT', async () => { + const mockApiResponse = { + data: [ + { + pathway: { srcEid: 30101, dstEid: USDT0_LZ_ENDPOINT_TON }, + source: { tx: { txHash: '0xabcd1234', blockNumber: '12345678' } }, + destination: { tx: undefined }, + status: { name: 'INFLIGHT' }, + }, + ], + }; + + (axiosGet as jest.Mock).mockResolvedValue({ data: mockApiResponse } as never); + + const result = await adapter.readyOnDestination('1000000', ethToTonRoute, mockReceipt); + + expect(result).toBe(false); + }); + + it('should return false and log error when status is FAILED', async () => { + const mockApiResponse = { + data: [ + { + pathway: { srcEid: 30101, dstEid: USDT0_LZ_ENDPOINT_TON }, + source: { tx: { txHash: '0xabcd1234', blockNumber: '12345678' } }, + destination: { tx: undefined }, + status: { name: 'FAILED' }, + }, + ], + }; + + (axiosGet as jest.Mock).mockResolvedValue({ data: mockApiResponse } as never); + + const result = await adapter.readyOnDestination('1000000', ethToTonRoute, mockReceipt); + + expect(result).toBe(false); + expect(mockLogger.error).toHaveBeenCalledWith( + 'USDT0 LayerZero message failed or blocked', + expect.objectContaining({ + status: 'FAILED', + }), + ); + }); + + it('should return false when no GUID can be extracted from receipt', async () => { + // Make decodeEventLog throw so no GUID is found + mockDecodeEventLog.mockImplementation(() => { + throw new Error('not OFTSent'); + }); + + const emptyReceipt = { + transactionHash: '0xnoevents', + logs: [{ data: '0x', topics: ['0xdeadbeef'] }], + } as unknown as TransactionReceipt; + + const result = await adapter.readyOnDestination('1000000', ethToTonRoute, emptyReceipt); + + expect(result).toBe(false); + expect(mockLogger.warn).toHaveBeenCalledWith( + 'USDT0: Could not extract GUID from OFTSent event', + expect.any(Object), + ); + }); + + it('should return false when LayerZero API returns no data', async () => { + (axiosGet as jest.Mock).mockResolvedValue({ data: { data: [] } } as never); + + const result = await adapter.readyOnDestination('1000000', ethToTonRoute, mockReceipt); + + expect(result).toBe(false); + }); + + it('should return false when LayerZero API fails', async () => { + (axiosGet as jest.Mock).mockRejectedValue(new Error('API error') as never); + + const result = await adapter.readyOnDestination('1000000', ethToTonRoute, mockReceipt); + + expect(result).toBe(false); + expect(mockLogger.error).toHaveBeenCalledWith( + 'USDT0: Failed to query LayerZero Scan API', + expect.objectContaining({ + txHash: '0xabcd1234', + }), + ); + }); + }); + + describe('getDestinationTxHash', () => { + it('should return destination tx hash when available', async () => { + const mockApiResponse = { + data: [ + { + pathway: { srcEid: 30101, dstEid: USDT0_LZ_ENDPOINT_TON }, + source: { tx: { txHash: '0xabcd1234', blockNumber: '12345678' } }, + destination: { tx: { txHash: '0xdest4567' } }, + status: { name: 'DELIVERED' }, + }, + ], + }; + + (axiosGet as jest.Mock).mockResolvedValue({ data: mockApiResponse } as never); + + const result = await adapter.getDestinationTxHash('0xabcd1234'); + + expect(result).toBe('0xdest4567'); + }); + + it('should return undefined when API fails', async () => { + (axiosGet as jest.Mock).mockRejectedValue(new Error('API error') as never); + + const result = await adapter.getDestinationTxHash('0xabcd1234'); + + expect(result).toBeUndefined(); + }); + }); + + describe('constants', () => { + it('should use correct Legacy Mesh contract address', () => { + expect(USDT0_LEGACY_MESH_ETH).toBe('0x1F748c76dE468e9D11bd340fA9D5CBADf315dFB0'); + }); + + it('should use correct TON endpoint ID (different from Stargate)', () => { + expect(USDT0_LZ_ENDPOINT_TON).toBe(30343); + // Stargate uses 30826, USDT0 uses 30343 + expect(USDT0_LZ_ENDPOINT_TON).not.toBe(30826); + }); + + it('should use correct USDT address', () => { + expect(USDT_ETH).toBe('0xdAC17F958D2ee523a2206206994597C13D831ec7'); + }); + + it('should have correct fee rate (0.03%)', () => { + expect(USDT0_LEGACY_MESH_FEE_BPS).toBe(3n); + }); + }); +}); diff --git a/packages/core/src/types/config.ts b/packages/core/src/types/config.ts index f171db9f..2228679f 100644 --- a/packages/core/src/types/config.ts +++ b/packages/core/src/types/config.ts @@ -83,6 +83,7 @@ export enum SupportedBridge { Zksync = 'zksync', Linea = 'linea', Zircuit = 'zircuit', + Usdt0 = 'usdt0', } export enum GasType { diff --git a/packages/poller/src/rebalance/tacUsdt.ts b/packages/poller/src/rebalance/tacUsdt.ts index 62311457..e213d0fc 100644 --- a/packages/poller/src/rebalance/tacUsdt.ts +++ b/packages/poller/src/rebalance/tacUsdt.ts @@ -612,8 +612,12 @@ const processOnDemandRebalancing = async ( invoiceId: invoice.intent_id.toString(), }); - // --- Leg 1: Bridge USDT from Ethereum to TON via Stargate --- - const bridgeType = SupportedBridge.Stargate; + // --- Leg 1: Bridge USDT from Ethereum to TON --- + // Try Stargate first, fall back to USDT0 Legacy Mesh if Stargate fails (e.g., no liquidity) + const bridgePreferences: { type: SupportedBridge; tag: string; label: string }[] = [ + { type: SupportedBridge.Stargate, tag: 'stargate-tac', label: 'TAC on-demand Stargate' }, + { type: SupportedBridge.Usdt0, tag: 'usdt0-tac', label: 'TAC on-demand USDT0' }, + ]; // Get addresses for the bridging flow // evmSender: The Ethereum address that holds USDT and will initiate the bridge @@ -628,7 +632,7 @@ const processOnDemandRebalancing = async ( // Validate TON address is configured if (!tonRecipient) { - logger.error('TON address not configured (config.ownTonAddress), cannot execute Stargate bridge', { + logger.error('TON address not configured (config.ownTonAddress), cannot execute bridge to TON', { requestId, note: 'Add ownTonAddress to config to enable TAC rebalancing', }); @@ -646,104 +650,139 @@ const processOnDemandRebalancing = async ( // Use slippage from config (default 500 = 5%) const slippageDbps = config.tacRebalance!.bridge.slippageDbps ?? 500; - const route = { - asset: USDT_ON_ETH_ADDRESS, - origin: origin, - destination: Number(TON_LZ_CHAIN_ID), // First leg goes to TON - maximum: amountToBridge.toString(), - slippagesDbps: [slippageDbps], - preferences: [bridgeType], - reserve: '0', - }; + // CRITICAL: Convert amount from 18 decimals to native USDT decimals (6) + const ethUsdtDecimals = getDecimalsFromConfig(USDT_TICKER_HASH, origin.toString(), config) ?? 6; + const amountInNativeUnits = convertToNativeUnits(amountToBridge, ethUsdtDecimals); - logger.info('Attempting Leg 1: Ethereum to TON via Stargate', { + logger.debug('Converting amount to native units for bridge', { requestId, - bridgeType, - amountToBridge: amountToBridge.toString(), - evmSender, - tonRecipient, - tacRecipient, + amountIn18Decimals: amountToBridge.toString(), + amountInNativeUnits: amountInNativeUnits.toString(), + decimals: ethUsdtDecimals, }); - const adapter = rebalance.getAdapter(bridgeType); - if (!adapter) { - logger.error('Stargate adapter not found', { requestId }); - continue; - } - - try { - // CRITICAL: Convert amount from 18 decimals to native USDT decimals (6) - const ethUsdtDecimals = getDecimalsFromConfig(USDT_TICKER_HASH, origin.toString(), config) ?? 6; - const amountInNativeUnits = convertToNativeUnits(amountToBridge, ethUsdtDecimals); + // Try each bridge in preference order (Stargate first, USDT0 fallback) + let onDemandBridgeSucceeded = false; + for (const bridgePref of bridgePreferences) { + const route = { + asset: USDT_ON_ETH_ADDRESS, + origin: origin, + destination: Number(TON_LZ_CHAIN_ID), // First leg goes to TON + maximum: amountToBridge.toString(), + slippagesDbps: [slippageDbps], + preferences: [bridgePref.type], + reserve: '0', + }; - logger.debug('Converting amount to native units for Stargate', { + logger.info('Attempting Leg 1: Ethereum to TON', { requestId, - amountIn18Decimals: amountToBridge.toString(), - amountInNativeUnits: amountInNativeUnits.toString(), - decimals: ethUsdtDecimals, + bridgeType: bridgePref.type, + bridgeLabel: bridgePref.label, + amountToBridge: amountToBridge.toString(), + evmSender, + tonRecipient, + tacRecipient, }); - const result = await executeEvmBridge({ - context, - adapter, - route, - amount: amountInNativeUnits, - dbAmount: amountToBridge, // preserve 18-decimal for DB record + committed-funds tracking - sender: evmSender, - recipient: tonRecipient, - dbRecipient: tacRecipient, - slippageTolerance: BigInt(route.slippagesDbps[0]), - slippageMultiplier: BPS_MULTIPLIER, - chainService, - dbRecord: { - earmarkId: earmark.id, - tickerHash: getTickerForAsset(route.asset, route.origin, config) || USDT_TICKER_HASH, - bridgeTag: 'stargate-tac', - status: RebalanceOperationStatus.PENDING, - }, - label: 'TAC on-demand Stargate', - }); + let adapter; + try { + adapter = rebalance.getAdapter(bridgePref.type); + } catch (adapterError) { + logger.warn('Bridge adapter not available, trying next preference', { + requestId, + bridgeType: bridgePref.type, + error: jsonifyError(adapterError), + }); + continue; + } - if (result.actions.length === 0) { - // Cancel orphaned earmark to prevent permanently blocking this invoice - try { - await database.updateEarmarkStatus(earmark.id, EarmarkStatus.CANCELLED); - logger.info('Cancelled earmark after bridge returned no actions', { - requestId, + try { + const result = await executeEvmBridge({ + context, + adapter, + route, + amount: amountInNativeUnits, + dbAmount: amountToBridge, // preserve 18-decimal for DB record + committed-funds tracking + sender: evmSender, + recipient: tonRecipient, + dbRecipient: tacRecipient, + slippageTolerance: BigInt(route.slippagesDbps[0]), + slippageMultiplier: BPS_MULTIPLIER, + chainService, + dbRecord: { earmarkId: earmark.id, - invoiceId: invoice.intent_id.toString(), - }); - } catch (cancelError) { - logger.error('Failed to cancel orphaned earmark', { + tickerHash: getTickerForAsset(route.asset, route.origin, config) || USDT_TICKER_HASH, + bridgeTag: bridgePref.tag, + status: RebalanceOperationStatus.PENDING, + }, + label: bridgePref.label, + }); + + if (result.actions.length === 0) { + logger.warn('Bridge returned no actions, trying next preference', { requestId, - earmarkId: earmark.id, - error: jsonifyError(cancelError), + bridgeType: bridgePref.type, + bridgeTag: bridgePref.tag, }); + continue; } - continue; - } - actions.push(...result.actions); + logger.info('Leg 1 bridge succeeded', { + requestId, + bridgeType: bridgePref.type, + bridgeTag: bridgePref.tag, + actionCount: result.actions.length, + }); - // Track committed funds to prevent over-committing in subsequent operations - const bridgedAmount = safeParseBigInt(result.effectiveBridgedAmount); - runState.committedAmount += bridgedAmount; - remainingEthUsdt -= bridgedAmount; + actions.push(...result.actions); - logger.debug('Updated committed funds after on-demand bridge', { - requestId, - invoiceId: invoice.intent_id.toString(), - bridgedAmount: bridgedAmount.toString(), - totalCommitted: runState.committedAmount.toString(), - remainingAvailable: remainingEthUsdt.toString(), - }); - } catch (error) { - logger.error('Failed to execute Stargate bridge', { + // Track committed funds to prevent over-committing in subsequent operations + const bridgedAmount = safeParseBigInt(result.effectiveBridgedAmount); + runState.committedAmount += bridgedAmount; + remainingEthUsdt -= bridgedAmount; + + logger.debug('Updated committed funds after on-demand bridge', { + requestId, + invoiceId: invoice.intent_id.toString(), + bridgedAmount: bridgedAmount.toString(), + totalCommitted: runState.committedAmount.toString(), + remainingAvailable: remainingEthUsdt.toString(), + }); + + onDemandBridgeSucceeded = true; + break; // Success — don't try remaining preferences + } catch (bridgeError) { + logger.warn('Bridge execution failed, trying next preference', { + requestId, + bridgeType: bridgePref.type, + bridgeTag: bridgePref.tag, + error: jsonifyError(bridgeError), + }); + continue; + } + } + + if (!onDemandBridgeSucceeded) { + logger.error('All bridge preferences failed for on-demand Leg 1 (Ethereum to TON)', { requestId, - route, - bridgeType, - error: jsonifyError(error), + preferences: bridgePreferences.map((p) => p.type), + amountToBridge: amountToBridge.toString(), }); + // Cancel orphaned earmark to prevent permanently blocking this invoice + try { + await database.updateEarmarkStatus(earmark.id, EarmarkStatus.CANCELLED); + logger.info('Cancelled earmark after all bridge preferences failed', { + requestId, + earmarkId: earmark.id, + invoiceId: invoice.intent_id.toString(), + }); + } catch (cancelError) { + logger.error('Failed to cancel orphaned earmark', { + requestId, + earmarkId: earmark.id, + error: jsonifyError(cancelError), + }); + } continue; } } @@ -944,8 +983,12 @@ const executeTacBridge = async ( const origin = Number(MAINNET_CHAIN_ID); // Always start from Ethereum mainnet - // --- Leg 1: Bridge USDT from Ethereum to TON via Stargate --- - const bridgeType = SupportedBridge.Stargate; + // --- Leg 1: Bridge USDT from Ethereum to TON --- + // Try Stargate first, fall back to USDT0 Legacy Mesh if Stargate fails (e.g., no liquidity) + const bridgePreferences: { type: SupportedBridge; tag: string; label: string }[] = [ + { type: SupportedBridge.Stargate, tag: 'stargate-tac', label: 'TAC threshold Stargate' }, + { type: SupportedBridge.Usdt0, tag: 'usdt0-tac', label: 'TAC threshold USDT0' }, + ]; // Determine sender for the bridge based on recipient type // For Fill Service recipient: prefer filler as sender, fallback to MM @@ -1057,7 +1100,7 @@ const executeTacBridge = async ( // Validate TON address is configured if (!tonRecipient) { - logger.error('TON address not configured (config.ownTonAddress), cannot execute Stargate bridge', { + logger.error('TON address not configured (config.ownTonAddress), cannot execute bridge to TON', { requestId, note: 'Add ownTonAddress to config to enable TAC rebalancing', }); @@ -1092,75 +1135,106 @@ const executeTacBridge = async ( // Use slippage from config (default 500 = 5%) const slippageDbps = config.tacRebalance!.bridge.slippageDbps ?? 500; - const route = { - asset: USDT_ON_ETH_ADDRESS, - origin: origin, - destination: Number(TON_LZ_CHAIN_ID), // First leg goes to TON - maximum: amount.toString(), - slippagesDbps: [slippageDbps], - preferences: [bridgeType], - reserve: '0', - }; + // CRITICAL: Convert amount from 18 decimals to native USDT decimals (6) + const ethUsdtDecimals = getDecimalsFromConfig(USDT_TICKER_HASH, origin.toString(), config) ?? 6; + const amountInNativeUnits = convertToNativeUnits(amount, ethUsdtDecimals); - logger.info('Attempting Leg 1: Ethereum to TON via Stargate', { + logger.debug('Converting amount to native units for bridge', { requestId, - bridgeType, - amount: amount.toString(), - evmSender, - tonRecipient, - tacRecipient, + amountIn18Decimals: amount.toString(), + amountInNativeUnits: amountInNativeUnits.toString(), + decimals: ethUsdtDecimals, }); - const adapter = rebalance.getAdapter(bridgeType); - if (!adapter) { - logger.error('Stargate adapter not found', { requestId }); - return []; - } - - try { - // CRITICAL: Convert amount from 18 decimals to native USDT decimals (6) - const ethUsdtDecimals = getDecimalsFromConfig(USDT_TICKER_HASH, origin.toString(), config) ?? 6; - const amountInNativeUnits = convertToNativeUnits(amount, ethUsdtDecimals); + // Try each bridge in preference order (Stargate first, USDT0 fallback) + for (const bridgePref of bridgePreferences) { + const route = { + asset: USDT_ON_ETH_ADDRESS, + origin: origin, + destination: Number(TON_LZ_CHAIN_ID), // First leg goes to TON + maximum: amount.toString(), + slippagesDbps: [slippageDbps], + preferences: [bridgePref.type], + reserve: '0', + }; - logger.debug('Converting amount to native units for Stargate', { + logger.info('Attempting Leg 1: Ethereum to TON', { requestId, - amountIn18Decimals: amount.toString(), - amountInNativeUnits: amountInNativeUnits.toString(), - decimals: ethUsdtDecimals, + bridgeType: bridgePref.type, + bridgeLabel: bridgePref.label, + amount: amount.toString(), + evmSender, + tonRecipient, + tacRecipient, }); - const result = await executeEvmBridge({ - context, - adapter, - route, - amount: amountInNativeUnits, - dbAmount: amount, // preserve 18-decimal for DB record - sender: evmSender, - recipient: tonRecipient, - dbRecipient: tacRecipient, - slippageTolerance: BigInt(route.slippagesDbps[0]), - slippageMultiplier: BPS_MULTIPLIER, - chainService: selectedChainService, - senderConfig, - dbRecord: { - earmarkId: earmarkId, - tickerHash: getTickerForAsset(route.asset, route.origin, config) || USDT_TICKER_HASH, - bridgeTag: 'stargate-tac', - status: RebalanceOperationStatus.PENDING, - }, - label: 'TAC threshold Stargate', - }); + let adapter; + try { + adapter = rebalance.getAdapter(bridgePref.type); + } catch (adapterError) { + logger.warn('Bridge adapter not available, trying next preference', { + requestId, + bridgeType: bridgePref.type, + error: jsonifyError(adapterError), + }); + continue; + } - return result.actions; - } catch (error) { - logger.error('Failed to execute Stargate bridge', { - requestId, - route, - bridgeType, - error: jsonifyError(error), - }); - return []; + try { + const result = await executeEvmBridge({ + context, + adapter, + route, + amount: amountInNativeUnits, + dbAmount: amount, // preserve 18-decimal for DB record + sender: evmSender, + recipient: tonRecipient, + dbRecipient: tacRecipient, + slippageTolerance: BigInt(route.slippagesDbps[0]), + slippageMultiplier: BPS_MULTIPLIER, + chainService: selectedChainService, + senderConfig, + dbRecord: { + earmarkId: earmarkId, + tickerHash: getTickerForAsset(route.asset, route.origin, config) || USDT_TICKER_HASH, + bridgeTag: bridgePref.tag, + status: RebalanceOperationStatus.PENDING, + }, + label: bridgePref.label, + }); + + if (result.actions.length > 0) { + logger.info('Leg 1 bridge succeeded', { + requestId, + bridgeType: bridgePref.type, + bridgeTag: bridgePref.tag, + actionCount: result.actions.length, + }); + return result.actions; + } + + logger.warn('Bridge returned no actions, trying next preference', { + requestId, + bridgeType: bridgePref.type, + bridgeTag: bridgePref.tag, + }); + } catch (bridgeError) { + logger.warn('Bridge execution failed, trying next preference', { + requestId, + bridgeType: bridgePref.type, + bridgeTag: bridgePref.tag, + error: jsonifyError(bridgeError), + }); + continue; + } } + + logger.error('All bridge preferences failed for threshold Leg 1 (Ethereum to TON)', { + requestId, + preferences: bridgePreferences.map((p) => p.type), + amount: amount.toString(), + }); + return []; }; /** @@ -1436,7 +1510,7 @@ export const executeTacCallbacks = async (context: ProcessingContext): Promise