diff --git a/examples/03-multichain-bridge-dapp/README.md b/examples/03-multichain-bridge-dapp/README.md index e72e922..c3633b1 100644 --- a/examples/03-multichain-bridge-dapp/README.md +++ b/examples/03-multichain-bridge-dapp/README.md @@ -53,6 +53,49 @@ Optional: copy `.env.example` to `.env`. Set `RPC_` (e.g. `RPC_ETHER - **Validation:** `isValidAddress(receiver, networkInfo(destNetworkId).family)` (shared-utils) handles all families. - **Pools:** Not every token is enabled on every lane. PoolInfo shows support and rate limits; unsupported lanes show "Lane Not Supported." +## SDK Inspector + +The app includes an SDK Inspector panel (toggle via the `` button) that visualizes every CCIP SDK call in real time, grouped into four phases: **Setup**, **Fee Estimation**, **Transfer**, and **Tracking**. Each entry shows the method name, arguments, result, latency, and an educational annotation explaining _what_ the call does and _why_ it happens at that point in the flow. + +The inspector is **optional instrumentation** layered on top of the SDK calls -- it does not change the SDK's behavior or API surface. If you are reading the source code to learn how to build your own frontend, here is how to navigate it: + +### Reading through the inspector code + +SDK calls in hooks like `useTransfer.ts` are wrapped in `logSDKCall()`: + +```ts +// The wrapper adds inspector instrumentation around the SDK call. +// The actual SDK usage is always the second argument (the lambda). +const tokenInfo = await logSDKCall( + { method: "chain.getTokenInfo", phase: "estimation", ... }, + () => chain.getTokenInfo(tokenAddress) // <-- this is the SDK call +); +``` + +To extract the SDK pattern, read the lambda. The config object above it (`method`, `phase`, `displayArgs`, `annotation`) is purely for the inspector UI. + +### What you can ignore + +| File / directory | Purpose | Needed for your app? | +| ---------------------------------------- | ---------------------------------------- | -------------------- | +| `src/inspector/` | Inspector store, annotations, re-exports | No | +| `logSDKCall` / `logSDKCallSync` wrappers | Record calls to the inspector | No | +| `getAnnotation(...)` | Educational text for each method | No | +| `displayArgs` in hook calls | Badge labels shown in the inspector | No | + +### What to focus on + +| File | What it teaches | +| ---------------------------- | ---------------------------------------------------------------------------------------------------------------------- | +| `hooks/useTransfer.ts` | End-to-end transfer flow: `networkInfo` → `getTokenInfo` → `getFee` → `getLaneLatency` → `generateUnsignedSendMessage` | +| `hooks/useEVMTransfer.ts` | EVM-specific signing: approval txs, simulation, `sendTransaction`, `getMessagesInTx` | +| `hooks/useSolanaTransfer.ts` | Solana-specific signing: `VersionedTransaction`, wallet adapter `sendTransaction` | +| `hooks/useAptosTransfer.ts` | Aptos-specific signing: `signAndSubmitTransaction`, transaction polling | +| `hooks/useTokenPoolInfo.ts` | Token pool discovery: registry → pool config → remote token + rate limits | +| `hooks/ChainContext.tsx` | Lazy chain instantiation: `EVMChain.fromUrl` / `SolanaChain.fromUrl` / `AptosChain.fromUrl` | + +Every SDK call in these files follows the same pattern: strip the `logSDKCall` wrapper and you have production-ready code. + ## Concepts - **Network IDs:** SDK format only (e.g. `ethereum-testnet-sepolia`, `solana-devnet`, `aptos-testnet`). diff --git a/examples/03-multichain-bridge-dapp/src/App.module.css b/examples/03-multichain-bridge-dapp/src/App.module.css new file mode 100644 index 0000000..15f8a7f --- /dev/null +++ b/examples/03-multichain-bridge-dapp/src/App.module.css @@ -0,0 +1,5 @@ +.appBody { + display: flex; + flex: 1; + position: relative; +} diff --git a/examples/03-multichain-bridge-dapp/src/App.tsx b/examples/03-multichain-bridge-dapp/src/App.tsx index d4e3e31..a564f4c 100644 --- a/examples/03-multichain-bridge-dapp/src/App.tsx +++ b/examples/03-multichain-bridge-dapp/src/App.tsx @@ -3,7 +3,7 @@ * Order: ErrorBoundary → QueryClient → Wagmi → RainbowKit → Solana → Aptos → ChainContext → TransactionHistory → App. */ -import { useMemo, useCallback, useContext } from "react"; +import { useMemo, useCallback, useContext, lazy, Suspense } from "react"; import { QueryClientProvider } from "@tanstack/react-query"; import { WagmiProvider } from "wagmi"; import { RainbowKitProvider, darkTheme } from "@rainbow-me/rainbowkit"; @@ -26,6 +26,8 @@ import { NETWORKS, type FeeTokenOptionItem } from "@ccip-examples/shared-config" import { networkInfo, NetworkType } from "@chainlink/ccip-sdk"; import { getWalletAddress, type WalletAddresses } from "@ccip-examples/shared-utils"; import { ErrorBoundary, Header } from "@ccip-examples/shared-components"; +import { SDKInspectorToggle } from "@ccip-examples/shared-components/inspector"; +import { useSDKInspector } from "@ccip-examples/shared-utils/inspector"; import { ChainContextProvider } from "./hooks/ChainContext.jsx"; import { TransactionHistoryContext } from "./hooks/transactionHistoryTypes.js"; import { TransactionHistoryProvider } from "./hooks/TransactionHistoryContext.jsx"; @@ -36,8 +38,16 @@ import { TransactionHistory } from "./components/transaction/TransactionHistory. import { useTransfer } from "./hooks/useTransfer.js"; import "@ccip-examples/shared-components/styles/globals.css"; import styles from "@ccip-examples/shared-components/layout/AppLayout.module.css"; +import appStyles from "./App.module.css"; import "@rainbow-me/rainbowkit/styles.css"; +// Lazy load the inspector panel — zero cost when inspector is disabled +const SDKInspectorPanel = lazy(() => + import("@ccip-examples/shared-components/inspector").then((m) => ({ + default: m.SDKInspectorPanel, + })) +); + const queryClient = createDefaultQueryClient(); /** RPC endpoints from shared-config (no wrapper files needed). */ @@ -69,6 +79,7 @@ function AppContent() { const transfer = useTransfer(); const addTransaction = useContext(TransactionHistoryContext).addTransaction; + const { enabled: inspectorEnabled } = useSDKInspector(); const isConnected = Boolean(evmAddress ?? solanaAddress ?? aptosAddress); const isLoading = ["estimating", "sending"].includes(transfer.status); @@ -94,9 +105,18 @@ function AppContent() { token: string, amount: string, receiver: string, - feeToken: FeeTokenOptionItem | null + feeToken: FeeTokenOptionItem | null, + remoteToken: string | null ) => { - const result = await transfer.transfer(source, dest, token, amount, receiver, feeToken); + const result = await transfer.transfer( + source, + dest, + token, + amount, + receiver, + feeToken, + remoteToken + ); if (result?.messageId == null) return; const sender = getWalletAddress(source, walletAddresses); if (sender) @@ -124,42 +144,52 @@ function AppContent() { return (
+
-
-
- -
- - {isConnected && ( - <> - - - - + +
+ {inspectorEnabled && ( + + + )} -
+ +
+
+ +
+ + {isConnected && ( + <> + + + + + )} +
+
diff --git a/examples/03-multichain-bridge-dapp/src/components/bridge/BridgeForm.tsx b/examples/03-multichain-bridge-dapp/src/components/bridge/BridgeForm.tsx index 365c994..5c6f7ae 100644 --- a/examples/03-multichain-bridge-dapp/src/components/bridge/BridgeForm.tsx +++ b/examples/03-multichain-bridge-dapp/src/components/bridge/BridgeForm.tsx @@ -34,6 +34,9 @@ import { type BalanceItem, } from "@ccip-examples/shared-components"; import { useWalletBalances, useFeeTokens } from "@ccip-examples/shared-utils/hooks"; +import { inspectorStore } from "../../inspector/index.js"; +import { getAnnotation } from "../../inspector/annotations.js"; +import { serializeForDisplay } from "@ccip-examples/shared-utils/inspector"; import { PoolInfo } from "./PoolInfo.js"; import { useChains } from "../../hooks/useChains.js"; import { NETWORK_TO_CHAIN_ID } from "@ccip-examples/shared-config/wagmi"; @@ -62,7 +65,8 @@ interface BridgeFormProps { token: string, amount: string, receiver: string, - feeToken: FeeTokenOptionItem | null + feeToken: FeeTokenOptionItem | null, + remoteToken: string | null ) => Promise; onSwitchChain: (chainId: number) => void; onClearEstimate: () => void; @@ -88,15 +92,17 @@ export function BridgeForm({ const [receiver, setReceiver] = useState(""); const [useSelfAsReceiver, setUseSelfAsReceiver] = useState(true); const [copied, setCopied] = useState(false); + const [poolRemoteToken, setPoolRemoteToken] = useState(null); const { isEVM, getChain } = useChains(); - /** Clear stale transfer result + fee when the user changes networks */ + /** Clear stale transfer result + fee + inspector when the user changes source network */ const handleSourceChange = useCallback( (id: string) => { setSourceNetworkId(id); onReset(); onClearEstimate(); + inspectorStore.clearCalls(); }, [onReset, onClearEstimate] ); @@ -127,13 +133,40 @@ export function BridgeForm({ ? (getTokenAddress(TOKEN_SYMBOL, sourceNetworkId) ?? null) : null; + /** Stable callback for PoolInfo to report remoteToken changes */ + const handleRemoteTokenResolved = useCallback((rt: string | null) => { + setPoolRemoteToken(rt); + }, []); + + const recordSDKCall = useCallback( + (method: string, args: Record, result?: unknown, durationMs?: number) => { + if (!inspectorStore.getSnapshot().enabled) return; + const ann = getAnnotation(method); + inspectorStore.addCall({ + id: crypto.randomUUID(), + timestamp: Date.now(), + phase: "setup", + method, + displayArgs: args, + codeSnippet: ann.codeSnippet, + annotation: ann.annotation, + status: "success", + result: serializeForDisplay(result), + durationMs, + }); + }, + [] + ); + const { feeTokens, selectedToken: feeToken, setSelectedToken: setFeeToken, isLoading: feeTokensLoading, error: feeTokensError, - } = useFeeTokens(sourceNetworkId || null, routerAddress, walletAddress, getChain); + } = useFeeTokens(sourceNetworkId || null, routerAddress, walletAddress, getChain, { + onSDKCall: recordSDKCall, + }); const { token, @@ -144,7 +177,8 @@ export function BridgeForm({ tokenAddress, walletAddress ?? null, getChain, - TOKEN_SYMBOL + TOKEN_SYMBOL, + { onSDKCall: recordSDKCall, skipNative: true, skipLink: true } ); /** Wallet address on the destination chain (for "send to myself") */ @@ -195,7 +229,15 @@ export function BridgeForm({ const handleTransfer = () => { if (canTransfer && token) - void onTransfer(sourceNetworkId, destNetworkId, TOKEN_SYMBOL, amount, receiver, feeToken); + void onTransfer( + sourceNetworkId, + destNetworkId, + TOKEN_SYMBOL, + amount, + receiver, + feeToken, + poolRemoteToken + ); }; const handleSwitchChain = () => { @@ -258,6 +300,7 @@ export function BridgeForm({ tokenAddress={tokenAddress ?? undefined} tokenDecimals={token?.decimals} tokenSymbol={TOKEN_SYMBOL} + onRemoteTokenResolved={handleRemoteTokenResolved} />
diff --git a/examples/03-multichain-bridge-dapp/src/components/bridge/PoolInfo.module.css b/examples/03-multichain-bridge-dapp/src/components/bridge/PoolInfo.module.css index d76b530..ebcd29c 100644 --- a/examples/03-multichain-bridge-dapp/src/components/bridge/PoolInfo.module.css +++ b/examples/03-multichain-bridge-dapp/src/components/bridge/PoolInfo.module.css @@ -71,6 +71,18 @@ .rateLimits { margin-top: var(--spacing-4); + padding-top: var(--spacing-3); + border-top: 1px solid var(--color-border-light); +} + +.rateLimitsLabel { + display: block; + font-size: var(--font-size-xs); + font-weight: 600; + color: var(--color-text-secondary); + text-transform: uppercase; + letter-spacing: 0.05em; + margin-bottom: var(--spacing-3); } .skeleton { diff --git a/examples/03-multichain-bridge-dapp/src/components/bridge/PoolInfo.tsx b/examples/03-multichain-bridge-dapp/src/components/bridge/PoolInfo.tsx index 3e98890..6b2d934 100644 --- a/examples/03-multichain-bridge-dapp/src/components/bridge/PoolInfo.tsx +++ b/examples/03-multichain-bridge-dapp/src/components/bridge/PoolInfo.tsx @@ -1,19 +1,24 @@ /** * Pool info: type, addresses, rate limits (collapsible). + * Calls useTokenPoolInfo internally and reports remoteToken via callback. */ -import { useState } from "react"; -import { useTokenPoolInfo } from "../../hooks/useTokenPoolInfo.js"; +import { useState, useEffect } from "react"; +import { useTokenPoolInfo, type TokenPoolInfo } from "../../hooks/useTokenPoolInfo.js"; import { RateLimitDisplay } from "./RateLimitDisplay.js"; import { truncateAddress, copyToClipboard } from "@ccip-examples/shared-utils"; import styles from "./PoolInfo.module.css"; +export type { TokenPoolInfo }; + interface PoolInfoProps { sourceNetworkId: string | undefined; destNetworkId: string | undefined; tokenAddress: string | undefined; tokenDecimals?: number; tokenSymbol?: string; + /** Called whenever remoteToken is resolved (or becomes null) */ + onRemoteTokenResolved?: (remoteToken: string | null) => void; } export function PoolInfo({ @@ -22,14 +27,22 @@ export function PoolInfo({ tokenAddress, tokenDecimals = 18, tokenSymbol = "tokens", + onRemoteTokenResolved, }: PoolInfoProps) { const [isExpanded, setIsExpanded] = useState(false); - const { poolInfo, isLaneSupported, isLoading, error } = useTokenPoolInfo( + + const { poolInfo, remoteToken, isLaneSupported, isLoading, error } = useTokenPoolInfo( sourceNetworkId, destNetworkId, - tokenAddress + tokenAddress, + tokenSymbol ); + // Report remoteToken changes to parent + useEffect(() => { + onRemoteTokenResolved?.(remoteToken); + }, [remoteToken, onRemoteTokenResolved]); + if (!sourceNetworkId || !destNetworkId || !tokenAddress) return null; if (isLoading) { @@ -95,22 +108,23 @@ export function PoolInfo({
{poolInfo.remoteToken != null && (() => { - const remoteToken = poolInfo.remoteToken; + const rt = poolInfo.remoteToken; return (
Remote Token
); })()}
+ Rate Limits
- - {formatted.percentage}% - -
- Refill: {formatted.rate} +
+ + {formatted.percentage}% available + + Refill: {formatted.rate}
); diff --git a/examples/03-multichain-bridge-dapp/src/components/transaction/TransactionStatusView.tsx b/examples/03-multichain-bridge-dapp/src/components/transaction/TransactionStatusView.tsx index 8847772..0bcecbb 100644 --- a/examples/03-multichain-bridge-dapp/src/components/transaction/TransactionStatusView.tsx +++ b/examples/03-multichain-bridge-dapp/src/components/transaction/TransactionStatusView.tsx @@ -3,11 +3,15 @@ * Replaces the split between TransferStatus and conditional MessageProgress in App. */ +import { useCallback } from "react"; import { getExplorerTxUrl } from "@ccip-examples/shared-config"; import type { CategorizedError, LastTransferContext } from "@ccip-examples/shared-utils"; import type { TransferStatusStatus } from "@ccip-examples/shared-utils"; import { useMessageStatus } from "@ccip-examples/shared-utils/hooks"; import { TransferStatus, MessageProgress, ErrorMessage } from "@ccip-examples/shared-components"; +import { inspectorStore } from "../../inspector/index.js"; +import { getAnnotation } from "../../inspector/annotations.js"; +import { serializeForDisplay } from "@ccip-examples/shared-utils/inspector"; import { TransferBalances } from "./TransferBalances.js"; import { TransferRateLimits } from "./TransferRateLimits.js"; import styles from "./TransactionStatusView.module.css"; @@ -33,7 +37,49 @@ export function TransactionStatusView({ lastTransferContext, categorizedError, }: TransactionStatusViewProps) { - const messageStatus = useMessageStatus(messageId); + const onSDKCall = useCallback( + (method: string, args: Record, result?: unknown, durationMs?: number) => { + if (!inspectorStore.getSnapshot().enabled) return; + const ann = getAnnotation(method); + // Polling: update existing entry in the SAME phase, or create new one + const calls = inspectorStore.getSnapshot().calls; + let existing = false; + for (let i = calls.length - 1; i >= 0; i--) { + const c = calls[i]; + if (c?.method === method && c.phase === "tracking") { + existing = true; + break; + } + } + if (existing) { + inspectorStore.updatePollingCall( + method, + { + status: "success", + result: serializeForDisplay(result), + durationMs, + }, + "tracking" + ); + } else { + inspectorStore.addCall({ + id: crypto.randomUUID(), + timestamp: Date.now(), + phase: "tracking", + method, + displayArgs: args, + codeSnippet: ann.codeSnippet, + annotation: ann.annotation, + status: "success", + result: serializeForDisplay(result), + durationMs, + }); + } + }, + [] + ); + + const messageStatus = useMessageStatus(messageId, { onSDKCall }); const isFinal = messageStatus.isFinal; const destTxHash = messageStatus.destTxHash; @@ -64,6 +110,7 @@ export function TransactionStatusView({ senderAddress={lastTransferContext.senderAddress} receiverAddress={lastTransferContext.receiverAddress} tokenAddress={lastTransferContext.tokenAddress} + remoteToken={lastTransferContext.remoteToken ?? null} isActive={!isFinal} tokenDecimals={lastTransferContext.tokenDecimals} destTokenDecimals={lastTransferContext.destTokenDecimals} diff --git a/examples/03-multichain-bridge-dapp/src/components/transaction/TransferBalances.tsx b/examples/03-multichain-bridge-dapp/src/components/transaction/TransferBalances.tsx index 389f6b9..b488751 100644 --- a/examples/03-multichain-bridge-dapp/src/components/transaction/TransferBalances.tsx +++ b/examples/03-multichain-bridge-dapp/src/components/transaction/TransferBalances.tsx @@ -14,6 +14,8 @@ export interface TransferBalancesProps { senderAddress: string | null; receiverAddress: string | null; tokenAddress: string | null; + /** Already-resolved remote token address (avoids redundant registry lookups) */ + remoteToken: string | null; isActive: boolean; tokenDecimals?: number; /** Token decimals on destination chain (may differ from source, e.g. 9 vs 18) */ @@ -42,6 +44,7 @@ export function TransferBalances({ senderAddress, receiverAddress, tokenAddress, + remoteToken, isActive, tokenDecimals = 18, destTokenDecimals, @@ -56,6 +59,7 @@ export function TransferBalances({ senderAddress, receiverAddress, tokenAddress, + remoteToken, isActive, tokenDecimals, initialSourceBalance, diff --git a/examples/03-multichain-bridge-dapp/src/hooks/ChainContext.tsx b/examples/03-multichain-bridge-dapp/src/hooks/ChainContext.tsx index 241208f..d53ac5e 100644 --- a/examples/03-multichain-bridge-dapp/src/hooks/ChainContext.tsx +++ b/examples/03-multichain-bridge-dapp/src/hooks/ChainContext.tsx @@ -6,11 +6,14 @@ import { createContext, useCallback, useMemo, useRef, type ReactNode } from "react"; import { networkInfo, ChainFamily } from "@chainlink/ccip-sdk"; -import { NETWORKS } from "@ccip-examples/shared-config"; -import { createChain, type ChainInstance } from "@ccip-examples/shared-utils"; +import type { SDKCallPhase } from "@ccip-examples/shared-utils/inspector"; +import { NETWORKS, CHAIN_FAMILY_LABELS } from "@ccip-examples/shared-config"; +import { createChain, obfuscateRpcUrl, type ChainInstance } from "@ccip-examples/shared-utils"; +import { logSDKCall } from "../inspector/index.js"; +import { getAnnotation } from "../inspector/annotations.js"; export interface ChainContextValue { - getChain: (networkId: string) => Promise; + getChain: (networkId: string, phase?: SDKCallPhase) => Promise; isEVM: (networkId: string) => boolean; isSolana: (networkId: string) => boolean; isAptos: (networkId: string) => boolean; @@ -20,20 +23,74 @@ const ChainContext = createContext(null); export { ChainContext }; +/** How long to suppress retries after a failed chain instantiation (ms) */ +const ERROR_COOLDOWN_MS = 30_000; + +interface FailedAttempt { + error: Error; + timestamp: number; +} + export function ChainContextProvider({ children }: { children: ReactNode }) { const cacheRef = useRef>(new Map()); + const pendingRef = useRef>>(new Map()); + const failedRef = useRef>(new Map()); - const getChain = useCallback(async (networkId: string): Promise => { - const cached = cacheRef.current.get(networkId); - if (cached) return cached; + const getChain = useCallback( + async (networkId: string, phase?: SDKCallPhase): Promise => { + const cached = cacheRef.current.get(networkId); + if (cached) return cached; - const config = NETWORKS[networkId]; - if (!config) throw new Error(`Unknown network: ${networkId}`); + // Deduplicate concurrent calls — return the in-flight promise if one exists + const pending = pendingRef.current.get(networkId); + if (pending) return pending; - const chain = (await createChain(networkId, config.rpcUrl)) as ChainInstance; - cacheRef.current.set(networkId, chain); - return chain; - }, []); + // If a recent attempt failed, throw immediately instead of hammering the RPC + const failed = failedRef.current.get(networkId); + if (failed && Date.now() - failed.timestamp < ERROR_COOLDOWN_MS) { + throw failed.error; + } + + const config = NETWORKS[networkId]; + if (!config) throw new Error(`Unknown network: ${networkId}`); + + const family = networkInfo(networkId).family; + const familyLabel = CHAIN_FAMILY_LABELS[family]; + + const promise = logSDKCall( + { + method: `createChain (${config.name})`, + phase: phase ?? "setup", + displayArgs: { networkId, rpcUrl: obfuscateRpcUrl(config.rpcUrl) }, + ...getAnnotation("createChain"), + }, + async () => { + const chain = await (createChain(networkId, config.rpcUrl) as Promise); + // Attach a display-friendly name so serializeForDisplay doesn't show minified class names + (chain as unknown as Record).__displayName = + `${familyLabel}Chain instance`; + return chain; + } + ).then( + (chain) => { + failedRef.current.delete(networkId); + cacheRef.current.set(networkId, chain); + pendingRef.current.delete(networkId); + return chain; + }, + (err) => { + const error = err instanceof Error ? err : new Error(String(err)); + failedRef.current.set(networkId, { error, timestamp: Date.now() }); + pendingRef.current.delete(networkId); + throw error; + } + ); + + pendingRef.current.set(networkId, promise); + return promise; + }, + [] + ); const isEVM = useCallback((networkId: string) => { if (!networkId) return false; diff --git a/examples/03-multichain-bridge-dapp/src/hooks/useAptosTransfer.ts b/examples/03-multichain-bridge-dapp/src/hooks/useAptosTransfer.ts index 1749c90..3caa9b3 100644 --- a/examples/03-multichain-bridge-dapp/src/hooks/useAptosTransfer.ts +++ b/examples/03-multichain-bridge-dapp/src/hooks/useAptosTransfer.ts @@ -10,6 +10,8 @@ import { Deserializer, SimpleTransaction } from "@aptos-labs/ts-sdk"; import type { AptosChain } from "@chainlink/ccip-sdk"; import { networkInfo } from "@chainlink/ccip-sdk"; import { NETWORKS } from "@ccip-examples/shared-config"; +import { logSDKCall } from "../inspector/index.js"; +import { getAnnotation } from "../inspector/annotations.js"; import type { TransactionResult, TransferMessage } from "./transferTypes.js"; export interface UseAptosTransferParams { @@ -39,12 +41,25 @@ export function useAptosTransfer({ onStateChange, onTxHash, onMessageId }: UseAp const destChainSelector = networkInfo(destNetworkId).chainSelector; - const unsignedTx = await chain.generateUnsignedSendMessage({ - sender: account.address.toString(), - router, - destChainSelector, - message: { ...message, fee }, - }); + const unsignedTx = await logSDKCall( + { + method: "chain.generateUnsignedSendMessage", + phase: "transfer", + displayArgs: { + sender: account.address.toString(), + router, + destChainSelector: String(destChainSelector), + }, + ...getAnnotation("chain.generateUnsignedSendMessage"), + }, + () => + chain.generateUnsignedSendMessage({ + sender: account.address.toString(), + router, + destChainSelector, + message: { ...message, fee }, + }) + ); onStateChange("sending"); @@ -65,8 +80,24 @@ export function useAptosTransfer({ onStateChange, onTxHash, onMessageId }: UseAp await chain.provider.waitForTransaction({ transactionHash: txHash }); onStateChange("tracking"); - const tx = await chain.getTransaction(txHash); - const messages = await chain.getMessagesInTx(tx); + const tx = await logSDKCall( + { + method: "chain.getTransaction", + phase: "transfer", + displayArgs: { txHash }, + ...getAnnotation("chain.getTransaction"), + }, + () => chain.getTransaction(txHash) + ); + const messages = await logSDKCall( + { + method: "chain.getMessagesInTx", + phase: "transfer", + displayArgs: { txHash }, + ...getAnnotation("chain.getMessagesInTx"), + }, + () => chain.getMessagesInTx(tx) + ); const msgId = messages[0]?.message.messageId; if (msgId) onMessageId(msgId); diff --git a/examples/03-multichain-bridge-dapp/src/hooks/useDestinationBalance.ts b/examples/03-multichain-bridge-dapp/src/hooks/useDestinationBalance.ts index f8e7415..39a8085 100644 --- a/examples/03-multichain-bridge-dapp/src/hooks/useDestinationBalance.ts +++ b/examples/03-multichain-bridge-dapp/src/hooks/useDestinationBalance.ts @@ -8,6 +8,8 @@ import { networkInfo, ChainFamily } from "@chainlink/ccip-sdk"; import { formatAmount, isValidAddress } from "@ccip-examples/shared-utils"; import { useChains } from "./useChains.js"; import { useTokenPoolInfo } from "./useTokenPoolInfo.js"; +import { logSDKCall } from "../inspector/index.js"; +import { getAnnotation } from "../inspector/annotations.js"; export interface UseDestinationBalanceResult { balance: bigint | null; @@ -23,12 +25,17 @@ function formatBalance(balance: bigint | null, decimals: number): string { return formatAmount(balance, decimals); } +/** + * @param remoteTokenProp - When provided, skips the internal useTokenPoolInfo + * lookup (avoids redundant registry calls when caller already resolved it). + */ export function useDestinationBalance( sourceNetworkId: string | undefined, destNetworkId: string | undefined, sourceTokenAddress: string | undefined, receiverAddress: string | undefined, - tokenDecimals?: number + tokenDecimals?: number, + remoteTokenProp?: string ): UseDestinationBalanceResult { const { getChain } = useChains(); const [balance, setBalance] = useState(null); @@ -36,11 +43,13 @@ export function useDestinationBalance( const [error, setError] = useState(null); const isMountedRef = useRef(true); - const { remoteToken, isLoading: poolLoading } = useTokenPoolInfo( - sourceNetworkId, - destNetworkId, - sourceTokenAddress + // Skip internal pool lookup when caller already provides remoteToken + const { remoteToken: poolRemoteToken, isLoading: poolLoading } = useTokenPoolInfo( + remoteTokenProp ? undefined : sourceNetworkId, + remoteTokenProp ? undefined : destNetworkId, + remoteTokenProp ? undefined : sourceTokenAddress ); + const remoteToken = remoteTokenProp ?? poolRemoteToken; const fetchBalance = useCallback(async () => { if (!destNetworkId || !receiverAddress || !remoteToken) { @@ -62,10 +71,17 @@ export function useDestinationBalance( try { const chain = await getChain(destNetworkId); - const tokenBalance = await chain.getBalance({ - holder: receiverAddress, - token: remoteToken, - }); + const tokenBalance = await logSDKCall( + { + method: "chain.getBalance", + phase: "tracking", + displayArgs: { holder: receiverAddress, token: remoteToken, side: "destination" }, + ...getAnnotation("chain.getBalance"), + isPolling: true, + pollingId: "chain.getBalance:destination", + }, + () => chain.getBalance({ holder: receiverAddress, token: remoteToken }) + ); if (isMountedRef.current) { setBalance(tokenBalance); diff --git a/examples/03-multichain-bridge-dapp/src/hooks/useEVMTransfer.ts b/examples/03-multichain-bridge-dapp/src/hooks/useEVMTransfer.ts index 3d78f7c..ea790ca 100644 --- a/examples/03-multichain-bridge-dapp/src/hooks/useEVMTransfer.ts +++ b/examples/03-multichain-bridge-dapp/src/hooks/useEVMTransfer.ts @@ -8,6 +8,8 @@ import { useAccount, useWalletClient, usePublicClient } from "wagmi"; import { networkInfo, type EVMChain } from "@chainlink/ccip-sdk"; import { NETWORKS } from "@ccip-examples/shared-config"; import { parseEVMError } from "@ccip-examples/shared-utils"; +import { logSDKCall } from "../inspector/index.js"; +import { getAnnotation } from "../inspector/annotations.js"; import type { TransactionResult, TransferMessage } from "./transferTypes.js"; type HexString = `0x${string}`; @@ -43,12 +45,21 @@ export function useEVMTransfer({ onStateChange, onTxHash, onMessageId }: UseEVMT const destChainSelector = networkInfo(destNetworkId).chainSelector; - const unsignedTx = await chain.generateUnsignedSendMessage({ - sender: evmAddress, - router, - destChainSelector, - message: { ...message, fee }, - }); + const unsignedTx = await logSDKCall( + { + method: "chain.generateUnsignedSendMessage", + phase: "transfer", + displayArgs: { sender: evmAddress, router, destChainSelector: String(destChainSelector) }, + ...getAnnotation("chain.generateUnsignedSendMessage"), + }, + () => + chain.generateUnsignedSendMessage({ + sender: evmAddress, + router, + destChainSelector, + message: { ...message, fee }, + }) + ); const transactions = unsignedTx.transactions; const approvalTxs = transactions.slice(0, -1); @@ -66,11 +77,15 @@ export function useEVMTransfer({ onStateChange, onTxHash, onMessageId }: UseEVMT const sendTx = transactions[transactions.length - 1]; if (!sendTx) throw new Error("No send transaction"); + // When paying with an ERC-20 fee token, no native value should be sent. + // Native value is only needed when feeToken is not specified (paying in ETH). + const txValue = message.feeToken ? 0n : fee; + try { await publicClient.call({ to: toHex(sendTx.to), data: toHex(sendTx.data), - value: fee, + value: txValue, account: evmAddress, }); } catch (simError: unknown) { @@ -82,7 +97,7 @@ export function useEVMTransfer({ onStateChange, onTxHash, onMessageId }: UseEVMT const sendHash = await walletClient.sendTransaction({ to: toHex(sendTx.to), data: toHex(sendTx.data), - value: fee, + value: txValue, account: evmAddress, }); @@ -100,7 +115,7 @@ export function useEVMTransfer({ onStateChange, onTxHash, onMessageId }: UseEVMT await publicClient.call({ to: toHex(sendTx.to), data: toHex(sendTx.data), - value: fee, + value: txValue, account: evmAddress, blockNumber: receipt.blockNumber, }); @@ -113,7 +128,15 @@ export function useEVMTransfer({ onStateChange, onTxHash, onMessageId }: UseEVMT } onStateChange("tracking"); - const messages = await chain.getMessagesInTx(sendHash); + const messages = await logSDKCall( + { + method: "chain.getMessagesInTx", + phase: "transfer", + displayArgs: { txHash: sendHash }, + ...getAnnotation("chain.getMessagesInTx"), + }, + () => chain.getMessagesInTx(sendHash) + ); const msgId = messages[0]?.message.messageId; if (msgId) onMessageId(msgId); diff --git a/examples/03-multichain-bridge-dapp/src/hooks/useSolanaTransfer.ts b/examples/03-multichain-bridge-dapp/src/hooks/useSolanaTransfer.ts index ba83ce5..5e07241 100644 --- a/examples/03-multichain-bridge-dapp/src/hooks/useSolanaTransfer.ts +++ b/examples/03-multichain-bridge-dapp/src/hooks/useSolanaTransfer.ts @@ -10,6 +10,8 @@ import type { SolanaChain } from "@chainlink/ccip-sdk"; import { networkInfo } from "@chainlink/ccip-sdk"; import { NETWORKS } from "@ccip-examples/shared-config"; import { parseSolanaError, confirmTransaction } from "@ccip-examples/shared-utils"; +import { logSDKCall } from "../inspector/index.js"; +import { getAnnotation } from "../inspector/annotations.js"; import type { TransactionResult, TransferMessage } from "./transferTypes.js"; export interface UseSolanaTransferParams { @@ -46,12 +48,25 @@ export function useSolanaTransfer({ const destChainSelector = networkInfo(destNetworkId).chainSelector; try { - const unsignedTx = await chain.generateUnsignedSendMessage({ - sender: solanaPublicKey.toBase58(), - router, - destChainSelector, - message: { ...message, fee }, - }); + const unsignedTx = await logSDKCall( + { + method: "chain.generateUnsignedSendMessage", + phase: "transfer", + displayArgs: { + sender: solanaPublicKey.toBase58(), + router, + destChainSelector: String(destChainSelector), + }, + ...getAnnotation("chain.generateUnsignedSendMessage"), + }, + () => + chain.generateUnsignedSendMessage({ + sender: solanaPublicKey.toBase58(), + router, + destChainSelector, + message: { ...message, fee }, + }) + ); // Retry loop covering both send AND confirm: if the user takes too long // to sign in the wallet popup the blockhash expires. This can surface as @@ -123,8 +138,24 @@ export function useSolanaTransfer({ } onStateChange("tracking"); - const tx = await chain.getTransaction(signature); - const messages = await chain.getMessagesInTx(tx); + const tx = await logSDKCall( + { + method: "chain.getTransaction", + phase: "transfer", + displayArgs: { txHash: signature }, + ...getAnnotation("chain.getTransaction"), + }, + () => chain.getTransaction(signature) + ); + const messages = await logSDKCall( + { + method: "chain.getMessagesInTx", + phase: "transfer", + displayArgs: { txHash: signature }, + ...getAnnotation("chain.getMessagesInTx"), + }, + () => chain.getMessagesInTx(tx) + ); const msgId = messages[0]?.message.messageId; if (msgId) onMessageId(msgId); diff --git a/examples/03-multichain-bridge-dapp/src/hooks/useTokenPoolInfo.ts b/examples/03-multichain-bridge-dapp/src/hooks/useTokenPoolInfo.ts index 3c32660..e0744ec 100644 --- a/examples/03-multichain-bridge-dapp/src/hooks/useTokenPoolInfo.ts +++ b/examples/03-multichain-bridge-dapp/src/hooks/useTokenPoolInfo.ts @@ -9,6 +9,8 @@ import type { RateLimiterState } from "@chainlink/ccip-sdk"; import { NETWORKS } from "@ccip-examples/shared-config"; import type { RateLimitBucket } from "@ccip-examples/shared-utils"; import { useChains } from "./useChains.js"; +import { logSDKCall } from "../inspector/index.js"; +import { getAnnotation } from "../inspector/annotations.js"; export type { RateLimitBucket }; @@ -44,7 +46,8 @@ export interface UseTokenPoolInfoResult { export function useTokenPoolInfo( sourceNetworkId: string | undefined, destNetworkId: string | undefined, - tokenAddress: string | undefined + tokenAddress: string | undefined, + tokenSymbol?: string ): UseTokenPoolInfoResult { const { getChain } = useChains(); const [poolInfo, setPoolInfo] = useState(null); @@ -74,8 +77,29 @@ export function useTokenPoolInfo( const chain = await getChain(sourceNetworkId); const router = sourceConfig.routerAddress; - const registryAddress = await chain.getTokenAdminRegistryFor(router); - const tokenConfig = await chain.getRegistryTokenConfig(registryAddress, tokenAddress); + const registryAddress = await logSDKCall( + { + method: "chain.getTokenAdminRegistryFor", + phase: "setup", + displayArgs: { routerAddress: router, token: tokenSymbol ?? "token", side: "source" }, + ...getAnnotation("chain.getTokenAdminRegistryFor"), + }, + () => chain.getTokenAdminRegistryFor(router) + ); + const tokenConfig = await logSDKCall( + { + method: "chain.getRegistryTokenConfig", + phase: "setup", + displayArgs: { + registryAddress: String(registryAddress), + tokenAddress, + token: tokenSymbol ?? "token", + side: "source", + }, + ...getAnnotation("chain.getRegistryTokenConfig"), + }, + () => chain.getRegistryTokenConfig(registryAddress, tokenAddress) + ); const poolAddress = tokenConfig.tokenPool; if (!poolAddress) { @@ -85,7 +109,15 @@ export function useTokenPoolInfo( return; } - const poolConfig = await chain.getTokenPoolConfig(poolAddress); + const poolConfig = await logSDKCall( + { + method: "chain.getTokenPoolConfig", + phase: "setup", + displayArgs: { poolAddress, token: tokenSymbol ?? "token", side: "source" }, + ...getAnnotation("chain.getTokenPoolConfig"), + }, + () => chain.getTokenPoolConfig(poolAddress) + ); const typeAndVersion = "typeAndVersion" in poolConfig && typeof poolConfig.typeAndVersion === "string" ? poolConfig.typeAndVersion @@ -97,7 +129,20 @@ export function useTokenPoolInfo( let outboundRateLimit: RateLimitBucket | null = null; try { - const remote = await chain.getTokenPoolRemote(poolAddress, destChainSelector); + const remote = await logSDKCall( + { + method: "chain.getTokenPoolRemote", + phase: "setup", + displayArgs: { + poolAddress, + destChainSelector: String(destChainSelector), + token: tokenSymbol ?? "token", + side: "source", + }, + ...getAnnotation("chain.getTokenPoolRemote"), + }, + () => chain.getTokenPoolRemote(poolAddress, destChainSelector) + ); remoteToken = remote.remoteToken; remotePools = remote.remotePools; inboundRateLimit = toRateLimitBucket(remote.inboundRateLimiterState); diff --git a/examples/03-multichain-bridge-dapp/src/hooks/useTransfer.ts b/examples/03-multichain-bridge-dapp/src/hooks/useTransfer.ts index 019555d..bcba9b5 100644 --- a/examples/03-multichain-bridge-dapp/src/hooks/useTransfer.ts +++ b/examples/03-multichain-bridge-dapp/src/hooks/useTransfer.ts @@ -3,7 +3,7 @@ * Uses getChain from context (lazy); supports EVM, Solana, and Aptos by ChainFamily. */ -import { useState, useCallback } from "react"; +import { useState, useCallback, useRef } from "react"; import { useAccount } from "wagmi"; import { useWallet as useSolanaWallet } from "@solana/wallet-adapter-react"; import { useWallet as useAptosWallet } from "@aptos-labs/wallet-adapter-react"; @@ -23,6 +23,8 @@ import { import { useChains } from "./useChains.js"; import { useTransactionExecution } from "./useTransactionExecution.js"; import type { TransferMessage } from "./transferTypes.js"; +import { logSDKCall, logSDKCallSync } from "../inspector/index.js"; +import { getAnnotation } from "../inspector/annotations.js"; const initialState: TransferState = { status: "idle", @@ -36,8 +38,15 @@ const initialState: TransferState = { lastTransferContext: null, }; +interface EstimationCache { + destChainSelector: bigint; + tokenDecimals: number; + message: TransferMessage; +} + export function useTransfer() { const [state, setState] = useState(initialState); + const estimationCacheRef = useRef(null); const { getChain } = useChains(); const { address: evmAddress } = useAccount(); const { publicKey: solanaPublicKey } = useSolanaWallet(); @@ -55,10 +64,14 @@ export function useTransfer() { onMessageId: (id) => setState((prev) => ({ ...prev, messageId: id })), }); - const reset = useCallback(() => setState(initialState), []); + const reset = useCallback(() => { + setState(initialState); + estimationCacheRef.current = null; + }, []); const clearEstimate = useCallback(() => { setState((prev) => ({ ...prev, fee: null, feeFormatted: null, estimatedTime: null })); + estimationCacheRef.current = null; }, []); const estimateFee = useCallback( @@ -80,30 +93,93 @@ export function useTransfer() { if (!tokenAddress) throw new Error(`Token ${tokenSymbol} not found on ${sourceNetworkId}`); const chain = await getChain(sourceNetworkId); - const destChainSelector = networkInfo(destNetworkId).chainSelector; - const tokenInfo = await chain.getTokenInfo(tokenAddress); + + const destInfo = logSDKCallSync( + { + method: "networkInfo", + phase: "estimation", + displayArgs: { networkId: destNetworkId, side: "destination" }, + ...getAnnotation("networkInfo"), + }, + () => networkInfo(destNetworkId) + ); + const destChainSelector = destInfo.chainSelector; + + const tokenInfo = await logSDKCall( + { + method: "chain.getTokenInfo", + phase: "estimation", + displayArgs: { tokenAddress, side: "source" }, + ...getAnnotation("chain.getTokenInfo"), + }, + () => chain.getTokenInfo(tokenAddress) + ); + const amountWei = parseAmount(amount, tokenInfo.decimals); const feeTokenAddress = feeToken?.address; - const message = buildTokenTransferMessage({ - receiver, - tokenAddress, - amount: amountWei, - feeToken: feeTokenAddress, - }); + const message = logSDKCallSync( + { + method: "MessageInput", + phase: "estimation", + displayArgs: { + receiver, + token: tokenAddress, + amount: amountWei.toString(), + ...(feeTokenAddress ? { feeToken: feeTokenAddress } : {}), + }, + ...getAnnotation("MessageInput"), + }, + () => + buildTokenTransferMessage({ + receiver, + tokenAddress, + amount: amountWei, + feeToken: feeTokenAddress, + }) + ); const [fee, latency] = await Promise.all([ - chain.getFee({ - router: sourceConfig.routerAddress, - destChainSelector, - message, - }), - chain.getLaneLatency(destChainSelector).catch((err) => { - console.warn("Failed to fetch lane latency:", err); - return null; - }), + logSDKCall( + { + method: "chain.getFee", + phase: "estimation", + displayArgs: { + router: sourceConfig.routerAddress, + destChainSelector: String(destChainSelector), + side: "source", + }, + ...getAnnotation("chain.getFee"), + }, + () => + chain.getFee({ + router: sourceConfig.routerAddress, + destChainSelector, + message, + }) + ), + logSDKCall( + { + method: "chain.getLaneLatency", + phase: "estimation", + displayArgs: { destChainSelector: String(destChainSelector), side: "source" }, + ...getAnnotation("chain.getLaneLatency"), + }, + () => + chain.getLaneLatency(destChainSelector).catch((err) => { + console.warn("Failed to fetch lane latency:", err); + return null; + }) + ), ]); + // Cache estimation results for reuse in transfer() + estimationCacheRef.current = { + destChainSelector, + tokenDecimals: tokenInfo.decimals, + message, + }; + const feeDecimals = feeToken?.decimals ?? sourceConfig.nativeCurrency.decimals; const feeSymbol = feeToken?.symbol ?? sourceConfig.nativeCurrency.symbol; const feeFormatted = `${formatAmount(fee, feeDecimals)} ${feeSymbol}`; @@ -140,7 +216,8 @@ export function useTransfer() { tokenSymbol: string, amount: string, receiver: string, - feeToken: FeeTokenOptionItem | null + feeToken: FeeTokenOptionItem | null, + remoteTokenFromPool?: string | null ): Promise<{ txHash: string; messageId: string | undefined } | null> => { const senderWallet = getWalletAddress(sourceNetworkId, walletAddresses); if (!senderWallet) { @@ -162,56 +239,181 @@ export function useTransfer() { if (!tokenAddress) throw new Error(`Token ${tokenSymbol} not found`); const chain = await getChain(sourceNetworkId); - const destChainSelector = networkInfo(destNetworkId).chainSelector; - const tokenInfo = await chain.getTokenInfo(tokenAddress); - const amountWei = parseAmount(amount, tokenInfo.decimals); - const feeTokenAddress = feeToken?.address; + const cache = estimationCacheRef.current; - const message: TransferMessage = buildTokenTransferMessage({ - receiver, - tokenAddress, - amount: amountWei, - feeToken: feeTokenAddress, - }); + // Use cached values from estimation when available, otherwise compute fresh + let destChainSelector: bigint; + let tokenDecimals: number; + let message: TransferMessage; + + if (cache) { + destChainSelector = cache.destChainSelector; + tokenDecimals = cache.tokenDecimals; + message = cache.message; + } else { + const destInfo = logSDKCallSync( + { + method: "networkInfo", + phase: "transfer", + displayArgs: { networkId: destNetworkId, side: "destination" }, + ...getAnnotation("networkInfo"), + }, + () => networkInfo(destNetworkId) + ); + destChainSelector = destInfo.chainSelector; + + const tokenInfo = await logSDKCall( + { + method: "chain.getTokenInfo", + phase: "transfer", + displayArgs: { tokenAddress, side: "source" }, + ...getAnnotation("chain.getTokenInfo"), + }, + () => chain.getTokenInfo(tokenAddress) + ); + tokenDecimals = tokenInfo.decimals; + + const amountWei = parseAmount(amount, tokenDecimals); + const feeTokenAddress = feeToken?.address; + + message = logSDKCallSync( + { + method: "MessageInput", + phase: "transfer", + displayArgs: { + receiver, + token: tokenAddress, + amount: amountWei.toString(), + ...(feeTokenAddress ? { feeToken: feeTokenAddress } : {}), + }, + ...getAnnotation("MessageInput"), + }, + () => + buildTokenTransferMessage({ + receiver, + tokenAddress, + amount: amountWei, + feeToken: feeTokenAddress, + }) + ); + } let fee: bigint; if (state.fee != null) { fee = state.fee; } else { - fee = await chain.getFee({ - router: sourceConfig.routerAddress, - destChainSelector, - message, - }); + fee = await logSDKCall( + { + method: "chain.getFee", + phase: "transfer", + displayArgs: { + router: sourceConfig.routerAddress, + destChainSelector: String(destChainSelector), + side: "source", + }, + ...getAnnotation("chain.getFee"), + }, + () => + chain.getFee({ + router: sourceConfig.routerAddress, + destChainSelector, + message, + }) + ); } const senderAddress = senderWallet; let initialSourceBalance: bigint | null = null; let initialDestBalance: bigint | null = null; - let destTokenDecimals = tokenInfo.decimals; + let destTokenDecimals = tokenDecimals; + + // Resolve remoteToken: use prop from PoolInfo if available, otherwise fall back to registry calls + let resolvedRemoteToken: string | null = remoteTokenFromPool ?? null; + try { const [sourceBal, remoteToken] = await Promise.all([ - chain.getBalance({ holder: senderAddress, token: tokenAddress }), - (async (): Promise => { - const registry = await chain.getTokenAdminRegistryFor(sourceConfig.routerAddress); - const tokenConfig = await chain.getRegistryTokenConfig(registry, tokenAddress); - const poolAddress = tokenConfig.tokenPool; - if (!poolAddress) return null; - try { - const remote = await chain.getTokenPoolRemote(poolAddress, destChainSelector); - return remote.remoteToken; - } catch { - return null; - } - })(), + logSDKCall( + { + method: "chain.getBalance", + phase: "transfer", + displayArgs: { holder: senderAddress, token: tokenSymbol, side: "source" }, + ...getAnnotation("chain.getBalance"), + }, + () => chain.getBalance({ holder: senderAddress, token: tokenAddress }) + ), + // Only do registry lookup if we don't already have remoteToken from PoolInfo + resolvedRemoteToken + ? Promise.resolve(resolvedRemoteToken) + : (async (): Promise => { + const registry = await logSDKCall( + { + method: "chain.getTokenAdminRegistryFor", + phase: "transfer", + displayArgs: { routerAddress: sourceConfig.routerAddress, side: "source" }, + ...getAnnotation("chain.getTokenAdminRegistryFor"), + }, + () => chain.getTokenAdminRegistryFor(sourceConfig.routerAddress) + ); + const tokenConfig = await logSDKCall( + { + method: "chain.getRegistryTokenConfig", + phase: "transfer", + displayArgs: { + registryAddress: String(registry), + tokenAddress, + side: "source", + }, + ...getAnnotation("chain.getRegistryTokenConfig"), + }, + () => chain.getRegistryTokenConfig(registry, tokenAddress) + ); + const poolAddress = tokenConfig.tokenPool; + if (!poolAddress) return null; + try { + const remote = await logSDKCall( + { + method: "chain.getTokenPoolRemote", + phase: "transfer", + displayArgs: { + poolAddress, + destChainSelector: String(destChainSelector), + side: "source", + }, + ...getAnnotation("chain.getTokenPoolRemote"), + }, + () => chain.getTokenPoolRemote(poolAddress, destChainSelector) + ); + return remote.remoteToken; + } catch { + return null; + } + })(), ]); initialSourceBalance = sourceBal; - if (remoteToken) { - const destChain = await getChain(destNetworkId); + resolvedRemoteToken = remoteToken; + if (resolvedRemoteToken) { + const destToken = resolvedRemoteToken; // narrow for closures + const destChain = await getChain(destNetworkId, "transfer"); const [destBal, destTokenInfo] = await Promise.all([ - destChain.getBalance({ holder: receiver, token: remoteToken }), - destChain.getTokenInfo(remoteToken), + logSDKCall( + { + method: "chain.getBalance", + phase: "transfer", + displayArgs: { holder: receiver, token: tokenSymbol, side: "destination" }, + ...getAnnotation("chain.getBalance"), + }, + () => destChain.getBalance({ holder: receiver, token: destToken }) + ), + logSDKCall( + { + method: "chain.getTokenInfo", + phase: "transfer", + displayArgs: { tokenAddress: destToken, side: "destination" }, + ...getAnnotation("chain.getTokenInfo"), + }, + () => destChain.getTokenInfo(destToken) + ), ]); initialDestBalance = destBal; destTokenDecimals = destTokenInfo.decimals; @@ -228,8 +430,9 @@ export function useTransfer() { tokenAddress, receiverAddress: receiver, senderAddress, - tokenDecimals: tokenInfo.decimals, + tokenDecimals, destTokenDecimals, + remoteToken: resolvedRemoteToken, initialSourceBalance, initialDestBalance, }; diff --git a/examples/03-multichain-bridge-dapp/src/hooks/useTransferBalances.ts b/examples/03-multichain-bridge-dapp/src/hooks/useTransferBalances.ts index e4d7ff9..b1bbc1a 100644 --- a/examples/03-multichain-bridge-dapp/src/hooks/useTransferBalances.ts +++ b/examples/03-multichain-bridge-dapp/src/hooks/useTransferBalances.ts @@ -1,12 +1,17 @@ /** * Source and destination token balances during an in-progress transfer. * Composes useDestinationBalance for destination; adds source balance and polling when isActive. + * + * Uses sequential timeout-based polling (waits for fetch completion before scheduling next) + * to prevent connection exhaustion when RPC responses are slow. */ import { useState, useEffect, useCallback, useRef } from "react"; import { BALANCE_POLLING_INTERVAL_MS } from "@ccip-examples/shared-config"; import { useChains } from "./useChains.js"; import { useDestinationBalance } from "./useDestinationBalance.js"; +import { logSDKCall } from "../inspector/index.js"; +import { getAnnotation } from "../inspector/annotations.js"; export interface UseTransferBalancesParams { sourceNetworkId: string | null; @@ -14,6 +19,8 @@ export interface UseTransferBalancesParams { senderAddress: string | null; receiverAddress: string | null; tokenAddress: string | null; + /** Already-resolved remote token address (skips useTokenPoolInfo in useDestinationBalance) */ + remoteToken?: string | null; isActive: boolean; tokenDecimals?: number; /** Captured before transfer for "before vs after" display */ @@ -42,6 +49,7 @@ export function useTransferBalances({ senderAddress, receiverAddress, tokenAddress, + remoteToken, isActive, tokenDecimals = 18, initialSourceBalance: initialSourceBalanceParam, @@ -55,14 +63,15 @@ export function useTransferBalances({ const [sourceError, setSourceError] = useState(null); const mountedRef = useRef(true); - const intervalRef = useRef | null>(null); + const timeoutRef = useRef | null>(null); const dest = useDestinationBalance( sourceNetworkId ?? undefined, destNetworkId ?? undefined, tokenAddress ?? undefined, receiverAddress ?? undefined, - tokenDecimals + tokenDecimals, + remoteToken ?? undefined ); const fetchSourceBalance = useCallback(async () => { @@ -75,10 +84,17 @@ export function useTransferBalances({ setSourceError(null); try { const chain = await getChain(sourceNetworkId); - const balance = await chain.getBalance({ - holder: senderAddress, - token: tokenAddress, - }); + const balance = await logSDKCall( + { + method: "chain.getBalance", + phase: "tracking", + displayArgs: { holder: senderAddress, token: tokenAddress, side: "source" }, + ...getAnnotation("chain.getBalance"), + isPolling: true, + pollingId: "chain.getBalance:source", + }, + () => chain.getBalance({ holder: senderAddress, token: tokenAddress }) + ); if (mountedRef.current) { setSourceBalance(balance); } @@ -99,7 +115,7 @@ export function useTransferBalances({ dest.refetch(); }, [fetchSourceBalance, dest]); - // Initial fetch and capture initial dest when first available + // Initial fetch useEffect(() => { mountedRef.current = true; void fetchSourceBalance(); @@ -108,24 +124,34 @@ export function useTransferBalances({ }; }, [fetchSourceBalance]); - // Poll when isActive + // Sequential polling: wait for fetch to complete, then wait BALANCE_POLLING_INTERVAL_MS, then repeat useEffect(() => { if (!isActive) { - if (intervalRef.current) { - clearInterval(intervalRef.current); - intervalRef.current = null; + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; } return; } - const tick = () => { - void fetchSourceBalance(); + + let cancelled = false; + + const pollOnce = async () => { + await fetchSourceBalance(); dest.refetch(); + if (!cancelled) { + timeoutRef.current = setTimeout(() => void pollOnce(), BALANCE_POLLING_INTERVAL_MS); + } }; - intervalRef.current = setInterval(tick, BALANCE_POLLING_INTERVAL_MS); + + // Start first poll after the interval (initial fetch already ran) + timeoutRef.current = setTimeout(() => void pollOnce(), BALANCE_POLLING_INTERVAL_MS); + return () => { - if (intervalRef.current) { - clearInterval(intervalRef.current); - intervalRef.current = null; + cancelled = true; + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; } }; }, [isActive, fetchSourceBalance, dest]); diff --git a/examples/03-multichain-bridge-dapp/src/hooks/useTransferRateLimits.ts b/examples/03-multichain-bridge-dapp/src/hooks/useTransferRateLimits.ts index fb3dbaa..ef74c2c 100644 --- a/examples/03-multichain-bridge-dapp/src/hooks/useTransferRateLimits.ts +++ b/examples/03-multichain-bridge-dapp/src/hooks/useTransferRateLimits.ts @@ -10,6 +10,8 @@ import { RATE_LIMIT_POLLING_INTERVAL_MS } from "@ccip-examples/shared-config"; import { NETWORKS } from "@ccip-examples/shared-config"; import { useChains } from "./useChains.js"; import type { RateLimitBucket } from "@ccip-examples/shared-utils"; +import { logSDKCall } from "../inspector/index.js"; +import { getAnnotation } from "../inspector/annotations.js"; function toRateLimitBucket(state: RateLimiterState): RateLimitBucket | null { if (!state) return null; @@ -61,7 +63,7 @@ export function useTransferRateLimits({ const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); const mountedRef = useRef(true); - const intervalRef = useRef | null>(null); + const timeoutRef = useRef | null>(null); const fetchRateLimits = useCallback(async () => { if (!sourceNetworkId || !destNetworkId || !tokenAddress) { @@ -91,10 +93,27 @@ export function useTransferRateLimits({ try { const sourceChain = await getChain(sourceNetworkId); const sourceRouter = sourceConfig.routerAddress; - const sourceRegistry = await sourceChain.getTokenAdminRegistryFor(sourceRouter); - const sourceTokenConfig = await sourceChain.getRegistryTokenConfig( - sourceRegistry, - tokenAddress + const sourceRegistry = await logSDKCall( + { + method: "chain.getTokenAdminRegistryFor", + phase: "tracking", + displayArgs: { routerAddress: sourceRouter, side: "source" }, + ...getAnnotation("chain.getTokenAdminRegistryFor"), + isPolling: true, + pollingId: "chain.getTokenAdminRegistryFor:source", + }, + () => sourceChain.getTokenAdminRegistryFor(sourceRouter) + ); + const sourceTokenConfig = await logSDKCall( + { + method: "chain.getRegistryTokenConfig", + phase: "tracking", + displayArgs: { registryAddress: String(sourceRegistry), tokenAddress, side: "source" }, + ...getAnnotation("chain.getRegistryTokenConfig"), + isPolling: true, + pollingId: "chain.getRegistryTokenConfig:source", + }, + () => sourceChain.getRegistryTokenConfig(sourceRegistry, tokenAddress) ); const sourcePoolAddress = sourceTokenConfig.tokenPool; @@ -109,9 +128,20 @@ export function useTransferRateLimits({ let sourceInbound: RateLimitBucket | null = null; try { - const sourceRemote = await sourceChain.getTokenPoolRemote( - sourcePoolAddress, - destChainSelector + const sourceRemote = await logSDKCall( + { + method: "chain.getTokenPoolRemote", + phase: "tracking", + displayArgs: { + poolAddress: sourcePoolAddress, + destChainSelector: String(destChainSelector), + side: "source", + }, + ...getAnnotation("chain.getTokenPoolRemote"), + isPolling: true, + pollingId: "chain.getTokenPoolRemote:source", + }, + () => sourceChain.getTokenPoolRemote(sourcePoolAddress, destChainSelector) ); remoteToken = sourceRemote.remoteToken; sourceOutbound = toRateLimitBucket(sourceRemote.outboundRateLimiterState); @@ -127,14 +157,49 @@ export function useTransferRateLimits({ try { const destChain = await getChain(destNetworkId); const destRouter = destConfig.routerAddress; - const destRegistry = await destChain.getTokenAdminRegistryFor(destRouter); - const destTokenConfig = await destChain.getRegistryTokenConfig(destRegistry, remoteToken); + const destRegistry = await logSDKCall( + { + method: "chain.getTokenAdminRegistryFor", + phase: "tracking", + displayArgs: { routerAddress: destRouter, side: "destination" }, + ...getAnnotation("chain.getTokenAdminRegistryFor"), + isPolling: true, + pollingId: "chain.getTokenAdminRegistryFor:destination", + }, + () => destChain.getTokenAdminRegistryFor(destRouter) + ); + const destTokenConfig = await logSDKCall( + { + method: "chain.getRegistryTokenConfig", + phase: "tracking", + displayArgs: { + registryAddress: String(destRegistry), + tokenAddress: remoteToken, + side: "destination", + }, + ...getAnnotation("chain.getRegistryTokenConfig"), + isPolling: true, + pollingId: "chain.getRegistryTokenConfig:destination", + }, + () => destChain.getRegistryTokenConfig(destRegistry, remoteToken) + ); const destPoolAddress = destTokenConfig.tokenPool; if (destPoolAddress) { - const destRemote = await destChain.getTokenPoolRemote( - destPoolAddress, - sourceChainSelector + const destRemote = await logSDKCall( + { + method: "chain.getTokenPoolRemote", + phase: "tracking", + displayArgs: { + poolAddress: destPoolAddress, + destChainSelector: String(sourceChainSelector), + side: "destination", + }, + ...getAnnotation("chain.getTokenPoolRemote"), + isPolling: true, + pollingId: "chain.getTokenPoolRemote:destination", + }, + () => destChain.getTokenPoolRemote(destPoolAddress, sourceChainSelector) ); destOutbound = toRateLimitBucket(destRemote.outboundRateLimiterState); destInbound = toRateLimitBucket(destRemote.inboundRateLimiterState); @@ -172,19 +237,33 @@ export function useTransferRateLimits({ }; }, [fetchRateLimits]); + // Sequential polling: wait for fetch to complete, then wait interval, then repeat useEffect(() => { if (!isActive) { - if (intervalRef.current) { - clearInterval(intervalRef.current); - intervalRef.current = null; + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; } return; } - intervalRef.current = setInterval(() => void fetchRateLimits(), RATE_LIMIT_POLLING_INTERVAL_MS); + + let cancelled = false; + + const pollOnce = async () => { + await fetchRateLimits(); + if (!cancelled) { + timeoutRef.current = setTimeout(() => void pollOnce(), RATE_LIMIT_POLLING_INTERVAL_MS); + } + }; + + // Start first poll after the interval (initial fetch already ran) + timeoutRef.current = setTimeout(() => void pollOnce(), RATE_LIMIT_POLLING_INTERVAL_MS); + return () => { - if (intervalRef.current) { - clearInterval(intervalRef.current); - intervalRef.current = null; + cancelled = true; + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; } }; }, [isActive, fetchRateLimits]); diff --git a/examples/03-multichain-bridge-dapp/src/inspector/annotations.ts b/examples/03-multichain-bridge-dapp/src/inspector/annotations.ts new file mode 100644 index 0000000..ba41fff --- /dev/null +++ b/examples/03-multichain-bridge-dapp/src/inspector/annotations.ts @@ -0,0 +1,147 @@ +/** + * Educational annotations and code signatures for each SDK method call. + * Content is specific to Example 03's transfer flow. + * + * Each entry contains: + * - annotation: educational "what" + "whyNow" text + * - codeSnippet: clean function signature with named parameters + * + * Actual argument values are shown separately in the "Arguments" section + * of the inspector UI via `displayArgs`. + */ + +import type { LogSDKCallOptions } from "@ccip-examples/shared-utils/inspector"; + +type Annotation = Pick; + +const annotations: Record = { + createChain: { + annotation: { + what: "Create a Chain instance connected to the network's RPC endpoint", + whyNow: "Chain instance is the entry point for all SDK operations on this network", + }, + codeSnippet: + "// Per chain family:\nconst chain = EVMChain.fromUrl(rpcUrl);\nconst chain = SolanaChain.fromUrl(rpcUrl);\nconst chain = AptosChain.fromUrl(rpcUrl);", + }, + MessageInput: { + annotation: { + what: "Construct the CCIP MessageInput object", + whyNow: + "This is the SDK's MessageInput shape -- passed to getFee() and generateUnsignedSendMessage()", + }, + codeSnippet: + "const message: MessageInput = {\n receiver,\n tokenAmounts: [{ token: tokenAddress, amount }],\n feeToken, // optional\n};", + }, + networkInfo: { + annotation: { + what: "Fetch CCIP network metadata (chain selector, family, type)", + whyNow: "Chain selector is CCIP's unique identifier -- needed for every cross-chain call", + }, + codeSnippet: "const { chainSelector } = networkInfo(networkId);", + }, + "chain.getTokenInfo": { + annotation: { + what: "Validate token and fetch metadata (decimals, symbol, name)", + whyNow: "Decimals required to convert human-readable amount to raw integer", + }, + codeSnippet: "const tokenInfo = await chain.getTokenInfo(tokenAddress);", + }, + "chain.getFee": { + annotation: { + what: "Query CCIP router for exact transfer fee on-chain", + whyNow: "Fees are dynamic -- depend on destination, token, amount, and network conditions", + }, + codeSnippet: + "const fee = await chain.getFee({\n router,\n destChainSelector,\n message,\n});", + }, + "chain.getLaneLatency": { + annotation: { + what: "Estimate delivery time for this source -> destination route", + whyNow: "Show user expected wait time before they commit funds", + }, + codeSnippet: "const latency = await chain.getLaneLatency(destChainSelector);", + }, + "chain.getBalance": { + annotation: { + what: "Check token balance for an address on-chain", + whyNow: "Track balances before/after transfer to confirm delivery", + }, + codeSnippet: "const balance = await chain.getBalance({\n holder,\n token,\n});", + }, + "chain.generateUnsignedSendMessage": { + annotation: { + what: "Build the cross-chain transaction for wallet signing", + whyNow: "THE key SDK call -- everything before was preparation for this moment", + }, + codeSnippet: + "const unsignedTx = await chain.generateUnsignedSendMessage({\n sender,\n router,\n destChainSelector,\n message: { ...message, fee },\n});", + }, + "chain.getTokenAdminRegistryFor": { + annotation: { + what: "Find the token admin registry for this CCIP router", + whyNow: "Registry holds the mapping from token -> token pool", + }, + codeSnippet: "const registry = await chain.getTokenAdminRegistryFor(routerAddress);", + }, + "chain.getRegistryTokenConfig": { + annotation: { + what: "Look up token pool address from the registry", + whyNow: "Token pool manages cross-chain liquidity for this token", + }, + codeSnippet: + "const config = await chain.getRegistryTokenConfig(\n registryAddress,\n tokenAddress,\n);", + }, + "chain.getTokenPoolRemote": { + annotation: { + what: "Find the token's address on the destination chain", + whyNow: "Destination token may have a different address than source", + }, + codeSnippet: + "const remote = await chain.getTokenPoolRemote(\n poolAddress,\n destChainSelector,\n);", + }, + "chain.getFeeTokens": { + annotation: { + what: "Fetch accepted fee payment tokens from the CCIP router", + whyNow: "Users can pay fees in native token or LINK -- need to show available options", + }, + codeSnippet: "const feeTokens = await chain.getFeeTokens(routerAddress);", + }, + "chain.getTokenPoolConfig": { + annotation: { + what: "Fetch token pool configuration (type, version, supported chains)", + whyNow: "Pool type determines the lock/burn mechanism used for cross-chain transfers", + }, + codeSnippet: "const poolConfig = await chain.getTokenPoolConfig(poolAddress);", + }, + "chain.getMessagesInTx": { + annotation: { + what: "Extract CCIP message IDs from a confirmed transaction", + whyNow: "Message ID is needed to track cross-chain delivery status", + }, + codeSnippet: "const messages = await chain.getMessagesInTx(txHash);", + }, + "chain.getTransaction": { + annotation: { + what: "Fetch full transaction data from the chain", + whyNow: "Non-EVM chains need the full transaction object to extract CCIP messages", + }, + codeSnippet: "const tx = await chain.getTransaction(txHash);", + }, + "CCIPAPIClient.getMessageById": { + annotation: { + what: "Poll CCIP API for cross-chain message status", + whyNow: "Track progress through CCIP lifecycle statuses until delivery completes", + }, + codeSnippet: "const msg = await apiClient.getMessageById(messageId);", + }, +}; + +const FALLBACK: Annotation = { + annotation: { what: "SDK call", whyNow: "" }, + codeSnippet: "", +}; + +/** Get annotation and code snippet for a method, with a safe fallback */ +export function getAnnotation(method: string): Annotation { + return annotations[method] ?? FALLBACK; +} diff --git a/examples/03-multichain-bridge-dapp/src/inspector/index.ts b/examples/03-multichain-bridge-dapp/src/inspector/index.ts new file mode 100644 index 0000000..3df53e7 --- /dev/null +++ b/examples/03-multichain-bridge-dapp/src/inspector/index.ts @@ -0,0 +1,2 @@ +export { getAnnotation } from "./annotations.js"; +export { logSDKCall, logSDKCallSync, inspectorStore } from "@ccip-examples/shared-utils/inspector"; diff --git a/package.json b/package.json index de35cbe..6a02b4a 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,8 @@ "dev:01": "pnpm -F 01-getting-started dev", "dev:02": "pnpm -F 02-evm-simple-bridge dev", "dev:03": "pnpm -F 03-multichain-bridge-dapp dev", + "prod:02": "pnpm -F 02-evm-simple-bridge build && pnpm -F 02-evm-simple-bridge preview", + "prod:03": "pnpm -F 03-multichain-bridge-dapp build && pnpm -F 03-multichain-bridge-dapp preview", "build": "pnpm -r build", "build:examples": "pnpm -F './examples/*' build", "build:packages": "pnpm -F './packages/*' build", diff --git a/packages/shared-components/package.json b/packages/shared-components/package.json index 25930c6..19262b6 100644 --- a/packages/shared-components/package.json +++ b/packages/shared-components/package.json @@ -21,14 +21,23 @@ }, "./layout/AppLayout.module.css": "./dist/layout/AppLayout.module.css", "./bridge/BridgeForm.module.css": "./dist/bridge/BridgeForm.module.css", - "./bridge/WalletConnect.module.css": "./dist/bridge/WalletConnect.module.css" + "./bridge/WalletConnect.module.css": "./dist/bridge/WalletConnect.module.css", + "./inspector": { + "types": "./dist/inspector/index.d.ts", + "import": "./dist/inspector/index.js" + }, + "./inspector/SDKInspectorPanel.module.css": "./dist/inspector/SDKInspectorPanel.module.css", + "./inspector/PhaseGroup.module.css": "./dist/inspector/PhaseGroup.module.css", + "./inspector/CallEntry.module.css": "./dist/inspector/CallEntry.module.css", + "./inspector/CodeSnippet.module.css": "./dist/inspector/CodeSnippet.module.css", + "./inspector/SDKInspectorToggle.module.css": "./dist/inspector/SDKInspectorToggle.module.css" }, "files": [ "dist" ], "scripts": { "build": "tsc && pnpm run copy-css", - "copy-css": "mkdir -p dist/primitives dist/bridge dist/styles && cp src/primitives/*.module.css dist/primitives/ && cp src/bridge/*.module.css dist/bridge/ && cp src/styles/*.css dist/styles/ && mkdir -p dist/layout && cp src/layout/*.module.css dist/layout/ && cp src/*.module.css dist/", + "copy-css": "mkdir -p dist/primitives dist/bridge dist/styles dist/layout dist/inspector && cp src/primitives/*.module.css dist/primitives/ && cp src/bridge/*.module.css dist/bridge/ && cp src/styles/*.css dist/styles/ && cp src/layout/*.module.css dist/layout/ && cp src/inspector/*.module.css dist/inspector/ && cp src/*.module.css dist/", "typecheck": "tsc --noEmit", "clean": "rm -rf dist" }, diff --git a/packages/shared-components/src/inspector/CallEntry.module.css b/packages/shared-components/src/inspector/CallEntry.module.css new file mode 100644 index 0000000..ef935f4 --- /dev/null +++ b/packages/shared-components/src/inspector/CallEntry.module.css @@ -0,0 +1,230 @@ +.entry { + border-radius: var(--radius-sm); + background: var(--color-background); + border: 1px solid var(--color-border-light); + overflow: hidden; + animation: slideIn var(--transition-normal) cubic-bezier(0.16, 1, 0.3, 1); +} + +@keyframes slideIn { + from { + opacity: 0; + transform: translateX(12px); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +@media (prefers-reduced-motion: reduce) { + .entry { + animation: fadeIn var(--transition-fast) ease; + } + + @keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } + } +} + +.pendingBorder { + border-left: 2px solid var(--color-primary); + animation: + slideIn var(--transition-normal) cubic-bezier(0.16, 1, 0.3, 1), + pulseBorder 1.5s ease-in-out infinite; +} + +@keyframes pulseBorder { + 0%, + 100% { + border-left-color: var(--color-primary); + } + 50% { + border-left-color: var(--color-primary-light); + } +} + +@media (prefers-reduced-motion: reduce) { + .pendingBorder { + animation: none; + border-left: 2px solid var(--color-primary); + } +} + +.header { + display: flex; + align-items: center; + gap: var(--spacing-2); + width: 100%; + padding: var(--spacing-2) var(--spacing-3); + border: none; + background: none; + cursor: pointer; + font-size: var(--font-size-xs); + color: var(--color-text-primary); + text-align: left; +} + +.header:hover { + background: var(--color-background-secondary); +} + +.statusDot { + width: 6px; + height: 6px; + border-radius: 50%; + flex-shrink: 0; +} + +.statusDot.pending { + background: var(--color-primary); +} + +.statusDot.success { + background: var(--color-success); +} + +.statusDot.error { + background: var(--color-error); +} + +.method { + font-family: var(--font-mono); + font-weight: 600; + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.sideBadge { + font-size: 9px; + font-weight: 400; + margin-left: var(--spacing-1); + padding: 0 var(--spacing-1); + background: var(--color-background-secondary); + color: var(--color-text-muted); + border-radius: var(--radius-sm); + vertical-align: middle; +} + +.pollBadge { + font-size: 10px; + font-family: var(--font-mono); + padding: 1px var(--spacing-1); + background: var(--color-primary-bg); + color: var(--color-primary); + border-radius: var(--radius-sm); + flex-shrink: 0; +} + +.duration { + font-size: 10px; + font-family: var(--font-mono); + color: var(--color-text-muted); + flex-shrink: 0; +} + +.chevron { + font-size: 8px; + color: var(--color-text-muted); + transition: transform var(--transition-fast); + flex-shrink: 0; +} + +.expanded { + transform: rotate(180deg); +} + +.body { + padding: 0 var(--spacing-3) var(--spacing-3); + border-top: 1px solid var(--color-border-light); +} + +.annotation { + margin-top: var(--spacing-2); +} + +.annotationWhat { + margin: 0; + font-size: var(--font-size-xs); + color: var(--color-text-primary); + font-weight: 500; +} + +.annotationWhy { + margin: var(--spacing-1) 0 0; + font-size: 11px; + color: var(--color-text-secondary); + font-style: italic; +} + +.args { + margin-top: var(--spacing-2); + display: flex; + flex-wrap: wrap; + gap: var(--spacing-1); + align-items: baseline; +} + +.argsLabel { + font-size: 10px; + font-weight: 600; + color: var(--color-text-muted); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.arg { + font-size: var(--font-size-xs); + font-family: var(--font-mono); + background: var(--color-background-secondary); + padding: 1px var(--spacing-2); + border-radius: var(--radius-sm); +} + +.argKey { + color: var(--color-text-secondary); +} + +.argVal { + color: var(--color-text-primary); +} + +.result, +.errorResult { + margin-top: var(--spacing-2); +} + +.resultLabel { + font-size: 10px; + font-weight: 600; + color: var(--color-text-muted); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.resultValue { + margin: var(--spacing-1) 0 0; + font-size: var(--font-size-xs); + font-family: var(--font-mono); + color: var(--color-text-secondary); + background: var(--color-background-secondary); + padding: var(--spacing-2); + border-radius: var(--radius-sm); + overflow-x: auto; + max-height: 120px; + overflow-y: auto; + white-space: pre-wrap; + word-break: break-word; +} + +.errorResult .resultValue { + color: var(--color-error); + background: var(--color-error-bg); +} diff --git a/packages/shared-components/src/inspector/CallEntry.tsx b/packages/shared-components/src/inspector/CallEntry.tsx new file mode 100644 index 0000000..aa62a2b --- /dev/null +++ b/packages/shared-components/src/inspector/CallEntry.tsx @@ -0,0 +1,111 @@ +/** + * Individual SDK call card with expand/collapse for code snippet and annotations. + */ + +import { useState } from "react"; +import type { SDKCallEntry } from "@ccip-examples/shared-utils/inspector"; +import { CodeSnippet } from "./CodeSnippet.js"; +import { PollingIndicator } from "./PollingIndicator.js"; +import styles from "./CallEntry.module.css"; + +interface CallEntryProps { + entry: SDKCallEntry; +} + +/** Keys to show as inline badges in collapsed header (order matters) */ +const BADGE_KEYS = ["side", "token", "type"] as const; + +/** Skip raw addresses and long strings — badges should be short human-readable labels */ +function isBadgeWorthy(value: string): boolean { + return value.length <= 20; +} + +export function CallEntry({ entry }: CallEntryProps) { + const [isExpanded, setIsExpanded] = useState(false); + + const badges = BADGE_KEYS.map((key) => entry.displayArgs[key]).filter( + (v): v is string => typeof v === "string" && v.length > 0 && isBadgeWorthy(v) + ); + + const statusClass = + entry.status === "success" + ? styles.success + : entry.status === "error" + ? styles.error + : styles.pending; + + return ( +
+ + + {isExpanded && ( +
+
+

{entry.annotation.what}

+

{entry.annotation.whyNow}

+
+ + + + {Object.keys(entry.displayArgs).length > 0 && ( +
+ Arguments: + {Object.entries(entry.displayArgs) + .filter(([key]) => !(BADGE_KEYS as readonly string[]).includes(key)) + .map(([key, val]) => ( + + {key}:{" "} + {val} + + ))} +
+ )} + + {entry.result && ( +
+ Result: +
{entry.result}
+
+ )} + + {entry.error && ( +
+ Error: +
{entry.error}
+
+ )} +
+ )} +
+ ); +} diff --git a/packages/shared-components/src/inspector/CodeSnippet.module.css b/packages/shared-components/src/inspector/CodeSnippet.module.css new file mode 100644 index 0000000..b49c320 --- /dev/null +++ b/packages/shared-components/src/inspector/CodeSnippet.module.css @@ -0,0 +1,65 @@ +.container { + position: relative; + background: var(--color-dark); + border-radius: var(--radius-sm); + margin-top: var(--spacing-2); + overflow: hidden; +} + +.pre { + margin: 0; + padding: var(--spacing-3); + padding-right: var(--spacing-8); + font-family: var(--font-mono); + font-size: var(--font-size-xs); + line-height: 1.5; + overflow-x: auto; + color: var(--color-light); + white-space: pre; +} + +.copyButton { + position: absolute; + top: var(--spacing-1); + right: var(--spacing-1); + padding: var(--spacing-1) var(--spacing-2); + font-size: 10px; + font-family: var(--font-mono); + background: var(--color-border); + color: var(--color-text-secondary); + border: none; + border-radius: var(--radius-sm); + cursor: pointer; + opacity: 0; + transition: opacity var(--transition-fast); +} + +.container:hover .copyButton, +.container:focus-within .copyButton { + opacity: 1; +} + +.keyword { + color: #c792ea; +} + +.string { + color: #c3e88d; +} + +.number { + color: #f78c6c; +} + +.comment { + color: #546e7a; + font-style: italic; +} + +.punct { + color: #89ddff; +} + +.ident { + color: #82aaff; +} diff --git a/packages/shared-components/src/inspector/CodeSnippet.tsx b/packages/shared-components/src/inspector/CodeSnippet.tsx new file mode 100644 index 0000000..d2df483 --- /dev/null +++ b/packages/shared-components/src/inspector/CodeSnippet.tsx @@ -0,0 +1,92 @@ +/** + * Syntax-highlighted code display with copy button. + * Uses CSS-only coloring via regex token classification. + */ + +import { useMemo } from "react"; +import { useCopyToClipboard } from "@ccip-examples/shared-utils/hooks"; +import styles from "./CodeSnippet.module.css"; + +interface CodeSnippetProps { + code: string; +} + +interface Token { + text: string; + className: string; +} + +function tokenize(code: string): Token[] { + const tokens: Token[] = []; + const keywords = new Set([ + "const", + "let", + "var", + "await", + "async", + "new", + "return", + "import", + "from", + "function", + ]); + // Simple tokenizer: strings, numbers, keywords, punctuation, identifiers + const regex = + /("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|`(?:[^`\\]|\\.)*`)|(\b\d[\d_]*n?\b)|(\/\/[^\n]*)|([(){}[\];,.:=<>!&|?+\-*/])|(\b[a-zA-Z_$][\w$]*\b)|(\s+)/g; + let match: RegExpExecArray | null; + while ((match = regex.exec(code)) !== null) { + const [full, str, num, comment, punct, ident, ws] = match; + if (str) { + tokens.push({ text: str, className: styles.string! }); + } else if (num) { + tokens.push({ text: num, className: styles.number! }); + } else if (comment) { + tokens.push({ text: comment, className: styles.comment! }); + } else if (punct) { + tokens.push({ text: punct, className: styles.punct! }); + } else if (ident) { + if (keywords.has(ident)) { + tokens.push({ text: ident, className: styles.keyword! }); + } else { + tokens.push({ text: ident, className: styles.ident! }); + } + } else if (ws) { + tokens.push({ text: ws, className: "" }); + } else { + tokens.push({ text: full, className: "" }); + } + } + return tokens; +} + +export function CodeSnippet({ code }: CodeSnippetProps) { + const { copied, copy } = useCopyToClipboard(); + const tokens = useMemo(() => tokenize(code), [code]); + + return ( +
+ +
+        
+          {tokens.map((t, i) =>
+            t.className ? (
+              
+                {t.text}
+              
+            ) : (
+              t.text
+            )
+          )}
+        
+      
+
+ ); +} diff --git a/packages/shared-components/src/inspector/InspectorEmptyState.tsx b/packages/shared-components/src/inspector/InspectorEmptyState.tsx new file mode 100644 index 0000000..8a3fc6e --- /dev/null +++ b/packages/shared-components/src/inspector/InspectorEmptyState.tsx @@ -0,0 +1,19 @@ +/** + * Empty state when no SDK calls have been logged. + */ + +import styles from "./SDKInspectorPanel.module.css"; + +export function InspectorEmptyState() { + return ( +
+ +

No SDK calls yet

+

+ Estimate a fee or start a transfer to see SDK calls appear here in real-time. +

+
+ ); +} diff --git a/packages/shared-components/src/inspector/PhaseGroup.module.css b/packages/shared-components/src/inspector/PhaseGroup.module.css new file mode 100644 index 0000000..a0dd765 --- /dev/null +++ b/packages/shared-components/src/inspector/PhaseGroup.module.css @@ -0,0 +1,132 @@ +.group { + border-bottom: 1px solid var(--color-border-light); +} + +.group:last-child { + border-bottom: none; +} + +.header { + display: flex; + align-items: center; + gap: var(--spacing-2); + width: 100%; + padding: var(--spacing-3) var(--spacing-4); + border: none; + background: none; + cursor: pointer; + font-size: var(--font-size-sm); + color: var(--color-text-primary); + text-align: left; + transition: background var(--transition-fast); +} + +.header:hover { + background: var(--color-background-secondary); +} + +.active { + background: var(--color-background-secondary); +} + +.icon { + font-size: var(--font-size-base); + flex-shrink: 0; +} + +.label { + font-weight: 600; + flex: 1; +} + +.badge { + font-size: 10px; + font-family: var(--font-mono); + font-weight: 600; + padding: 1px 6px; + border-radius: var(--radius-full); + background: var(--color-border); + color: var(--color-text-secondary); +} + +.spinner { + width: 12px; + height: 12px; + border: 2px solid var(--color-primary-light); + border-top-color: var(--color-primary); + border-radius: 50%; + animation: spin 1s linear infinite; + flex-shrink: 0; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +@media (prefers-reduced-motion: reduce) { + .spinner { + animation: none; + opacity: 0.6; + } +} + +.errorBadge { + font-size: 10px; + font-weight: 700; + width: 16px; + height: 16px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 50%; + background: var(--color-error); + color: white; + flex-shrink: 0; +} + +.totalDuration { + font-size: 10px; + font-family: var(--font-mono); + color: var(--color-text-muted); +} + +.chevron { + font-size: 8px; + color: var(--color-text-muted); + transition: transform var(--transition-fast); + flex-shrink: 0; +} + +.expanded { + transform: rotate(180deg); +} + +.content { + display: grid; + grid-template-rows: 0fr; + transition: grid-template-rows var(--transition-normal) ease; +} + +.contentExpanded { + grid-template-rows: 1fr; +} + +@media (prefers-reduced-motion: reduce) { + .content { + transition: none; + } +} + +.inner { + overflow: hidden; + display: flex; + flex-direction: column; + gap: var(--spacing-1); + padding: 0 var(--spacing-3); +} + +.contentExpanded .inner { + padding-bottom: var(--spacing-3); +} diff --git a/packages/shared-components/src/inspector/PhaseGroup.tsx b/packages/shared-components/src/inspector/PhaseGroup.tsx new file mode 100644 index 0000000..56be949 --- /dev/null +++ b/packages/shared-components/src/inspector/PhaseGroup.tsx @@ -0,0 +1,81 @@ +/** + * Collapsible phase section grouping SDK calls by lifecycle phase. + */ + +import { useState, useEffect } from "react"; +import type { SDKCallEntry, SDKCallPhase } from "@ccip-examples/shared-utils/inspector"; +import { CallEntry } from "./CallEntry.js"; +import styles from "./PhaseGroup.module.css"; + +const PHASE_LABELS: Record = { + setup: "Setup", + estimation: "Fee Estimation", + transfer: "Transfer", + tracking: "Tracking", +}; + +const PHASE_ICONS: Record = { + setup: "\u2699", + estimation: "\u{1F4B0}", + transfer: "\u{1F680}", + tracking: "\u{1F4E1}", +}; + +interface PhaseGroupProps { + phase: SDKCallPhase; + calls: SDKCallEntry[]; + isActive: boolean; +} + +export function PhaseGroup({ phase, calls, isActive }: PhaseGroupProps) { + const [isExpanded, setIsExpanded] = useState(isActive); + + useEffect(() => { + if (isActive) setIsExpanded(true); + }, [isActive]); + + const totalDuration = calls.reduce((sum, c) => sum + (c.durationMs ?? 0), 0); + const hasErrors = calls.some((c) => c.status === "error"); + const hasPending = calls.some((c) => c.status === "pending"); + + return ( +
+ + +
+
+ {calls.map((call) => ( + + ))} +
+
+
+ ); +} diff --git a/packages/shared-components/src/inspector/PollingIndicator.tsx b/packages/shared-components/src/inspector/PollingIndicator.tsx new file mode 100644 index 0000000..713ec49 --- /dev/null +++ b/packages/shared-components/src/inspector/PollingIndicator.tsx @@ -0,0 +1,17 @@ +/** + * Visual indicator for repeated polling calls (e.g. getMessageById). + */ + +import styles from "./CallEntry.module.css"; + +interface PollingIndicatorProps { + pollCount: number; +} + +export function PollingIndicator({ pollCount }: PollingIndicatorProps) { + return ( + + {pollCount}x + + ); +} diff --git a/packages/shared-components/src/inspector/SDKInspectorPanel.module.css b/packages/shared-components/src/inspector/SDKInspectorPanel.module.css new file mode 100644 index 0000000..a02096e --- /dev/null +++ b/packages/shared-components/src/inspector/SDKInspectorPanel.module.css @@ -0,0 +1,119 @@ +.panel { + width: 420px; + min-width: 280px; + max-width: 60vw; + flex-shrink: 0; + display: flex; + flex-direction: column; + background: var(--color-surface); + border-right: 1px solid var(--color-border); + height: calc(100vh - 120px); + position: sticky; + top: 0; + overflow: hidden; + z-index: 10; + resize: horizontal; +} + +.panelHeader { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--spacing-3) var(--spacing-4); + border-bottom: 1px solid var(--color-border-light); + flex-shrink: 0; +} + +.panelTitle { + margin: 0; + font-size: var(--font-size-sm); + font-weight: 700; + color: var(--color-text-primary); + font-family: var(--font-mono); +} + +.clearButton { + padding: var(--spacing-1) var(--spacing-2); + font-size: var(--font-size-xs); + background: var(--color-background-secondary); + color: var(--color-text-secondary); + border: 1px solid var(--color-border-light); + border-radius: var(--radius-sm); + cursor: pointer; + transition: background var(--transition-fast); +} + +.clearButton:hover { + background: var(--color-border); +} + +.panelBody { + flex: 1; + overflow-y: auto; + overflow-x: hidden; +} + +/* Empty state */ +.emptyState { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: var(--spacing-8) var(--spacing-4); + text-align: center; + min-height: 200px; +} + +.emptyIcon { + font-size: var(--font-size-3xl); + font-family: var(--font-mono); + color: var(--color-text-muted); + margin-bottom: var(--spacing-3); + opacity: 0.5; +} + +.emptyTitle { + margin: 0; + font-size: var(--font-size-sm); + font-weight: 600; + color: var(--color-text-secondary); +} + +.emptyHint { + margin: var(--spacing-2) 0 0; + font-size: var(--font-size-xs); + color: var(--color-text-muted); + max-width: 240px; + line-height: 1.5; +} + +/* Tablet: overlay */ +@media (max-width: 1024px) { + .panel { + position: fixed; + left: 0; + top: 0; + height: 100vh; + z-index: 100; + box-shadow: var(--shadow-lg); + resize: horizontal; + } +} + +/* Mobile: bottom sheet */ +@media (max-width: 768px) { + .panel { + position: fixed; + left: 0; + right: 0; + bottom: 0; + top: auto; + width: 100%; + height: 50vh; + border-right: none; + border-top: 1px solid var(--color-border); + border-radius: var(--radius-lg) var(--radius-lg) 0 0; + box-shadow: var(--shadow-lg); + resize: none; + } +} diff --git a/packages/shared-components/src/inspector/SDKInspectorPanel.tsx b/packages/shared-components/src/inspector/SDKInspectorPanel.tsx new file mode 100644 index 0000000..33ed159 --- /dev/null +++ b/packages/shared-components/src/inspector/SDKInspectorPanel.tsx @@ -0,0 +1,66 @@ +/** + * Main SDK Inspector panel. Groups logged SDK calls by phase. + * Desktop: fixed left panel. Mobile: bottom sheet. + */ + +import { useMemo } from "react"; +import { + useSDKInspector, + useSDKInspectorActions, + type SDKCallPhase, +} from "@ccip-examples/shared-utils/inspector"; +import { PhaseGroup } from "./PhaseGroup.js"; +import { InspectorEmptyState } from "./InspectorEmptyState.js"; +import styles from "./SDKInspectorPanel.module.css"; + +const PHASE_ORDER: SDKCallPhase[] = ["setup", "estimation", "transfer", "tracking"]; + +export function SDKInspectorPanel() { + const { calls, activePhase } = useSDKInspector(); + const { clear } = useSDKInspectorActions(); + + const groupedCalls = useMemo(() => { + const groups = new Map(); + for (const call of calls) { + const list = groups.get(call.phase) ?? []; + list.push(call); + groups.set(call.phase, list); + } + return groups; + }, [calls]); + + const phases = PHASE_ORDER.filter((p) => groupedCalls.has(p)); + + return ( + + ); +} diff --git a/packages/shared-components/src/inspector/SDKInspectorToggle.module.css b/packages/shared-components/src/inspector/SDKInspectorToggle.module.css new file mode 100644 index 0000000..ab1e4ae --- /dev/null +++ b/packages/shared-components/src/inspector/SDKInspectorToggle.module.css @@ -0,0 +1,58 @@ +.toggle { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + width: var(--touch-target-min); + height: var(--touch-target-min); + padding: 0; + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: var(--color-background); + color: var(--color-text-secondary); + cursor: pointer; + transition: + background var(--transition-fast), + border-color var(--transition-fast), + color var(--transition-fast); +} + +.toggle:hover { + background: var(--color-background-secondary); + border-color: var(--color-border-light); + color: var(--color-text-primary); +} + +.active { + background: var(--color-primary-bg); + border-color: var(--color-primary); + color: var(--color-primary); +} + +.active:hover { + background: var(--color-primary-bg); + border-color: var(--color-primary-dark); + color: var(--color-primary-dark); +} + +.icon { + font-family: var(--font-mono); + font-size: var(--font-size-xs); + font-weight: 700; +} + +.badge { + position: absolute; + top: -4px; + right: -4px; + min-width: 16px; + height: 16px; + padding: 0 4px; + font-size: 10px; + font-weight: 700; + line-height: 16px; + text-align: center; + border-radius: var(--radius-full); + background: var(--color-primary); + color: white; +} diff --git a/packages/shared-components/src/inspector/SDKInspectorToggle.tsx b/packages/shared-components/src/inspector/SDKInspectorToggle.tsx new file mode 100644 index 0000000..e842727 --- /dev/null +++ b/packages/shared-components/src/inspector/SDKInspectorToggle.tsx @@ -0,0 +1,30 @@ +/** + * Toggle button for the SDK Inspector panel. + * Designed to be placed in Header's children slot. + */ + +import { useSDKInspector, useSDKInspectorActions } from "@ccip-examples/shared-utils/inspector"; +import styles from "./SDKInspectorToggle.module.css"; + +export function SDKInspectorToggle() { + const { enabled, calls } = useSDKInspector(); + const { toggle } = useSDKInspectorActions(); + const pendingCount = calls.filter((c) => c.status === "pending").length; + + return ( + + ); +} diff --git a/packages/shared-components/src/inspector/index.ts b/packages/shared-components/src/inspector/index.ts new file mode 100644 index 0000000..b8bd89e --- /dev/null +++ b/packages/shared-components/src/inspector/index.ts @@ -0,0 +1,7 @@ +export { SDKInspectorPanel } from "./SDKInspectorPanel.js"; +export { SDKInspectorToggle } from "./SDKInspectorToggle.js"; +export { PhaseGroup } from "./PhaseGroup.js"; +export { CallEntry } from "./CallEntry.js"; +export { CodeSnippet } from "./CodeSnippet.js"; +export { PollingIndicator } from "./PollingIndicator.js"; +export { InspectorEmptyState } from "./InspectorEmptyState.js"; diff --git a/packages/shared-config/src/constants.ts b/packages/shared-config/src/constants.ts index b0ca994..e94b59c 100644 --- a/packages/shared-config/src/constants.ts +++ b/packages/shared-config/src/constants.ts @@ -111,22 +111,22 @@ export function getStageFromStatus(status: string): number { * Polling interval for live balances during an in-progress transfer (source + destination). * Separate from POLLING_CONFIG which is for message-status polling. */ -export const BALANCE_POLLING_INTERVAL_MS = 5_000; +export const BALANCE_POLLING_INTERVAL_MS = 15_000; /** * Polling interval for live rate limits during an in-progress transfer (source + destination pools). * Separate from POLLING_CONFIG which is for message-status polling. */ -export const RATE_LIMIT_POLLING_INTERVAL_MS = 10_000; +export const RATE_LIMIT_POLLING_INTERVAL_MS = 30_000; /** * Polling configuration for message status */ export const POLLING_CONFIG = { /** Initial delay between polls (ms) */ - initialDelay: 10_000, + initialDelay: 15_000, /** Maximum delay between polls (ms) */ - maxDelay: 30_000, + maxDelay: 60_000, /** Delay increment per poll (ms) */ delayIncrement: 5_000, /** Maximum polling duration before timeout (ms) - 35 minutes */ diff --git a/packages/shared-utils/package.json b/packages/shared-utils/package.json index 0d2e2e4..3f639f1 100644 --- a/packages/shared-utils/package.json +++ b/packages/shared-utils/package.json @@ -45,8 +45,13 @@ "./solana": { "types": "./dist/solana.d.ts", "import": "./dist/solana.js" + }, + "./inspector": { + "types": "./dist/inspector/index.d.ts", + "import": "./dist/inspector/index.js" } }, + "sideEffects": false, "files": [ "dist" ], diff --git a/packages/shared-utils/src/formatting.ts b/packages/shared-utils/src/formatting.ts index 7970c26..90d92ed 100644 --- a/packages/shared-utils/src/formatting.ts +++ b/packages/shared-utils/src/formatting.ts @@ -36,3 +36,18 @@ export function formatRelativeTime(timestamp: number): string { if (minutes > 0) return `${minutes}m ago`; return "just now"; } + +/** + * Obfuscate an RPC URL so API keys in .env are never displayed. + * Keeps protocol + hostname, replaces the rest with `***`. + * + * @example obfuscateRpcUrl("https://sepolia.infura.io/v3/abc123") => "https://sepolia.infura.io/***" + */ +export function obfuscateRpcUrl(url: string): string { + try { + const u = new URL(url); + return `${u.protocol}//${u.hostname}/***`; + } catch { + return "***"; + } +} diff --git a/packages/shared-utils/src/hooks/useFeeTokens.ts b/packages/shared-utils/src/hooks/useFeeTokens.ts index 5819fec..023b097 100644 --- a/packages/shared-utils/src/hooks/useFeeTokens.ts +++ b/packages/shared-utils/src/hooks/useFeeTokens.ts @@ -9,6 +9,7 @@ import { useState, useEffect, useCallback, useRef } from "react"; import type { Chain } from "@chainlink/ccip-sdk"; import { NETWORKS, type FeeTokenOptionItem } from "@ccip-examples/shared-config"; import { formatAmount } from "../validation.js"; +import type { SDKCallReporter } from "../inspector/types.js"; export type GetChain = (networkId: string) => Promise; @@ -52,7 +53,8 @@ export function useFeeTokens( networkId: string | null, routerAddress: string | null, holderAddress: string | null, - getChain: GetChain + getChain: GetChain, + inspectorOptions?: { onSDKCall?: SDKCallReporter } ): UseFeeTokensResult { const [feeTokens, setFeeTokens] = useState([]); const [selectedToken, setSelectedTokenState] = useState(null); @@ -96,13 +98,37 @@ export function useFeeTokens( } // Fetch native balance and fee tokens in parallel. + const nativeBalStart = performance.now(); + const feeTokensStart = performance.now(); const [nativeBal, feeTokenResult] = await Promise.all([ - chain.getBalance({ holder: holderAddress }).catch((err) => { - console.error("Failed to fetch native balance for fee tokens:", err); - return null; - }), + chain + .getBalance({ holder: holderAddress }) + .then((bal) => { + inspectorOptions?.onSDKCall?.( + "chain.getBalance", + { holder: holderAddress, token: config.nativeCurrency.symbol, type: "fee" }, + bal, + performance.now() - nativeBalStart + ); + return bal; + }) + .catch((err) => { + console.error("Failed to fetch native balance for fee tokens:", err); + return null; + }), withTimeout( - chain.getFeeTokens(routerAddress).catch(() => null), + chain + .getFeeTokens(routerAddress) + .then((tokens) => { + inspectorOptions?.onSDKCall?.( + "chain.getFeeTokens", + { routerAddress }, + tokens, + performance.now() - feeTokensStart + ); + return tokens; + }) + .catch(() => null), GET_FEE_TOKENS_TIMEOUT_MS ), ]); @@ -121,10 +147,17 @@ export function useFeeTokens( feeTokenEntries.map(async ([address, info]): Promise => { let balance = 0n; try { + const feeBalStart = performance.now(); balance = await chain.getBalance({ holder: holderAddress, token: address, }); + inspectorOptions?.onSDKCall?.( + "chain.getBalance", + { holder: holderAddress, token: info.symbol, type: "fee" }, + balance, + performance.now() - feeBalStart + ); } catch { // Token account may not exist (e.g. Solana wallet without this token) — treat as 0 } diff --git a/packages/shared-utils/src/hooks/useMessageStatus.ts b/packages/shared-utils/src/hooks/useMessageStatus.ts index d89bd7e..a16d2b3 100644 --- a/packages/shared-utils/src/hooks/useMessageStatus.ts +++ b/packages/shared-utils/src/hooks/useMessageStatus.ts @@ -14,6 +14,7 @@ import { getStatusDescription as getSharedStatusDescription, } from "@ccip-examples/shared-config"; import { formatElapsedTime } from "../formatting.js"; +import type { SDKCallReporter } from "../inspector/types.js"; export interface MessageStatusResult { status: MessageStatus | null; @@ -45,7 +46,10 @@ function isFinalStatus(status: MessageStatus | null): boolean { * * @param messageId - CCIP message ID to track (null to disable polling) */ -export function useMessageStatus(messageId: string | null): MessageStatusResult { +export function useMessageStatus( + messageId: string | null, + options?: { onSDKCall?: SDKCallReporter } +): MessageStatusResult { const [status, setStatus] = useState(null); const [destTxHash, setDestTxHash] = useState(null); const [isPolling, setIsPolling] = useState(false); @@ -59,6 +63,10 @@ export function useMessageStatus(messageId: string | null): MessageStatusResult const shouldStopRef = useRef(false); const apiClientRef = useRef(null); + // Store onSDKCall in a ref so it never triggers dependency cascades + const onSDKCallRef = useRef(options?.onSDKCall); + onSDKCallRef.current = options?.onSDKCall; + const stopPolling = useCallback(() => { shouldStopRef.current = true; if (pollTimeoutRef.current) { @@ -74,7 +82,15 @@ export function useMessageStatus(messageId: string | null): MessageStatusResult ): Promise> | null> => { if (!messageId) return null; apiClientRef.current ??= new CCIPAPIClient(); + const start = performance.now(); const result = await apiClientRef.current.getMessageById(messageId); + const durationMs = performance.now() - start; + onSDKCallRef.current?.( + "CCIPAPIClient.getMessageById", + { messageId, type: "api" }, + result, + durationMs + ); if (signal?.aborted) return null; return result; }, diff --git a/packages/shared-utils/src/hooks/useWalletBalances.ts b/packages/shared-utils/src/hooks/useWalletBalances.ts index 5d64ed3..3ad50de 100644 --- a/packages/shared-utils/src/hooks/useWalletBalances.ts +++ b/packages/shared-utils/src/hooks/useWalletBalances.ts @@ -23,6 +23,7 @@ import { useState, useEffect, useCallback, useRef } from "react"; import type { Chain } from "@chainlink/ccip-sdk"; import { NETWORKS, getTokenAddress } from "@ccip-examples/shared-config"; import { formatAmount } from "../validation.js"; +import type { SDKCallReporter } from "../inspector/types.js"; export interface BalanceData { /** Raw balance in smallest unit */ @@ -69,7 +70,14 @@ export function useWalletBalances( tokenAddress: string | null, holderAddress: string | null, getChain: GetChain, - tokenSymbolHint?: string + tokenSymbolHint?: string, + options?: { + onSDKCall?: SDKCallReporter; + /** Skip native balance fetch (e.g. when useFeeTokens already handles it) */ + skipNative?: boolean; + /** Skip LINK balance + getTokenInfo fetch (e.g. when useFeeTokens already handles it) */ + skipLink?: boolean; + } ): WalletBalances { const [native, setNative] = useState(defaultBalanceData("", 18)); const [link, setLink] = useState(null); @@ -106,31 +114,58 @@ export function useWalletBalances( const promises: Promise[] = []; - promises.push( - chain - .getBalance({ holder: holderAddress }) - .then((bal) => { - if (fetchId !== fetchIdRef.current) return; - setNative({ - balance: bal, - formatted: formatAmount(bal, config.nativeCurrency.decimals), - symbol: config.nativeCurrency.symbol, - decimals: config.nativeCurrency.decimals, - }); - }) - .catch((err) => { - if (fetchId !== fetchIdRef.current) return; - console.error("Failed to fetch native balance:", err); - }) - ); - - if (linkAddress) { + if (!options?.skipNative) { + const nativeStart = performance.now(); + promises.push( + chain + .getBalance({ holder: holderAddress }) + .then((bal) => { + options?.onSDKCall?.( + "chain.getBalance", + { holder: holderAddress, token: config.nativeCurrency.symbol, type: "native" }, + bal, + performance.now() - nativeStart + ); + if (fetchId !== fetchIdRef.current) return; + setNative({ + balance: bal, + formatted: formatAmount(bal, config.nativeCurrency.decimals), + symbol: config.nativeCurrency.symbol, + decimals: config.nativeCurrency.decimals, + }); + }) + .catch((err) => { + if (fetchId !== fetchIdRef.current) return; + console.error("Failed to fetch native balance:", err); + }) + ); + } + + if (linkAddress && !options?.skipLink) { promises.push( (async () => { try { + const linkInfoStart = performance.now(); + const linkBalStart = performance.now(); const [linkInfo, linkBal] = await Promise.all([ - chain.getTokenInfo(linkAddress), - chain.getBalance({ holder: holderAddress, token: linkAddress }), + chain.getTokenInfo(linkAddress).then((info) => { + options?.onSDKCall?.( + "chain.getTokenInfo", + { tokenAddress: linkAddress, token: "LINK" }, + info, + performance.now() - linkInfoStart + ); + return info; + }), + chain.getBalance({ holder: holderAddress, token: linkAddress }).then((bal) => { + options?.onSDKCall?.( + "chain.getBalance", + { holder: holderAddress, token: "LINK" }, + bal, + performance.now() - linkBalStart + ); + return bal; + }), ]); if (fetchId !== fetchIdRef.current) return; const linkSymbol = @@ -156,9 +191,31 @@ export function useWalletBalances( promises.push( (async () => { try { + const tokenInfoStart = performance.now(); + const tokenBalStart = performance.now(); const [tokenInfo, tokenBal] = await Promise.all([ - chain.getTokenInfo(tokenAddress), - chain.getBalance({ holder: holderAddress, token: tokenAddress }), + chain.getTokenInfo(tokenAddress).then((info) => { + const sym = + info.symbol && info.symbol !== "UNKNOWN" + ? info.symbol + : (tokenSymbolHint ?? "token"); + options?.onSDKCall?.( + "chain.getTokenInfo", + { tokenAddress, token: sym }, + info, + performance.now() - tokenInfoStart + ); + return info; + }), + chain.getBalance({ holder: holderAddress, token: tokenAddress }).then((bal) => { + options?.onSDKCall?.( + "chain.getBalance", + { holder: holderAddress, token: tokenSymbolHint ?? tokenAddress }, + bal, + performance.now() - tokenBalStart + ); + return bal; + }), ]); if (fetchId !== fetchIdRef.current) return; // Prefer on-chain symbol; fall back to hint for tokens without metadata (e.g. Solana SPL) diff --git a/packages/shared-utils/src/index.ts b/packages/shared-utils/src/index.ts index 6727418..31560c3 100644 --- a/packages/shared-utils/src/index.ts +++ b/packages/shared-utils/src/index.ts @@ -29,7 +29,12 @@ export { } from "./ccipErrors.js"; // Formatting (no React) -export { formatLatency, formatElapsedTime, formatRelativeTime } from "./formatting.js"; +export { + formatLatency, + formatElapsedTime, + formatRelativeTime, + obfuscateRpcUrl, +} from "./formatting.js"; export { copyToClipboard, COPIED_FEEDBACK_MS } from "./clipboard.js"; // Validation exports diff --git a/packages/shared-utils/src/inspector/index.ts b/packages/shared-utils/src/inspector/index.ts new file mode 100644 index 0000000..bb12716 --- /dev/null +++ b/packages/shared-utils/src/inspector/index.ts @@ -0,0 +1,12 @@ +export type { + SDKCallEntry, + SDKCallPhase, + SDKCallStatus, + InspectorMode, + SDKInspectorState, + LogSDKCallOptions, + SDKCallReporter, +} from "./types.js"; +export { inspectorStore } from "./store.js"; +export { logSDKCall, logSDKCallSync, serializeForDisplay } from "./logSDKCall.js"; +export { useSDKInspector, useSDKInspectorActions } from "./useSDKInspector.js"; diff --git a/packages/shared-utils/src/inspector/logSDKCall.ts b/packages/shared-utils/src/inspector/logSDKCall.ts new file mode 100644 index 0000000..93946d2 --- /dev/null +++ b/packages/shared-utils/src/inspector/logSDKCall.ts @@ -0,0 +1,149 @@ +import { inspectorStore } from "./store.js"; +import type { LogSDKCallOptions } from "./types.js"; + +/** Serialize values for display (bigint -> string, addresses -> truncated) */ +export function serializeForDisplay(value: unknown): string { + if (typeof value === "bigint") return value.toString(); + if (typeof value === "string" && value.startsWith("0x") && value.length > 12) + return `${value.slice(0, 6)}...${value.slice(-4)}`; + if (typeof value === "object" && value !== null) { + // Check for explicit display name (e.g. Chain instances with minified class names) + const displayName = (value as Record).__displayName; + if (typeof displayName === "string") return displayName; + try { + return JSON.stringify( + value, + (_, v: unknown) => (typeof v === "bigint" ? v.toString() : v), + 2 + ); + } catch { + // Circular references (e.g. Chain instances with WebSocket clients) + const name = (value.constructor as { name?: string } | undefined)?.name ?? "Object"; + return `[${name}]`; + } + } + if (value == null) return "undefined"; + // Remaining primitives (number, boolean, symbol) are safe to stringify + return typeof value === "symbol" ? value.toString() : `${value as string | number | boolean}`; +} + +/** + * Wrap an async SDK call with inspector logging. + * Zero overhead when inspector is disabled -- immediately calls fn(). + * NEVER swallows errors -- always re-throws. + */ +export async function logSDKCall(options: LogSDKCallOptions, fn: () => Promise): Promise { + if (!inspectorStore.getSnapshot().enabled) return fn(); + + // Polling aggregation: update existing entry instead of creating new + // Match on pollingId (or method) AND phase so tracking-phase polls don't clobber setup/transfer entries + if (options.isPolling) { + const matchKey = options.pollingId ?? options.method; + const calls = inspectorStore.getSnapshot().calls; + let existing: (typeof calls)[number] | undefined; + for (let i = calls.length - 1; i >= 0; i--) { + const entry = calls[i]; + const entryKey = entry?.pollingId ?? entry?.method; + if (entryKey === matchKey && entry?.phase === options.phase) { + existing = entry; + break; + } + } + if (existing) { + inspectorStore.updatePollingCall(matchKey, { status: "pending" }, options.phase); + const start = performance.now(); + try { + const result = await fn(); + inspectorStore.updatePollingCall( + matchKey, + { + status: "success", + result: serializeForDisplay(result), + durationMs: performance.now() - start, + }, + options.phase + ); + return result; + } catch (err) { + inspectorStore.updatePollingCall( + matchKey, + { + status: "error", + error: String(err), + durationMs: performance.now() - start, + }, + options.phase + ); + throw err; + } + } + } + + const id = crypto.randomUUID(); + const start = performance.now(); + + inspectorStore.addCall({ + id, + timestamp: Date.now(), + phase: options.phase, + method: options.method, + pollingId: options.pollingId, + displayArgs: options.displayArgs, + codeSnippet: options.codeSnippet, + annotation: options.annotation, + status: "pending", + }); + + try { + const result = await fn(); + inspectorStore.updateCall(id, { + status: "success", + result: serializeForDisplay(result), + durationMs: performance.now() - start, + }); + return result; + } catch (err) { + inspectorStore.updateCall(id, { + status: "error", + error: String(err), + durationMs: performance.now() - start, + }); + throw err; + } +} + +/** Synchronous variant for non-async calls like networkInfo() */ +export function logSDKCallSync(options: LogSDKCallOptions, fn: () => T): T { + if (!inspectorStore.getSnapshot().enabled) return fn(); + + const id = crypto.randomUUID(); + const start = performance.now(); + + inspectorStore.addCall({ + id, + timestamp: Date.now(), + phase: options.phase, + method: options.method, + displayArgs: options.displayArgs, + codeSnippet: options.codeSnippet, + annotation: options.annotation, + status: "pending", + }); + + try { + const result = fn(); + inspectorStore.updateCall(id, { + status: "success", + result: serializeForDisplay(result), + durationMs: performance.now() - start, + }); + return result; + } catch (err) { + inspectorStore.updateCall(id, { + status: "error", + error: String(err), + durationMs: performance.now() - start, + }); + throw err; + } +} diff --git a/packages/shared-utils/src/inspector/store.ts b/packages/shared-utils/src/inspector/store.ts new file mode 100644 index 0000000..e72a3c2 --- /dev/null +++ b/packages/shared-utils/src/inspector/store.ts @@ -0,0 +1,91 @@ +import type { SDKCallEntry, SDKInspectorState } from "./types.js"; + +const MAX_ENTRIES = 100; +const STORAGE_KEY = "ccip-sdk-inspector-enabled"; + +function readEnabledFromStorage(): boolean { + try { + return typeof localStorage !== "undefined" && localStorage.getItem(STORAGE_KEY) === "true"; + } catch { + return false; + } +} + +let state: SDKInspectorState = { + enabled: readEnabledFromStorage(), + calls: [], + activePhase: null, +}; + +const listeners = new Set<() => void>(); + +function emit() { + listeners.forEach((l) => l()); +} + +export const inspectorStore = { + subscribe(listener: () => void) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + + getSnapshot(): SDKInspectorState { + return state; + }, + + setEnabled(enabled: boolean) { + state = { ...state, enabled }; + try { + localStorage.setItem(STORAGE_KEY, String(enabled)); + } catch { + // localStorage may not be available + } + emit(); + }, + + addCall(entry: SDKCallEntry) { + const calls = [...state.calls, entry].slice(-MAX_ENTRIES); + state = { ...state, calls, activePhase: entry.phase }; + emit(); + }, + + updateCall(id: string, patch: Partial) { + state = { + ...state, + calls: state.calls.map((c) => (c.id === id ? { ...c, ...patch } : c)), + }; + emit(); + }, + + /** @param key - pollingId or method to match against */ + updatePollingCall(key: string, patch: Partial, phase?: string) { + let idx = -1; + for (let i = state.calls.length - 1; i >= 0; i--) { + const c = state.calls[i]; + const entryKey = c?.pollingId ?? c?.method; + if (entryKey === key && (!phase || c?.phase === phase)) { + idx = i; + break; + } + } + if (idx === -1) return; + const entry = state.calls[idx]; + if (!entry) return; + const updated = { + ...entry, + ...patch, + pollCount: (entry.pollCount ?? 1) + 1, + }; + const calls = [...state.calls]; + calls[idx] = updated; + state = { ...state, calls }; + emit(); + }, + + clearCalls() { + state = { ...state, calls: [], activePhase: null }; + emit(); + }, +}; diff --git a/packages/shared-utils/src/inspector/types.ts b/packages/shared-utils/src/inspector/types.ts new file mode 100644 index 0000000..054c863 --- /dev/null +++ b/packages/shared-utils/src/inspector/types.ts @@ -0,0 +1,56 @@ +/** Phase of the SDK call in the transfer lifecycle */ +export type SDKCallPhase = "setup" | "estimation" | "transfer" | "tracking"; + +/** Status of an individual SDK call */ +export type SDKCallStatus = "pending" | "success" | "error"; + +/** Display mode for the inspector */ +export type InspectorMode = "quick" | "code"; + +/** A single logged SDK call */ +export interface SDKCallEntry { + id: string; + timestamp: number; + phase: SDKCallPhase; + method: string; + /** Optional key for polling aggregation (defaults to method if unset) */ + pollingId?: string; + displayArgs: Record; + codeSnippet: string; + status: SDKCallStatus; + result?: string; + error?: string; + durationMs?: number; + pollCount?: number; + annotation: { + what: string; + whyNow: string; + }; +} + +/** Full inspector state */ +export interface SDKInspectorState { + enabled: boolean; + calls: SDKCallEntry[]; + activePhase: SDKCallPhase | null; +} + +/** Options for logging an SDK call */ +export interface LogSDKCallOptions { + method: string; + phase: SDKCallPhase; + displayArgs: Record; + codeSnippet: string; + annotation: { what: string; whyNow: string }; + isPolling?: boolean; + /** Unique key for polling aggregation when multiple calls share the same method+phase */ + pollingId?: string; +} + +/** Callback signature for optional SDK call reporting in shared hooks */ +export type SDKCallReporter = ( + method: string, + args: Record, + result?: unknown, + durationMs?: number +) => void; diff --git a/packages/shared-utils/src/inspector/useSDKInspector.ts b/packages/shared-utils/src/inspector/useSDKInspector.ts new file mode 100644 index 0000000..3d93dc2 --- /dev/null +++ b/packages/shared-utils/src/inspector/useSDKInspector.ts @@ -0,0 +1,19 @@ +import { useSyncExternalStore, useCallback } from "react"; +import { inspectorStore } from "./store.js"; + +const subscribe = (listener: () => void) => inspectorStore.subscribe(listener); +const getSnapshot = () => inspectorStore.getSnapshot(); + +export function useSDKInspector() { + return useSyncExternalStore(subscribe, getSnapshot); +} + +export function useSDKInspectorActions() { + const toggle = useCallback(() => { + const current = inspectorStore.getSnapshot().enabled; + inspectorStore.setEnabled(!current); + }, []); + const clear = useCallback(() => inspectorStore.clearCalls(), []); + const setEnabled = useCallback((v: boolean) => inspectorStore.setEnabled(v), []); + return { toggle, clear, setEnabled }; +} diff --git a/packages/shared-utils/src/types/transfer.ts b/packages/shared-utils/src/types/transfer.ts index d0eed10..824b7af 100644 --- a/packages/shared-utils/src/types/transfer.ts +++ b/packages/shared-utils/src/types/transfer.ts @@ -19,6 +19,8 @@ export interface LastTransferContext { tokenDecimals: number; /** Token decimals on the destination chain (may differ, e.g. 9 on Solana vs 18 on EVM) */ destTokenDecimals: number; + /** Token address on the destination chain (resolved from pool info) */ + remoteToken?: string | null; /** Captured before executeTransfer for "before vs after" display */ initialSourceBalance?: bigint | null; /** Captured before executeTransfer for "before vs after" display */