Skip to content

feat(l2): implement ERC20 bridge #3241

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 47 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
b7330b0
add L1Messenger contract
iovoid Jun 12, 2025
9c87eee
fix function name
iovoid Jun 13, 2025
25cbdd1
start rewriting
iovoid Jun 13, 2025
7346be0
Do withdrawal->l1message renames. Merge branch 'main' into feat/l1-me…
iovoid Jun 17, 2025
09ea674
fix and cleanup
iovoid Jun 17, 2025
c402545
Merge branch 'main' into feat/l1-messenger
iovoid Jun 17, 2025
895d0fe
rename in based
iovoid Jun 17, 2025
a618588
update docs
iovoid Jun 17, 2025
0212475
add l1messenger to system contract updater
iovoid Jun 18, 2025
d902587
fix behavior on single-node tree
iovoid Jun 18, 2025
06cb2ee
return all proofs in rpc
iovoid Jun 18, 2025
5655820
rename variables
iovoid Jun 18, 2025
1bf5eeb
clarify that l1message has the hash of the data
iovoid Jun 18, 2025
6c0be9b
Send bytes32 hash instead of the whole data
iovoid Jun 18, 2025
52cea43
update docs
iovoid Jun 18, 2025
37af32d
clarify contract documentation
iovoid Jun 18, 2025
4173ddd
Fix typo
iovoid Jun 18, 2025
c8573f6
reorder fields
iovoid Jun 18, 2025
acd8840
Merge branch 'main' into feat/l1-messenger
iovoid Jun 18, 2025
ef3f7b3
fix event signature
iovoid Jun 19, 2025
2273223
fix merkle on n=1
iovoid Jun 19, 2025
d57860f
Revert "fix merkle on n=1"
iovoid Jun 19, 2025
df461d3
implement erc20 bridging
iovoid Jun 19, 2025
9c897dc
use same format for ETH and ERC20
iovoid Jun 23, 2025
9a21ea3
use typechecked call for mint
iovoid Jun 23, 2025
3e35cc7
update l2 genesis
iovoid Jun 23, 2025
9618ced
Merge branch 'main' into feat/l1-messenger
iovoid Jun 23, 2025
fe650bf
resolve merge
iovoid Jun 23, 2025
1eca664
implement integration tests
iovoid Jun 23, 2025
6c68d5b
Merge branch 'feat/l1-messenger' into HEAD
iovoid Jun 23, 2025
b69f916
Fix typo
iovoid Jun 24, 2025
b12f7ec
rename L1Messenger->L2ToL1Messenger
iovoid Jun 24, 2025
7e8e08d
Merge branch 'main' into feat/erc20-bridge
iovoid Jun 24, 2025
092dfd3
fix merge
iovoid Jun 24, 2025
72b6b8c
fix merge
iovoid Jun 24, 2025
18013bc
fix merge
iovoid Jun 24, 2025
fe00936
implement suggestions
iovoid Jun 26, 2025
4f2b9f1
fix typoed function
iovoid Jun 26, 2025
057256a
Merge branch 'main' into feat/erc20-bridge
iovoid Jun 26, 2025
c1e7d3e
clone contract dependencies when running tests
iovoid Jun 26, 2025
b9dee2f
clippy
iovoid Jun 26, 2025
53eaa11
debug tdx ci
iovoid Jun 26, 2025
06232b5
fix unchanged comment
iovoid Jun 26, 2025
20c5759
implement suggestions
iovoid Jun 26, 2025
fd5b1d2
remove tdx debug
iovoid Jun 26, 2025
90592a8
rearrange and format
iovoid Jun 26, 2025
542d7f2
fix storage
iovoid Jun 26, 2025
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
10 changes: 4 additions & 6 deletions crates/l2/contracts/bin/deployer/error.rs
Original file line number Diff line number Diff line change
@@ -1,18 +1,14 @@
use ethrex_l2_sdk::{ContractCompilationError, DeployError};
use ethrex_l2_sdk::{ContractCompilationError, DeployError, GitError};
use ethrex_rpc::clients::{EthClientError, eth::errors::CalldataEncodeError};

#[derive(Debug, thiserror::Error)]
pub enum DeployerError {
#[error("Failed to lock SALT: {0}")]
FailedToLockSALT(String),
#[error("The path is not a valid utf-8 string")]
FailedToGetStringFromPath,
#[error("Deployer setup error: {0} not set")]
ConfigValueNotSet(String),
#[error("Deployer setup parse error: {0}")]
ParseError(String),
#[error("Deployer dependency error: {0}")]
DependencyError(String),
DependencyError(#[from] GitError),
#[error("Deployer EthClient error: {0}")]
EthClientError(#[from] EthClientError),
#[error("Deployer decoding error: {0}")]
Expand All @@ -23,6 +19,8 @@ pub enum DeployerError {
FailedToCompileContract(#[from] ContractCompilationError),
#[error("Failed to deploy contract: {0}")]
FailedToDeployContract(#[from] DeployError),
#[error("Deployment subtask failed: {0}")]
DeploymentSubtaskFailed(String),
#[error("Internal error: {0}")]
InternalError(String),
#[error("IO error: {0}")]
Expand Down
195 changes: 8 additions & 187 deletions crates/l2/contracts/bin/deployer/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ use std::{
fs::{File, OpenOptions, read_to_string},
io::{BufWriter, Write},
path::PathBuf,
process::{Command, ExitStatus, Stdio},
process::{Command, Stdio},
str::FromStr,
};

Expand Down Expand Up @@ -87,190 +87,7 @@ async fn main() -> Result<(), DeployerError> {
}

fn download_contract_deps(opts: &DeployerOptions) -> Result<(), DeployerError> {
trace!("Downloading contract dependencies");
std::fs::create_dir_all(opts.contracts_path.join("lib")).map_err(|err| {
DeployerError::DependencyError(format!("Failed to create contracts/lib: {err}"))
})?;

git_clone(
"https://github.com/OpenZeppelin/openzeppelin-contracts-upgradeable.git",
opts.contracts_path
.join("lib/openzeppelin-contracts-upgradeable")
.to_str()
.ok_or(DeployerError::FailedToGetStringFromPath)?,
None,
true,
)?;

git_clone(
"https://github.com/succinctlabs/sp1-contracts.git",
opts.contracts_path
.join("lib/sp1-contracts")
.to_str()
.ok_or(DeployerError::FailedToGetStringFromPath)?,
None,
false,
)?;

trace!("Contract dependencies downloaded");
Ok(())
}

pub fn git_clone(
repository_url: &str,
outdir: &str,
branch: Option<&str>,
submodules: bool,
) -> Result<ExitStatus, DeployerError> {
info!(repository_url = %repository_url, outdir = %outdir, branch = ?branch, "Cloning or updating git repository");

if PathBuf::from(outdir).join(".git").exists() {
info!(outdir = %outdir, "Found existing git repository, updating...");

let branch_name = if let Some(b) = branch {
b.to_string()
} else {
// Look for default branch name (could be main, master or other)
let output = Command::new("git")
.current_dir(outdir)
.arg("symbolic-ref")
.arg("refs/remotes/origin/HEAD")
.output()
.map_err(|e| {
DeployerError::DependencyError(format!(
"Failed to get default branch for {outdir}: {e}"
))
})?;

if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(DeployerError::DependencyError(format!(
"Failed to get default branch for {outdir}: {stderr}"
)));
}

String::from_utf8(output.stdout)
.map_err(|_| {
DeployerError::InternalError("Failed to parse git output".to_string())
})?
.trim()
.split('/')
.next_back()
.ok_or(DeployerError::InternalError(
"Failed to parse default branch".to_string(),
))?
.to_string()
};

trace!(branch = %branch_name, "Updating to branch");

// Fetch
let fetch_status = Command::new("git")
.current_dir(outdir)
.args(["fetch", "origin"])
.spawn()
.map_err(|err| {
DeployerError::DependencyError(format!("Failed to spawn git fetch: {err}"))
})?
.wait()
.map_err(|err| {
DeployerError::DependencyError(format!("Failed to wait for git fetch: {err}"))
})?;
if !fetch_status.success() {
return Err(DeployerError::DependencyError(format!(
"git fetch failed for {outdir}"
)));
}

// Checkout to branch
let checkout_status = Command::new("git")
.current_dir(outdir)
.arg("checkout")
.arg(&branch_name)
.spawn()
.map_err(|err| {
DeployerError::DependencyError(format!("Failed to spawn git checkout: {err}"))
})?
.wait()
.map_err(|err| {
DeployerError::DependencyError(format!("Failed to wait for git checkout: {err}"))
})?;
if !checkout_status.success() {
return Err(DeployerError::DependencyError(format!(
"git checkout of branch {branch_name} failed for {outdir}, try deleting the repo folder"
)));
}

// Reset branch to origin
let reset_status = Command::new("git")
.current_dir(outdir)
.arg("reset")
.arg("--hard")
.arg(format!("origin/{}", branch_name))
.spawn()
.map_err(|err| {
DeployerError::DependencyError(format!("Failed to spawn git reset: {err}"))
})?
.wait()
.map_err(|err| {
DeployerError::DependencyError(format!("Failed to wait for git reset: {err}"))
})?;

if !reset_status.success() {
return Err(DeployerError::DependencyError(format!(
"git reset failed for {outdir}"
)));
}

// Update submodules
if submodules {
let submodule_status = Command::new("git")
.current_dir(outdir)
.arg("submodule")
.arg("update")
.arg("--init")
.arg("--recursive")
.spawn()
.map_err(|err| {
DeployerError::DependencyError(format!(
"Failed to spawn git submodule update: {err}"
))
})?
.wait()
.map_err(|err| {
DeployerError::DependencyError(format!(
"Failed to wait for git submodule update: {err}"
))
})?;
if !submodule_status.success() {
return Err(DeployerError::DependencyError(format!(
"git submodule update failed for {outdir}"
)));
}
}

Ok(reset_status)
} else {
trace!(repository_url = %repository_url, outdir = %outdir, branch = ?branch, "Cloning git repository");
let mut git_cmd = Command::new("git");

let git_clone_cmd = git_cmd.arg("clone").arg(repository_url);

if let Some(branch) = branch {
git_clone_cmd.arg("--branch").arg(branch);
}

if submodules {
git_clone_cmd.arg("--recurse-submodules");
}

git_clone_cmd
.arg(outdir)
.spawn()
.map_err(|err| DeployerError::DependencyError(format!("Failed to spawn git: {err}")))?
.wait()
.map_err(|err| DeployerError::DependencyError(format!("Failed to wait for git: {err}")))
}
ethrex_l2_sdk::download_contract_deps(&opts.contracts_path).map_err(DeployerError::from)
}

fn compile_contracts(opts: &DeployerOptions) -> Result<(), DeployerError> {
Expand Down Expand Up @@ -462,9 +279,13 @@ fn deploy_tdx_contracts(
.current_dir("tee/contracts")
.stdout(Stdio::null())
.spawn()
.map_err(|err| DeployerError::DependencyError(format!("Failed to spawn make: {err}")))?
.map_err(|err| {
DeployerError::DeploymentSubtaskFailed(format!("Failed to spawn make: {err}"))
})?
.wait()
.map_err(|err| DeployerError::DependencyError(format!("Failed to wait for make: {err}")))?;
.map_err(|err| {
DeployerError::DeploymentSubtaskFailed(format!("Failed to wait for make: {err}"))
})?;

let address = read_tdx_deployment_address("TDXVerifier");
Ok(address)
Expand Down
8 changes: 4 additions & 4 deletions crates/l2/contracts/bin/system_contracts_updater/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,15 @@ use error::SystemContractsUpdaterError;
use ethrex_common::U256;
use ethrex_common::types::GenesisAccount;
use ethrex_l2::utils::test_data_io::read_genesis_file;
use ethrex_l2_sdk::{COMMON_BRIDGE_L2_ADDRESS, L1_MESSENGER_ADDRESS, compile_contract};
use ethrex_l2_sdk::{COMMON_BRIDGE_L2_ADDRESS, L2_TO_L1_MESSENGER_ADDRESS, compile_contract};
use genesis_tool::genesis::write_genesis_as_json;
mod cli;
mod error;

fn main() -> Result<(), SystemContractsUpdaterError> {
let opts = SystemContractsUpdaterOptions::parse();
compile_contract(&opts.contracts_path, "src/l2/CommonBridgeL2.sol", true)?;
compile_contract(&opts.contracts_path, "src/l2/L1Messenger.sol", true)?;
compile_contract(&opts.contracts_path, "src/l2/L2ToL1Messenger.sol", true)?;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can this contract change in the future? Consider this scenario:

  1. You update the code, this yields a new bytecode.
  2. You then add the bytecode to the genesis file like what happens below, on lines 42-45.

If the contract was already deployed, this would result in a different genesis file from the original one, right?

Of course, feel free to correct me.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would result in a different genesis, but that is intended. The purpose of system_contracts_updater is to update the genesis file with the new bytecode for the system contracts.

As for the contract, it could change albeit very rarely. If you wanted, for example, to allow submitting signed messages on behalf of other users.

update_genesis_file(&opts.l2_genesis_path)?;
Ok(())
}
Expand All @@ -39,10 +39,10 @@ fn update_genesis_file(l2_genesis_path: &PathBuf) -> Result<(), SystemContractsU
},
);

let l1_messenger_runtime = std::fs::read("contracts/solc_out/L1Messenger.bin-runtime")?;
let l1_messenger_runtime = std::fs::read("contracts/solc_out/L2ToL1Messenger.bin-runtime")?;

genesis.alloc.insert(
L1_MESSENGER_ADDRESS,
L2_TO_L1_MESSENGER_ADDRESS,
GenesisAccount {
code: Bytes::from(hex::decode(l1_messenger_runtime)?),
storage: HashMap::new(),
Expand Down
33 changes: 33 additions & 0 deletions crates/l2/contracts/src/example/L2ERC20.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// SPDX-License-Identifier: MIT
pragma solidity =0.8.29;

import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "../l2/interfaces/IERC20L2.sol";

/// @title Example L2-side bridgeable token
/// @author LambdaClass
contract TestTokenL2 is ERC20, IERC20L2 {
address public L1_TOKEN = address(0);
address public constant BRIDGE = 0x000000000000000000000000000000000000FFff;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we put the bridge address on the interface so token operators don't mess up?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Eventually this part of the interface will just become a IERC7802 when ERC-7802 becomes a standard. Token operators may want to allow several bridges.


constructor(address l1Addr) ERC20("TestTokenL2", "TEST") {
L1_TOKEN = l1Addr;
}

modifier onlyBridge() {
require(msg.sender == BRIDGE, "TestToken: not authorized to mint");
_;
}

function l1Address() external view returns (address) {
return L1_TOKEN;
}

function crosschainMint(address destination, uint256 amount) external onlyBridge {
_mint(destination, amount);
}

function crosschainBurn(address from, uint256 value) external onlyBridge {
_burn(from, value);
}
}
Loading
Loading