From ca36a0b46fea8a0c429658f4da13c641d3dd3001 Mon Sep 17 00:00:00 2001 From: Igor Date: Thu, 8 Oct 2026 13:50:53 -0500 Subject: [PATCH] feat: on-chain LCAI bridge history with Lightscan links Hyperlane's hosted explorer does not index Lightchain, so the tray's API history was always empty, and Buy & Bridge transfers were never recorded. - history/useBridgeHistory: rebuild the wallet's bridge history from the warp-route events (SentTransferRemote / ReceivedTransferRemote) via the Etherscan logs API (Ethereum/Sepolia) and Lightscan's Blockscout API (Lightchain). Sends are paired with deliveries by amount and order to derive Delivered vs pending and the destination tx hash. Both directions. - SideBarMenu: use on-chain history merged with this browser's local list (de-duplicated by origin tx). Each row links to the Lightscan tx of the delivery (or the origin tx on the other explorer). Drops the Hyperlane GraphQL source and infinite scroll. - ZapForm: record the zap in local history once mined, from the Zapped event, so it appears immediately and upgrades to Delivered on refresh. - TransfersDetailsModal: show the destination tx with its explorer link. - TransferContext: add destinationTxHash. Co-Authored-By: Claude Fable 5.1 --- src/consts/zap.ts | 19 +- src/features/history/useBridgeHistory.ts | 110 +++++++ .../transfer/TransfersDetailsModal.tsx | 13 + src/features/transfer/types.ts | 1 + src/features/wallet/SideBarMenu.tsx | 294 ++++++------------ src/features/zap/ZapForm.tsx | 40 ++- src/features/zap/abi.ts | 18 ++ 7 files changed, 286 insertions(+), 209 deletions(-) create mode 100644 src/features/history/useBridgeHistory.ts diff --git a/src/consts/zap.ts b/src/consts/zap.ts index e033265..12fb079 100644 --- a/src/consts/zap.ts +++ b/src/consts/zap.ts @@ -14,11 +14,20 @@ interface ZapNetworkConfig { quoterV2: Address; swapRouter02: Address; poolFee: number; - collateralRouter: Address; // Hyperlane HypERC20Collateral for LCAI + collateralRouter: Address; // Hyperlane HypERC20Collateral for LCAI (source chain) + nativeRouter: Address; // Hyperlane HypNative for LCAI (Lightchain) explorerTxUrl: (hash: string) => string; // source-chain tx + destinationTxUrl: (hash: string) => string; // Lightscan tx destinationAddressUrl: (address: string) => string; // Lightscan page where the LCAI lands + // Etherscan-compatible `module=logs&action=getLogs` endpoints, used to rebuild bridge history + // without an indexer (Hyperlane's explorer does not cover Lightchain). + sourceLogsApi: string; + destinationLogsApi: string; } +// Public Etherscan key already shipped in chains.ts; Lightscan (Blockscout) needs none. +const ETHERSCAN_API_KEY = 'GP69JEAP2W7YFJT9ZJTEPGQT6Y6KMW44ZN'; + // Mainnet pool: 0x0d047a370611437a1b8e6c2a95ea36f69fdda3be. Route: hyperlane-bridge/lcai/configs. const mainnet: ZapNetworkConfig = { chainId: 1, @@ -33,8 +42,12 @@ const mainnet: ZapNetworkConfig = { swapRouter02: '0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45', poolFee: 3000, collateralRouter: '0x01f80bb8e78e79881E8Ec7832fB6C2c59f64e353', + nativeRouter: '0xEc7096A3116EE769457C939617375Ec1785AA6f1', explorerTxUrl: (hash) => `https://etherscan.io/tx/${hash}`, + destinationTxUrl: (hash) => `https://mainnet.lightscan.app/tx/${hash}`, destinationAddressUrl: (address) => `https://mainnet.lightscan.app/address/${address}`, + sourceLogsApi: `https://api.etherscan.io/v2/api?chainid=1&apikey=${ETHERSCAN_API_KEY}`, + destinationLogsApi: 'https://mainnet.lightscan.app/api', }; // Sepolia pool: 0x46E84fE79693235f98898341e237200e11282e61 (demo liquidity only). Route: hyperlane-bridge/testnet/configs. @@ -51,8 +64,12 @@ const testnet: ZapNetworkConfig = { swapRouter02: '0x3bFA4769FB09eefC5a80d6E87c3B9C650f7Ae48E', poolFee: 3000, collateralRouter: '0xa4d14290Cc01ec798a7852cB1b3b314ea8F992AF', + nativeRouter: '0x5Ff6b59Bf2eB5fD64176650011b0d74E8A55300b', explorerTxUrl: (hash) => `https://sepolia.etherscan.io/tx/${hash}`, + destinationTxUrl: (hash) => `https://testnet.lightscan.app/tx/${hash}`, destinationAddressUrl: (address) => `https://testnet.lightscan.app/address/${address}`, + sourceLogsApi: `https://api.etherscan.io/v2/api?chainid=11155111&apikey=${ETHERSCAN_API_KEY}`, + destinationLogsApi: 'https://testnet.lightscan.app/api', }; export const ZAP = IS_TESTNET ? testnet : mainnet; diff --git a/src/features/history/useBridgeHistory.ts b/src/features/history/useBridgeHistory.ts new file mode 100644 index 0000000..32646e9 --- /dev/null +++ b/src/features/history/useBridgeHistory.ts @@ -0,0 +1,110 @@ +import { useQuery } from '@tanstack/react-query'; +import { formatEther, padHex, toEventSelector, type Address, type Hex } from 'viem'; +import { ZAP } from '../../consts/zap'; +import { logger } from '../../utils/logger'; +import { TransferContext, TransferStatus } from '../transfer/types'; + +/** + * Rebuilds a wallet's LCAI bridge history straight from the Hyperlane warp-route events, + * using Etherscan-compatible log APIs (Etherscan for Ethereum/Sepolia, Lightscan for Lightchain). + * Hyperlane's hosted explorer does not index Lightchain, so this is the only cross-device source. + * + * Covers transfers where the connected wallet is the recipient (the default for Buy & Bridge and + * for self-bridges). Sends to a different recipient are still shown from local history. + */ + +const SENT_TOPIC = toEventSelector('SentTransferRemote(uint32,bytes32,uint256)'); +const RECEIVED_TOPIC = toEventSelector('ReceivedTransferRemote(uint32,bytes32,uint256)'); +const MAX_ITEMS = 50; + +interface RawLog { + transactionHash: Hex; + timeStamp: Hex; + data: Hex; + topics: Hex[]; +} + +interface RouterEvent { + txHash: Hex; + timestamp: number; // seconds + amountWei: bigint; +} + +async function fetchLogs(api: string, router: Address, topic0: Hex, recipientTopic: Hex): Promise { + const sep = api.includes('?') ? '&' : '?'; + const url = `${api}${sep}module=logs&action=getLogs&address=${router}&topic0=${topic0}&topic0_2_opr=and&topic2=${recipientTopic}&fromBlock=0&toBlock=latest`; + const res = await fetch(url); + if (!res.ok) throw new Error(`logs api ${res.status}`); + const json = (await res.json()) as { status: string; message: string; result: RawLog[] | string }; + // Etherscan returns status "0" / "No records found" for an empty set; that's not an error. + if (!Array.isArray(json.result)) { + if (/no records/i.test(json.message)) return []; + throw new Error(`logs api: ${json.message} ${json.result}`); + } + return json.result.map((l) => ({ + txHash: l.transactionHash, + timestamp: Number(BigInt(l.timeStamp)), + amountWei: BigInt(l.data), + })); +} + +function pairUp( + sent: RouterEvent[], + received: RouterEvent[], + origin: string, + destination: string, + wallet: Address, +): TransferContext[] { + // Oldest first so the n-th send of a given amount pairs with the n-th matching receipt. + const sentSorted = [...sent].sort((a, b) => a.timestamp - b.timestamp); + const pool = [...received].sort((a, b) => a.timestamp - b.timestamp); + return sentSorted.map((s) => { + const idx = pool.findIndex((r) => r.amountWei === s.amountWei && r.timestamp >= s.timestamp - 60); + const match = idx >= 0 ? pool.splice(idx, 1)[0] : undefined; + return { + status: match ? TransferStatus.Delivered : TransferStatus.ConfirmedTransfer, + origin, + destination, + amount: formatEther(s.amountWei), + sender: wallet, + recipient: wallet, + originTxHash: s.txHash, + destinationTxHash: match?.txHash, + timestamp: s.timestamp * 1000, + }; + }); +} + +export function useBridgeHistory(wallet: Address | undefined) { + return useQuery({ + queryKey: ['bridgeHistory', ZAP.chainId, wallet], + enabled: !!wallet, + refetchInterval: 30_000, + staleTime: 20_000, + queryFn: async (): Promise => { + if (!wallet) return []; + const me = padHex(wallet.toLowerCase() as Hex, { size: 32 }); + const [toLcSent, toLcRecv, fromLcSent, fromLcRecv] = await Promise.all([ + fetchLogs(ZAP.sourceLogsApi, ZAP.collateralRouter, SENT_TOPIC, me), + fetchLogs(ZAP.destinationLogsApi, ZAP.nativeRouter, RECEIVED_TOPIC, me), + fetchLogs(ZAP.destinationLogsApi, ZAP.nativeRouter, SENT_TOPIC, me), + fetchLogs(ZAP.sourceLogsApi, ZAP.collateralRouter, RECEIVED_TOPIC, me), + ]).catch((err) => { + logger.warn('Bridge history fetch failed', err); + throw err; + }); + const items = [ + ...pairUp(toLcSent, toLcRecv, ZAP.sourceChainName, ZAP.destinationChainName, wallet), + ...pairUp(fromLcSent, fromLcRecv, ZAP.destinationChainName, ZAP.sourceChainName, wallet), + ]; + return items.sort((a, b) => b.timestamp - a.timestamp).slice(0, MAX_ITEMS); + }, + }); +} + +/** Local (this browser) + on-chain history, de-duplicated by origin tx hash. On-chain wins. */ +export function mergeHistory(local: TransferContext[], onchain: TransferContext[]): TransferContext[] { + const seen = new Set(onchain.map((t) => t.originTxHash?.toLowerCase()).filter(Boolean)); + const extra = local.filter((t) => !t.originTxHash || !seen.has(t.originTxHash.toLowerCase())); + return [...onchain, ...extra].sort((a, b) => b.timestamp - a.timestamp); +} diff --git a/src/features/transfer/TransfersDetailsModal.tsx b/src/features/transfer/TransfersDetailsModal.tsx index 1c15585..b4bafb4 100644 --- a/src/features/transfer/TransfersDetailsModal.tsx +++ b/src/features/transfer/TransfersDetailsModal.tsx @@ -16,6 +16,7 @@ import Image from 'next/image'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { ChainLogo } from '../../components/icons/ChainLogo'; import { TokenIcon } from '../../components/icons/TokenIcon'; +import { ZAP } from '../../consts/zap'; import LinkIcon from '../../images/icons/external-link-icon.svg'; import { Color } from '../../styles/Color'; import { formatTimestamp } from '../../utils/date'; @@ -54,6 +55,7 @@ export function TransfersDetailsModal({ recipient, originTokenAddressOrDenom, originTxHash, + destinationTxHash, msgId, timestamp, } = transfer || {}; @@ -204,6 +206,17 @@ export function TransfersDetailsModal({ url={originTxUrl} /> )} + {destinationTxHash && ( + + )} {msgId && } {explorerLink && (
diff --git a/src/features/transfer/types.ts b/src/features/transfer/types.ts index d2e6e04..c221dcf 100644 --- a/src/features/transfer/types.ts +++ b/src/features/transfer/types.ts @@ -34,6 +34,7 @@ export interface TransferContext { sender: Address; recipient: Address; originTxHash?: string; + destinationTxHash?: string; // set once delivery is observed on the destination chain msgId?: string; timestamp: number; } diff --git a/src/features/wallet/SideBarMenu.tsx b/src/features/wallet/SideBarMenu.tsx index ecfe11d..0fb836a 100644 --- a/src/features/wallet/SideBarMenu.tsx +++ b/src/features/wallet/SideBarMenu.tsx @@ -1,27 +1,20 @@ -import type { ChainName } from '@hyperlane-xyz/sdk'; -import { fromWei, normalizeAddress } from '@hyperlane-xyz/utils'; import { AccountList, SpinnerIcon, useAccounts } from '@hyperlane-xyz/widgets'; import Image from 'next/image'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { LuHistory, LuRefreshCw, LuWallet, LuX } from 'react-icons/lu'; +import { useEffect, useMemo, useRef, useState } from 'react'; +import { LuExternalLink, LuHistory, LuRefreshCw, LuWallet, LuX } from 'react-icons/lu'; import { toast } from 'react-toastify'; +import type { Address } from 'viem'; import { ChainLogo } from '../../components/icons/ChainLogo'; import { config } from '../../consts/config'; +import { ZAP } from '../../consts/zap'; import ArrowRightIcon from '../../images/icons/arrow-right.svg'; import { useMultiProvider } from '../chains/hooks'; import { getChainDisplayName } from '../chains/utils'; -import { MessageStatus } from '../messages/types'; -import { - messageToTransferContext, - TransferItem, - TransferItemType, - useMergedTransferHistory, -} from '../messages/useMergedTransferHistory'; -import { useMessageHistory } from '../messages/useMessageHistory'; -import { RouterAddressInfo, useStore } from '../store'; +import { mergeHistory, useBridgeHistory } from '../history/useBridgeHistory'; +import { useStore } from '../store'; import { tryFindToken, useWarpCore } from '../tokens/hooks'; import { TransfersDetailsModal } from '../transfer/TransfersDetailsModal'; -import { TransferContext, TransferStatus } from '../transfer/types'; +import { TransferContext } from '../transfer/types'; import { getIconByTransferStatus, STATUSES_WITH_ICON } from '../transfer/utils'; export function SideBarMenu({ @@ -40,89 +33,40 @@ export function SideBarMenu({ const [selectedTransfer, setSelectedTransfer] = useState(null); const multiProvider = useMultiProvider(); + const warpCore = useWarpCore(); - const { transfers, transferLoading, originChainName, routerAddressesByChainMap } = useStore( - (s) => ({ - transfers: s.transfers, - transferLoading: s.transferLoading, - originChainName: s.originChainName, - routerAddressesByChainMap: s.routerAddressesByChainMap, - }), - ); + const { transfers, transferLoading, originChainName } = useStore((s) => ({ + transfers: s.transfers, + transferLoading: s.transferLoading, + originChainName: s.originChainName, + })); - // Get all connected wallet addresses (normalized for consistent matching) + // Connected EVM wallet (the bridge is EVM-only on both ends) const { accounts } = useAccounts(multiProvider, config.addressBlacklist); - const walletAddresses = useMemo(() => { - const addresses: string[] = []; + const wallet = useMemo(() => { for (const accountInfo of Object.values(accounts)) { - if (accountInfo.addresses) { - for (const addrInfo of accountInfo.addresses) { - if (addrInfo.address) { - addresses.push(normalizeAddress(addrInfo.address)); - } - } - } + const addr = accountInfo.addresses?.[0]?.address; + if (addr) return addr as Address; } - return addresses; + return undefined; }, [accounts]); - // Get all warp route addresses from configured routes (normalized) - const warpRouteAddresses = useMemo(() => { - const addresses: string[] = []; - for (const addressMap of Object.values(routerAddressesByChainMap)) { - for (const addr of Object.keys(addressMap)) { - addresses.push(normalizeAddress(addr)); - } - } - return addresses; - }, [routerAddressesByChainMap]); - - // Fetch message history from API - const { messages, isLoading, isRefreshing, hasMore, loadMore, refresh } = useMessageHistory( - walletAddresses, - warpRouteAddresses, - multiProvider, - ); - - // Merge local transfers with API messages - const warpCore = useWarpCore(); - const allMergedTransfers = useMergedTransferHistory(transfers, messages); - - // Filter out API messages with unknown tokens + // LCAI bridge history straight from the warp-route events, merged with this browser's local list + const history = useBridgeHistory(wallet); + const isLoading = history.isLoading; + const isRefreshing = history.isFetching && !history.isLoading; + const refresh = () => void history.refetch(); const mergedTransfers = useMemo( - () => - allMergedTransfers.filter((item) => { - if (item.type === TransferItemType.Local) return true; - const originChain = multiProvider.tryGetChainName(item.data.originDomainId); - if (!originChain) return false; - return !!tryFindToken(warpCore, originChain, item.data.sender); - }), - [allMergedTransfers, multiProvider, warpCore], + () => mergeHistory(transfers, history.data ?? []), + [transfers, history.data], ); - // Infinite scroll handler - const handleScroll = useCallback(() => { - const container = scrollContainerRef.current; - if (!container || isLoading || !hasMore) return; - - const { scrollTop, scrollHeight, clientHeight } = container; - if (scrollHeight - scrollTop - clientHeight < 100) { - loadMore(); - } - }, [isLoading, hasMore, loadMore]); - const onCopySuccess = () => { toast.success('Address copied to clipboard', { autoClose: 2000 }); }; - const handleItemClick = (item: TransferItem) => { - if (item.type === TransferItemType.Local) { - setSelectedTransfer(item.data); - } else { - setSelectedTransfer( - messageToTransferContext(item.data, multiProvider, warpCore, routerAddressesByChainMap), - ); - } + const handleItemClick = (item: TransferContext) => { + setSelectedTransfer(item); setIsModalOpen(true); }; @@ -175,7 +119,6 @@ export function SideBarMenu({
{/* Accounts */} @@ -223,17 +166,12 @@ export function SideBarMenu({
)} {mergedTransfers.map((item) => ( - handleItemClick(item)} multiProvider={multiProvider} warpCore={warpCore} - routerAddressesByChainMap={routerAddressesByChainMap} /> ))}
@@ -242,9 +180,9 @@ export function SideBarMenu({ )} - {!hasMore && mergedTransfers.length > 0 && ( -
- No more transfers + {history.isError && ( +
+ Could not load on-chain history. Showing this device only.
)} @@ -266,91 +204,6 @@ export function SideBarMenu({ ); } -function TransferSummary({ - item, - onClick, - multiProvider, - warpCore, - routerAddressesByChainMap, -}: { - item: TransferItem; - onClick: () => void; - multiProvider: ReturnType; - warpCore: ReturnType; - routerAddressesByChainMap: Record>; -}) { - if (item.type === TransferItemType.Local) { - return ( - - ); - } - - const msg = item.data; - const originChain = multiProvider.tryGetChainName(msg.originDomainId) || ''; - const destChain = multiProvider.tryGetChainName(msg.destinationDomainId) || ''; - const status = - msg.status === MessageStatus.Delivered - ? TransferStatus.Delivered - : TransferStatus.ConfirmedTransfer; - - // Find token by sender (origin warp route address - the token contract) - const token = tryFindToken(warpCore, originChain, msg.sender); - - // Format amount using wire decimals from precomputed map - let formattedAmount = ''; - if (msg.warpTransfer?.amount && token) { - const normalizedSender = normalizeAddress(msg.sender); - const routerInfo = routerAddressesByChainMap[originChain]?.[normalizedSender]; - const wireDecimals = routerInfo?.wireDecimals ?? token.decimals; - formattedAmount = fromWei(msg.warpTransfer.amount, wireDecimals); - } - - return ( - - ); -} - function LocalTransferSummary({ transfer, onClick, @@ -362,41 +215,72 @@ function LocalTransferSummary({ multiProvider: ReturnType; warpCore: ReturnType; }) { - const { amount, origin, destination, status, timestamp, originTokenAddressOrDenom } = transfer; + const { + amount, + origin, + destination, + status, + timestamp, + originTokenAddressOrDenom, + originTxHash, + destinationTxHash, + } = transfer; const token = tryFindToken(warpCore, origin, originTokenAddressOrDenom); + const symbol = token?.symbol || 'LCAI'; + + // Prefer the Lightchain side on Lightscan; fall back to the origin tx on the other explorer. + const isToLightchain = destination === ZAP.destinationChainName; + const scanUrl = destinationTxHash + ? isToLightchain + ? ZAP.destinationTxUrl(destinationTxHash) + : ZAP.explorerTxUrl(destinationTxHash) + : originTxHash + ? isToLightchain + ? ZAP.explorerTxUrl(originTxHash) + : ZAP.destinationTxUrl(originTxHash) + : undefined; + const amountDisplay = Number(amount).toLocaleString('en-US', { maximumFractionDigits: 4 }); return ( ); diff --git a/src/features/zap/ZapForm.tsx b/src/features/zap/ZapForm.tsx index f0b7bc6..48bd187 100644 --- a/src/features/zap/ZapForm.tsx +++ b/src/features/zap/ZapForm.tsx @@ -1,9 +1,9 @@ import { useAppKit } from '@reown/appkit/react'; import { useQuery } from '@tanstack/react-query'; import clsx from 'clsx'; -import { useMemo, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { LuArrowDown, LuChevronDown, LuExternalLink, LuPencil } from 'react-icons/lu'; -import { formatEther, isAddress, type Address } from 'viem'; +import { decodeEventLog, formatEther, isAddress, type Address } from 'viem'; import { useAccount, useBalance, @@ -21,8 +21,10 @@ import { ZAP_SLIPPAGE_BPS, ZAP_SOFT_CAP_ETH, } from '../../consts/zap'; +import { useStore } from '../store'; import { fetchPrices } from '../tokens/useTokenPrice'; -import { lcaiZapAbi } from './abi'; +import { TransferStatus } from '../transfer/types'; +import { lcaiZapAbi, zappedEvent } from './abi'; import { useZapQuote } from './useZapQuote'; const MAX_GAS_RESERVE_WEI = 5_000_000_000_000_000n; // 0.005 ETH kept back for the tx itself @@ -77,6 +79,38 @@ export function ZapForm() { } = useWriteContract(); const receipt = useWaitForTransactionReceipt({ hash: txHash, chainId: ZAP_CHAIN_ID }); + // Record the zap in local transfer history (same list the Bridge tab uses) once it is mined. + const addTransfer = useStore((s) => s.addTransfer); + const recordedTx = useRef(null); + useEffect(() => { + if (!receipt.isSuccess || !receipt.data || !txHash || !address) return; + if (recordedTx.current === txHash) return; + recordedTx.current = txHash; + const zapLog = receipt.data.logs.find( + (l) => l.address.toLowerCase() === (LCAI_ZAP_ADDRESS as string).toLowerCase(), + ); + if (!zapLog) return; + // viem's generic inference collapses here against wagmi's Log type; the ABI has one event. + const zapped = decodeEventLog({ + abi: [zappedEvent], + data: zapLog.data, + topics: zapLog.topics, + }) as unknown as { + args: { lcaiBridged: bigint; recipient: Address; messageId: `0x${string}` }; + }; + addTransfer({ + status: TransferStatus.ConfirmedTransfer, + origin: ZAP.sourceChainName, + destination: ZAP.destinationChainName, + amount: formatEther(zapped.args.lcaiBridged), + sender: address, + recipient: zapped.args.recipient, + originTxHash: txHash, + msgId: zapped.args.messageId, + timestamp: Date.now(), + }); + }, [receipt.isSuccess, receipt.data, txHash, address, addTransfer]); + const isWrongChain = isConnected && chainId !== ZAP_CHAIN_ID; const isDeployed = !!LCAI_ZAP_ADDRESS; const ethNumber = Number(ethInput) || 0; diff --git a/src/features/zap/abi.ts b/src/features/zap/abi.ts index 25a1aab..48239ea 100644 --- a/src/features/zap/abi.ts +++ b/src/features/zap/abi.ts @@ -1,3 +1,5 @@ +import { parseAbiItem } from 'viem'; + export const quoterV2Abi = [ { type: 'function', @@ -35,7 +37,23 @@ export const hypCollateralAbi = [ }, ] as const; +export const zappedEvent = parseAbiItem( + 'event Zapped(address indexed sender, address indexed recipient, uint256 ethSwapped, uint256 gasPayment, uint256 lcaiBridged, bytes32 messageId)', +); + export const lcaiZapAbi = [ + { + type: 'event', + name: 'Zapped', + inputs: [ + { name: 'sender', type: 'address', indexed: true }, + { name: 'recipient', type: 'address', indexed: true }, + { name: 'ethSwapped', type: 'uint256', indexed: false }, + { name: 'gasPayment', type: 'uint256', indexed: false }, + { name: 'lcaiBridged', type: 'uint256', indexed: false }, + { name: 'messageId', type: 'bytes32', indexed: false }, + ], + }, { type: 'function', name: 'zapToLightchain',