import { ethers } from "https://esm.sh/ethers@6.13.4"; import { Options } from "https://esm.sh/@layerzerolabs/lz-v2-utilities@3.0.85"; // ========= CONSTANTS ========= const ADDRS = { BASE_OFT: "0xFbA669C72b588439B29F050b93500D8b645F9354", BASE_ROUTER: "0x480C0d523511dd96A65A38f36aaEF69aC2BaA82a", LINEA_ADAPTER: "0x54B4E88E9775647614440Acc8B13A079277fa2A6", DTC_LINEA: "0xEb1fD1dBB8aDDA4fa2b5A5C4bcE34F6F20d125D2", }; const CHAINS = { BASE: { chainId: 8453, name: "Base", eid: 30184, currency: "ETH" }, LINEA: { chainId: 59144, name: "Linea", eid: 30183, currency: "ETH" }, }; // ========= ABIs ========= const ERC20_ABI = [ "function symbol() view returns (string)", "function decimals() view returns (uint8)", "function balanceOf(address) view returns (uint256)", "function allowance(address owner, address spender) view returns (uint256)", "function approve(address spender, uint256 amount) returns (bool)", ]; const OFT_ABI = [ "function peers(uint32 eid) view returns (bytes32)", "function quoteSend((uint32 dstEid,bytes32 to,uint256 amountLD,uint256 minAmountLD,bytes extraOptions,bytes composeMsg,bytes oftCmd) p, bool payInLzToken) view returns ((uint256 nativeFee, uint256 lzTokenFee) msgFee)", "function send((uint32 dstEid,bytes32 to,uint256 amountLD,uint256 minAmountLD,bytes extraOptions,bytes composeMsg,bytes oftCmd) p, (uint256 nativeFee, uint256 lzTokenFee) fee, address refundAddress) payable returns ((bytes32 guid, uint64 nonce, (uint256 nativeFee, uint256 lzTokenFee) fee) msgReceipt, (uint256 amountSentLD,uint256 amountReceivedLD) oftReceipt)", ]; // Base->Linea must use router.bridgeToLinea const ROUTER_ABI = [ "function bridgeToLinea(address to, uint256 amountLD) payable", ]; // ========= UI ========= const $ = (id) => document.getElementById(id); const logEl = $("log"); function log(msg) { const t = new Date().toLocaleTimeString(); logEl.textContent += `[${t}] ${msg}\n`; logEl.scrollTop = logEl.scrollHeight; } function short(addr) { if (!addr) return "—"; return addr.slice(0, 6) + "..." + addr.slice(-4); } function toBytes32Address(addr) { return ethers.zeroPadValue(addr, 32); } function percentToMin(amountBN, slippagePct) { const slip = Number(slippagePct); const bps = Math.max(0, Math.min(100, slip)) * 100; // 0..10000 const keepBps = 10000 - Math.round(bps); return (amountBN * BigInt(keepBps)) / 10000n; } function buildOptionsBytes(lzGas) { const gas = Number(lzGas); if (!Number.isFinite(gas) || gas <= 0) throw new Error("Invalid lzGas"); return Options.newOptions().addExecutorLzReceiveOption(gas, 0).toBytes(); } function requireEthereum() { if (!window.ethereum) { alert("MetaMask not found. Install MetaMask and refresh."); throw new Error("No window.ethereum"); } return window.ethereum; } // ========= Provider state ========= let browserProvider = null; let signer = null; let userAddress = null; function installChainListeners() { const eth = requireEthereum(); // Ethers v6 + wallet_switchEthereumChain often triggers NETWORK_ERROR if you keep old provider. // Easiest stable behavior: hard reload on chain/account change. eth.removeAllListeners?.("chainChanged"); eth.on?.("chainChanged", () => window.location.reload()); eth.removeAllListeners?.("accountsChanged"); eth.on?.("accountsChanged", () => window.location.reload()); } async function connect() { const eth = requireEthereum(); installChainListeners(); await eth.request({ method: "eth_requestAccounts" }); browserProvider = new ethers.BrowserProvider(eth); signer = await browserProvider.getSigner(); userAddress = await signer.getAddress(); $("walletLine").textContent = `Connected: ${short(userAddress)} (${userAddress})`; log(`Connected: ${userAddress}`); await refreshAll(); } async function getChainId() { const net = await browserProvider.getNetwork(); return Number(net.chainId); } function getDirection() { return $("direction").value; } // ========= IMPORTANT: Asymmetric direction meta ========= function directionMeta() { const dir = getDirection(); if (dir === "LINEA_TO_BASE") { return { from: CHAINS.LINEA, to: CHAINS.BASE, token: ADDRS.DTC_LINEA, approveSpender: ADDRS.LINEA_ADAPTER, quoteOn: ADDRS.LINEA_ADAPTER, executeType: "OFT_SEND", executeOn: ADDRS.LINEA_ADAPTER, }; } // BASE_TO_LINEA return { from: CHAINS.BASE, to: CHAINS.LINEA, token: ADDRS.BASE_OFT, // approval only helps AFTER router is fixed with transferFrom; still safe to keep approveSpender: ADDRS.BASE_ROUTER, quoteOn: ADDRS.BASE_OFT, executeType: "ROUTER_BRIDGE", executeOn: ADDRS.BASE_ROUTER, }; } async function switchNetwork(chainIdDec) { const eth = requireEthereum(); const hex = "0x" + chainIdDec.toString(16); log(`Switching network to chainId=${chainIdDec}...`); try { await eth.request({ method: "wallet_switchEthereumChain", params: [{ chainId: hex }] }); } catch (e) { if (e && (e.code === 4902 || String(e.message || "").includes("Unrecognized chain"))) { const params = (chainIdDec === CHAINS.LINEA.chainId) ? { chainId: hex, chainName: "Linea", nativeCurrency: { name: "Ether", symbol: "ETH", decimals: 18 }, rpcUrls: ["https://rpc.linea.build"], blockExplorerUrls: ["https://lineascan.build"] } : { chainId: hex, chainName: "Base", nativeCurrency: { name: "Ether", symbol: "ETH", decimals: 18 }, rpcUrls: ["https://mainnet.base.org"], blockExplorerUrls: ["https://basescan.org"] }; await eth.request({ method: "wallet_addEthereumChain", params: [params] }); await eth.request({ method: "wallet_switchEthereumChain", params: [{ chainId: hex }] }); } else { throw e; } } } async function getTokenMeta(tokenAddr) { const token = new ethers.Contract(tokenAddr, ERC20_ABI, browserProvider); const [sym, dec] = await Promise.all([token.symbol(), token.decimals()]); return { token, sym, dec: Number(dec) }; } function parseAmount(amountStr, decimals) { if (!amountStr || isNaN(Number(amountStr))) throw new Error("Invalid amount"); return ethers.parseUnits(amountStr, decimals); } function formatAmount(amountBN, decimals) { return ethers.formatUnits(amountBN, decimals); } function peerBytes32ToAddr(peerBytes32) { return ethers.getAddress("0x" + peerBytes32.slice(26)); } async function readPeer(contractAddr, dstEid) { const c = new ethers.Contract(contractAddr, OFT_ABI, browserProvider); const peerB32 = await c.peers(dstEid); return { peerB32, peerAddr: peerBytes32ToAddr(peerB32) }; } // ========= Quote ========= async function quote() { if (!signer) throw new Error("Not connected"); const meta = directionMeta(); const chainId = await getChainId(); if (chainId !== meta.from.chainId) { log(`Wrong network. Switching to ${meta.from.name}...`); await switchNetwork(meta.from.chainId); return null; } const { token, sym, dec } = await getTokenMeta(meta.token); const amountStr = $("amount").value.trim(); const slipStr = $("slippage").value.trim(); const lzGas = $("lzGas").value.trim(); const amountLD = parseAmount(amountStr, dec); const minAmountLD = percentToMin(amountLD, slipStr); const sendParam = { dstEid: meta.to.eid, to: toBytes32Address(userAddress), amountLD, minAmountLD, extraOptions: ethers.hexlify(buildOptionsBytes(lzGas)), composeMsg: "0x", oftCmd: "0x", }; // Show peer best-effort try { const peer = await readPeer(meta.quoteOn, meta.to.eid); $("peerLine").textContent = `${peer.peerAddr} (bytes32: ${peer.peerB32})`; log(`Diag: peer=${peer.peerAddr}`); } catch (e) { $("peerLine").textContent = `Peer read failed`; log(`Peer read failed: ${e?.shortMessage || e?.message || e}`); } const q = new ethers.Contract(meta.quoteOn, OFT_ABI, browserProvider); log(`Quote: ${meta.from.name} → ${meta.to.name} quoteOn=${meta.quoteOn} amount=${amountStr} min=${formatAmount(minAmountLD, dec)} ${sym}`); const res = await q.quoteSend(sendParam, false); const nativeFee = res.msgFee.nativeFee; $("feeLine").textContent = `${ethers.formatEther(nativeFee)} ${meta.from.currency}`; log(`Native fee: ${ethers.formatEther(nativeFee)} ${meta.from.currency}`); $("spenderLine").textContent = meta.approveSpender; $("senderLine").textContent = meta.executeOn; return { meta, token, sym, dec, amountLD, minAmountLD, sendParam, nativeFee }; } // ========= Approve ========= async function approveIfNeeded() { if (!signer) throw new Error("Not connected"); const meta = directionMeta(); const chainId = await getChainId(); if (chainId !== meta.from.chainId) { log(`Wrong network. Switching to ${meta.from.name}...`); await switchNetwork(meta.from.chainId); return false; } const { token, sym, dec } = await getTokenMeta(meta.token); const amountStr = $("amount").value.trim(); const amountLD = parseAmount(amountStr, dec); const allowance = await token.allowance(userAddress, meta.approveSpender); if (allowance >= amountLD) { log(`Approve not needed. allowance=${formatAmount(allowance, dec)} ${sym}`); return true; } const tokenWithSigner = token.connect(signer); log(`Approving ${amountStr} ${sym} to spender=${meta.approveSpender}...`); const tx = await tokenWithSigner.approve(meta.approveSpender, amountLD); log(`Approve tx: ${tx.hash}`); await tx.wait(); log(`Approve confirmed.`); return true; } // ========= Router balance warning (Base->Linea) ========= async function updateRouterBalanceLine(amountLDNeeded = null) { const meta = directionMeta(); const chainId = await getChainId(); // Only relevant on Base side when using router bridge if (meta.executeType !== "ROUTER_BRIDGE" || chainId !== CHAINS.BASE.chainId) { $("routerBalLine").textContent = "—"; return; } const { token, sym, dec } = await getTokenMeta(ADDRS.BASE_OFT); const routerBal = await token.balanceOf(ADDRS.BASE_ROUTER); $("routerBalLine").textContent = `${formatAmount(routerBal, dec)} ${sym}`; if (amountLDNeeded != null && routerBal < amountLDNeeded) { log(`⚠️ Router has insufficient ${sym}. router=${formatAmount(routerBal, dec)} needed=${formatAmount(amountLDNeeded, dec)}.`); log(`Temporary workaround: transfer ${sym} to router address first: ${ADDRS.BASE_ROUTER}`); log(`Permanent fix: redeploy router to transferFrom(msg.sender, router, amount) before calling oft.send.`); } } // ========= Send (asymmetric execution) ========= async function send() { if (!signer) throw new Error("Not connected"); const q = await quote(); if (!q) { log(`Switched network. Click Quote again.`); return; } await approveIfNeeded(); if (q.meta.executeType === "OFT_SEND") { // Linea -> Base: adapter.send const c = new ethers.Contract(q.meta.executeOn, OFT_ABI, signer); const fee = { nativeFee: q.nativeFee, lzTokenFee: 0n }; log(`Sending (Linea→Base) via adapter.send with value=${ethers.formatEther(q.nativeFee)} ETH...`); const tx = await c.send(q.sendParam, fee, userAddress, { value: q.nativeFee }); log(`Send tx: ${tx.hash}`); const rcpt = await tx.wait(); log(`Send confirmed (source). status=${rcpt.status}. Delivery is async.`); return; } if (q.meta.executeType === "ROUTER_BRIDGE") { // Base -> Linea: router.bridgeToLinea await updateRouterBalanceLine(q.amountLD); const r = new ethers.Contract(q.meta.executeOn, ROUTER_ABI, signer); log(`Sending (Base→Linea) via router.bridgeToLinea(to=${short(userAddress)}, amount=${formatAmount(q.amountLD, q.dec)} DTC) value=${ethers.formatEther(q.nativeFee)} ETH...`); const tx = await r.bridgeToLinea(userAddress, q.amountLD, { value: q.nativeFee }); log(`Router tx: ${tx.hash}`); const rcpt = await tx.wait(); log(`Router confirmed (source). status=${rcpt.status}. Delivery is async.`); return; } throw new Error("Unknown executeType"); } // ========= Refresh ========= async function refreshAll() { if (!signer) return; const meta = directionMeta(); const chainId = await getChainId(); $("netLine").textContent = `Network: ${chainId}`; $("spenderLine").textContent = meta.approveSpender; $("senderLine").textContent = meta.executeOn; try { const { token, sym, dec } = await getTokenMeta(meta.token); const bal = await token.balanceOf(userAddress); $("balLine").textContent = `Balance: ${formatAmount(bal, dec)} ${sym}`; // Update router balance line if relevant await updateRouterBalanceLine(null); } catch (e) { log(`Refresh error: ${e?.shortMessage || e?.message || e}`); } } // ========= Wire up ========= $("btnConnect").onclick = () => connect().catch(e => log(`Connect failed: ${e?.message || e}`)); $("btnSwitchLinea").onclick = () => switchNetwork(CHAINS.LINEA.chainId).catch(e => log(`Switch failed: ${e?.message || e}`)); $("btnSwitchBase").onclick = () => switchNetwork(CHAINS.BASE.chainId).catch(e => log(`Switch failed: ${e?.message || e}`)); $("btnRefresh").onclick = () => refreshAll(); $("btnQuote").onclick = () => quote().catch(e => log(`Quote failed: ${e?.shortMessage || e?.message || e}`)); $("btnApprove").onclick = () => approveIfNeeded().catch(e => log(`Approve failed: ${e?.shortMessage || e?.message || e}`)); $("btnSend").onclick = () => send().catch(e => log(`Send failed: ${e?.shortMessage || e?.message || e}`)); $("btnClear").onclick = () => { logEl.textContent = ""; }; $("direction").onchange = () => { $("feeLine").textContent = "—"; refreshAll(); }; window.addEventListener("load", () => { $("spenderLine").textContent = "—"; $("senderLine").textContent = "—"; $("peerLine").textContent = "—"; $("feeLine").textContent = "—"; $("routerBalLine").textContent = "—"; log(`Ready. Click "Connect Wallet".`); });