Fund Rollup from Sepolia
Move ETH from Sepolia to Rollup A with a direct bridge transaction from your connected wallet.
This flow starts on Sepolia, sends ETH through ComposeL1Bridge, and delivers the bridged ETH to the same wallet address on Rollup A after the deposit is finalized on the destination rollup.
Prerequisites
Before building this flow, complete the Ethera SDK setup:
- Wrap your app with
WagmiProvider,QueryClientProvider, andComposeProvider - Create a compose config with
createComposeConfig(...) - Configure
accountAbstractionContractsfor Rollup A - Install and connect a browser wallet such as MetaMask
- Keep ETH in your connected Sepolia wallet for the bridge amount and Sepolia gas
Project Setup
If you started from the Ethera SDK setup, add Sepolia to your wagmi chain list so the wallet can connect to the source chain used by this bridge transaction.
import { createComposeConfig } from '@ssv-labs/ethera-sdk';
import { createConfig, http } from '@wagmi/core';
import { createPublicClient, rpcSchema } from 'viem';
import { sepolia } from 'viem/chains';
import { injected } from 'wagmi/connectors';
import { rollupA, rollupsAccountAbstractionContracts } from '@ssv-labs/ethera-sdk';
import type { ComposeRpcSchema } from '@ssv-labs/ethera-sdk';
export const wagmiConfig = createConfig({
chains: [sepolia, rollupA],
connectors: [injected()],
client(parameters) {
return createPublicClient({
chain: parameters.chain,
transport: http(parameters.chain.rpcUrls.default.http[0]),
rpcSchema: rpcSchema<ComposeRpcSchema>()
});
}
});
export const composeConfig = createComposeConfig({
wagmi: wagmiConfig,
accountAbstractionContracts: {
[rollupA.id]: rollupsAccountAbstractionContracts
}
});
Place the bridge component in src/SepoliaToRollupBridge.tsx, then render it inside the component already mounted by your SDK provider setup.
Sepolia to Rollup Bridge
-
Import the bridge dependencies: Pull in the wagmi hooks used for wallet connection and chain switching, plus the chain definitions and helpers needed to submit the bridge transaction.
src/SepoliaToRollupBridge.tsximport { useState } from 'react';
import { useMutation } from '@tanstack/react-query';
import { rollupA } from '@ssv-labs/ethera-sdk';
import { parseEther } from 'viem';
import { sepolia } from 'viem/chains';
import {
useAccount,
useChainId,
useConnect,
useDisconnect,
usePublicClient,
useSwitchChain,
useWalletClient
} from 'wagmi'; -
Define the bridge address and ABI: Use the deployed
ComposeL1Bridgeaddress for Rollup A on Sepolia and keep the ABI limited to the single function used by this flow.src/SepoliaToRollupBridge.tsxconst composeL1BridgeAddress = '0xF3504fc6AAB6Da84cc466bB707a109a8824a0c24' as const;
const minGasLimit = 200_000;
const composeL1BridgeAbi = [
{
type: 'function',
name: 'bridgeETHTo',
stateMutability: 'payable',
inputs: [
{ name: '_to', type: 'address' },
{ name: '_minGasLimit', type: 'uint32' },
{ name: '_extraData', type: 'bytes' }
],
outputs: []
}
] as const; -
Define the basic UI styles: Add a small set of inline styles so the bridge interface renders as a compact card instead of plain text.
src/SepoliaToRollupBridge.tsxconst shellStyle = {
maxWidth: '860px',
margin: '32px auto',
padding: '0 16px'
} as const;
const cardStyle = {
border: '1px solid #d9e2ec',
borderRadius: '20px',
padding: '24px',
background: '#ffffff',
boxShadow: '0 18px 40px rgba(15, 23, 42, 0.08)'
} as const;
const headingStyle = {
margin: '0 0 8px',
fontSize: '1.75rem'
} as const;
const mutedTextStyle = {
margin: 0,
color: '#52606d',
lineHeight: 1.6
} as const;
const addressTextStyle = {
margin: '4px 0 0',
color: '#102a43',
lineHeight: 1.5,
overflowWrap: 'anywhere'
} as const;
const sectionStyle = {
marginTop: '24px'
} as const;
const metadataGridStyle = {
display: 'grid',
gap: '16px',
gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))'
} as const;
const metadataCardStyle = {
border: '1px solid #e5edf5',
borderRadius: '16px',
padding: '16px',
background: '#f8fbff'
} as const;
const labelStyle = {
display: 'block',
fontWeight: 600,
color: '#243b53'
} as const;
const inputStyle = {
width: '100%',
padding: '14px 16px',
borderRadius: '14px',
border: '1px solid #bcccdc',
fontSize: '1rem',
boxSizing: 'border-box' as const
} as const;
const buttonRowStyle = {
display: 'flex',
gap: '12px',
flexWrap: 'wrap' as const
} as const;
const primaryButtonStyle = {
border: 'none',
borderRadius: '14px',
padding: '14px 18px',
background: '#0f172a',
color: '#ffffff',
fontWeight: 600,
cursor: 'pointer'
} as const;
const secondaryButtonStyle = {
border: '1px solid #bcccdc',
borderRadius: '14px',
padding: '14px 18px',
background: '#ffffff',
color: '#102a43',
fontWeight: 600,
cursor: 'pointer'
} as const;
const statusStyle = {
marginTop: '16px',
padding: '14px 16px',
borderRadius: '14px',
background: '#ecfdf3',
color: '#166534'
} as const;
const errorStyle = {
marginTop: '16px',
padding: '14px 16px',
borderRadius: '14px',
background: '#fef2f2',
color: '#b91c1c'
} as const;
const linkListStyle = {
marginTop: '12px',
display: 'flex',
flexDirection: 'column' as const,
gap: '8px'
} as const;
const linkStyle = {
color: '#1d4ed8',
textDecoration: 'none'
} as const; -
Load wallet state and public clients: Read the connected wallet, current chain, and Sepolia public client from wagmi so the component can gate the flow before the bridge transaction is submitted.
src/SepoliaToRollupBridge.tsxexport function SepoliaToRollupBridge() {
const [amountInput, setAmountInput] = useState('0.1');
const { address, isConnected } = useAccount();
const chainId = useChainId();
const { connectors, connect, isPending: isConnecting } = useConnect();
const { disconnect } = useDisconnect();
const { switchChainAsync, isPending: isSwitching } = useSwitchChain();
const { data: walletClient } = useWalletClient({ chainId: sepolia.id });
const sepoliaPublicClient = usePublicClient({ chainId: sepolia.id });
const injectedConnector = connectors[0]; -
Submit the bridge transaction: Parse the ETH amount, call
bridgeETHTo(...)from the connected Sepolia wallet, and wait for the Sepolia transaction receipt before showing the explorer links.src/SepoliaToRollupBridge.tsxconst bridgeMutation = useMutation({
mutationFn: async () => {
const normalizedAmount = amountInput.trim();
if (!normalizedAmount) {
throw new Error('Enter an amount to bridge');
}
if (!address) {
throw new Error('Connect your wallet before bridging');
}
if (!walletClient) {
throw new Error('Wallet client is not ready on Sepolia');
}
if (!sepoliaPublicClient) {
throw new Error('Sepolia public client is not available');
}
const amount = parseEther(normalizedAmount);
const hash = await walletClient.writeContract({
address: composeL1BridgeAddress,
abi: composeL1BridgeAbi,
functionName: 'bridgeETHTo',
args: [address, minGasLimit, '0x'],
value: amount,
chain: sepolia,
account: address
});
await sepoliaPublicClient.waitForTransactionReceipt({ hash });
const sourceExplorerUrl = new URL(`tx/${hash}`, sepolia.blockExplorers.default.url).toString();
const destinationAddressUrl = new URL(`address/${address}`, rollupA.blockExplorers.default.url).toString();
return {
sourceExplorerUrl,
destinationAddressUrl
};
}
}); -
Render the wallet and bridge actions: Reuse the bridge logic from the previous step and use this section to show the connection states, chain switch state, form, and result UI.
src/SepoliaToRollupBridge.tsxreturn (
<div style={shellStyle}>
<div style={cardStyle}>
<h1 style={headingStyle}>Fund Rollup A from Sepolia</h1>
<p style={mutedTextStyle}>
Send ETH from your connected Sepolia wallet and receive it on Rollup A at the same address.
</p>
<div style={{ ...sectionStyle, ...metadataGridStyle }}>
<div style={metadataCardStyle}>
<span style={labelStyle}>Connected Wallet</span>
<p style={addressTextStyle}>{address ?? 'Not connected'}</p>
</div>
<div style={metadataCardStyle}>
<span style={labelStyle}>Source Network</span>
<p style={addressTextStyle}>{sepolia.name}</p>
</div>
<div style={metadataCardStyle}>
<span style={labelStyle}>Destination Network</span>
<p style={addressTextStyle}>{rollupA.name}</p>
</div>
</div>
{!isConnected ? (
<div style={sectionStyle}>
<div style={buttonRowStyle}>
<button
type="button"
onClick={() => injectedConnector && connect({ connector: injectedConnector })}
disabled={!injectedConnector || isConnecting}
style={primaryButtonStyle}
>
{isConnecting ? 'Connecting...' : 'Connect Wallet'}
</button>
</div>
</div>
) : chainId !== sepolia.id ? (
<div style={sectionStyle}>
<p style={mutedTextStyle}>Switch your wallet to Sepolia before submitting the bridge transaction.</p>
<div style={{ ...buttonRowStyle, marginTop: '12px' }}>
<button
type="button"
onClick={() => switchChainAsync({ chainId: sepolia.id })}
disabled={isSwitching}
style={primaryButtonStyle}
>
{isSwitching ? 'Switching...' : 'Switch to Sepolia'}
</button>
<button type="button" onClick={() => disconnect()} style={secondaryButtonStyle}>
Disconnect
</button>
</div>
</div>
) : (
<div style={sectionStyle}>
<label style={labelStyle} htmlFor="bridge-amount">
Amount in ETH
</label>
<input
id="bridge-amount"
type="text"
inputMode="decimal"
value={amountInput}
onChange={(event) => setAmountInput(event.target.value)}
placeholder="0.1"
style={{ ...inputStyle, marginTop: '8px' }}
/>
<div style={{ ...buttonRowStyle, marginTop: '16px' }}>
<button
type="button"
onClick={() => bridgeMutation.mutate()}
disabled={bridgeMutation.isPending}
style={primaryButtonStyle}
>
{bridgeMutation.isPending ? 'Bridging...' : 'Bridge ETH to Rollup A'}
</button>
<button type="button" onClick={() => disconnect()} style={secondaryButtonStyle}>
Disconnect
</button>
</div>
</div>
)}
{bridgeMutation.isSuccess ? (
<div style={statusStyle}>
<strong>Bridge submitted on Sepolia.</strong>
<div style={linkListStyle}>
<a
href={bridgeMutation.data.sourceExplorerUrl}
target="_blank"
rel="noopener noreferrer"
style={linkStyle}
>
View the Sepolia transaction
</a>
<a
href={bridgeMutation.data.destinationAddressUrl}
target="_blank"
rel="noopener noreferrer"
style={linkStyle}
>
View the destination wallet on Rollup A
</a>
</div>
</div>
) : null}
{bridgeMutation.isError ? (
<div style={errorStyle}>
<strong>Bridge failed.</strong>
<div>{bridgeMutation.error.message}</div>
</div>
) : null}
</div>
</div>
);
}
Complete Example
import { useState } from 'react';
import { useMutation } from '@tanstack/react-query';
import { rollupA } from '@ssv-labs/ethera-sdk';
import { parseEther } from 'viem';
import { sepolia } from 'viem/chains';
import {
useAccount,
useChainId,
useConnect,
useDisconnect,
usePublicClient,
useSwitchChain,
useWalletClient
} from 'wagmi';
const composeL1BridgeAddress = '0xF3504fc6AAB6Da84cc466bB707a109a8824a0c24' as const;
const minGasLimit = 200_000;
const composeL1BridgeAbi = [
{
type: 'function',
name: 'bridgeETHTo',
stateMutability: 'payable',
inputs: [
{ name: '_to', type: 'address' },
{ name: '_minGasLimit', type: 'uint32' },
{ name: '_extraData', type: 'bytes' }
],
outputs: []
}
] as const;
const shellStyle = {
maxWidth: '860px',
margin: '32px auto',
padding: '0 16px'
} as const;
const cardStyle = {
border: '1px solid #d9e2ec',
borderRadius: '20px',
padding: '24px',
background: '#ffffff',
boxShadow: '0 18px 40px rgba(15, 23, 42, 0.08)'
} as const;
const headingStyle = {
margin: '0 0 8px',
fontSize: '1.75rem'
} as const;
const mutedTextStyle = {
margin: 0,
color: '#52606d',
lineHeight: 1.6
} as const;
const addressTextStyle = {
margin: '4px 0 0',
color: '#102a43',
lineHeight: 1.5,
overflowWrap: 'anywhere'
} as const;
const sectionStyle = {
marginTop: '24px'
} as const;
const metadataGridStyle = {
display: 'grid',
gap: '16px',
gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))'
} as const;
const metadataCardStyle = {
border: '1px solid #e5edf5',
borderRadius: '16px',
padding: '16px',
background: '#f8fbff'
} as const;
const labelStyle = {
display: 'block',
fontWeight: 600,
color: '#243b53'
} as const;
const inputStyle = {
width: '100%',
padding: '14px 16px',
borderRadius: '14px',
border: '1px solid #bcccdc',
fontSize: '1rem',
boxSizing: 'border-box' as const
} as const;
const buttonRowStyle = {
display: 'flex',
gap: '12px',
flexWrap: 'wrap' as const
} as const;
const primaryButtonStyle = {
border: 'none',
borderRadius: '14px',
padding: '14px 18px',
background: '#0f172a',
color: '#ffffff',
fontWeight: 600,
cursor: 'pointer'
} as const;
const secondaryButtonStyle = {
border: '1px solid #bcccdc',
borderRadius: '14px',
padding: '14px 18px',
background: '#ffffff',
color: '#102a43',
fontWeight: 600,
cursor: 'pointer'
} as const;
const statusStyle = {
marginTop: '16px',
padding: '14px 16px',
borderRadius: '14px',
background: '#ecfdf3',
color: '#166534'
} as const;
const errorStyle = {
marginTop: '16px',
padding: '14px 16px',
borderRadius: '14px',
background: '#fef2f2',
color: '#b91c1c'
} as const;
const linkListStyle = {
marginTop: '12px',
display: 'flex',
flexDirection: 'column' as const,
gap: '8px'
} as const;
const linkStyle = {
color: '#1d4ed8',
textDecoration: 'none'
} as const;
export function SepoliaToRollupBridge() {
const [amountInput, setAmountInput] = useState('0.1');
const { address, isConnected } = useAccount();
const chainId = useChainId();
const { connectors, connect, isPending: isConnecting } = useConnect();
const { disconnect } = useDisconnect();
const { switchChainAsync, isPending: isSwitching } = useSwitchChain();
const { data: walletClient } = useWalletClient({ chainId: sepolia.id });
const sepoliaPublicClient = usePublicClient({ chainId: sepolia.id });
const injectedConnector = connectors[0];
const bridgeMutation = useMutation({
mutationFn: async () => {
const normalizedAmount = amountInput.trim();
if (!normalizedAmount) {
throw new Error('Enter an amount to bridge');
}
if (!address) {
throw new Error('Connect your wallet before bridging');
}
if (!walletClient) {
throw new Error('Wallet client is not ready on Sepolia');
}
if (!sepoliaPublicClient) {
throw new Error('Sepolia public client is not available');
}
const amount = parseEther(normalizedAmount);
const hash = await walletClient.writeContract({
address: composeL1BridgeAddress,
abi: composeL1BridgeAbi,
functionName: 'bridgeETHTo',
args: [address, minGasLimit, '0x'],
value: amount,
chain: sepolia,
account: address
});
await sepoliaPublicClient.waitForTransactionReceipt({ hash });
const sourceExplorerUrl = new URL(`tx/${hash}`, sepolia.blockExplorers.default.url).toString();
const destinationAddressUrl = new URL(`address/${address}`, rollupA.blockExplorers.default.url).toString();
return {
sourceExplorerUrl,
destinationAddressUrl
};
}
});
return (
<div style={shellStyle}>
<div style={cardStyle}>
<h1 style={headingStyle}>Fund Rollup A from Sepolia</h1>
<p style={mutedTextStyle}>
Send ETH from your connected Sepolia wallet and receive it on Rollup A at the same address.
</p>
<div style={{ ...sectionStyle, ...metadataGridStyle }}>
<div style={metadataCardStyle}>
<span style={labelStyle}>Connected Wallet</span>
<p style={addressTextStyle}>{address ?? 'Not connected'}</p>
</div>
<div style={metadataCardStyle}>
<span style={labelStyle}>Source Network</span>
<p style={addressTextStyle}>{sepolia.name}</p>
</div>
<div style={metadataCardStyle}>
<span style={labelStyle}>Destination Network</span>
<p style={addressTextStyle}>{rollupA.name}</p>
</div>
</div>
{!isConnected ? (
<div style={sectionStyle}>
<div style={buttonRowStyle}>
<button
type="button"
onClick={() => injectedConnector && connect({ connector: injectedConnector })}
disabled={!injectedConnector || isConnecting}
style={primaryButtonStyle}
>
{isConnecting ? 'Connecting...' : 'Connect Wallet'}
</button>
</div>
</div>
) : chainId !== sepolia.id ? (
<div style={sectionStyle}>
<p style={mutedTextStyle}>Switch your wallet to Sepolia before submitting the bridge transaction.</p>
<div style={{ ...buttonRowStyle, marginTop: '12px' }}>
<button
type="button"
onClick={() => switchChainAsync({ chainId: sepolia.id })}
disabled={isSwitching}
style={primaryButtonStyle}
>
{isSwitching ? 'Switching...' : 'Switch to Sepolia'}
</button>
<button type="button" onClick={() => disconnect()} style={secondaryButtonStyle}>
Disconnect
</button>
</div>
</div>
) : (
<div style={sectionStyle}>
<label style={labelStyle} htmlFor="bridge-amount">
Amount in ETH
</label>
<input
id="bridge-amount"
type="text"
inputMode="decimal"
value={amountInput}
onChange={(event) => setAmountInput(event.target.value)}
placeholder="0.1"
style={{ ...inputStyle, marginTop: '8px' }}
/>
<div style={{ ...buttonRowStyle, marginTop: '16px' }}>
<button
type="button"
onClick={() => bridgeMutation.mutate()}
disabled={bridgeMutation.isPending}
style={primaryButtonStyle}
>
{bridgeMutation.isPending ? 'Bridging...' : 'Bridge ETH to Rollup A'}
</button>
<button type="button" onClick={() => disconnect()} style={secondaryButtonStyle}>
Disconnect
</button>
</div>
</div>
)}
{bridgeMutation.isSuccess ? (
<div style={statusStyle}>
<strong>Bridge submitted on Sepolia.</strong>
<div style={linkListStyle}>
<a
href={bridgeMutation.data.sourceExplorerUrl}
target="_blank"
rel="noopener noreferrer"
style={linkStyle}
>
View the Sepolia transaction
</a>
<a
href={bridgeMutation.data.destinationAddressUrl}
target="_blank"
rel="noopener noreferrer"
style={linkStyle}
>
View the destination wallet on Rollup A
</a>
</div>
</div>
) : null}
{bridgeMutation.isError ? (
<div style={errorStyle}>
<strong>Bridge failed.</strong>
<div>{bridgeMutation.error.message}</div>
</div>
) : null}
</div>
</div>
);
}
Expected Result
- The wallet connects before the bridge form appears
- The bridge action only becomes available once the wallet is on Sepolia
- The source transaction is submitted on Sepolia
- The bridged ETH becomes available on Rollup A after the deposit is finalized
Notes
- This tutorial uses a direct
bridgeETHTo(...)call from the connected Sepolia wallet - It does not use smart accounts or a paymaster, because the bridge transaction is submitted as a normal L1 wallet transaction
- For a complete end-to-end reference implementation of Ethera bridge flows, see the Ethera Bridge webapp