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/bun.lock b/bun.lock index b2a90a1..f88ff68 100644 --- a/bun.lock +++ b/bun.lock @@ -8,7 +8,7 @@ "@base-org/account": "^2.5.1", "@coinbase/wallet-sdk": "^4.3.6", "@electric-sql/pglite": "^0.3.15", - "@lifi/intent": "file:../intent.ts", + "@lifi/intent": "0.1.6", "@metamask/sdk": "^0.34.0", "@safe-global/safe-apps-provider": "~0.18.6", "@safe-global/safe-apps-sdk": "^9.1.0", @@ -231,7 +231,7 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.29", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ=="], - "@lifi/intent": ["@lifi/intent@file:../intent.ts", { "dependencies": { "borsh": "^2.0.0", "ky": "^1.12.0", "viem": "~2.45.1" }, "devDependencies": { "@types/bun": "^1.3.8", "bun": "^1.3.5", "husky": "9.1.7", "lint-staged": "16.1.0", "prettier": "3.4.2", "typescript": "^5.6.3" } }], + "@lifi/intent": ["@lifi/intent@0.1.6", "", { "dependencies": { "borsh": "^2.0.0", "ky": "^1.12.0", "viem": "~2.45.1" } }, "sha512-3y2LYTHenYNAlcafcmZL0EtbbrZtUpBbpDC499IrjVTYjbPKsDrvHFnmnypsNc7sYWhNgGjUFQr0MdCb5GkRUQ=="], "@lit-labs/ssr-dom-shim": ["@lit-labs/ssr-dom-shim@1.5.1", "", {}, "sha512-Aou5UdlSpr5whQe8AA/bZG0jMj96CoJIWbGfZ91qieWu5AWUMKw8VR/pAkQkJYvBNhmCcWnZlyyk5oze8JIqYA=="], @@ -279,30 +279,6 @@ "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], - "@oven/bun-darwin-aarch64": ["@oven/bun-darwin-aarch64@1.3.13", "", { "os": "darwin", "cpu": "arm64" }, "sha512-qAS6Hg8Q14ckfBuqJ2Zh7gBQSVSUHeibSq4OFqBTv6DzyJuxYlr0sdYQzmYmnbPxbqobekqUDTa/4XEaqRi7vg=="], - - "@oven/bun-darwin-x64": ["@oven/bun-darwin-x64@1.3.13", "", { "os": "darwin", "cpu": "x64" }, "sha512-kGePeDD4IN4imo+H4uLjQGZLmvyYQg+nKr2P0nt4ksXXrWA4HE+mb0/TUPHfRI127DocXQpew+fvrHuHR5mpJQ=="], - - "@oven/bun-darwin-x64-baseline": ["@oven/bun-darwin-x64-baseline@1.3.13", "", { "os": "darwin", "cpu": "x64" }, "sha512-gMEQayUpmCPYaE9zkNBj9TiQqHupnhjOYcuSzxFjzIjHJBUO4VjNnrpbKVeXNs+rKHFothORDd2QKquu5paSPQ=="], - - "@oven/bun-linux-aarch64": ["@oven/bun-linux-aarch64@1.3.13", "", { "os": "linux", "cpu": "arm64" }, "sha512-NbLOJdr+RBFO1vFZ2YUFg4oVJ+2ua6zrwo4ZWRs0jKKcGJWtbY2wY5uz+i0PkwH6b9HYaYDgVTzE4ev06ncYZw=="], - - "@oven/bun-linux-aarch64-musl": ["@oven/bun-linux-aarch64-musl@1.3.13", "", { "os": "linux", "cpu": "arm64" }, "sha512-UV9EE18VE5aRhWtV2L6MTAGGn3slhJJ2OW/m+FJM15maHm0qf1V7TaZY0FovxhdQRvnklSiQ7Ntv0H5TUX4w0g=="], - - "@oven/bun-linux-x64": ["@oven/bun-linux-x64@1.3.13", "", { "os": "linux", "cpu": "x64" }, "sha512-UwttIUXoe9fS+40OcjoaRHgZw+HCPFqBVWEXkXqAJ3W7wA0XPZrWsoMAD9sGh3TaLqrwdiMo5xPogwpXhOtVXA=="], - - "@oven/bun-linux-x64-baseline": ["@oven/bun-linux-x64-baseline@1.3.13", "", { "os": "linux", "cpu": "x64" }, "sha512-fOi4ziKzgJG4UrrNd4AicBs6Fu9GY5xOqg+9tC76nuZNDAdSh6++kzab6TNi1Ck0Yzq6zIBIdGit6/0uSbBn8A=="], - - "@oven/bun-linux-x64-musl": ["@oven/bun-linux-x64-musl@1.3.13", "", { "os": "linux", "cpu": "x64" }, "sha512-+VHhE44kEjCXcTFHyc81zfTxL9+vzh9RqIh7gM1iWNhxpctD9kzntbUkP3UTFTwwNjoou1o8VRyxQafvc4OepA=="], - - "@oven/bun-linux-x64-musl-baseline": ["@oven/bun-linux-x64-musl-baseline@1.3.13", "", { "os": "linux", "cpu": "x64" }, "sha512-fqBKuiiWLEu2dVkowZaXgKS98xfrvBqivdoxRtRP3eINcpI1dcelGbsOz+Xphn7tbGAuBiE1/0AelvvvdqS9rg=="], - - "@oven/bun-windows-aarch64": ["@oven/bun-windows-aarch64@1.3.13", "", { "os": "win32", "cpu": "arm64" }, "sha512-+EvdRWRCRg95Xea4M2lqSJFTjzQBTJDQTMlbG8bmwFkVTN16MdmSH7xhfxVQWUOyZBLEpIwuNFIlBBxVCwSUyQ=="], - - "@oven/bun-windows-x64": ["@oven/bun-windows-x64@1.3.13", "", { "os": "win32", "cpu": "x64" }, "sha512-vqDEFX63ZZQF3YstPSpPD+RxNm5AILPdUuuKpNwsj7ld4NjhdHUYkAmLXDtKNWt9JMRL10bop//W8faY/LV+RQ=="], - - "@oven/bun-windows-x64-baseline": ["@oven/bun-windows-x64-baseline@1.3.13", "", { "os": "win32", "cpu": "x64" }, "sha512-6gy4hhQSjq/T/S9hC9m3NxY0RY+9Ww+XNlB+8koIMTsMSYEjk7Ho+hFHQz1Bn4W61Ub7Vykufg+jgDgPfa2GFA=="], - "@paulmillr/qr": ["@paulmillr/qr@0.2.1", "", {}, "sha512-IHnV6A+zxU7XwmKFinmYjUcwlyK9+xkG3/s9KcQhI9BjQKycrJ1JRO+FbNYPwZiPKW3je/DR0k7w8/gLa5eaxQ=="], "@phosphor-icons/webcomponents": ["@phosphor-icons/webcomponents@2.1.5", "", { "dependencies": { "lit": "^3" } }, "sha512-JcvQkZxvcX2jK+QCclm8+e8HXqtdFW9xV4/kk2aL9Y3dJA2oQVt+pzbv1orkumz3rfx4K9mn9fDoMr1He1yr7Q=="], @@ -691,8 +667,6 @@ "bufferutil": ["bufferutil@4.1.0", "", { "dependencies": { "node-gyp-build": "^4.3.0" } }, "sha512-ZMANVnAixE6AWWnPzlW2KpUrxhm9woycYvPOo67jWHyFowASTEd9s+QN1EIMsSDtwhIxN4sWE1jotpuDUIgyIw=="], - "bun": ["bun@1.3.13", "", { "optionalDependencies": { "@oven/bun-darwin-aarch64": "1.3.13", "@oven/bun-darwin-x64": "1.3.13", "@oven/bun-darwin-x64-baseline": "1.3.13", "@oven/bun-linux-aarch64": "1.3.13", "@oven/bun-linux-aarch64-musl": "1.3.13", "@oven/bun-linux-x64": "1.3.13", "@oven/bun-linux-x64-baseline": "1.3.13", "@oven/bun-linux-x64-musl": "1.3.13", "@oven/bun-linux-x64-musl-baseline": "1.3.13", "@oven/bun-windows-aarch64": "1.3.13", "@oven/bun-windows-x64": "1.3.13", "@oven/bun-windows-x64-baseline": "1.3.13" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ], "bin": { "bun": "bin/bun.exe", "bunx": "bin/bunx.exe" } }, "sha512-b9T4xZ8KqCHs4+TkHJv540LG1B8OD7noKu0Qaizusx3jFtMDHY6osNqgbaOlwW2B8RB2AKzz+sjzlGKIGxIjZw=="], - "bun-types": ["bun-types@1.3.8", "", { "dependencies": { "@types/node": "*" } }, "sha512-fL99nxdOWvV4LqjmC+8Q9kW3M4QTtTR1eePs94v5ctGqU8OeceWrSUaRw3JYb7tU3FkMIAjkueehrHPPPGKi5Q=="], "call-bind": ["call-bind@1.0.8", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.0", "es-define-property": "^1.0.0", "get-intrinsic": "^1.2.4", "set-function-length": "^1.2.2" } }, "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww=="], diff --git a/package.json b/package.json index 8916358..855c563 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,7 @@ "@base-org/account": "^2.5.1", "@coinbase/wallet-sdk": "^4.3.6", "@electric-sql/pglite": "^0.3.15", - "@lifi/intent": "file:../intent.ts", + "@lifi/intent": "0.1.6", "@metamask/sdk": "^0.34.0", "@safe-global/safe-apps-provider": "~0.18.6", "@safe-global/safe-apps-sdk": "^9.1.0", diff --git a/src/app.d.ts b/src/app.d.ts index 3a0a4bf..7ea7ca5 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -11,6 +11,58 @@ 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>; + getTransaction(txId: string): Promise>; + getBlock(blockNumber: number): Promise<{ + block_header: { raw_data: { timestamp: number | string } }; + }>; + 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/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/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 2d5e2c8..386ed7b 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -1,4 +1,5 @@ import { createPublicClient, createWalletClient, custom, defineChain, 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 pharos = defineChain({ id: 1672, @@ -37,6 +51,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", @@ -51,6 +66,7 @@ export const POLYMER_ORACLE: Partial> = { [polygon.id]: "0x0000003E06000007A224AeE90052fA6bb46d43C9", [bsc.id]: "0x0000003E06000007A224AeE90052fA6bb46d43C9", [pharos.id]: "0x0000003E06000007A224AeE90052fA6bb46d43C9", + [tron.id]: "0x1d586aa1bd8ea3fda890057bad5a7d373886dbc1", // testnet [sepolia.id]: "0xe15b438C6267B0011aDa1e40fD8757Aa8Fe1E5a0", [baseSepolia.id]: "0xe15b438C6267B0011aDa1e40fD8757Aa8Fe1E5a0", @@ -78,7 +94,8 @@ export const chainMap = { bsc, polygon, pharos, - arcTestnet + arcTestnet, + tron } as const; type ChainName = keyof typeof chainMap; export const chains = Object.keys(chainMap) as ChainName[]; @@ -92,7 +109,8 @@ export const chainList = (mainnet: boolean) => { "katana", "polygon", "bsc", - "pharos" + "pharos", + "tron" ] as ChainName[]; } else return [ @@ -236,6 +254,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 @@ -361,7 +397,8 @@ export const polymerChainIds = { bsc: bsc.id, polygon: polygon.id, pharos: pharos.id, - arcTestnet: arcTestnet.id + arcTestnet: arcTestnet.id, + tron: tron.id } as const; export type Verifier = "wormhole" | "polymer"; @@ -453,6 +490,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)) ]) @@ -460,6 +498,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)) ]) @@ -467,6 +506,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)) ]) @@ -474,6 +514,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)) ]) @@ -481,6 +522,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)) ]) @@ -488,17 +530,24 @@ 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)) + ]) }), pharos: createPublicClient({ chain: pharos, @@ -508,6 +557,7 @@ export const clients = { sepolia: createPublicClient({ chain: sepolia, transport: fallback([ + ...routemeshRpc(sepolia.id), http("https://ethereum-sepolia-rpc.publicnode.com"), ...sepolia.rpcUrls.default.http.map((v) => http(v)) ]) @@ -515,6 +565,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)) ]) @@ -522,6 +573,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)) ]) @@ -529,13 +581,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/coreDeps.ts b/src/lib/libraries/coreDeps.ts index 947449d..5c43b87 100644 --- a/src/lib/libraries/coreDeps.ts +++ b/src/lib/libraries/coreDeps.ts @@ -3,6 +3,7 @@ import { INPUT_SETTLER_COMPACT_LIFI, MULTICHAIN_INPUT_SETTLER_COMPACT, POLYMER_ORACLE, + TRON_MAINNET_OUTPUT_SETTLER, WORMHOLE_ORACLE } from "$lib/config"; import type { IntentDeps, OrderContainerValidationDeps } from "@lifi/intent"; @@ -42,13 +43,17 @@ export const orderValidationDeps: OrderContainerValidationDeps = { if (!Number.isFinite(key)) return undefined; const polymer = POLYMER_ORACLE[key]; const wormhole = WORMHOLE_ORACLE[key]; - const allowed: `0x${string}`[] = []; - if (polymer) allowed.push(polymer); + if (!polymer && !isNonZeroAddress(wormhole)) return undefined; + // For Polymer cross-chain, output.oracle is the INPUT chain's oracle, + // so all Polymer oracle addresses are valid. + const allPolymerOracles = [ + ...new Set(Object.values(POLYMER_ORACLE).filter((v): v is `0x${string}` => !!v)) + ]; + const allowed: `0x${string}`[] = [...allPolymerOracles]; if (isNonZeroAddress(wormhole)) allowed.push(wormhole); - if (allowed.length === 0) return undefined; return allowed; }, allowedOutputSettlers() { - return [COIN_FILLER]; + return [COIN_FILLER, TRON_MAINNET_OUTPUT_SETTLER]; } }; diff --git a/src/lib/libraries/flowProgress.ts b/src/lib/libraries/flowProgress.ts index ccdfb29..e443acd 100644 --- a/src/lib/libraries/flowProgress.ts +++ b/src/lib/libraries/flowProgress.ts @@ -19,6 +19,15 @@ import { containerToIntent } from "$lib/utils/intent"; import { getOrFetchRpc } from "$lib/libraries/rpcCache"; import type { MandateOutput, OrderContainer } from "@lifi/intent"; import store from "$lib/state.svelte"; +import { isTronChain } from "$lib/utils/chainType"; +import { getTronBlockTimestamp } from "$lib/libraries/tronExecution"; +import { + readTronIsProven, + readTronIsOutputFilled, + getTronTransactionFrom, + readTronOrderStatus, + getTronTransactionInfo +} from "$lib/libraries/tronSolver"; const PROGRESS_TTL_MS = 30_000; const OrderStatus_Claimed = 2; @@ -44,11 +53,18 @@ function isValidHash(hash: string | undefined): hash is `0x${string}` { async function isOutputFilled(orderId: `0x${string}`, output: MandateOutput) { const outputKey = getOutputStorageKey(output); + const outputHash = getOutputHash(output); + if (isTronChain(output.chainId)) { + return getOrFetchRpc( + `progress:filled:${orderId}:${outputKey}`, + () => readTronIsOutputFilled(orderId, output.settler, outputHash), + { ttlMs: PROGRESS_TTL_MS } + ); + } return getOrFetchRpc( `progress:filled:${orderId}:${outputKey}`, async () => { const outputClient = getClient(output.chainId); - const outputHash = getOutputHash(output); const result = await outputClient.readContract({ address: bytes32ToAddress(output.settler), abi: COIN_FILLER_ABI, @@ -69,49 +85,94 @@ async function isOutputValidatedOnChain( fillTransactionHash: `0x${string}` ) { const outputKey = getOutputStorageKey(output); - const cachedReceipt = store.getTransactionReceipt(output.chainId, fillTransactionHash); - const receipt = ( - cachedReceipt - ? cachedReceipt - : await getOrFetchRpc( - `progress:receipt:${output.chainId.toString()}:${fillTransactionHash}`, - async () => { - const outputClient = getClient(output.chainId); - return outputClient.getTransactionReceipt({ - hash: fillTransactionHash - }); - }, - { ttlMs: PROGRESS_TTL_MS } - ) - ) as { - blockHash: `0x${string}`; - from: `0x${string}`; - }; - if (!cachedReceipt) { - store - .saveTransactionReceipt(output.chainId, fillTransactionHash, receipt) - .catch((error) => console.warn("saveTransactionReceipt error", error)); + + let from: `0x${string}`; + let blockNumber: bigint | undefined; + let blockHashFallback: `0x${string}` | undefined; + + if (isTronChain(output.chainId)) { + const tronTxId = fillTransactionHash.replace("0x", ""); + const [txInfo, fromHex] = await Promise.all([ + getOrFetchRpc(`progress:troninfo:${tronTxId}`, () => getTronTransactionInfo(tronTxId), { + ttlMs: PROGRESS_TTL_MS + }), + getOrFetchRpc(`progress:tronfrom:${tronTxId}`, () => getTronTransactionFrom(tronTxId), { + ttlMs: PROGRESS_TTL_MS + }) + ]); + blockNumber = BigInt(Number(txInfo.blockNumber)); + from = fromHex; + } else { + const cachedReceipt = store.getTransactionReceipt(output.chainId, fillTransactionHash); + const receipt = ( + cachedReceipt + ? cachedReceipt + : await getOrFetchRpc( + `progress:receipt:${output.chainId.toString()}:${fillTransactionHash}`, + async () => { + const outputClient = getClient(output.chainId); + return outputClient.getTransactionReceipt({ hash: fillTransactionHash }); + }, + { ttlMs: PROGRESS_TTL_MS } + ) + ) as { blockHash: `0x${string}`; from: `0x${string}`; blockNumber?: bigint }; + if (!cachedReceipt) { + store + .saveTransactionReceipt(output.chainId, fillTransactionHash, receipt) + .catch((error) => console.warn("saveTransactionReceipt error", error)); + } + from = receipt.from; + blockNumber = receipt.blockNumber; + blockHashFallback = receipt.blockHash; } - const block = await getOrFetchRpc( - `progress:block:${output.chainId.toString()}:${receipt.blockHash}`, - async () => { - const outputClient = getClient(output.chainId); - return outputClient.getBlock({ blockHash: receipt.blockHash }); - }, - { ttlMs: PROGRESS_TTL_MS } - ); + let timestamp: number; + if (isTronChain(output.chainId)) { + timestamp = await getOrFetchRpc( + `progress:block:${output.chainId.toString()}:${blockNumber}`, + () => getTronBlockTimestamp(Number(blockNumber)), + { ttlMs: PROGRESS_TTL_MS } + ); + } else { + const block = await getOrFetchRpc( + `progress:block:${output.chainId.toString()}:${blockNumber ?? blockHashFallback}`, + async () => { + const outputClient = getClient(output.chainId); + return blockNumber !== undefined + ? outputClient.getBlock({ blockNumber }) + : outputClient.getBlock({ blockHash: blockHashFallback! }); + }, + { ttlMs: PROGRESS_TTL_MS } + ); + timestamp = Number(block.timestamp); + } const encodedOutput = encodeMandateOutput({ - solver: addressToBytes32(receipt.from), + solver: addressToBytes32(from), orderId, - timestamp: Number(block.timestamp), + timestamp, output }); const outputHash = keccak256(encodedOutput); + const provenCacheKey = `progress:proven:${orderId}:${inputChain.toString()}:${outputKey}:${fillTransactionHash}`; + if (isTronChain(inputChain)) { + return getOrFetchRpc( + provenCacheKey, + () => + readTronIsProven( + orderContainer.order.inputOracle, + output.chainId, + output.oracle, + output.settler, + outputHash + ), + { ttlMs: PROGRESS_TTL_MS } + ); + } + return getOrFetchRpc( - `progress:proven:${orderId}:${inputChain.toString()}:${outputKey}:${fillTransactionHash}`, + provenCacheKey, async () => { const sourceChainClient = getClient(inputChain); return sourceChainClient.readContract({ @@ -127,10 +188,22 @@ async function isOutputValidatedOnChain( async function isInputChainFinalised(chainId: bigint, container: OrderContainer) { const { order, inputSettler } = container; - const inputChainClient = getClient(chainId); const intent = containerToIntent(container); const orderId = intent.orderId(); + if (isTronChain(chainId)) { + return getOrFetchRpc( + `progress:finalised:tron:${orderId}`, + async () => { + const status = await readTronOrderStatus(orderId); + return status === OrderStatus_Claimed || status === OrderStatus_Refunded; + }, + { ttlMs: PROGRESS_TTL_MS } + ); + } + + const inputChainClient = getClient(chainId); + if ( inputSettler === INPUT_SETTLER_ESCROW_LIFI || inputSettler === MULTICHAIN_INPUT_SETTLER_ESCROW 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..e96c82c 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(); @@ -284,7 +301,7 @@ export class IntentFactory { await this.saveOrder({ order: intent.asOrder(), - inputSettler: store.inputSettler + inputSettler: intent.inputSettler }); return transactionHashes; @@ -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/libraries/rpcCache.ts b/src/lib/libraries/rpcCache.ts index 1167031..7679d1f 100644 --- a/src/lib/libraries/rpcCache.ts +++ b/src/lib/libraries/rpcCache.ts @@ -5,6 +5,7 @@ type CacheEntry = { const cache = new Map>(); const inflight = new Map>(); +const errorBackoff = new Map(); // key -> backoff expiry timestamp const stats = { hits: 0, @@ -12,6 +13,22 @@ const stats = { inflightJoins: 0 }; +function is429(error: unknown): boolean { + if (!error) return false; + if (error instanceof Error && error.message.includes("429")) return true; + if (typeof error === "object") { + const e = error as Record; + if (e.status === 429) return true; + if ( + typeof e.response === "object" && + e.response !== null && + (e.response as Record).status === 429 + ) + return true; + } + return false; +} + export function getRpcCacheStats() { return { ...stats }; } @@ -19,11 +36,13 @@ export function getRpcCacheStats() { export function clearRpcCache() { cache.clear(); inflight.clear(); + errorBackoff.clear(); } export function invalidateRpcKey(key: string) { cache.delete(key); inflight.delete(key); + errorBackoff.delete(key); } export function invalidateRpcPrefix(prefix: string) { @@ -33,6 +52,9 @@ export function invalidateRpcPrefix(prefix: string) { for (const key of inflight.keys()) { if (key.startsWith(prefix)) inflight.delete(key); } + for (const key of errorBackoff.keys()) { + if (key.startsWith(prefix)) errorBackoff.delete(key); + } } export async function getOrFetchRpc( @@ -54,14 +76,25 @@ export async function getOrFetchRpc( stats.inflightJoins += 1; return pending; } + const backoffExpiry = errorBackoff.get(key); + if (backoffExpiry && backoffExpiry > now) { + return Promise.reject(new Error("RPC rate limited, backing off")); + } } stats.misses += 1; const request = fetcher() .then((value) => { cache.set(key, { value, expiresAt: Date.now() + ttlMs }); + errorBackoff.delete(key); return value; }) + .catch((error) => { + if (is429(error)) { + errorBackoff.set(key, Date.now() + 15_000); + } + throw error; + }) .finally(() => { inflight.delete(key); }); diff --git a/src/lib/libraries/solver.ts b/src/lib/libraries/solver.ts index 42817dc..818b9f9 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}`, @@ -58,10 +85,12 @@ export class Solver { preHook?: (chainId: number) => Promise; postHook?: () => Promise; account: () => `0x${string}`; + solver?: () => `0x${string}`; } ) { return async () => { - const { preHook, postHook, account } = opts; + const { preHook, postHook, account, solver } = opts; + const solverAddress = solver ? solver() : account(); const { orderContainer: { order, inputSettler }, outputs @@ -69,8 +98,14 @@ 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(), solverAddress); + 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); const connectedChainId = await walletClient.getChainId(); const expectedChainId = outputChain.id; @@ -91,7 +126,6 @@ export class Solver { throw new Error("Different settlers on outputs, not supported"); } - // Check allowance & set allowance if needed const assetAddress = bytes32ToAddress(output.token); const allowance = await getClient(outputChain.id).readContract({ address: assetAddress, @@ -122,13 +156,12 @@ export class Solver { value, abi: COIN_FILLER_ABI, functionName: "fillOrderOutputs", - args: [orderId, outputs, order.fillDeadline, addressToBytes32(account())] + args: [orderId, outputs, order.fillDeadline, addressToBytes32(solverAddress)] }); const fillReceipt = await getClient(outputChain.id).waitForTransactionReceipt({ hash: transactionHash }); await Solver.persistReceipt(outputs[0].chainId, transactionHash, fillReceipt); - // orderInputs.validate[index] = transactionHash; if (postHook) await postHook(); return transactionHash; }; @@ -168,6 +201,15 @@ export class Solver { if (existingValidation) return existingValidation; const validationPromise = (async () => { + console.log("[validate] start", { + sourceChainId: Number(sourceChainId), + outputChainId: Number(output.chainId), + fillTransactionHash, + expectedOutputHash, + inputOracle: order.inputOracle, + mainnet + }); + if ( !fillTransactionHash || !fillTransactionHash.startsWith("0x") || @@ -176,17 +218,39 @@ export class Solver { throw new Error(`Invalid fill transaction hash: ${fillTransactionHash}`); } - // Get the output filled event. - const transactionReceipt = await Solver.getReceiptCachedOrRpc( - output.chainId, - fillTransactionHash as `0x${string}` - ); + // Always fetch fresh receipt from RPC — cached receipts may have + // transaction-local logIndex values instead of block-global ones, + // which breaks Polymer proof requests. + console.log("[validate] fetching fresh receipt from output chain", Number(output.chainId)); + const transactionReceipt = await getClient(output.chainId).getTransactionReceipt({ + hash: fillTransactionHash as `0x${string}` + }); + console.log("[validate] receipt", { + blockNumber: Number(transactionReceipt.blockNumber), + logsCount: transactionReceipt.logs.length, + logIndices: transactionReceipt.logs.map((l) => l.logIndex), + from: transactionReceipt.from, + status: transactionReceipt.status + }); const logs = parseEventLogs({ abi: COIN_FILLER_ABI, eventName: "OutputFilled", logs: transactionReceipt.logs }); + console.log( + "[validate] OutputFilled logs found:", + logs.length, + logs.map((l) => ({ + logIndex: l.logIndex, + outputHash: hashStruct({ + types: compactTypes, + primaryType: "MandateOutput", + data: l.args.output + }) + })) + ); + // We need to search through each log until we find one matching our output. let logIndex = -1; for (const log of logs) { @@ -202,13 +266,28 @@ export class Solver { break; } } + console.log( + "[validate] matched logIndex:", + logIndex, + "expectedOutputHash:", + expectedOutputHash + ); if (logIndex === -1) throw Error(`Could not find matching log`); if (order.inputOracle === getOracle("polymer", sourceChainId)) { + console.log("[validate] using Polymer oracle path"); let proof: string | undefined; const polymerKey = `${Number(output.chainId)}:${Number(transactionReceipt.blockNumber)}:${Number(logIndex)}`; let polymerIndex: number | undefined = Solver.polymerRequestIndexByLog.get(polymerKey); + console.log("[validate] polymer request", { + polymerKey, + cachedPolymerIndex: polymerIndex, + srcChainId: Number(output.chainId), + srcBlockNumber: Number(transactionReceipt.blockNumber), + globalLogIndex: Number(logIndex) + }); for (const waitMs of [1000, 2000, 4000, 8000]) { + console.log("[validate] polling polymer proof (next wait:", waitMs, "ms)"); const response = await axios.post( `/polymer`, { @@ -224,6 +303,11 @@ export class Solver { proof: undefined | string; polymerIndex: number; }; + console.log("[validate] polymer response", { + hasProof: !!dat.proof, + proofLength: dat.proof?.length, + polymerIndex: dat.polymerIndex + }); polymerIndex = dat.polymerIndex; if (polymerIndex !== undefined) { Solver.polymerRequestIndexByLog.set(polymerKey, polymerIndex); @@ -235,32 +319,82 @@ export class Solver { await Solver.sleep(waitMs); } if (proof) { + console.log("[validate] got proof, length:", proof.length); + + if (isTronChain(sourceChainId)) { + console.log("[validate] submitting receiveMessage to Tron", { + inputOracle: order.inputOracle, + proofLength: proof.length + }); + const txId = await submitTronReceiveMessage(order.inputOracle, proof); + console.log("[validate] Tron receiveMessage txId:", txId); + 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] + }); + console.log("[validate] simulating receiveMessage on chain", Number(sourceChainId), { + to: order.inputOracle, + account: account(), + calldataLength: simCalldata.length + }); + try { + await getClient(sourceChainId).call({ + to: order.inputOracle, + data: simCalldata, + account: account() + }); + console.log("[validate] simulation succeeded"); + } catch (simError) { + console.error("[validate] simulation FAILED", simError); + throw new Error( + `receiveMessage simulation failed on chain ${Number(sourceChainId)}: ${Solver.extractRevertReason(simError)}`, + { cause: simError as Error } + ); + } + + console.log("[validate] sending receiveMessage tx on chain", Number(sourceChainId)); 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] }); + console.log("[validate] receiveMessage tx sent:", transactionHash); const result = await getClient(sourceChainId).waitForTransactionReceipt({ hash: transactionHash, timeout: 120_000, pollingInterval: 2_000 }); + console.log("[validate] receiveMessage confirmed, status:", result.status); await Solver.persistReceipt(sourceChainId, transactionHash, result); if (postHook) await postHook(); return result; } + console.warn("[validate] polymer proof unavailable after all retries"); throw new Error( `Polymer proof unavailable for output on ${output.chainId.toString()}. Try again after the fill attestation is indexed.` ); } else if (order.inputOracle === COIN_FILLER) { + console.log("[validate] using COIN_FILLER oracle path"); const log = logs.find((log) => log.logIndex === logIndex); if (!log) throw new Error(`Log with index ${logIndex} not found`); + console.log("[validate] setAttestation args", { + orderId: log.args.orderId, + solver: log.args.solver, + timestamp: log.args.timestamp, + output: log.args.output + }); if (preHook) await preHook(Number(sourceChainId)); const transactionHash = await walletClient.writeContract({ chain: getChain(sourceChainId), @@ -270,12 +404,14 @@ export class Solver { functionName: "setAttestation", args: [log.args.orderId, log.args.solver, log.args.timestamp, log.args.output] }); + console.log("[validate] setAttestation tx sent:", transactionHash); const result = await getClient(sourceChainId).waitForTransactionReceipt({ hash: transactionHash, timeout: 120_000, pollingInterval: 2_000 }); + console.log("[validate] setAttestation confirmed, status:", result.status); await Solver.persistReceipt(sourceChainId, transactionHash, result); if (postHook) await postHook(); return result; @@ -314,6 +450,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 +462,34 @@ 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}`); + 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..5ef39e9 --- /dev/null +++ b/src/lib/libraries/tronExecution.ts @@ -0,0 +1,92 @@ +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. Address-typed fields must also be +// converted to Tron base58 format or TronLink encodes them incorrectly. +function orderToTronTuple(tw: TronWeb, order: EVMOrder): unknown[] { + return [ + toTronAddress(tw, order.user), + order.nonce.toString(), + order.originChainId.toString(), + order.expires, + order.fillDeadline, + toTronAddress(tw, 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 getTronBlockTimestamp(blockNumber: number): Promise { + const tw = requireTronWeb(); + const block = await tw.trx.getBlock(blockNumber); + // Tron block timestamps are milliseconds; convert to seconds for consistency with EVM + return Math.floor(Number(block.block_header.raw_data.timestamp) / 1000); +} + +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(tw, 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..7722b83 --- /dev/null +++ b/src/lib/libraries/tronSolver.ts @@ -0,0 +1,277 @@ +import { getTronWeb } from "$lib/utils/tronlink"; +import { BYTES32_ZERO, 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}`, + solverHex?: `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 fillerContract = await tw.contract( + [...COIN_FILLER_ABI] as Record[], + settlerBase58 + ); + + // TronLink's ethers.js v6 strips localName from coders in nested tuples, + // so named objects fail — pass as positional arrays. + const outputTuples = outputs.map((o) => [ + o.oracle, + o.settler, + o.chainId, + o.token, + o.amount, + o.recipient, + o.callbackData, + o.context + ]); + + const txId = await fillerContract + .fillOrderOutputs( + orderId, + outputTuples, + order.fillDeadline, + addressToBytes32(solverHex ?? 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); +} + +export async function readTronIsOutputFilled( + orderId: `0x${string}`, + outputSettlerBytes32: `0x${string}`, + outputHash: `0x${string}` +): Promise { + const tw = requireTronWeb(); + const settlerBase58 = toTronAddress(tw, bytes32ToAddress(outputSettlerBytes32)); + const contract = await tw.contract( + [...COIN_FILLER_ABI] as Record[], + settlerBase58 + ); + const result = await contract.getFillRecord(orderId, outputHash).call(); + return result !== BYTES32_ZERO; +} + +export async function getTronTransactionFrom(txId: string): Promise<`0x${string}`> { + const tw = requireTronWeb(); + const tx = await tw.trx.getTransaction(txId); + const rawData = tx.raw_data as { + contract: [{ parameter: { value: { owner_address: string } } }]; + }; + const ownerAddress = rawData.contract[0].parameter.value.owner_address; + return `0x${ownerAddress.replace(/^41/, "")}` as `0x${string}`; +} + +// 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/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..8a824e3 100644 --- a/src/lib/screens/FillIntent.svelte +++ b/src/lib/screens/FillIntent.svelte @@ -14,6 +14,8 @@ import { containerToIntent } from "$lib/utils/intent"; import { compactTypes } from "@lifi/intent"; import { hashStruct } from "viem"; + import { isTronChain, isTronBase58Address } from "$lib/utils/chainType"; + import { tronBase58ToHex } from "@lifi/intent"; let { scroll, @@ -37,6 +39,16 @@ let manualFillTxSaving = $state>({}); let manualFillTxSaved = $state>({}); let manualFillTxErrors = $state>({}); + let solverOverride = $state(""); + const parsedSolver = $derived.by((): `0x${string}` | undefined => { + const trimmed = solverOverride.trim(); + if (!trimmed) return undefined; + if (/^0x[0-9a-fA-F]{40}$/.test(trimmed)) return trimmed as `0x${string}`; + if (isTronBase58Address(trimmed)) return tronBase58ToHex(trimmed); + if (/^41[0-9a-fA-F]{40}$/.test(trimmed)) return `0x${trimmed.slice(2)}` as `0x${string}`; + return undefined; + }); + const solverGetter = $derived(parsedSolver ? () => parsedSolver : undefined); const postHookScroll = async () => { await postHook(); refreshValidation += 1; @@ -77,8 +89,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 +106,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) { @@ -156,6 +179,23 @@ description="Fill each chain once and continue to the right. If you refreshed the page provide your fill tx hash in the input box." >

+ +
+ + {#if solverOverride.trim() && !solverGetter} + Invalid address + {:else if solverGetter} + Override active + {:else} + Using connected wallet + {/if} +
+
{#each orderContainer.order.outputs as output} @@ -229,7 +269,8 @@ { preHook, postHook: postHookScroll, - account + account, + solver: solverGetter } ) ) diff --git a/src/lib/screens/Finalise.svelte b/src/lib/screens/Finalise.svelte index 87a5f5d..a52edd7 100644 --- a/src/lib/screens/Finalise.svelte +++ b/src/lib/screens/Finalise.svelte @@ -25,6 +25,9 @@ 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"; + import { getOrFetchRpc } from "$lib/libraries/rpcCache"; let { orderContainer, @@ -87,10 +90,19 @@ 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 getOrFetchRpc( + `claim:tron:${orderId}`, + () => readTronOrderStatus(orderId), + { ttlMs: 30_000 } + ); + return orderStatus === OrderStatus_Claimed || orderStatus === OrderStatus_Refunded; + } + + const inputChainClient = getClient(chainId); // Determine the order type. if ( inputSettler === INPUT_SETTLER_ESCROW_LIFI || @@ -147,7 +159,13 @@ for (const [key, value] of entries) next[key] = value; claimedByChain = next; }) - .catch((e) => console.warn("claim status refresh failed", e)); + .catch((e) => { + console.warn("claim status refresh failed", e); + if (currentRun !== claimStatusRun) return; + const next: Record = {}; + for (const chain of inputChains) next[chain.toString()] = false; + claimedByChain = next; + }); }); diff --git a/src/lib/screens/IntentList.svelte b/src/lib/screens/IntentList.svelte index f216c90..b23e766 100644 --- a/src/lib/screens/IntentList.svelte +++ b/src/lib/screens/IntentList.svelte @@ -156,6 +156,7 @@ class="w-full cursor-pointer rounded border border-gray-200 bg-white px-2 py-2 text-left transition-shadow ease-linear select-none hover:shadow-md focus:outline-none focus-visible:outline-none" style="-webkit-tap-highlight-color: transparent;" onclick={async () => { + console.log("Selected order:", row.orderContainer); selectedOrder = row.orderContainer; await tick(); scroll(3)(); 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..338b6ab 100644 --- a/src/lib/screens/ReceiveMessage.svelte +++ b/src/lib/screens/ReceiveMessage.svelte @@ -14,6 +14,9 @@ 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"; + import { getTronBlockTimestamp } from "$lib/libraries/tronExecution"; // 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,17 +71,31 @@ const transactionReceipt = await outputClient.getTransactionReceipt({ hash: fillTransactionHash }); - const blockHashOfFill = transactionReceipt.blockHash; - const block = await outputClient.getBlock({ - blockHash: blockHashOfFill - }); + let timestamp: number; + if (isTronChain(output.chainId)) { + timestamp = await getTronBlockTimestamp(Number(transactionReceipt.blockNumber)); + } else { + const block = await (transactionReceipt.blockNumber !== undefined + ? outputClient.getBlock({ blockNumber: transactionReceipt.blockNumber }) + : outputClient.getBlock({ blockHash: transactionReceipt.blockHash })); + timestamp = Number(block.timestamp); + } const encodedOutput = encodeMandateOutput({ solver: addressToBytes32(transactionReceipt.from), orderId, - timestamp: Number(block.timestamp), + timestamp, 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 +159,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..3855afd 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,26 @@ 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, + tronTtlMs: 120_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 @@ -256,9 +290,15 @@ class Store { return this.mapOverCoinsCached({ bucket: "allowance", ttlMs: 60_000, + tronTtlMs: 180_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 +311,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 +544,7 @@ class Store { } async setWalletToCorrectChain(chainId: number | bigint) { + if (isTronChain(chainId)) return; try { return await switchWalletChain(this.walletClient, Number(chainId)); } catch (error) { @@ -515,22 +559,25 @@ class Store { mapOverCoinsCached(opts: { bucket: "balance" | "allowance" | "compact"; ttlMs: number; + tronTtlMs?: number; isMainnet: boolean; 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; + const { bucket, ttlMs, tronTtlMs, isMainnet, scopeKey, fetcher } = opts; const resolved: Record>> = {}; for (const token of this.availableTokens) { if (!resolved[token.chainId]) resolved[token.chainId] = {}; const key = `${bucket}:${isMainnet ? "mainnet" : "testnet"}:${token.chainId}:${token.address}:${scopeKey}`; + const effectiveTtl = tronTtlMs && isTronChain(token.chainId) ? tronTtlMs : ttlMs; resolved[token.chainId][token.address] = getOrFetchRpc( key, - () => fetcher(token.address, clientsById[token.chainId]), - { ttlMs } + () => fetcher(token.address, clientsById[token.chainId], token.chainId), + { ttlMs: effectiveTtl } ); } return resolved; @@ -555,6 +602,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/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/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/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/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}