Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion src/consts/zap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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.
Expand All @@ -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;
Expand Down
110 changes: 110 additions & 0 deletions src/features/history/useBridgeHistory.ts
Original file line number Diff line number Diff line change
@@ -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<RouterEvent[]> {
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<TransferContext[]> => {
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);
}
13 changes: 13 additions & 0 deletions src/features/transfer/TransfersDetailsModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -54,6 +55,7 @@ export function TransfersDetailsModal({
recipient,
originTokenAddressOrDenom,
originTxHash,
destinationTxHash,
msgId,
timestamp,
} = transfer || {};
Expand Down Expand Up @@ -204,6 +206,17 @@ export function TransfersDetailsModal({
url={originTxUrl}
/>
)}
{destinationTxHash && (
<TransferProperty
name="Destination Transaction Hash"
value={destinationTxHash}
url={
destination === ZAP.destinationChainName
? ZAP.destinationTxUrl(destinationTxHash)
: ZAP.explorerTxUrl(destinationTxHash)
}
/>
)}
{msgId && <TransferProperty name="Message ID" value={msgId} />}
{explorerLink && (
<div className="flex justify-center">
Expand Down
1 change: 1 addition & 0 deletions src/features/transfer/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Loading
Loading