Skip to main content

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, and ComposeProvider
  • Create a compose config with createComposeConfig(...)
  • Configure accountAbstractionContracts for 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 ComposeL2ToL2Bridge address 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

  1. Import the required dependencies: These imports cover React state, wallet connection, chain switching, smart-account access, and cross-rollup composition.

    src/RollupToRollupBridge.tsx
    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';
  2. Define the bridge address and ABI: Use the deployed ComposeL2ToL2Bridge address for your target environment and include only the functions used in this flow.

    src/RollupToRollupBridge.tsx
    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);
  3. 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'
    };
  4. 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.tsx
    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;
  5. 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.tsx
    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'
    }
    ]);
  6. Prepare, compose, and send the cross-rollup payload: Prepare both UserOperations, compose them into one payload, and submit them together.

    src/RollupToRollupBridge.tsx
    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();
  7. Render the wallet and bridge actions: Wrap the bridge logic from the previous steps in a handleBridge function, then use that function in the wallet connection, bridge form, and result states shown below.

    src/RollupToRollupBridge.tsx
    async 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
src/RollupToRollupBridge.tsx
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>
);
}
src/App.tsx
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 of receiveETH(...)
  • 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 sessionId and 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