From 052ea23c46376db9e8769f8bdf4cef6dd0a99bba Mon Sep 17 00:00:00 2001 From: Alexander Date: Fri, 8 May 2026 16:51:39 +0200 Subject: [PATCH 01/11] feat: add Tron chain support with bug fixes New files: - tronExecution.ts: open escrow, approve tokens, get block timestamp - tronSolver.ts: fill outputs, claim, validate proofs, read state - chainType.ts: isTronChain / isEvmChain helpers - tronlink.ts: TronLink connection utilities - WalletStatus.svelte: wallet status component - tronSupport.test.ts: unit tests for chain type utilities Bug fixes: - solver.ts: use eth_getBlockByNumber instead of eth_getBlockByHash (blockByHash is unreliable on many public RPCs); add Tron-aware fill timestamp path for EVM->Tron outputs Co-Authored-By: Claude Sonnet 4.6 (1M context) --- src/lib/components/WalletStatus.svelte | 100 +++++++++++ src/lib/libraries/solver.ts | 103 +++++++++-- src/lib/libraries/tronExecution.ts | 84 +++++++++ src/lib/libraries/tronSolver.ts | 234 +++++++++++++++++++++++++ src/lib/utils/chainType.ts | 64 +++++++ src/lib/utils/tronlink.ts | 93 ++++++++++ tests/unit/tronSupport.test.ts | 52 ++++++ 7 files changed, 717 insertions(+), 13 deletions(-) create mode 100644 src/lib/components/WalletStatus.svelte create mode 100644 src/lib/libraries/tronExecution.ts create mode 100644 src/lib/libraries/tronSolver.ts create mode 100644 src/lib/utils/chainType.ts create mode 100644 src/lib/utils/tronlink.ts create mode 100644 tests/unit/tronSupport.test.ts diff --git a/src/lib/components/WalletStatus.svelte b/src/lib/components/WalletStatus.svelte new file mode 100644 index 0000000..5d46a59 --- /dev/null +++ b/src/lib/components/WalletStatus.svelte @@ -0,0 +1,100 @@ + + +
+ {#if store.connectedAccount} + + EVM: {truncate(store.connectedAccount.address)} + + {:else} +
+ {#if connectors.length === 1} + + {:else} + + {#if showEvmDropdown} +
+ {#each connectors as connector (connector.id)} + + {/each} +
+ {/if} + {/if} +
+ {/if} + + | + + {#if store.tronConnectedAccount} + + Tron: {truncate(store.tronConnectedAccount.base58Address)} + + {:else if isTronLinkAvailable()} + + {:else} + Tron: No wallet + {/if} +
diff --git a/src/lib/libraries/solver.ts b/src/lib/libraries/solver.ts index 42817dc..62bda08 100644 --- a/src/lib/libraries/solver.ts +++ b/src/lib/libraries/solver.ts @@ -1,5 +1,5 @@ import { BYTES32_ZERO, COIN_FILLER, getChain, getClient, getOracle, type WC } from "$lib/config"; -import { hashStruct, maxUint256, parseEventLogs } from "viem"; +import { encodeFunctionData, hashStruct, maxUint256, parseEventLogs } from "viem"; import type { MandateOutput, OrderContainer } from "@lifi/intent"; import { addressToBytes32, bytes32ToAddress, StandardSolanaIntent } from "@lifi/intent"; import axios from "axios"; @@ -10,6 +10,14 @@ import { containerToIntent } from "$lib/utils/intent"; import { compactTypes } from "@lifi/intent"; import store from "$lib/state.svelte"; import { finaliseIntent } from "./intentExecution"; +import { isTronChain } from "$lib/utils/chainType"; +import { + fillTronOutputs, + claimTronIntent, + submitTronReceiveMessage, + getTronTransactionInfo +} from "./tronSolver"; +import { getTronBlockTimestamp } from "./tronExecution"; /** * @notice Class for solving intents. Functions called by solvers. @@ -22,6 +30,25 @@ export class Solver { return new Promise((resolve) => setTimeout(resolve, ms)); } + private static extractRevertReason(error: unknown): string { + if ( + error && + typeof error === "object" && + "cause" in error && + error.cause && + typeof error.cause === "object" && + "data" in error.cause + ) { + const reverted = error.cause as { data?: { errorName?: string; args?: unknown[] } }; + if (reverted.data?.errorName) { + const args = reverted.data.args?.length ? ` (${reverted.data.args.join(", ")})` : ""; + return `${reverted.data.errorName}${args}`; + } + } + if (error instanceof Error) return error.message; + return String(error); + } + private static async persistReceipt( chainId: number | bigint, txHash: `0x${string}`, @@ -69,6 +96,13 @@ export class Solver { const orderId = containerToIntent(args.orderContainer).orderId(); const outputChainId = Number(outputs[0].chainId); + + if (isTronChain(outputChainId)) { + const txId = await fillTronOutputs(args.orderContainer, outputs, account()); + if (postHook) await postHook(); + return `0x${txId.replace("0x", "")}` as `0x${string}`; + } + const outputChain = getChain(outputChainId); // Always attempt chain switch before fill, including native-token fills. if (preHook) await preHook(outputChain.id); @@ -235,15 +269,40 @@ export class Solver { await Solver.sleep(waitMs); } if (proof) { + if (isTronChain(sourceChainId)) { + const txId = await submitTronReceiveMessage(order.inputOracle, proof); + if (postHook) await postHook(); + return { transactionHash: `0x${txId.replace("0x", "")}` }; + } + if (preHook) await preHook(Number(sourceChainId)); + const proofHex = `0x${proof.replace("0x", "")}` as `0x${string}`; + const simCalldata = encodeFunctionData({ + abi: POLYMER_ORACLE_ABI, + functionName: "receiveMessage", + args: [proofHex] + }); + try { + await getClient(sourceChainId).call({ + to: order.inputOracle, + data: simCalldata, + account: account() + }); + } catch (simError) { + throw new Error( + `receiveMessage simulation failed on chain ${Number(sourceChainId)}: ${Solver.extractRevertReason(simError)}`, + { cause: simError as Error } + ); + } + const transactionHash = await walletClient.writeContract({ chain: getChain(sourceChainId), account: account(), address: order.inputOracle, abi: POLYMER_ORACLE_ABI, functionName: "receiveMessage", - args: [`0x${proof.replace("0x", "")}`] + args: [proofHex] }); const result = await getClient(sourceChainId).waitForTransactionReceipt({ @@ -314,6 +373,7 @@ export class Solver { const intent = containerToIntent(orderContainer); if (intent instanceof StandardSolanaIntent) throw new Error("Finalise is not supported for Solana input intents."); + if (fillTransactionHashes.length !== order.outputs.length) { throw new Error( `Fill transaction hash count (${fillTransactionHashes.length}) does not match output count (${order.outputs.length}).` @@ -325,19 +385,36 @@ export class Solver { throw new Error(`Invalid fill tx hash at index ${i}: ${hash}`); } } - const transactionReceipts = await Promise.all( - fillTransactionHashes.map((fth, i) => - Solver.getReceiptCachedOrRpc(order.outputs[i].chainId, fth as `0x${string}`) - ) - ); - const blocks = await Promise.all( - transactionReceipts.map((r, i) => { - return getClient(order.outputs[i].chainId).getBlock({ - blockHash: r.blockHash - }); + const fillTimestamps = await Promise.all( + fillTransactionHashes.map(async (fth, i) => { + const outputChainId = order.outputs[i].chainId; + if (isTronChain(outputChainId)) { + const txInfo = await getTronTransactionInfo(fth.replace("0x", "")); + return getTronBlockTimestamp(Number(txInfo.blockNumber)); + } + const receipt = await Solver.getReceiptCachedOrRpc(outputChainId, fth as `0x${string}`); + // Prefer blockNumber — eth_getBlockByHash is unreliable on many public RPCs. + // Coerce to bigint in case the cached receipt deserialized blockNumber as a number. + const blockNumber = receipt.blockNumber != null ? BigInt(receipt.blockNumber) : null; + const block = + blockNumber != null + ? await getClient(outputChainId).getBlock({ blockNumber }) + : await getClient(outputChainId).getBlock({ + blockHash: receipt.blockHash as `0x${string}` + }); + return Number(block.timestamp); }) ); - const fillTimestamps = blocks.map((b) => b.timestamp); + + if (isTronChain(sourceChainId)) { + const txId = await claimTronIntent({ + orderContainer, + fillTimestamps: fillTimestamps.map(Number), + account: account() + }); + if (postHook) await postHook(); + return `0x${txId.replace("0x", "")}`; + } if (preHook) await preHook(Number(sourceChainId)); const expectedChainId = Number(sourceChainId); diff --git a/src/lib/libraries/tronExecution.ts b/src/lib/libraries/tronExecution.ts new file mode 100644 index 0000000..d2b9903 --- /dev/null +++ b/src/lib/libraries/tronExecution.ts @@ -0,0 +1,84 @@ +import { getTronWeb } from "$lib/utils/tronlink"; +import { SETTLER_ESCROW_ABI } from "$lib/abi/escrow"; +import { ERC20_ABI } from "$lib/abi/erc20"; +import { TRON_MAINNET_INPUT_SETTLER } from "$lib/config"; +import type { StandardEVMIntent } from "@lifi/intent"; +import type { EVMOrder } from "@lifi/intent"; + +function requireTronWeb(): TronWeb { + const tw = getTronWeb(); + if (!tw) throw new Error("TronLink is not connected"); + return tw; +} + +function toTronAddress(tw: TronWeb, hex: `0x${string}`): string { + return tw.address.fromHex("41" + hex.replace("0x", "")); +} + +// TronLink's injected ethers.js v6 can't encode named-object structs because +// its ABI parser leaves localName empty. Convert to positional arrays so the +// encoder uses index-based matching instead. +function orderToTronTuple(order: EVMOrder): unknown[] { + return [ + order.user, + order.nonce.toString(), + order.originChainId.toString(), + order.expires, + order.fillDeadline, + order.inputOracle, + order.inputs.map(([token, amount]) => [token.toString(), amount.toString()]), + order.outputs.map((o) => [ + o.oracle, + o.settler, + o.chainId.toString(), + o.token, + o.amount.toString(), + o.recipient, + o.callbackData, + o.context + ]) + ]; +} + +export async function openTronEscrowIntent( + intent: StandardEVMIntent, + _userHexAddress: `0x${string}` +): Promise { + const tw = requireTronWeb(); + const order = intent.asOrder(); + + const settlerAddress = toTronAddress(tw, TRON_MAINNET_INPUT_SETTLER); + const contract = await tw.contract( + [...SETTLER_ESCROW_ABI] as Record[], + settlerAddress + ); + + const txId = await contract.open(orderToTronTuple(order)).send({ + feeLimit: 150_000_000 + }); + return txId; +} + +export async function approveTronToken( + tokenHex: `0x${string}`, + spenderHex: `0x${string}`, + _amount: bigint +): Promise { + const tw = requireTronWeb(); + + const tokenAddress = toTronAddress(tw, tokenHex); + const spenderAddress = toTronAddress(tw, spenderHex); + const contract = await tw.contract([...ERC20_ABI] as Record[], tokenAddress); + + const maxUint256 = "0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"; + const txId = await contract.approve(spenderAddress, maxUint256).send({ + feeLimit: 50_000_000 + }); + return txId; +} + +export function signTronCompact(): never { + throw new Error( + "Tron compact signing not yet supported — pending protocol decision on EIP-712 alternative" + ); +} diff --git a/src/lib/libraries/tronSolver.ts b/src/lib/libraries/tronSolver.ts new file mode 100644 index 0000000..5f6e4dc --- /dev/null +++ b/src/lib/libraries/tronSolver.ts @@ -0,0 +1,234 @@ +import { getTronWeb } from "$lib/utils/tronlink"; +import { COIN_FILLER, TRON_MAINNET_INPUT_SETTLER } from "$lib/config"; +import { SETTLER_ESCROW_ABI } from "$lib/abi/escrow"; +import { ERC20_ABI } from "$lib/abi/erc20"; +import { COIN_FILLER_ABI } from "$lib/abi/outputsettler"; +import { POLYMER_ORACLE_ABI } from "$lib/abi/polymeroracle"; +import type { MandateOutput, OrderContainer } from "@lifi/intent"; +import { addressToBytes32, bytes32ToAddress } from "@lifi/intent"; +import { containerToIntent } from "$lib/utils/intent"; +import axios from "axios"; + +function requireTronWeb(): TronWeb { + const tw = getTronWeb(); + if (!tw) throw new Error("TronLink is not connected"); + return tw; +} + +function toTronAddress(tw: TronWeb, hex: string): string { + return tw.address.fromHex("41" + hex.replace("0x", "")); +} + +export async function fillTronOutputs( + orderContainer: OrderContainer, + outputs: MandateOutput[], + accountHex: `0x${string}` +): Promise { + const tw = requireTronWeb(); + const { order } = orderContainer; + const orderId = containerToIntent(orderContainer).orderId(); + + const settlerBase58 = toTronAddress(tw, bytes32ToAddress(outputs[0].settler)); + + for (const output of outputs) { + if (output.token === "0x0000000000000000000000000000000000000000000000000000000000000000") + continue; + + const assetAddress = bytes32ToAddress(output.token); + const assetBase58 = toTronAddress(tw, assetAddress); + const tokenContract = await tw.contract( + [...ERC20_ABI] as Record[], + assetBase58 + ); + + const allowance = (await tokenContract + .allowance(tw.defaultAddress.base58, settlerBase58) + .call()) as bigint | string | number; + + if (BigInt(allowance.toString()) < output.amount) { + const maxUint256 = "0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"; + await tokenContract.approve(settlerBase58, maxUint256).send({ feeLimit: 50_000_000 }); + } + } + + const coinFillerBase58 = toTronAddress(tw, COIN_FILLER); + const fillerContract = await tw.contract( + [...COIN_FILLER_ABI] as Record[], + coinFillerBase58 + ); + + const txId = await fillerContract + .fillOrderOutputs(orderId, outputs, order.fillDeadline, addressToBytes32(accountHex)) + .send({ feeLimit: 150_000_000 }); + + return txId; +} + +export async function getTronTransactionInfo(txId: string): Promise> { + const tw = requireTronWeb(); + return await tw.trx.getTransactionInfo(txId); +} + +export async function validateTronFill(args: { + output: MandateOutput; + fillTxId: string; + sourceChainId: number | bigint; + mainnet: boolean; + account: `0x${string}`; +}): Promise { + const tw = requireTronWeb(); + const { output, fillTxId, sourceChainId, mainnet, account } = args; + + const txInfo = await getTronTransactionInfo(fillTxId); + const logs = (txInfo.log as Array<{ topics: string[]; data: string; address: string }>) ?? []; + + if (!logs.length) { + throw new Error(`No logs found in Tron transaction ${fillTxId}`); + } + + const response = await axios.post( + `/polymer`, + { + srcChainId: Number(output.chainId), + srcBlockNumber: Number(txInfo.blockNumber), + globalLogIndex: 0, + mainnet + }, + { timeout: 15_000 } + ); + const dat = response.data as { proof: undefined | string; polymerIndex: number }; + if (!dat.proof) { + throw new Error( + "Polymer proof unavailable for Tron fill. Try again after attestation is indexed." + ); + } + + // TODO: This function needs rework — receiveMessage must be called on the + // SOURCE chain's oracle, not on Tron. When source is EVM, use walletClient. + const oracleBase58 = toTronAddress(tw, TRON_MAINNET_INPUT_SETTLER); + const oracleContract = await tw.contract( + [...SETTLER_ESCROW_ABI] as Record[], + oracleBase58 + ); + const resultTxId = await oracleContract.receiveMessage(`0x${dat.proof.replace("0x", "")}`).send({ + feeLimit: 150_000_000 + }); + return resultTxId; +} + +export async function readTronOrderStatus(orderId: `0x${string}`): Promise { + const tw = requireTronWeb(); + const settlerBase58 = toTronAddress(tw, TRON_MAINNET_INPUT_SETTLER); + const contract = await tw.contract( + [...SETTLER_ESCROW_ABI] as Record[], + settlerBase58 + ); + const status = await contract.orderStatus(orderId).call(); + return Number(status); +} + +export async function readTronIsProven( + oracleHex: `0x${string}`, + remoteChainId: bigint, + remoteOracle: `0x${string}`, + application: `0x${string}`, + dataHash: `0x${string}` +): Promise { + const tw = requireTronWeb(); + const oracleBase58 = toTronAddress(tw, oracleHex); + const contract = await tw.contract( + [...POLYMER_ORACLE_ABI] as Record[], + oracleBase58 + ); + const result = await contract + .isProven(remoteChainId.toString(), remoteOracle, application, dataHash) + .call(); + return Boolean(result); +} + +// Use only the single-bytes overload to avoid TronLink's ethers.js picking bytes[] +const RECEIVE_MESSAGE_SINGLE_ABI = [ + { + type: "function", + name: "receiveMessage", + inputs: [{ name: "proof", type: "bytes", internalType: "bytes" }], + outputs: [], + stateMutability: "nonpayable" + } +] as const; + +export async function submitTronReceiveMessage( + oracleHex: `0x${string}`, + proof: string +): Promise { + const tw = requireTronWeb(); + const oracleBase58 = toTronAddress(tw, oracleHex); + const contract = await tw.contract( + [...RECEIVE_MESSAGE_SINGLE_ABI] as Record[], + oracleBase58 + ); + + const proofBytes = `0x${proof.replace("0x", "")}`; + + const txId = await contract.receiveMessage(proofBytes).send({ feeLimit: 150_000_000 }); + return txId; +} + +export async function claimTronIntent(args: { + orderContainer: OrderContainer; + fillTimestamps: number[]; + account: `0x${string}`; +}): Promise { + const tw = requireTronWeb(); + const { orderContainer, fillTimestamps, account } = args; + const { order } = orderContainer; + const intent = containerToIntent(orderContainer); + + // TronLink's ethers.js v6 mangles localName for all coders in nested tuples, + // so named objects fail. Pass solveParams as positional arrays [timestamp, solver]. + const solveParams = fillTimestamps.map((timestamp) => [ + Math.floor(timestamp), + addressToBytes32(account) + ]); + + if (!("originChainId" in order)) { + throw new Error("Tron claim only supports single-chain (StandardOrder) intents"); + } + + const settlerBase58 = toTronAddress(tw, TRON_MAINNET_INPUT_SETTLER); + const settlerContract = await tw.contract( + [...SETTLER_ESCROW_ABI] as Record[], + settlerBase58 + ); + + // TronLink's ethers.js v6 replaces `address` type coders with Tron-specific + // ones that lose their localName, so encoding a named object fails with + // "cannot encode object for signature with missing names". Pass as a + // positional array (index-based encoding) and convert address fields to + // Tron base58 format. + const orderTuple = [ + toTronAddress(tw, order.user), // user (address) + order.nonce, // nonce (uint256) + order.originChainId, // originChainId (uint256) + order.expires, // expires (uint32) + order.fillDeadline, // fillDeadline (uint32) + toTronAddress(tw, order.inputOracle), // inputOracle (address) + order.inputs, // inputs (uint256[2][]) + order.outputs.map((o: MandateOutput) => [ + o.oracle, + o.settler, + o.chainId, + o.token, + o.amount, + o.recipient, + o.callbackData, + o.context + ]) + ]; + + const txId = await settlerContract + .finalise(orderTuple, solveParams, addressToBytes32(account), "0x") + .send({ feeLimit: 150_000_000 }); + + return txId; +} diff --git a/src/lib/utils/chainType.ts b/src/lib/utils/chainType.ts new file mode 100644 index 0000000..171a7f9 --- /dev/null +++ b/src/lib/utils/chainType.ts @@ -0,0 +1,64 @@ +import { TRON_MAINNET_CHAIN_ID } from "@lifi/intent"; + +export type ChainType = "evm" | "tron"; + +const TRON_CHAIN_IDS = new Set([Number(TRON_MAINNET_CHAIN_ID)]); + +export function getChainType(chainId: number | bigint): ChainType { + if (TRON_CHAIN_IDS.has(Number(chainId))) return "tron"; + return "evm"; +} + +export function isTronChain(chainId: number | bigint): boolean { + return TRON_CHAIN_IDS.has(Number(chainId)); +} + +export function isEvmChain(chainId: number | bigint): boolean { + return !isTronChain(chainId); +} + +const BASE58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; + +export function hexToTronBase58(hex: `0x${string}`): string { + if (typeof window !== "undefined" && window.tronWeb) { + return window.tronWeb.address.fromHex("0x" + hex.replace("0x", "")); + } + // Fallback: Base58 encode with 0x41 prefix (without checksum — display only) + const addressHex = "41" + hex.replace("0x", ""); + return encodeBase58(hexToBytes(addressHex)); +} + +export function isTronBase58Address(value: string): boolean { + return /^T[1-9A-HJ-NP-Za-km-z]{33}$/.test(value); +} + +export function formatAddressForChain(address: `0x${string}`, chainId: number | bigint): string { + if (isTronChain(chainId)) return hexToTronBase58(address); + return address; +} + +function hexToBytes(hex: string): Uint8Array { + const bytes = new Uint8Array(hex.length / 2); + for (let i = 0; i < hex.length; i += 2) { + bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16); + } + return bytes; +} + +function encodeBase58(bytes: Uint8Array): string { + let num = 0n; + for (const byte of bytes) { + num = num * 256n + BigInt(byte); + } + let result = ""; + while (num > 0n) { + const remainder = Number(num % 58n); + num = num / 58n; + result = BASE58_ALPHABET[remainder] + result; + } + for (const byte of bytes) { + if (byte === 0) result = "1" + result; + else break; + } + return result; +} diff --git a/src/lib/utils/tronlink.ts b/src/lib/utils/tronlink.ts new file mode 100644 index 0000000..be7cea4 --- /dev/null +++ b/src/lib/utils/tronlink.ts @@ -0,0 +1,93 @@ +import { browser } from "$app/environment"; + +export type TronWalletConnection = { + status: "connected" | "disconnected"; + address?: string; + hexAddress?: `0x${string}`; +}; + +export function isTronLinkAvailable(): boolean { + if (!browser) return false; + return !!(window.tronLink || window.tronWeb); +} + +export function getTronWeb(): TronWeb | undefined { + if (!browser) return undefined; + return window.tronWeb ?? window.tronLink?.tronWeb; +} + +export function getTronConnection(): TronWalletConnection { + const tw = getTronWeb(); + if (!tw?.ready || !tw.defaultAddress?.base58) { + return { status: "disconnected" }; + } + const hex = tw.defaultAddress.hex; + return { + status: "connected", + address: tw.defaultAddress.base58, + hexAddress: `0x${hex.replace(/^(41|0x)/, "")}` as `0x${string}` + }; +} + +export async function connectTronLink(): Promise { + if (!browser) return { status: "disconnected" }; + + const tronLink = window.tronLink; + if (!tronLink) { + throw new Error("TronLink is not installed"); + } + + // Check if already connected before requesting + const existing = getTronConnection(); + if (existing.status === "connected") return existing; + + const result = await tronLink.request({ method: "tron_requestAccounts" }); + + // TronLink may take a moment to populate tronWeb after approval + await new Promise((r) => setTimeout(r, 500)); + + const conn = getTronConnection(); + if (conn.status === "connected") return conn; + + if (result.code === 4001) { + throw new Error("User rejected the connection request"); + } + throw new Error(result.message ?? "TronLink connection failed"); +} + +export function disconnectTronLink(): void { + // TronLink doesn't have a programmatic disconnect — clear local state only +} + +export function watchTronConnection(onChange: (conn: TronWalletConnection) => void): () => void { + if (!browser) return () => {}; + + let prev = getTronConnection(); + + const onMessage = (e: MessageEvent) => { + if (e.data?.message?.action === "setAccount" || e.data?.message?.action === "setNode") { + const next = getTronConnection(); + if (next.status !== prev.status || next.hexAddress !== prev.hexAddress) { + prev = next; + onChange(next); + } + } + }; + + // TronLink communicates account changes via window messages + window.addEventListener("message", onMessage); + + // Also poll for changes (some TronLink versions don't emit messages reliably) + const interval = setInterval(() => { + const next = getTronConnection(); + if (next.status !== prev.status || next.hexAddress !== prev.hexAddress) { + prev = next; + onChange(next); + } + }, 2000); + + return () => { + window.removeEventListener("message", onMessage); + clearInterval(interval); + }; +} diff --git a/tests/unit/tronSupport.test.ts b/tests/unit/tronSupport.test.ts new file mode 100644 index 0000000..1eb5d6d --- /dev/null +++ b/tests/unit/tronSupport.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, test } from "bun:test"; +import { + isTronChain, + isEvmChain, + getChainType, + isTronBase58Address +} from "../../src/lib/utils/chainType"; +import { TRON_MAINNET_CHAIN_ID } from "@lifi/intent"; + +describe("chainType", () => { + test("isTronChain returns true for Tron mainnet chain ID", () => { + expect(isTronChain(728126428)).toBe(true); + expect(isTronChain(728126428n)).toBe(true); + expect(isTronChain(Number(TRON_MAINNET_CHAIN_ID))).toBe(true); + }); + + test("isTronChain returns false for EVM chains", () => { + expect(isTronChain(1)).toBe(false); + expect(isTronChain(8453)).toBe(false); + expect(isTronChain(42161)).toBe(false); + }); + + test("isEvmChain is inverse of isTronChain", () => { + expect(isEvmChain(1)).toBe(true); + expect(isEvmChain(728126428)).toBe(false); + }); + + test("getChainType returns correct type", () => { + expect(getChainType(1)).toBe("evm"); + expect(getChainType(728126428)).toBe("tron"); + }); +}); + +describe("isTronBase58Address", () => { + test("validates Tron Base58Check addresses", () => { + expect(isTronBase58Address("TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t")).toBe(true); + expect(isTronBase58Address("TEkxiTehnzSmSe2XqrBj4w32RUN966rdz8")).toBe(true); + }); + + test("rejects non-Tron addresses", () => { + expect(isTronBase58Address("0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913")).toBe(false); + expect(isTronBase58Address("")).toBe(false); + expect(isTronBase58Address("short")).toBe(false); + }); +}); + +describe("Tron namespace routing", () => { + test("toCoreTokenContext routes Tron chain IDs to tron namespace", async () => { + const { tron } = await import("viem/chains"); + expect(tron.id).toBe(728126428); + }); +}); From 0932c9397319e1a025e16a638a64d0f94c85c8eb Mon Sep 17 00:00:00 2001 From: Alexander Date: Fri, 8 May 2026 16:52:39 +0200 Subject: [PATCH 02/11] feat: integrate Tron chain support across app - config.ts: add Tron chain config, TRON_MAINNET_INPUT/OUTPUT_SETTLER - app.d.ts: add TronWeb type declarations - state.svelte.ts: add TronLink account tracking, accountForChain() - intentFactory.ts: use intent.inputSettler (not store.inputSettler) to correctly save TRON_MAINNET_INPUT_SETTLER for Tron source intents - flowProgress.ts: add Tron branch in isInputChainFinalised() - intentExecution.ts: route Tron source intents to openTronEscrowIntent - ConnectWallet.svelte: show TronLink connect option - FillIntent.svelte: Tron-aware isFilled() via readTronFillRecord - ReceiveMessage.svelte: Tron-aware receipt/timestamp in isValidated() - Finalise.svelte: handle Tron source chain claim - IssueIntent.svelte: handle Tron input token approval/open - +page.svelte: guard account() for disconnected TronLink - FlowStepTracker.svelte: display Tron chain steps - intent.ts: containerToIntent dispatches to Tron intent type - vite.config.ts: add TronLink global polyfill Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .env.example | 1 + src/app.d.ts | 48 +++++++++++ src/lib/components/ui/FlowStepTracker.svelte | 6 +- src/lib/config.ts | 86 ++++++++++++++++++-- src/lib/libraries/flowProgress.ts | 7 +- src/lib/libraries/intentExecution.ts | 13 ++- src/lib/libraries/intentFactory.ts | 25 +++++- src/lib/screens/ConnectWallet.svelte | 38 +++++++++ src/lib/screens/FillIntent.svelte | 24 ++++-- src/lib/screens/Finalise.svelte | 11 ++- src/lib/screens/IssueIntent.svelte | 15 +++- src/lib/screens/ReceiveMessage.svelte | 45 ++++++---- src/lib/state.svelte.ts | 66 +++++++++++++-- src/lib/utils/intent.ts | 4 + src/routes/+page.svelte | 21 +++-- vite.config.ts | 1 + 16 files changed, 356 insertions(+), 55 deletions(-) diff --git a/.env.example b/.env.example index e2a3a71..d396f85 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,4 @@ PUBLIC_WALLET_CONNECT_PROJECT_ID= +PUBLIC_ROUTEMESH_API_KEY= PRIVATE_POLYMER_MAINNET_ZONE_API_KEY= PRIVATE_POLYMER_TESTNET_ZONE_API_KEY= diff --git a/src/app.d.ts b/src/app.d.ts index 3a0a4bf..5b6a884 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -11,6 +11,54 @@ declare global { interface BigInt { toJSON(): string; } + + interface TronWebDefaultAddress { + base58: string; + hex: string; + } + + interface TronWebContract { + at(address: string): Promise; + } + + interface TronWebContractFactory { + (): TronWebContract; + (abi: Record[], address: string): Promise; + } + + interface TronWebContractInstance { + [method: string]: (...args: unknown[]) => { + send: (opts?: Record) => Promise; + call: (opts?: Record) => Promise; + }; + } + + interface TronWeb { + ready: boolean; + defaultAddress: TronWebDefaultAddress; + trx: { + getBalance(address: string): Promise; + getTransactionInfo(txId: string): Promise>; + sign(message: string): Promise; + }; + contract: TronWebContractFactory; + toHex(value: string): string; + address: { + fromHex(hex: string): string; + toHex(base58: string): string; + }; + } + + interface TronLink { + ready: boolean; + request(args: { method: string }): Promise<{ code: number; message?: string }>; + tronWeb: TronWeb; + } + + interface Window { + tronWeb?: TronWeb; + tronLink?: TronLink; + } } export {}; diff --git a/src/lib/components/ui/FlowStepTracker.svelte b/src/lib/components/ui/FlowStepTracker.svelte index 434ed6b..e07ebb7 100644 --- a/src/lib/components/ui/FlowStepTracker.svelte +++ b/src/lib/components/ui/FlowStepTracker.svelte @@ -47,7 +47,7 @@ selectedOrder; selectedOutputFillHashSignature; - if (!store.connectedAccount || !store.walletClient || !selectedOrder) { + if (!store.anyWalletConnected || !selectedOrder) { flowChecks = { allFilled: false, allValidated: false, @@ -74,7 +74,7 @@ }); const progressSteps = $derived.by(() => { - const connected = !!store.connectedAccount && !!store.walletClient; + const connected = store.anyWalletConnected; if (!connected) { return [ { @@ -216,7 +216,7 @@ }); const progressConnectorPosition = $derived.by(() => { - if (!store.connectedAccount || !store.walletClient) return 0; + if (!store.anyWalletConnected) return 0; const maxIndex = Math.max(progressSteps.length - 1, 0); return Math.max(0, Math.min(scrollStepProgress, maxIndex)); }); diff --git a/src/lib/config.ts b/src/lib/config.ts index 122936a..3752843 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -1,4 +1,5 @@ import { createPublicClient, createWalletClient, custom, fallback, http } from "viem"; +import type { HttpTransport } from "viem"; import { arbitrum, arbitrumSepolia, @@ -12,8 +13,21 @@ import { katana, megaeth, optimism, - arcTestnet + arcTestnet, + tron } from "viem/chains"; +import { + TRON_MAINNET_INPUT_SETTLER, + TRON_MAINNET_OUTPUT_SETTLER, + tronBase58ToHex +} from "@lifi/intent"; +const routemeshApiKey: string | undefined = + import.meta.env?.PUBLIC_ROUTEMESH_API_KEY?.trim() || undefined; + +function routemeshRpc(chainId: number): HttpTransport[] { + if (!routemeshApiKey) return []; + return [http(`https://lb.routeme.sh/rpc/${chainId}/${routemeshApiKey}`)]; +} export const ADDRESS_ZERO = "0x0000000000000000000000000000000000000000" as const; export const BYTES32_ZERO = @@ -28,6 +42,7 @@ export const MULTICHAIN_INPUT_SETTLER_COMPACT = export const ALWAYS_OK_ALLOCATOR = "281773970620737143753120258" as const; export const POLYMER_ALLOCATOR = "116450367070547927622991121" as const; // 0x02ecC89C25A5DCB1206053530c58E002a737BD11 signing by 0x934244C8cd6BeBDBd0696A659D77C9BDfE86Efe6 export const COIN_FILLER = "0x0000000000eC36B683C2E6AC89e9A75989C22a2e" as const; +export { TRON_MAINNET_INPUT_SETTLER, TRON_MAINNET_OUTPUT_SETTLER }; export const WORMHOLE_ORACLE: Partial> = { [ethereum.id]: "0x0000000000000000000000000000000000000000", [arbitrum.id]: "0x0000000000000000000000000000000000000000", @@ -41,6 +56,7 @@ export const POLYMER_ORACLE: Partial> = { [katana.id]: "0x0000003E06000007A224AeE90052fA6bb46d43C9", [polygon.id]: "0x0000003E06000007A224AeE90052fA6bb46d43C9", [bsc.id]: "0x0000003E06000007A224AeE90052fA6bb46d43C9", + [tron.id]: "0xfa5fabd73c86e1822fda06418c332800c0d7d73b", // testnet [sepolia.id]: "0xe15b438C6267B0011aDa1e40fD8757Aa8Fe1E5a0", [baseSepolia.id]: "0xe15b438C6267B0011aDa1e40fD8757Aa8Fe1E5a0", @@ -67,13 +83,23 @@ export const chainMap = { megaeth, bsc, polygon, - arcTestnet + arcTestnet, + tron } as const; type ChainName = keyof typeof chainMap; export const chains = Object.keys(chainMap) as ChainName[]; export const chainList = (mainnet: boolean) => { if (mainnet == true) { - return ["ethereum", "base", "arbitrum", "megaeth", "katana", "polygon", "bsc"] as ChainName[]; + return [ + "ethereum", + "base", + "arbitrum", + "megaeth", + "katana", + "polygon", + "bsc", + "tron" + ] as ChainName[]; } else return [ "sepolia", @@ -216,6 +242,24 @@ export const coinList = (mainnet: boolean) => { name: "usdc.e", chainId: polygon.id, decimals: 6 + }, + { + address: tronBase58ToHex("TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"), + name: "usdt", + chainId: tron.id, + decimals: 6 + }, + { + address: tronBase58ToHex("TEkxiTehnzSmSe2XqrBj4w32RUN966rdz8"), + name: "usdc", + chainId: tron.id, + decimals: 6 + }, + { + address: ADDRESS_ZERO, + name: "trx", + chainId: tron.id, + decimals: 6 } ] as const; else @@ -340,7 +384,8 @@ export const polymerChainIds = { katana: katana.id, bsc: bsc.id, polygon: polygon.id, - arcTestnet: arcTestnet.id + arcTestnet: arcTestnet.id, + tron: tron.id } as const; export type Verifier = "wormhole" | "polymer"; @@ -432,6 +477,7 @@ export const clients = { ethereum: createPublicClient({ chain: ethereum, transport: fallback([ + ...routemeshRpc(ethereum.id), http("https://ethereum-rpc.publicnode.com"), ...ethereum.rpcUrls.default.http.map((v) => http(v)) ]) @@ -439,6 +485,7 @@ export const clients = { arbitrum: createPublicClient({ chain: arbitrum, transport: fallback([ + ...routemeshRpc(arbitrum.id), http("https://arbitrum-rpc.publicnode.com"), ...arbitrum.rpcUrls.default.http.map((v) => http(v)) ]) @@ -446,6 +493,7 @@ export const clients = { base: createPublicClient({ chain: base, transport: fallback([ + ...routemeshRpc(base.id), http("https://base-rpc.publicnode.com"), ...base.rpcUrls.default.http.map((v) => http(v)) ]) @@ -453,6 +501,7 @@ export const clients = { optimism: createPublicClient({ chain: optimism, transport: fallback([ + ...routemeshRpc(optimism.id), http("https://optimism-rpc.publicnode.com"), ...optimism.rpcUrls.default.http.map((v) => http(v)) ]) @@ -460,6 +509,7 @@ export const clients = { bsc: createPublicClient({ chain: bsc, transport: fallback([ + ...routemeshRpc(bsc.id), http("https://bsc-rpc.publicnode.com"), ...bsc.rpcUrls.default.http.map((v) => http(v)) ]) @@ -467,22 +517,30 @@ export const clients = { polygon: createPublicClient({ chain: base, transport: fallback([ + ...routemeshRpc(polygon.id), http("https://polygon-bor-rpc.publicnode.com"), ...polygon.rpcUrls.default.http.map((v) => http(v)) ]) }), megaeth: createPublicClient({ chain: megaeth, - transport: fallback([...megaeth.rpcUrls.default.http.map((v) => http(v))]) + transport: fallback([ + ...routemeshRpc(megaeth.id), + ...megaeth.rpcUrls.default.http.map((v) => http(v)) + ]) }), katana: createPublicClient({ chain: katana, - transport: fallback([...katana.rpcUrls.default.http.map((v) => http(v))]) + transport: fallback([ + ...routemeshRpc(katana.id), + ...katana.rpcUrls.default.http.map((v) => http(v)) + ]) }), // Testnet sepolia: createPublicClient({ chain: sepolia, transport: fallback([ + ...routemeshRpc(sepolia.id), http("https://ethereum-sepolia-rpc.publicnode.com"), ...sepolia.rpcUrls.default.http.map((v) => http(v)) ]) @@ -490,6 +548,7 @@ export const clients = { arbitrumSepolia: createPublicClient({ chain: arbitrumSepolia, transport: fallback([ + ...routemeshRpc(arbitrumSepolia.id), http("https://arbitrum-sepolia-rpc.publicnode.com"), ...arbitrumSepolia.rpcUrls.default.http.map((v) => http(v)) ]) @@ -497,6 +556,7 @@ export const clients = { baseSepolia: createPublicClient({ chain: baseSepolia, transport: fallback([ + ...routemeshRpc(baseSepolia.id), http("https://base-sepolia-rpc.publicnode.com"), ...baseSepolia.rpcUrls.default.http.map((v) => http(v)) ]) @@ -504,13 +564,25 @@ export const clients = { optimismSepolia: createPublicClient({ chain: optimismSepolia, transport: fallback([ + ...routemeshRpc(optimismSepolia.id), http("https://optimism-sepolia-rpc.publicnode.com"), ...optimismSepolia.rpcUrls.default.http.map((v) => http(v)) ]) }), arcTestnet: createPublicClient({ chain: arcTestnet, - transport: fallback([...arcTestnet.rpcUrls.default.http.map((v) => http(v))]) + transport: fallback([ + ...routemeshRpc(arcTestnet.id), + ...arcTestnet.rpcUrls.default.http.map((v) => http(v)) + ]) + }), + tron: createPublicClient({ + chain: tron, + transport: fallback([ + ...routemeshRpc(tron.id), + ...tron.rpcUrls.default.http.map((v) => http(v, { retryCount: 0 })) + ]), + batch: { multicall: false } }) } as const; diff --git a/src/lib/libraries/flowProgress.ts b/src/lib/libraries/flowProgress.ts index ccdfb29..fe46742 100644 --- a/src/lib/libraries/flowProgress.ts +++ b/src/lib/libraries/flowProgress.ts @@ -94,10 +94,13 @@ async function isOutputValidatedOnChain( } const block = await getOrFetchRpc( - `progress:block:${output.chainId.toString()}:${receipt.blockHash}`, + `progress:block:${output.chainId.toString()}:${(receipt as { blockNumber?: unknown }).blockNumber ?? receipt.blockHash}`, async () => { const outputClient = getClient(output.chainId); - return outputClient.getBlock({ blockHash: receipt.blockHash }); + const blockNumber = (receipt as { blockNumber?: bigint }).blockNumber; + return blockNumber !== undefined + ? outputClient.getBlock({ blockNumber }) + : outputClient.getBlock({ blockHash: receipt.blockHash }); }, { ttlMs: PROGRESS_TTL_MS } ); diff --git a/src/lib/libraries/intentExecution.ts b/src/lib/libraries/intentExecution.ts index 780ecb7..a69c975 100644 --- a/src/lib/libraries/intentExecution.ts +++ b/src/lib/libraries/intentExecution.ts @@ -20,6 +20,8 @@ import { MultichainOrderIntent, StandardEVMIntent } from "@lifi/intent"; import type { NoSignature, Signature } from "@lifi/intent"; import type { TypedDataSigner } from "@lifi/intent"; import { switchWalletChain } from "$lib/utils/walletClientRuntime"; +import { isTronChain } from "$lib/utils/chainType"; +import { openTronEscrowIntent, signTronCompact } from "./tronExecution"; function combineSignatures(signatures: { sponsorSignature: Signature | NoSignature; @@ -37,11 +39,13 @@ export function signIntentCompact( account: `0x${string}`, walletClient: WC ): Promise<`0x${string}`> { - const signer = walletClient as unknown as TypedDataSigner; if (intent instanceof StandardEVMIntent) { const order = intent.asOrder(); + if (isTronChain(order.originChainId)) signTronCompact(); + const signer = walletClient as unknown as TypedDataSigner; return signStandardCompact(account, signer, order.originChainId, intent.asBatchCompact()); } + const signer = walletClient as unknown as TypedDataSigner; const order = intent.asOrder(); return signMultichainCompact( account, @@ -57,6 +61,9 @@ export async function depositAndRegisterCompact( walletClient: WC ): Promise<`0x${string}`> { const order = intent.asOrder(); + if (isTronChain(order.originChainId)) { + throw new Error("Compact deposit and register is not supported for Tron intents"); + } const chain = getChain(order.originChainId); return walletClient.writeContract({ chain, @@ -75,6 +82,10 @@ export async function openEscrowIntent( ): Promise<`0x${string}`[]> { if (intent instanceof StandardEVMIntent) { const order = intent.asOrder(); + if (isTronChain(order.originChainId)) { + const txId = await openTronEscrowIntent(intent, account); + return [`0x${txId.replace("0x", "")}` as `0x${string}`]; + } await switchWalletChain(walletClient, Number(order.originChainId)); const chain = getChain(order.originChainId); return [ diff --git a/src/lib/libraries/intentFactory.ts b/src/lib/libraries/intentFactory.ts index 557381e..f56bdef 100644 --- a/src/lib/libraries/intentFactory.ts +++ b/src/lib/libraries/intentFactory.ts @@ -4,6 +4,7 @@ import { INPUT_SETTLER_COMPACT_LIFI, INPUT_SETTLER_ESCROW_LIFI, MULTICHAIN_INPUT_SETTLER_ESCROW, + TRON_MAINNET_INPUT_SETTLER, type WC } from "$lib/config"; import { encodePacked, maxUint256 } from "viem"; @@ -24,10 +25,12 @@ import { SOLANA_TESTNET_CHAIN_ID, SOLANA_DEVNET_CHAIN_ID } from "@lifi/intent"; +import { isTronChain } from "$lib/utils/chainType"; import type { AppCreateIntentOptions, AppTokenContext } from "$lib/appTypes"; import { ERC20_ABI } from "$lib/abi/erc20"; import { store } from "$lib/state.svelte"; import { depositAndRegisterCompact, openEscrowIntent, signIntentCompact } from "./intentExecution"; +import { approveTronToken } from "./tronExecution"; import { intentDeps } from "./coreDeps"; const SOLANA_CHAIN_IDS = new Set([ @@ -75,7 +78,11 @@ function toCoreTokenContext(input: AppTokenContext): TokenContext { name: input.token.name, chainId, decimals: input.token.decimals, - chainNamespace: SOLANA_CHAIN_IDS.has(chainId) ? "solana" : "eip155" + chainNamespace: SOLANA_CHAIN_IDS.has(chainId) + ? "solana" + : isTronChain(chainId) + ? "tron" + : "eip155" }, amount: input.amount }; @@ -173,6 +180,11 @@ export class IntentFactory { return async () => { const { account, inputTokens } = opts; const inputChain = inputTokens[0].token.chainId; + if (isTronChain(inputChain)) { + throw new Error( + "Compact intents are not supported for Tron — pending protocol decision on signing scheme" + ); + } if (this.preHook) await this.preHook(inputChain); const intentInstance = new Intent(toCoreCreateIntentOptions(opts), intentDeps); applySameChainTimings(intentInstance); @@ -218,6 +230,11 @@ export class IntentFactory { compactDepositAndRegister(opts: AppCreateIntentOptions) { return async () => { const { inputTokens, account } = opts; + if (isTronChain(inputTokens[0].token.chainId)) { + throw new Error( + "Compact intents are not supported for Tron — pending protocol decision on signing scheme" + ); + } const intentInstance2 = new Intent(toCoreCreateIntentOptions(opts), intentDeps); applySameChainTimings(intentInstance2); const sameChain2 = intentInstance2.isSameChain(); @@ -308,6 +325,12 @@ export function escrowApprove( for (let i = 0; i < inputTokens.length; ++i) { const { token, amount } = inputTokens[i]; if (preHook) await preHook(token.chainId); + + if (isTronChain(token.chainId)) { + await approveTronToken(token.address, TRON_MAINNET_INPUT_SETTLER, amount); + continue; + } + const publicClient = getClient(token.chainId); const currentAllowance = await publicClient.readContract({ address: token.address, diff --git a/src/lib/screens/ConnectWallet.svelte b/src/lib/screens/ConnectWallet.svelte index 7461fea..e000792 100644 --- a/src/lib/screens/ConnectWallet.svelte +++ b/src/lib/screens/ConnectWallet.svelte @@ -1,10 +1,13 @@ @@ -37,6 +54,27 @@ {/each} + {#if tronLinkAvailable} + + {:else} +

+ TronLink not detected — install it to use Tron chains. +

+ {/if} + {#if !walletConnectProjectId}

WalletConnect is disabled (missing `PUBLIC_WALLET_CONNECT_PROJECT_ID`). diff --git a/src/lib/screens/FillIntent.svelte b/src/lib/screens/FillIntent.svelte index 11907e9..2271a4b 100644 --- a/src/lib/screens/FillIntent.svelte +++ b/src/lib/screens/FillIntent.svelte @@ -14,6 +14,7 @@ import { containerToIntent } from "$lib/utils/intent"; import { compactTypes } from "@lifi/intent"; import { hashStruct } from "viem"; + import { isTronChain } from "$lib/utils/chainType"; let { scroll, @@ -77,8 +78,16 @@ types: compactTypes, primaryType: "MandateOutput" }); - const isValidFillTxHash = (value: string): value is `0x${string}` => - value.startsWith("0x") && value.length === 66; + const isValidFillTxHash = (value: string, chainId?: bigint): value is `0x${string}` => { + if (value.startsWith("0x") && value.length === 66) return true; + if (chainId !== undefined && isTronChain(chainId) && /^[0-9a-fA-F]{64}$/.test(value)) + return true; + return false; + }; + const normalizeTxHash = (value: string, chainId?: bigint): `0x${string}` => { + if (value.startsWith("0x")) return value as `0x${string}`; + return `0x${value}` as `0x${string}`; + }; const getManualFillTxInputValue = (output: MandateOutput) => { const key = outputKey(output); return manualFillTxInputs[key] ?? store.fillTransactions[key] ?? ""; @@ -86,16 +95,19 @@ const saveManualFillTransaction = async (output: MandateOutput) => { const key = outputKey(output); const txHash = getManualFillTxInputValue(output).trim(); - if (!isValidFillTxHash(txHash)) { - manualFillTxErrors[key] = "Use a 0x-prefixed 66-char tx hash."; + if (!isValidFillTxHash(txHash, output.chainId)) { + manualFillTxErrors[key] = isTronChain(output.chainId) + ? "Use a 64-char hex Tron tx ID or 0x-prefixed hash." + : "Use a 0x-prefixed 66-char tx hash."; manualFillTxSaved[key] = false; return; } manualFillTxSaving[key] = true; manualFillTxErrors[key] = ""; try { - store.fillTransactions[key] = txHash; - await store.saveFillTransaction(key, txHash); + const normalizedHash = normalizeTxHash(txHash, output.chainId); + store.fillTransactions[key] = normalizedHash; + await store.saveFillTransaction(key, normalizedHash); manualFillTxSaved[key] = true; refreshValidation += 1; } catch (error) { diff --git a/src/lib/screens/Finalise.svelte b/src/lib/screens/Finalise.svelte index 87a5f5d..ff60ad3 100644 --- a/src/lib/screens/Finalise.svelte +++ b/src/lib/screens/Finalise.svelte @@ -25,6 +25,8 @@ import { containerToIntent } from "$lib/utils/intent"; import { hashStruct } from "viem"; import { compactTypes } from "@lifi/intent"; + import { isTronChain } from "$lib/utils/chainType"; + import { readTronOrderStatus } from "$lib/libraries/tronSolver"; let { orderContainer, @@ -87,10 +89,15 @@ async function isClaimed(chainId: bigint, container: OrderContainer, _: any) { const { order, inputSettler } = container; - const inputChainClient = getClient(chainId); - const intent = containerToIntent(container); const orderId = intent.orderId(); + + if (isTronChain(chainId)) { + const orderStatus = await readTronOrderStatus(orderId); + return orderStatus === OrderStatus_Claimed || orderStatus === OrderStatus_Refunded; + } + + const inputChainClient = getClient(chainId); // Determine the order type. if ( inputSettler === INPUT_SETTLER_ESCROW_LIFI || diff --git a/src/lib/screens/IssueIntent.svelte b/src/lib/screens/IssueIntent.svelte index d038bb1..7523909 100644 --- a/src/lib/screens/IssueIntent.svelte +++ b/src/lib/screens/IssueIntent.svelte @@ -13,6 +13,8 @@ import { ResetPeriod } from "@lifi/intent"; import type { AppCreateIntentOptions } from "$lib/appTypes"; import { isAddress } from "viem"; + import { isTronBase58Address } from "$lib/utils/chainType"; + import { tronBase58ToHex } from "@lifi/intent"; const bigIntSum = (...nums: bigint[]) => nums.reduce((a, b) => a + b, 0n); const REQUIRED_INPUT_USDC_RAW = 100n; @@ -31,11 +33,16 @@ let inputTokenSelectorActive = $state(false); let outputTokenSelectorActive = $state(false); - const resolveExclusiveFor = (value: string): `0x${string}` | undefined => - isAddress(value, { strict: false }) ? value : undefined; - const resolveRecipient = (value: string): `0x${string}` | undefined => - isAddress(value, { strict: false }) ? value : undefined; + function resolveAddress(value: string): `0x${string}` | undefined { + if (isAddress(value, { strict: false })) return value; + if (isTronBase58Address(value)) return tronBase58ToHex(value); + return undefined; + } + + const resolveExclusiveFor = (value: string): `0x${string}` | undefined => resolveAddress(value); + + const resolveRecipient = (value: string): `0x${string}` | undefined => resolveAddress(value); const intentOptions = $derived.by( (): AppCreateIntentOptions => ({ diff --git a/src/lib/screens/ReceiveMessage.svelte b/src/lib/screens/ReceiveMessage.svelte index 3d2d430..6888b17 100644 --- a/src/lib/screens/ReceiveMessage.svelte +++ b/src/lib/screens/ReceiveMessage.svelte @@ -14,6 +14,8 @@ import store from "$lib/state.svelte"; import { containerToIntent } from "$lib/utils/intent"; import { compactTypes } from "@lifi/intent"; + import { isTronChain } from "$lib/utils/chainType"; + import { readTronIsProven } from "$lib/libraries/tronSolver"; // This script needs to be updated to be able to fetch the associated events of fills. Currently, this presents an issue since it can only fill single outputs. @@ -68,10 +70,9 @@ const transactionReceipt = await outputClient.getTransactionReceipt({ hash: fillTransactionHash }); - const blockHashOfFill = transactionReceipt.blockHash; - const block = await outputClient.getBlock({ - blockHash: blockHashOfFill - }); + const block = await (transactionReceipt.blockNumber !== undefined + ? outputClient.getBlock({ blockNumber: transactionReceipt.blockNumber }) + : outputClient.getBlock({ blockHash: transactionReceipt.blockHash })); const encodedOutput = encodeMandateOutput({ solver: addressToBytes32(transactionReceipt.from), orderId, @@ -79,6 +80,15 @@ output }); const outputHash = keccak256(encodedOutput); + if (isTronChain(chainId)) { + return await readTronIsProven( + order.inputOracle, + output.chainId, + output.oracle, + output.settler, + outputHash + ); + } const sourceChainClient = getClient(chainId); return await sourceChainClient.readContract({ address: order.inputOracle, @@ -142,17 +152,24 @@ ) })) ); - Promise.all(pairs.map(async (pair) => [pair.key, await pair.run()] as const)) - .then((entries) => { - if (currentRun !== validationRun) return; - const nextStatuses: Record = {}; - for (const [key, validated] of entries) nextStatuses[key] = validated; - validationStatuses = nextStatuses; - if (entries.length === 0 || !entries.every(([, validated]) => validated)) return; - autoScrolledOrderId = orderId; - scroll(5)(); + Promise.all( + pairs.map(async (pair) => { + try { + return [pair.key, await pair.run()] as const; + } catch (e) { + console.warn(`validation check failed for ${pair.key}`, e); + return [pair.key, false] as const; + } }) - .catch((e) => console.warn("auto-scroll validation check failed", e)); + ).then((entries) => { + if (currentRun !== validationRun) return; + const nextStatuses: Record = {}; + for (const [key, validated] of entries) nextStatuses[key] = validated; + validationStatuses = nextStatuses; + if (entries.length === 0 || !entries.every(([, validated]) => validated)) return; + autoScrolledOrderId = orderId; + scroll(5)(); + }); }); diff --git a/src/lib/state.svelte.ts b/src/lib/state.svelte.ts index ff64a49..87feb89 100644 --- a/src/lib/state.svelte.ts +++ b/src/lib/state.svelte.ts @@ -12,6 +12,7 @@ import { INPUT_SETTLER_ESCROW_LIFI, MULTICHAIN_INPUT_SETTLER_COMPACT, MULTICHAIN_INPUT_SETTLER_ESCROW, + TRON_MAINNET_INPUT_SETTLER, isChainIdTestnet, type availableAllocators, type Token, @@ -38,6 +39,12 @@ import { watchWalletConnection } from "./utils/wagmi"; import { switchWalletChain } from "./utils/walletClientRuntime"; +import { + type TronWalletConnection, + getTronConnection, + watchTronConnection +} from "./utils/tronlink"; +import { isTronChain } from "./utils/chainType"; function generateUUID(): string { return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => { @@ -222,6 +229,26 @@ class Store { walletClient = $state(undefined as unknown as WC); _unwatchWalletConnection?: () => void; + tronWalletConnection = $state({ status: "disconnected" }); + tronConnectedAccount = $derived( + this.tronWalletConnection.status === "connected" && this.tronWalletConnection.hexAddress + ? { + address: this.tronWalletConnection.hexAddress, + base58Address: this.tronWalletConnection.address! + } + : undefined + ); + _unwatchTronConnection?: () => void; + + anyWalletConnected = $derived( + (!!this.connectedAccount && !!this.walletClient) || !!this.tronConnectedAccount + ); + + accountForChain(chainId: number): `0x${string}` | undefined { + if (isTronChain(chainId)) return this.tronConnectedAccount?.address; + return this.connectedAccount?.address; + } + availableTokens = $state([...(coinList(true) as readonly Token[])]); manualTokenKeys = $state>(new Set()); inputTokens = $state([]); @@ -235,19 +262,25 @@ class Store { balances = $derived.by(() => { this.refreshEpoch; - const account = this.connectedAccount?.address; + const evmAccount = this.connectedAccount?.address; + const tronAccount = this.tronConnectedAccount?.address; return this.mapOverCoinsCached({ bucket: "balance", ttlMs: 30_000, isMainnet: this.mainnet, - scopeKey: account ?? "none", - fetcher: (asset, client) => getBalance(account, asset, client) + scopeKey: `${evmAccount ?? "none"}:${tronAccount ?? "none"}`, + fetcher: (asset, client, chainId) => { + const account = isTronChain(chainId) ? tronAccount : evmAccount; + if (!account) return Promise.resolve(0n); + return getBalance(account, asset, client); + } }); }); allowances = $derived.by(() => { this.refreshEpoch; - const account = this.connectedAccount?.address; + const evmAccount = this.connectedAccount?.address; + const tronAccount = this.tronConnectedAccount?.address; const spender = this.inputSettler === INPUT_SETTLER_COMPACT_LIFI || this.inputSettler === MULTICHAIN_INPUT_SETTLER_COMPACT @@ -257,8 +290,13 @@ class Store { bucket: "allowance", ttlMs: 60_000, isMainnet: this.mainnet, - scopeKey: `${account ?? "none"}:${spender}`, - fetcher: (asset, client) => getAllowance(spender)(account, asset, client) + scopeKey: `${evmAccount ?? "none"}:${tronAccount ?? "none"}:${spender}`, + fetcher: (asset, client, chainId) => { + const account = isTronChain(chainId) ? tronAccount : evmAccount; + if (!account) return Promise.resolve(0n); + const tokenSpender = isTronChain(chainId) ? TRON_MAINNET_INPUT_SETTLER : spender; + return getAllowance(tokenSpender)(account, asset, client); + } }); }); @@ -271,7 +309,10 @@ class Store { ttlMs: 60_000, isMainnet: this.mainnet, scopeKey: `${account ?? "none"}:${allocatorId}`, - fetcher: (asset, client) => getCompactBalance(account, asset, client, allocatorId) + fetcher: (asset, client, chainId) => { + if (isTronChain(chainId)) return Promise.resolve(0n); + return getCompactBalance(account, asset, client, allocatorId); + } }); }); @@ -501,6 +542,7 @@ class Store { } async setWalletToCorrectChain(chainId: number | bigint) { + if (isTronChain(chainId)) return; try { return await switchWalletChain(this.walletClient, Number(chainId)); } catch (error) { @@ -519,7 +561,8 @@ class Store { scopeKey: string; fetcher: ( asset: `0x${string}`, - client: (typeof clientsById)[keyof typeof clientsById] + client: (typeof clientsById)[keyof typeof clientsById], + chainId: number ) => Promise; }) { const { bucket, ttlMs, isMainnet, scopeKey, fetcher } = opts; @@ -529,7 +572,7 @@ class Store { const key = `${bucket}:${isMainnet ? "mainnet" : "testnet"}:${token.chainId}:${token.address}:${scopeKey}`; resolved[token.chainId][token.address] = getOrFetchRpc( key, - () => fetcher(token.address, clientsById[token.chainId]), + () => fetcher(token.address, clientsById[token.chainId], token.chainId), { ttlMs } ); } @@ -555,6 +598,11 @@ class Store { this.walletConnection = connection; this.syncWalletClient().catch((error) => console.warn("syncWalletClient failed", error)); }); + + this.tronWalletConnection = getTronConnection(); + this._unwatchTronConnection = watchTronConnection((connection) => { + this.tronWalletConnection = connection; + }); } this.startRpcRefreshLoop(); diff --git a/src/lib/utils/intent.ts b/src/lib/utils/intent.ts index 8d2f266..28e2cd4 100644 --- a/src/lib/utils/intent.ts +++ b/src/lib/utils/intent.ts @@ -8,6 +8,7 @@ import { MultichainOrderIntent } from "@lifi/intent"; import type { OrderContainer } from "@lifi/intent"; +import { isTronChain } from "./chainType"; const SOLANA_CHAIN_IDS = new Set([ SOLANA_MAINNET_CHAIN_ID, @@ -25,5 +26,8 @@ export function containerToIntent( if (SOLANA_CHAIN_IDS.has(order.originChainId)) { return orderToIntent({ namespace: "solana", inputSettler, order }); } + if (isTronChain(order.originChainId)) { + return orderToIntent({ namespace: "tron", inputSettler, order }); + } return orderToIntent({ namespace: "eip155", inputSettler, order }); } diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 455ead1..cac90f3 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -10,6 +10,7 @@ import ReceiveMessage from "$lib/screens/ReceiveMessage.svelte"; import Finalise from "$lib/screens/Finalise.svelte"; import ConnectWallet from "$lib/screens/ConnectWallet.svelte"; + import WalletStatus from "$lib/components/WalletStatus.svelte"; import FlowStepTracker from "$lib/components/ui/FlowStepTracker.svelte"; import store from "$lib/state.svelte"; import { containerToIntent } from "$lib/utils/intent"; @@ -95,7 +96,10 @@ // --- Execute Transaction Variables --- // const preHook = (chainId: number) => store.setWalletToCorrectChain(chainId); const postHook = async () => store.forceUpdate(); - const account = () => store.connectedAccount?.address!; + const account = () => { + const inputChainId = store.inputTokens[0]?.token.chainId; + return store.accountForChain(inputChainId) ?? store.connectedAccount?.address!; + }; let selectedOrder = $state(undefined); let currentScreenIndex = $state(0); @@ -168,9 +172,14 @@

-

- Resource lock intents using OIF -

+
+

+ Resource lock intents using OIF +

+ {#if store.anyWalletConnected} + + {/if} +
@@ -194,7 +203,7 @@ Preview by LI.FI - {#if !(!store.connectedAccount || !store.walletClient)} + {#if store.anyWalletConnected}