Smart contracts to manage synchronous rollups in Ethereum.
Sync Rollups enables synchronous composability between based rollups sharing the same L1 sequencer. By pre-computing state transitions off-chain and loading them with ZK proofs, the protocol enables atomic cross-rollup calls that execute within a single L1 block.
This restores the synchronous execution semantics that DeFi protocols depend on—now across multiple rollups.
- Atomic Multi-Rollup Execution: State changes across multiple rollups happen atomically in a single transaction
- Cross-Rollup Flash Loans: Borrow on Rollup A, use on Rollup B, repay on A—all atomic
- Unified Liquidity: AMMs can source liquidity from multiple rollups
- ZK-Verified State Transitions: All executions are verified with ZK proofs
- Scope-Based Nested Calls: Hierarchical scope mechanism for nested cross-chain calls with revert handling
- L1 + L2 Contracts: L1
Rollupscontract manages state and proofs; L2CrossChainManagerL2handles execution without ZK overhead - ETH Balance Tracking: Per-rollup ETH accounting with conservation guarantees
| Contract | Description |
|---|---|
Rollups.sol |
L1 contract managing rollup state roots, ZK-proven execution tables, and cross-chain call execution |
CrossChainProxy.sol |
Proxy contract deployed via CREATE2 for each (address, rollupId) pair. Forwards calls to the manager and executes on behalf of cross-chain callers |
CrossChainManagerL2.sol |
L2-side contract for cross-chain execution via pre-computed execution tables loaded by a system address (no ZK proofs on L2) |
IZKVerifier.sol |
Interface for ZK proof verification |
enum ActionType { CALL, RESULT, L2TX, REVERT, REVERT_CONTINUE }
struct Action {
ActionType actionType;
uint256 rollupId;
address destination; // for CALL
uint256 value; // for CALL
bytes data; // callData/returnData/rlpEncodedTx
bool failed; // for RESULT
address sourceAddress; // for CALL - immediate caller address
uint256 sourceRollup; // for CALL - immediate caller's rollup ID
uint256[] scope; // hierarchical scope for nested call navigation
}
struct StateDelta {
uint256 rollupId;
bytes32 currentState;
bytes32 newState;
int256 etherDelta; // Change in ETH balance for this rollup
}
struct ExecutionEntry {
StateDelta[] stateDeltas;
bytes32 actionHash; // bytes32(0) = immediate state commitment, otherwise deferred
Action nextAction;
}
struct ProxyInfo {
address originalAddress;
uint64 originalRollupId;
}
struct RollupConfig {
address owner;
bytes32 verificationKey;
bytes32 stateRoot;
uint256 etherBalance;
}- Load Phase: Off-chain provers compute valid executions and submit them via
postBatch()with a ZK proof. Entries withactionHash == bytes32(0)are applied immediately as state commitments; entries with a non-zeroactionHashare stored in the execution table for later consumption. - Execute Phase: Users call
CrossChainProxycontracts, which triggerexecuteL2Call()on the manager. The manager builds a CALL action, looks up the matching execution, applies state deltas, and returns the next action. - Scope Navigation: If the next action is a CALL at a deeper scope,
newScope()recursively navigates the scope tree, executing calls through source proxies viaexecuteOnBehalf(). Reverts at any scope are caught and handled viaScopeReverted. - Cleanup: Used executions are removed from storage (swap-and-pop).
User calls CrossChainProxy.someFunction()
|-> CrossChainProxy forwards to manager.executeL2Call(sender, calldata)
|-> Build CALL action, hash it
|-> _findAndApplyExecution(actionHash)
|-> Match execution by current rollup states
|-> Apply state deltas atomically
|-> Return nextAction
|-> If nextAction is CALL: enter scope navigation
|-> newScope() recursively processes nested calls
|-> Calls executed through source proxy.executeOnBehalf()
|-> REVERT actions trigger ScopeReverted with state rollback
|-> Return final RESULT to caller
On L2, the CrossChainManagerL2 handles cross-chain execution without ZK proofs or rollup state management:
- A system address loads execution tables via
loadExecutionTable() - Local proxy calls go through
executeCrossChainCall() - Remote calls from other chains go through
executeRemoteCall()(system only) - Scope navigation and revert handling work the same as on L1
Each rollup maintains an ETH balance held by the Rollups contract. This enables cross-rollup value transfers while maintaining conservation guarantees.
Key Properties:
- Cross-rollup transfers require the sum of ether deltas to be zero in
postBatch()(for immediate entries) - Executions can transfer ETH between rollups via
etherDeltain StateDelta - Outgoing CALL actions with value deduct from the source rollup's balance
- Rollup ETH balances cannot go negative
# Clone the repository
git clone https://github.com/jbaylina/sync-rollups.git
cd sync-rollups
# Install dependencies
forge install# Compile contracts
forge build
# Run tests
forge test
# Run tests with verbose output
forge test -vvv
# Format code
forge fmtRollups rollups = new Rollups(zkVerifierAddress, startingRollupId);
uint256 rollupId = rollups.createRollup(
initialState, // bytes32
verificationKey, // bytes32
owner // address
);address proxy = rollups.createCrossChainProxy(
originalAddress, // The L2 contract address
originalRollupId // The rollup ID
);ExecutionEntry[] memory entries = new ExecutionEntry[](2);
// Immediate state commitment (actionHash == 0)
entries[0] = ExecutionEntry({
stateDeltas: immediateDeltas,
actionHash: bytes32(0),
nextAction: Action(...) // ignored for immediate entries
});
// Deferred execution (stored for later consumption)
entries[1] = ExecutionEntry({
stateDeltas: deferredDeltas,
actionHash: actionHash,
nextAction: nextAction
});
rollups.postBatch(entries, blobCount, callData, zkProof);address proxyAddr = rollups.computeCrossChainProxyAddress(
originalAddress,
originalRollupId,
domain // chain ID where proxy will be deployed
);| Function | Description |
|---|---|
createRollup() |
Creates a new rollup with initial state, verification key, and owner |
createCrossChainProxy() |
Deploys a CrossChainProxy via CREATE2 |
postBatch() |
Posts execution entries with ZK proof (immediate + deferred) |
executeL2Call() |
Executes a cross-chain call initiated by an authorized proxy |
executeL2TX() |
Executes a pre-computed L2 transaction (permissionless) |
newScope() |
Navigates scope tree for nested cross-chain calls |
depositEther() |
Deposits ETH to a rollup's balance |
computeCrossChainProxyAddress() |
Computes deterministic proxy address |
setStateByOwner() |
Updates state root without proof (owner only) |
setVerificationKey() |
Updates verification key (owner only) |
transferRollupOwnership() |
Transfers rollup ownership |
| Function | Description |
|---|---|
loadExecutionTable() |
Loads execution entries (system address only) |
executeCrossChainCall() |
Executes a cross-chain call from a local proxy |
executeRemoteCall() |
Executes a remote call from another chain (system only) |
createCrossChainProxy() |
Deploys a CrossChainProxy via CREATE2 |
computeCrossChainProxyAddress() |
Computes deterministic proxy address |
- Only authorized proxies can execute cross-chain calls via
executeL2Call() executeL2TX()is permissionless - anyone can trigger pre-loaded L2 transactions- Same-block protection prevents conflicts between async and sync state updates
- All L1 state transitions are verified with ZK proofs
- Rollup owners can update verification keys and transfer ownership
- ETH balance conservation: sum of ether deltas in immediate batch entries must be zero
- Rollup ETH balances cannot go negative (enforced on every state update)
- Scope reverts restore rollup state roots to pre-scope values
- On L2, only the system address can load execution tables and trigger remote calls
MIT