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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions examples/03-multichain-bridge-dapp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,49 @@ Optional: copy `.env.example` to `.env`. Set `RPC_<NETWORK_ID>` (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`).
Expand Down
5 changes: 5 additions & 0 deletions examples/03-multichain-bridge-dapp/src/App.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
.appBody {
display: flex;
flex: 1;
position: relative;
}
102 changes: 66 additions & 36 deletions examples/03-multichain-bridge-dapp/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";
Expand All @@ -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). */
Expand Down Expand Up @@ -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);
Expand All @@ -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)
Expand Down Expand Up @@ -124,42 +144,52 @@ function AppContent() {
return (
<div className={styles.app}>
<Header title="Multichain Family Bridge" subtitle="EVM, Solana, and Aptos token transfers">
<SDKInspectorToggle />
<HistoryButton />
</Header>
<main className={`${styles.main} ${styles.mainWide}`}>
<div className={styles.section}>
<WalletConnect />
</div>

{isConnected && (
<>
<BridgeForm
walletAddresses={walletAddresses}
currentChainId={chainId ?? null}
fee={transfer.fee}
feeFormatted={transfer.feeFormatted}
estimatedTime={transfer.estimatedTime}
isLoading={isLoading}
onEstimateFee={handleEstimateFee}
onTransfer={handleTransfer}
onSwitchChain={handleSwitchChain}
onClearEstimate={transfer.clearEstimate}
onReset={transfer.reset}
/>

<TransactionStatusView
status={transfer.status}
error={transfer.error}
txHash={transfer.txHash}
messageId={transfer.messageId}
estimatedTime={transfer.estimatedTime}
onReset={transfer.reset}
lastTransferContext={transfer.lastTransferContext}
categorizedError={transfer.categorizedError}
/>
</>

<div className={appStyles.appBody}>
{inspectorEnabled && (
<Suspense fallback={null}>
<SDKInspectorPanel />
</Suspense>
)}
</main>

<main className={`${styles.main} ${styles.mainWide}`}>
<div className={styles.section}>
<WalletConnect />
</div>

{isConnected && (
<>
<BridgeForm
walletAddresses={walletAddresses}
currentChainId={chainId ?? null}
fee={transfer.fee}
feeFormatted={transfer.feeFormatted}
estimatedTime={transfer.estimatedTime}
isLoading={isLoading}
onEstimateFee={handleEstimateFee}
onTransfer={handleTransfer}
onSwitchChain={handleSwitchChain}
onClearEstimate={transfer.clearEstimate}
onReset={transfer.reset}
/>

<TransactionStatusView
status={transfer.status}
error={transfer.error}
txHash={transfer.txHash}
messageId={transfer.messageId}
estimatedTime={transfer.estimatedTime}
onReset={transfer.reset}
lastTransferContext={transfer.lastTransferContext}
categorizedError={transfer.categorizedError}
/>
</>
)}
</main>
</div>

<TransactionHistory />
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -62,7 +65,8 @@ interface BridgeFormProps {
token: string,
amount: string,
receiver: string,
feeToken: FeeTokenOptionItem | null
feeToken: FeeTokenOptionItem | null,
remoteToken: string | null
) => Promise<void>;
onSwitchChain: (chainId: number) => void;
onClearEstimate: () => void;
Expand All @@ -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<string | null>(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]
);
Expand Down Expand Up @@ -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<string, string>, 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,
Expand All @@ -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") */
Expand Down Expand Up @@ -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 = () => {
Expand Down Expand Up @@ -258,6 +300,7 @@ export function BridgeForm({
tokenAddress={tokenAddress ?? undefined}
tokenDecimals={token?.decimals}
tokenSymbol={TOKEN_SYMBOL}
onRemoteTokenResolved={handleRemoteTokenResolved}
/>

<div className={styles.tokenRow}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading