Rollup to Rollup Bridge
In this tutorial, you will learn how to bridge ETH from Rollup A to Rollup B using a single cross-rollup flow. You will fund a smart account on Rollup A, execute the bridge across both rollups, and receive the ETH back in your wallet on Rollup B.
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 and Rollup B
You will also need:
- A browser wallet extension that exposes an injected connector, such as MetaMask
- ETH in the connected wallet on Rollup A for the bridge amount and gas
- The current
ComposeL2ToL2Bridgeaddress from Testnet, or your own deployment address
Project Setup
If your project does not already include the Ethera SDK and its peers, install them first:
npm install @ssv-labs/ethera-sdk @wagmi/core wagmi viem @tanstack/react-query
This tutorial assumes your app is already wrapped with the providers shown in Ethera SDK.
For the simplest setup, place the bridge logic in src/RollupToRollupBridge.tsx, then render that component from src/App.tsx.
Cross Rollup Bridge
-
Import the required dependencies: These imports cover React state, wallet connection, chain switching, smart-account access, and cross-rollup composition.
src/RollupToRollupBridge.tsximport { useState } from 'react';
import { useMutation } from '@tanstack/react-query';
import { createAbiEncoder, composePreparedUserOps, rollupA, rollupB } from '@ssv-labs/ethera-sdk';
import { useSmartAccount } from '@ssv-labs/ethera-sdk/react';
import { prepareUserOperation } from 'viem/account-abstraction';
import { parseEther } from 'viem';
import { useAccount, useChainId, useConnect, useDisconnect, useSwitchChain, useWalletClient } from 'wagmi'; -
Define the bridge address and ABI: Use the deployed
ComposeL2ToL2Bridgeaddress for your target environment and include only the functions used in this flow.src/RollupToRollupBridge.tsxconst composeL2ToL2BridgeAddress = '0x57d93F3E2fD17E3e42A24F113D5728441Bf79cb3' as const;
const universalBridgeAbi = [
{
type: 'function',
name: 'bridgeEthTo',
stateMutability: 'payable',
inputs: [
{ name: 'sessionId', type: 'uint256' },
{ name: 'chainDest', type: 'uint256' },
{ name: 'receiver', type: 'address' }
],
outputs: []
},
{
type: 'function',
name: 'receiveETH',
stateMutability: 'nonpayable',
inputs: [
{
name: 'msgHeader',
type: 'tuple',
components: [
{ name: 'chainSrc', type: 'uint256' },
{ name: 'chainDest', type: 'uint256' },
{ name: 'sender', type: 'address' },
{ name: 'receiver', type: 'address' },
{ name: 'sessionId', type: 'uint256' },
{ name: 'label', type: 'string' }
]
}
],
outputs: [{ name: 'amount', type: 'uint256' }]
}
] as const;
const bridge = createAbiEncoder(universalBridgeAbi); -
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/RollupToRollupBridge.tsx
const shellStyle = {
maxWidth: '920px',
margin: '48px auto',
padding: '24px'
};
const cardStyle = {
border: '1px solid #e5e7eb',
borderRadius: '20px',
background: '#ffffff',
boxShadow: '0 20px 45px rgba(15, 23, 42, 0.08)',
padding: '28px'
};
const headingStyle = {
margin: 0,
fontSize: '28px',
color: '#0f172a'
};
const mutedTextStyle = {
margin: 0,
color: '#475569',
lineHeight: 1.6
};
const addressTextStyle = {
margin: 0,
color: '#475569',
lineHeight: 1.6,
overflowWrap: 'anywhere' as const
};
const sectionStyle = {
display: 'grid',
gap: '12px',
marginTop: '24px'
};
const metadataGridStyle = {
display: 'grid',
gap: '12px',
gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))'
};
const metadataCardStyle = {
padding: '14px 16px',
borderRadius: '14px',
background: '#f8fafc',
border: '1px solid #e2e8f0'
};
const labelStyle = {
display: 'block',
marginBottom: '8px',
fontSize: '14px',
fontWeight: 600,
color: '#334155'
};
const inputStyle = {
width: '100%',
boxSizing: 'border-box' as const,
padding: '12px 14px',
borderRadius: '12px',
border: '1px solid #cbd5e1',
fontSize: '16px'
};
const buttonRowStyle = {
display: 'flex',
flexWrap: 'wrap' as const,
gap: '12px'
};
const primaryButtonStyle = {
padding: '12px 16px',
borderRadius: '12px',
border: 'none',
background: '#0f172a',
color: '#ffffff',
fontWeight: 600,
cursor: 'pointer'
};
const secondaryButtonStyle = {
padding: '12px 16px',
borderRadius: '12px',
border: '1px solid #cbd5e1',
background: '#ffffff',
color: '#0f172a',
fontWeight: 600,
cursor: 'pointer'
};
const statusStyle = {
padding: '14px 16px',
borderRadius: '14px',
background: '#f8fafc',
border: '1px solid #e2e8f0',
display: 'grid',
gap: '10px'
};
const errorStyle = {
padding: '12px 14px',
borderRadius: '12px',
background: '#fef2f2',
border: '1px solid #fecaca',
color: '#b91c1c'
};
const linkListStyle = {
display: 'grid',
gap: '8px'
};
const linkStyle = {
color: '#2563eb',
textDecoration: 'none'
}; -
Load the wallet state and smart accounts: Track the transfer amount, read the current chain, expose wallet actions, and create one smart-account query per rollup.
src/RollupToRollupBridge.tsxconst [amountInput, setAmountInput] = useState('0.01');
const { address, isConnected } = useAccount();
const chainId = useChainId();
const { connect, connectors, isPending: isConnectPending } = useConnect();
const { disconnect } = useDisconnect();
const { data: walletClient } = useWalletClient();
const { switchChain, isPending: isSwitchingChain } = useSwitchChain();
const injectedConnectors = connectors.filter((connector) => connector.type === 'injected');
const smartAccountAQuery = useSmartAccount({
chainId: rollupA.id,
multiChainIds: [rollupA.id, rollupB.id]
});
const smartAccountBQuery = useSmartAccount({
chainId: rollupB.id,
multiChainIds: [rollupA.id, rollupB.id]
});
const smartAccountA = smartAccountAQuery.data?.account;
const publicClientA = smartAccountAQuery.data?.publicClient;
const smartAccountB = smartAccountBQuery.data?.account;
const publicClientB = smartAccountBQuery.data?.publicClient; -
Fund the source smart account and build the
UserOperations: Parse the entered ETH amount, fund the source smart account from the connected wallet, and then create one source bridge operation and one destination receive operation for the same transfer.src/RollupToRollupBridge.tsxconst normalizedAmount = amountInput.trim();
if (!normalizedAmount) {
throw new Error('Enter an amount to bridge');
}
const amount = parseEther(normalizedAmount);
const sessionId = BigInt(Date.now());
const destinationReceiver = smartAccountB.address;
if (!walletClient) {
throw new Error('Wallet client is not ready');
}
const fundingHash = await walletClient.sendTransaction({
to: smartAccountA.address,
value: amount,
account: address,
chain: rollupA
});
await publicClientA.waitForTransactionReceipt({ hash: fundingHash });
const { userOp: sourceUserOp } = await smartAccountA.createUserOp([
{
to: composeL2ToL2BridgeAddress,
value: amount,
data: bridge.bridgeEthTo({
sessionId,
chainDest: BigInt(rollupB.id),
receiver: destinationReceiver
})
}
]);
const { userOp: destinationUserOp } = await smartAccountB.createUserOp([
{
to: composeL2ToL2BridgeAddress,
value: 0n,
data: bridge.receiveETH({
msgHeader: {
chainSrc: BigInt(rollupA.id),
chainDest: BigInt(rollupB.id),
sender: composeL2ToL2BridgeAddress,
receiver: destinationReceiver,
sessionId,
label: 'SEND_ETH'
}
})
},
{
to: address,
value: amount,
data: '0x'
}
]); -
Prepare, compose, and send the cross-rollup payload: Prepare both
UserOperations, compose them into one payload, and submit them together.src/RollupToRollupBridge.tsxconst preparedSourceUserOp = await prepareUserOperation(publicClientA, sourceUserOp);
const preparedDestinationUserOp = await prepareUserOperation(publicClientB, destinationUserOp);
const { send, explorerUrls } = await composePreparedUserOps([
{
account: smartAccountA,
publicClient: publicClientA,
userOp: preparedSourceUserOp
},
{
account: smartAccountB,
publicClient: publicClientB,
userOp: preparedDestinationUserOp
}
]);
const { wait, hashes } = await send();
const receipts = await wait(); -
Render the wallet and bridge actions: Wrap the bridge logic from the previous steps in a
handleBridgefunction, then use that function in the wallet connection, bridge form, and result states shown below.src/RollupToRollupBridge.tsxasync function handleBridge() {
// Reuse the funding, UserOperation, and compose flow from steps 4 and 5.
}
const bridgeMutation = useMutation({
mutationFn: handleBridge
});
if (!isConnected) {
if (!injectedConnectors.length) {
return (
<div style={shellStyle}>
<div style={cardStyle}>
<h1 style={headingStyle}>Rollup to Rollup Bridge</h1>
<p style={{ ...mutedTextStyle, marginTop: '12px' }}>
Install a browser wallet extension such as MetaMask to continue.
</p>
</div>
</div>
);
}
return (
<div style={shellStyle}>
<div style={cardStyle}>
<h1 style={headingStyle}>Rollup to Rollup Bridge</h1>
<p style={{ ...mutedTextStyle, marginTop: '12px' }}>
Connect a browser wallet to create and control your smart accounts on both rollups.
</p>
<div style={{ ...buttonRowStyle, marginTop: '24px' }}>
{injectedConnectors.map((connector) => (
<button
key={connector.id}
onClick={() => connect({ connector })}
disabled={isConnectPending}
style={primaryButtonStyle}
>
{isConnectPending ? 'Connecting...' : `Connect ${connector.name}`}
</button>
))}
</div>
</div>
</div>
);
}
if (smartAccountAQuery.isLoading || smartAccountBQuery.isLoading) {
return (
<div style={shellStyle}>
<div style={cardStyle}>
<h1 style={headingStyle}>Rollup to Rollup Bridge</h1>
<p style={{ ...mutedTextStyle, marginTop: '12px' }}>Loading smart accounts...</p>
</div>
</div>
);
}
if (chainId !== rollupA.id) {
return (
<div style={shellStyle}>
<div style={cardStyle}>
<h1 style={headingStyle}>Rollup to Rollup Bridge</h1>
<p style={{ ...mutedTextStyle, marginTop: '12px' }}>
Switch your wallet to Rollup A before funding the source smart account and starting the bridge.
</p>
<div style={{ ...buttonRowStyle, marginTop: '24px' }}>
<button
onClick={() => switchChain({ chainId: rollupA.id })}
disabled={isSwitchingChain}
style={primaryButtonStyle}
>
{isSwitchingChain ? 'Switching...' : 'Switch to Rollup A'}
</button>
</div>
</div>
</div>
);
}
return (
<div style={shellStyle}>
<div style={cardStyle}>
<h1 style={headingStyle}>Rollup to Rollup Bridge</h1>
<p style={{ ...mutedTextStyle, marginTop: '12px' }}>
Bridge ETH from Rollup A to Rollup B with one coordinated cross-rollup flow.
</p>
<div style={sectionStyle}>
<div style={metadataGridStyle}>
<div style={metadataCardStyle}>
<span style={labelStyle}>Connected Wallet</span>
<span style={addressTextStyle}>{address ?? 'Not available'}</span>
</div>
<div style={metadataCardStyle}>
<span style={labelStyle}>Shared Smart Account</span>
<span style={addressTextStyle}>{smartAccountA?.address ?? 'Not available'}</span>
</div>
</div>
</div>
<div style={sectionStyle}>
<div>
<label htmlFor="bridge-amount" style={labelStyle}>
Amount to Bridge (ETH)
</label>
<input
id="bridge-amount"
type="text"
inputMode="decimal"
value={amountInput}
onChange={(event) => setAmountInput(event.target.value)}
style={inputStyle}
/>
</div>
<div style={buttonRowStyle}>
<button onClick={() => disconnect()} style={secondaryButtonStyle}>
Disconnect Wallet
</button>
<button
onClick={() => bridgeMutation.mutate()}
disabled={!smartAccountA || !smartAccountB || !walletClient || bridgeMutation.isPending}
style={primaryButtonStyle}
>
{bridgeMutation.isPending ? 'Bridging...' : 'Bridge ETH to Rollup B'}
</button>
</div>
{bridgeMutation.isError && (
<div style={errorStyle}>
{bridgeMutation.error instanceof Error
? bridgeMutation.error.message
: 'Bridge execution failed'}
</div>
)}
{bridgeMutation.isSuccess && (
<div style={statusStyle}>
<span style={{ ...labelStyle, marginBottom: 0 }}>Bridge Completed</span>
<div style={linkListStyle}>
{bridgeMutation.data.explorerUrls.map((url, index) => (
<a
key={url}
href={url}
target="_blank"
rel="noopener noreferrer"
style={linkStyle}
>
View transaction on Chain {index === 0 ? 'A' : 'B'}
</a>
))}
</div>
</div>
)}
</div>
</div>
</div>
);
Complete Example
import { useState } from 'react';
import { useMutation } from '@tanstack/react-query';
import { createAbiEncoder, composePreparedUserOps, rollupA, rollupB } from '@ssv-labs/ethera-sdk';
import { useSmartAccount } from '@ssv-labs/ethera-sdk/react';
import { prepareUserOperation } from 'viem/account-abstraction';
import { parseEther } from 'viem';
import { useAccount, useChainId, useConnect, useDisconnect, useSwitchChain, useWalletClient } from 'wagmi';
const composeL2ToL2BridgeAddress = '0x57d93F3E2fD17E3e42A24F113D5728441Bf79cb3' as const;
const universalBridgeAbi = [
{
type: 'function',
name: 'bridgeEthTo',
stateMutability: 'payable',
inputs: [
{ name: 'sessionId', type: 'uint256' },
{ name: 'chainDest', type: 'uint256' },
{ name: 'receiver', type: 'address' }
],
outputs: []
},
{
type: 'function',
name: 'receiveETH',
stateMutability: 'nonpayable',
inputs: [
{
name: 'msgHeader',
type: 'tuple',
components: [
{ name: 'chainSrc', type: 'uint256' },
{ name: 'chainDest', type: 'uint256' },
{ name: 'sender', type: 'address' },
{ name: 'receiver', type: 'address' },
{ name: 'sessionId', type: 'uint256' },
{ name: 'label', type: 'string' }
]
}
],
outputs: [{ name: 'amount', type: 'uint256' }]
}
] as const;
const bridge = createAbiEncoder(universalBridgeAbi);
const shellStyle = {
maxWidth: '920px',
margin: '48px auto',
padding: '24px'
};
const cardStyle = {
border: '1px solid #e5e7eb',
borderRadius: '20px',
background: '#ffffff',
boxShadow: '0 20px 45px rgba(15, 23, 42, 0.08)',
padding: '28px'
};
const headingStyle = {
margin: 0,
fontSize: '28px',
color: '#0f172a'
};
const mutedTextStyle = {
margin: 0,
color: '#475569',
lineHeight: 1.6
};
const addressTextStyle = {
margin: 0,
color: '#475569',
lineHeight: 1.6,
overflowWrap: 'anywhere' as const
};
const sectionStyle = {
display: 'grid',
gap: '12px',
marginTop: '24px'
};
const metadataGridStyle = {
display: 'grid',
gap: '12px',
gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))'
};
const metadataCardStyle = {
padding: '14px 16px',
borderRadius: '14px',
background: '#f8fafc',
border: '1px solid #e2e8f0'
};
const labelStyle = {
display: 'block',
marginBottom: '8px',
fontSize: '14px',
fontWeight: 600,
color: '#334155'
};
const inputStyle = {
width: '100%',
boxSizing: 'border-box' as const,
padding: '12px 14px',
borderRadius: '12px',
border: '1px solid #cbd5e1',
fontSize: '16px'
};
const buttonRowStyle = {
display: 'flex',
flexWrap: 'wrap' as const,
gap: '12px'
};
const primaryButtonStyle = {
padding: '12px 16px',
borderRadius: '12px',
border: 'none',
background: '#0f172a',
color: '#ffffff',
fontWeight: 600,
cursor: 'pointer'
};
const secondaryButtonStyle = {
padding: '12px 16px',
borderRadius: '12px',
border: '1px solid #cbd5e1',
background: '#ffffff',
color: '#0f172a',
fontWeight: 600,
cursor: 'pointer'
};
const statusStyle = {
padding: '14px 16px',
borderRadius: '14px',
background: '#f8fafc',
border: '1px solid #e2e8f0',
display: 'grid',
gap: '10px'
};
const errorStyle = {
padding: '12px 14px',
borderRadius: '12px',
background: '#fef2f2',
border: '1px solid #fecaca',
color: '#b91c1c'
};
const linkListStyle = {
display: 'grid',
gap: '8px'
};
const linkStyle = {
color: '#2563eb',
textDecoration: 'none'
};
export function RollupToRollupBridge() {
const [amountInput, setAmountInput] = useState('0.01');
const { address, isConnected } = useAccount();
const chainId = useChainId();
const { connect, connectors, isPending: isConnectPending } = useConnect();
const { disconnect } = useDisconnect();
const { data: walletClient } = useWalletClient();
const { switchChain, isPending: isSwitchingChain } = useSwitchChain();
const injectedConnectors = connectors.filter((connector) => connector.type === 'injected');
const smartAccountAQuery = useSmartAccount({
chainId: rollupA.id,
multiChainIds: [rollupA.id, rollupB.id]
});
const smartAccountBQuery = useSmartAccount({
chainId: rollupB.id,
multiChainIds: [rollupA.id, rollupB.id]
});
const smartAccountA = smartAccountAQuery.data?.account;
const publicClientA = smartAccountAQuery.data?.publicClient;
const smartAccountB = smartAccountBQuery.data?.account;
const publicClientB = smartAccountBQuery.data?.publicClient;
const bridgeMutation = useMutation({
mutationFn: async () => {
if (!address || !smartAccountA || !publicClientA || !smartAccountB || !publicClientB) {
throw new Error('Smart accounts are not ready');
}
const normalizedAmount = amountInput.trim();
if (!normalizedAmount) {
throw new Error('Enter an amount to bridge');
}
const amount = parseEther(normalizedAmount);
const sessionId = BigInt(Date.now());
const destinationReceiver = smartAccountB.address;
if (!walletClient) {
throw new Error('Wallet client is not ready');
}
const fundingHash = await walletClient.sendTransaction({
to: smartAccountA.address,
value: amount,
account: address,
chain: rollupA
});
await publicClientA.waitForTransactionReceipt({ hash: fundingHash });
const { userOp: sourceUserOp } = await smartAccountA.createUserOp([
{
to: composeL2ToL2BridgeAddress,
value: amount,
data: bridge.bridgeEthTo({
sessionId,
chainDest: BigInt(rollupB.id),
receiver: destinationReceiver
})
}
]);
const { userOp: destinationUserOp } = await smartAccountB.createUserOp([
{
to: composeL2ToL2BridgeAddress,
value: 0n,
data: bridge.receiveETH({
msgHeader: {
chainSrc: BigInt(rollupA.id),
chainDest: BigInt(rollupB.id),
sender: composeL2ToL2BridgeAddress,
receiver: destinationReceiver,
sessionId,
label: 'SEND_ETH'
}
})
},
{
to: address,
value: amount,
data: '0x'
}
]);
const preparedSourceUserOp = await prepareUserOperation(publicClientA, sourceUserOp);
const preparedDestinationUserOp = await prepareUserOperation(publicClientB, destinationUserOp);
const { send, explorerUrls } = await composePreparedUserOps([
{
account: smartAccountA,
publicClient: publicClientA,
userOp: preparedSourceUserOp
},
{
account: smartAccountB,
publicClient: publicClientB,
userOp: preparedDestinationUserOp
}
]);
const { wait, hashes } = await send();
const receipts = await wait();
return { explorerUrls, hashes, receipts };
}
});
if (!isConnected) {
if (!injectedConnectors.length) {
return (
<div style={shellStyle}>
<div style={cardStyle}>
<h1 style={headingStyle}>Rollup to Rollup Bridge</h1>
<p style={{ ...mutedTextStyle, marginTop: '12px' }}>
Install a browser wallet extension such as MetaMask to continue.
</p>
</div>
</div>
);
}
return (
<div style={shellStyle}>
<div style={cardStyle}>
<h1 style={headingStyle}>Rollup to Rollup Bridge</h1>
<p style={{ ...mutedTextStyle, marginTop: '12px' }}>
Connect a browser wallet to create and control your smart accounts on both rollups.
</p>
<div style={{ ...buttonRowStyle, marginTop: '24px' }}>
{injectedConnectors.map((connector) => (
<button
key={connector.id}
onClick={() => connect({ connector })}
disabled={isConnectPending}
style={primaryButtonStyle}
>
{isConnectPending ? 'Connecting...' : `Connect ${connector.name}`}
</button>
))}
</div>
</div>
</div>
);
}
if (smartAccountAQuery.isLoading || smartAccountBQuery.isLoading) {
return (
<div style={shellStyle}>
<div style={cardStyle}>
<h1 style={headingStyle}>Rollup to Rollup Bridge</h1>
<p style={{ ...mutedTextStyle, marginTop: '12px' }}>Loading smart accounts...</p>
</div>
</div>
);
}
if (chainId !== rollupA.id) {
return (
<div style={shellStyle}>
<div style={cardStyle}>
<h1 style={headingStyle}>Rollup to Rollup Bridge</h1>
<p style={{ ...mutedTextStyle, marginTop: '12px' }}>
Switch your wallet to Rollup A before funding the source smart account and starting the bridge.
</p>
<div style={{ ...buttonRowStyle, marginTop: '24px' }}>
<button
onClick={() => switchChain({ chainId: rollupA.id })}
disabled={isSwitchingChain}
style={primaryButtonStyle}
>
{isSwitchingChain ? 'Switching...' : 'Switch to Rollup A'}
</button>
</div>
</div>
</div>
);
}
return (
<div style={shellStyle}>
<div style={cardStyle}>
<h1 style={headingStyle}>Rollup to Rollup Bridge</h1>
<p style={{ ...mutedTextStyle, marginTop: '12px' }}>
Bridge ETH from Rollup A to Rollup B with one coordinated cross-rollup flow.
</p>
<div style={sectionStyle}>
<div style={metadataGridStyle}>
<div style={metadataCardStyle}>
<span style={labelStyle}>Connected Wallet</span>
<span style={addressTextStyle}>{address ?? 'Not available'}</span>
</div>
<div style={metadataCardStyle}>
<span style={labelStyle}>Shared Smart Account</span>
<span style={addressTextStyle}>{smartAccountA?.address ?? 'Not available'}</span>
</div>
</div>
</div>
<div style={sectionStyle}>
<div>
<label htmlFor="bridge-amount" style={labelStyle}>
Amount to Bridge (ETH)
</label>
<input
id="bridge-amount"
type="text"
inputMode="decimal"
value={amountInput}
onChange={(event) => setAmountInput(event.target.value)}
style={inputStyle}
/>
</div>
<div style={buttonRowStyle}>
<button onClick={() => disconnect()} style={secondaryButtonStyle}>
Disconnect Wallet
</button>
<button
onClick={() => bridgeMutation.mutate()}
disabled={!smartAccountA || !smartAccountB || !walletClient || bridgeMutation.isPending}
style={primaryButtonStyle}
>
{bridgeMutation.isPending ? 'Bridging...' : 'Bridge ETH to Rollup B'}
</button>
</div>
{bridgeMutation.isError && (
<div style={errorStyle}>
{bridgeMutation.error instanceof Error
? bridgeMutation.error.message
: 'Bridge execution failed'}
</div>
)}
{bridgeMutation.isSuccess && (
<div style={statusStyle}>
<span style={{ ...labelStyle, marginBottom: 0 }}>Bridge Completed</span>
<div style={linkListStyle}>
{bridgeMutation.data.explorerUrls.map((url, index) => (
<a
key={url}
href={url}
target="_blank"
rel="noopener noreferrer"
style={linkStyle}
>
View transaction on Chain {index === 0 ? 'A' : 'B'}
</a>
))}
</div>
</div>
)}
</div>
</div>
</div>
);
}
import { RollupToRollupBridge } from './RollupToRollupBridge';
export default function App() {
return <RollupToRollupBridge />;
}
Expected Result
When the flow succeeds:
- A wallet connect button is shown before the bridge flow starts
- The connected wallet funds the Rollup A smart account before the bridge runs
- The source transaction is built and submitted on Rollup A
- The destination transaction is built and submitted on Rollup B
- The destination smart account receives the bridged ETH and forwards it to the connected wallet
- Explorer links are available for both chain transactions
Notes
- ERC20 and CET routes follow the same composed structure, but use token approvals and
receiveTokens(...)instead ofreceiveETH(...) - Replace the bridge address with your own deployment if you are not targeting the Ethera Sepolia environment
- The example always funds the source smart account from the connected wallet with the requested bridge amount before submitting the bridge
- The same
sessionIdand message header must be used across the source and destination sides of the flow - For a complete end-to-end reference implementation of Ethera bridge flows, see the Ethera Bridge webapp