From 30078ad6ba2c9deb439a5280ae98657cce67fc7b Mon Sep 17 00:00:00 2001 From: Mod-Net CI Date: Fri, 29 Aug 2025 19:52:59 -0600 Subject: [PATCH 1/3] missed pushing bridge code --- Cargo.lock | 15 +++ Cargo.toml | 1 + node/Cargo.toml | 1 + node/src/cli.rs | 5 + node/src/command.rs | 1 + node/src/main.rs | 1 + node/src/relayer.rs | 168 ++++++++++++++++++++++++++++++++++ node/src/service.rs | 8 +- pallets/bridge-out/Cargo.toml | 29 ++++++ pallets/bridge-out/src/lib.rs | 78 ++++++++++++++++ runtime/Cargo.toml | 2 + runtime/src/lib.rs | 10 ++ scripts/external_libraries.sh | 5 + 13 files changed, 323 insertions(+), 1 deletion(-) create mode 100644 node/src/relayer.rs create mode 100644 pallets/bridge-out/Cargo.toml create mode 100644 pallets/bridge-out/src/lib.rs create mode 100755 scripts/external_libraries.sh diff --git a/Cargo.lock b/Cargo.lock index 0d5a736be..80aac879e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5691,6 +5691,7 @@ dependencies = [ "log", "node-subspace-runtime", "ow_extensions", + "pallet-bridge-out", "pallet-subspace-genesis-config", "pallet-transaction-payment", "pallet-transaction-payment-rpc", @@ -5756,6 +5757,7 @@ dependencies = [ "pallet-aura", "pallet-balances", "pallet-base-fee", + "pallet-bridge-out", "pallet-dynamic-fee", "pallet-ethereum", "pallet-evm", @@ -6184,6 +6186,19 @@ dependencies = [ "sp-runtime", ] +[[package]] +name = "pallet-bridge-out" +version = "0.1.0" +dependencies = [ + "frame-support", + "frame-system", + "parity-scale-codec", + "scale-info", + "sp-core", + "sp-runtime", + "sp-std", +] + [[package]] name = "pallet-dynamic-fee" version = "4.0.0-dev" diff --git a/Cargo.toml b/Cargo.toml index acd68a3e9..033eaac7f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ members = [ "pallets/subnet_emission", "pallets/subspace", "pallets/subspace/genesis-config", + "pallets/bridge-out", "runtime", "tests", "ow_extensions", diff --git a/node/Cargo.toml b/node/Cargo.toml index 8fafcbc70..59a49accf 100644 --- a/node/Cargo.toml +++ b/node/Cargo.toml @@ -90,6 +90,7 @@ sp-io.workspace = true sp-runtime.workspace = true sp-timestamp.workspace = true substrate-frame-rpc-system.workspace = true +pallet-bridge-out = { path = "../pallets/bridge-out" } [build-dependencies] substrate-build-script-utils.workspace = true diff --git a/node/src/cli.rs b/node/src/cli.rs index 76eb5b28e..d3063766f 100644 --- a/node/src/cli.rs +++ b/node/src/cli.rs @@ -2,6 +2,7 @@ use std::path::PathBuf; #[cfg(feature = "testnet")] use crate::service::EthConfiguration; +use crate::service::RelayerConfiguration; /// Available Sealing methods. #[derive(Copy, Clone, Debug, Default, clap::ValueEnum)] @@ -35,6 +36,10 @@ pub struct Cli { #[cfg(feature = "testnet")] #[command(flatten)] pub eth: EthConfiguration, + + /// Bridge relayer configuration + #[command(flatten)] + pub relayer: RelayerConfiguration, } #[derive(Debug, clap::Subcommand)] diff --git a/node/src/command.rs b/node/src/command.rs index 20d3ae4fb..5fc5e4c5c 100644 --- a/node/src/command.rs +++ b/node/src/command.rs @@ -327,6 +327,7 @@ pub fn run() -> sc_cli::Result<()> { cli.eth, cli.sealing, cli.rsa_path, + cli.relayer, ) .map_err(Into::into) .await diff --git a/node/src/main.rs b/node/src/main.rs index 4fdaf61f1..c42157f14 100644 --- a/node/src/main.rs +++ b/node/src/main.rs @@ -18,6 +18,7 @@ mod command; mod eth; mod rpc; mod service; +mod relayer; fn main() -> sc_cli::Result<()> { command::run() diff --git a/node/src/relayer.rs b/node/src/relayer.rs new file mode 100644 index 000000000..f2d031ca9 --- /dev/null +++ b/node/src/relayer.rs @@ -0,0 +1,168 @@ +use std::sync::Arc; + +use sc_client_api::{BlockchainEvents, StorageProvider}; +use futures::StreamExt; +use scale_codec::Decode; +use sp_core::{twox_128, keccak_256, H256}; +use sp_core::storage::StorageKey; +use sp_runtime::traits::Block as BlockT; +use std::process::{Command, Stdio}; +use sc_service::SpawnTaskHandle; + +use crate::client::Client; + +#[derive(Clone, Debug, clap::Args)] +pub struct RelayerConfiguration { + /// Enable the bridge relayer service + #[arg(long)] + pub bridge_enable: bool, + + /// External L1 RPC endpoint (e.g., Ethereum JSON-RPC) + #[arg(long)] + pub bridge_l1_rpc: Option, + + /// Private key for relayer (hex, 0x-prefixed) + #[arg(long)] + pub bridge_pk: Option, + + /// BridgeMinter contract address on L1 + #[arg(long)] + pub bridge_minter: Option, + + /// L1 token address (if required by minter) + #[arg(long)] + pub bridge_l1_token: Option, + + /// L2 gas limit or fee parameter used by minter + #[arg(long)] + pub bridge_l2_gas: Option, + + /// Decimals for Substrate native token + #[arg(long, default_value_t = 12u32)] + pub bridge_substrate_decimals: u32, + + /// Decimals for L1 ERC20 token + #[arg(long, default_value_t = 18u32)] + pub bridge_erc20_decimals: u32, +} + +fn merge_env(mut cfg: RelayerConfiguration) -> RelayerConfiguration { + use std::env; + if let Ok(v) = env::var("BRIDGE_ENABLE") { + let v = v.trim().to_ascii_lowercase(); + cfg.bridge_enable = matches!(v.as_str(), "1" | "true" | "yes"); + } + if let Ok(v) = env::var("BRIDGE_L1_RPC") { if !v.is_empty() { cfg.bridge_l1_rpc = Some(v); } } + if let Ok(v) = env::var("BRIDGE_PK") { if !v.is_empty() { cfg.bridge_pk = Some(v); } } + if let Ok(v) = env::var("BRIDGE_MINTER") { if !v.is_empty() { cfg.bridge_minter = Some(v); } } + if let Ok(v) = env::var("BRIDGE_L1_TOKEN") { if !v.is_empty() { cfg.bridge_l1_token = Some(v); } } + if let Ok(v) = env::var("BRIDGE_L2_GAS") { if let Ok(p) = v.parse::() { cfg.bridge_l2_gas = Some(p); } } + if let Ok(v) = env::var("BRIDGE_SUBSTRATE_DECIMALS") { if let Ok(p) = v.parse::() { cfg.bridge_substrate_decimals = p; } } + if let Ok(v) = env::var("BRIDGE_ERC20_DECIMALS") { if let Ok(p) = v.parse::() { cfg.bridge_erc20_decimals = p; } } + cfg +} + +/// Spawn the bridge relayer if enabled. +pub fn maybe_spawn(spawn: SpawnTaskHandle, client: Arc, cfg: RelayerConfiguration) { + let cfg = merge_env(cfg); + if cfg.bridge_enable { + spawn.spawn("bridge-relayer", None, async move { + log::info!(target: "bridge", "Bridge relayer enabled. Starting..."); + log::info!(target: "bridge", "Config: l1_rpc={:?} minter={:?} decimals(n/erc20)={}/{}", + cfg.bridge_l1_rpc, cfg.bridge_minter, cfg.bridge_substrate_decimals, cfg.bridge_erc20_decimals); + + // Subscribe to imported blocks; in a later step switch to finalized if desired + let mut imports = client.import_notification_stream(); + while let Some(notif) = imports.next().await { + let number = notif.header.number; + let hash = notif.hash; + log::debug!(target: "bridge", "Imported block #{}, hash={:?}", number, hash); + // Read frame_system::Events at this block and act on BridgeOut::BridgeToL1Locked + let mut key = Vec::with_capacity(32); + key.extend_from_slice(&twox_128(b"System")); + key.extend_from_slice(&twox_128(b"Events")); + let storage_key = StorageKey(key); + + match client.storage(hash, &storage_key) { + Ok(Some(storage_data)) => { + type EventRecord = frame_system::EventRecord; + let mut bytes: &[u8] = storage_data.0.as_slice(); + match >::decode(&mut bytes) { + Ok(records) => { + for rec in records { + if let node_subspace_runtime::RuntimeEvent::BridgeOut(ev) = rec.event { + if let pallet_bridge_out::Event::BridgeToL1Locked { who: _, amount_native, l2_recipient, nonce } = ev { + let Some(ref l1_url) = cfg.bridge_l1_rpc else { log::warn!(target: "bridge", "BRIDGE_L1_RPC unset; skip event"); continue; }; + let Some(ref pk) = cfg.bridge_pk else { log::warn!(target: "bridge", "BRIDGE_PK unset; skip event"); continue; }; + let Some(ref minter) = cfg.bridge_minter else { log::warn!(target: "bridge", "BRIDGE_MINTER unset; skip event"); continue; }; + + // Convert Substrate native amount (u64) to ERC20 wei amount using decimals + let n_dec = cfg.bridge_substrate_decimals as i32; + let e_dec = cfg.bridge_erc20_decimals as i32; + let native: u128 = amount_native as u128; + let amount_wei: u128 = if e_dec >= n_dec { + native.saturating_mul(10u128.saturating_pow((e_dec - n_dec) as u32)) + } else { + native.saturating_div(10u128.saturating_pow((n_dec - e_dec) as u32)) + }; + let l2_gas: u32 = cfg.bridge_l2_gas.unwrap_or(200_000) as u32; + + // Build unique event id + let mut preimage = Vec::with_capacity(32 + 8 + 20); + preimage.extend_from_slice(hash.as_bytes()); + let nonce_u64: u64 = nonce.into(); + preimage.extend_from_slice(&nonce_u64.to_le_bytes()); + preimage.extend_from_slice(l2_recipient.as_bytes()); + let event_id = keccak_256(&preimage); + let event_id_hex = format!("0x{}", hex::encode(event_id)); + let to_hex = format!("0x{:x}", l2_recipient); + + // Call L1 BridgeMinter via foundry cast + let mut cmd = Command::new("cast"); + cmd.arg("send") + .arg(minter) + .arg("mintAndBridge(bytes32,address,uint256,uint32)") + .arg(&event_id_hex) + .arg(&to_hex) + .arg(amount_wei.to_string()) + .arg(l2_gas.to_string()) + .arg("--rpc-url").arg(l1_url) + .arg("--private-key").arg(pk) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + log::info!(target: "bridge", "mintAndBridge: id={} to={} amount={} gas={}", event_id_hex, to_hex, amount_wei, l2_gas); + match cmd.output() { + Ok(out) if out.status.success() => { + let stdout = String::from_utf8_lossy(&out.stdout); + let first = stdout.lines().next().unwrap_or(""); + log::info!(target: "bridge", "L1 tx sent: {}", first); + } + Ok(out) => { + let stderr = String::from_utf8_lossy(&out.stderr); + log::error!(target: "bridge", "cast send failed: status={} stderr={} ", out.status, stderr.trim()); + } + Err(e) => { + log::error!(target: "bridge", "Failed to spawn cast: {}", e); + } + } + } + } + } + } + Err(e) => { + log::warn!(target: "bridge", "Failed to decode System::Events at #{}: {}", number, e); + } + } + } + Ok(None) => { /* no events */ } + Err(e) => { + log::warn!(target: "bridge", "Error reading System::Events: {}", e); + } + } + } + }); + } else { + log::info!(target: "bridge", "Bridge relayer disabled. Set BRIDGE_ENABLE=true or --bridge-enable to enable."); + } +} diff --git a/node/src/service.rs b/node/src/service.rs index 693dfc33e..0df7e24cd 100644 --- a/node/src/service.rs +++ b/node/src/service.rs @@ -33,6 +33,7 @@ pub use crate::eth::{ mod decrypter; mod manual_seal; +pub use crate::relayer::RelayerConfiguration; type BasicImportQueue = sc_consensus::DefaultImportQueue; type FullPool = sc_transaction_pool::FullPool; @@ -344,6 +345,7 @@ pub async fn new_full( sealing: Option, rsa_key: Option, + relayer_cfg: crate::service::RelayerConfiguration, ) -> Result where N: sc_network::NetworkBackend::Hash>, @@ -692,7 +694,7 @@ where let aura = sc_consensus_aura::start_aura::( sc_consensus_aura::StartAuraParams { slot_duration, - client, + client: client.clone(), select_chain, block_import: other.block_import, proposer_factory, @@ -764,6 +766,8 @@ where } network_starter.start_network(); + // Spawn bridge relayer with configuration. + crate::relayer::maybe_spawn(task_manager.spawn_handle(), client.clone(), relayer_cfg); Ok(task_manager) } @@ -774,6 +778,7 @@ pub async fn build_full( sealing: Option, rsa_key: Option, + relayer_cfg: crate::service::RelayerConfiguration, ) -> Result { new_full::>( config, @@ -781,6 +786,7 @@ pub async fn build_full( eth_config, sealing, rsa_key, + relayer_cfg, ) .await } diff --git a/pallets/bridge-out/Cargo.toml b/pallets/bridge-out/Cargo.toml new file mode 100644 index 000000000..19ae91855 --- /dev/null +++ b/pallets/bridge-out/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "pallet-bridge-out" +version = "0.1.0" +edition = "2021" +license = "Apache-2.0" + +[package.metadata.cargo-machete] +ignored = ["scale-info"] + +[dependencies] +parity-scale-codec = { workspace = true, features = ["derive"], default-features = false } +scale-info = { workspace = true, features = ["derive"], default-features = false } +sp-std = { workspace = true, default-features = false } +sp-core = { workspace = true, default-features = false } +sp-runtime = { workspace = true, default-features = false } +frame-support = { workspace = true, default-features = false } +frame-system = { workspace = true, default-features = false } + +[features] +default = ["std"] +std = [ + "parity-scale-codec/std", + "scale-info/std", + "sp-std/std", + "sp-core/std", + "sp-runtime/std", + "frame-support/std", + "frame-system/std", +] diff --git a/pallets/bridge-out/src/lib.rs b/pallets/bridge-out/src/lib.rs new file mode 100644 index 000000000..1df34e4a7 --- /dev/null +++ b/pallets/bridge-out/src/lib.rs @@ -0,0 +1,78 @@ +#![cfg_attr(not(feature = "std"), no_std)] + +pub use pallet::*; + +#[frame_support::pallet] +pub mod pallet { + use frame_support::{ + pallet_prelude::*, + traits::{Currency, ExistenceRequirement::AllowDeath}, + PalletId, + }; + use frame_system::pallet_prelude::*; + use parity_scale_codec::MaxEncodedLen; + use sp_core::H160; + use sp_runtime::traits::{AccountIdConversion, Zero}; + + pub type BalanceOf = + <::Currency as Currency<::AccountId>>::Balance; + + #[pallet::config] + pub trait Config: frame_system::Config { + type RuntimeEvent: From> + IsType<::RuntimeEvent>; + type Currency: Currency; + #[pallet::constant] + type PalletId: Get; + /// Nonce storage counter type + type Nonce: Parameter + Default + Copy + From + Into + MaxEncodedLen; + } + + #[pallet::pallet] + pub struct Pallet(_); + + #[pallet::storage] + #[pallet::getter(fn next_nonce)] + pub type NextNonce = StorageValue<_, ::Nonce, ValueQuery>; + + #[pallet::event] + #[pallet::generate_deposit(pub(super) fn deposit_event)] + pub enum Event { + BridgeToL1Locked { + who: T::AccountId, + amount_native: BalanceOf, + l2_recipient: H160, + nonce: ::Nonce, + }, + } + + #[pallet::error] + pub enum Error { + AmountZero, + } + + #[pallet::call] + impl Pallet { + #[pallet::call_index(0)] + #[pallet::weight(T::DbWeight::get().reads_writes(1,1))] + pub fn lock_for_base(origin: OriginFor, amount: BalanceOf, l2_recipient: H160) -> DispatchResult { + let who = ensure_signed(origin)?; + ensure!(!amount.is_zero(), Error::::AmountZero); + + let reserve = Self::reserve_account(); + ::Currency::transfer(&who, &reserve, amount, AllowDeath)?; + + let nonce = Self::next_nonce(); + let next: ::Nonce = (nonce.into().saturating_add(1u64)).into(); + NextNonce::::put(next); + + Self::deposit_event(Event::BridgeToL1Locked { who, amount_native: amount, l2_recipient, nonce }); + Ok(()) + } + } + + impl Pallet { + pub fn reserve_account() -> T::AccountId { + T::PalletId::get().into_account_truncating() + } + } +} diff --git a/runtime/Cargo.toml b/runtime/Cargo.toml index 397a83bf9..4b0c49b31 100644 --- a/runtime/Cargo.toml +++ b/runtime/Cargo.toml @@ -21,6 +21,7 @@ pallet-governance = { path = "../pallets/governance", default-features = false } pallet-subnet-emission = { path = "../pallets/subnet_emission", default-features = false } pallet-subspace = { path = "../pallets/subspace", default-features = false } pallet-offworker = { path = "../pallets/offworker", default-features = false } +pallet-bridge-out = { path = "../pallets/bridge-out", default-features = false } smallvec.workspace = true parity-scale-codec.workspace = true @@ -111,6 +112,7 @@ std = [ "pallet-subspace/std", "pallet-governance/std", "pallet-offworker/std", + "pallet-bridge-out/std", "pallet-faucet/std", "pallet-aura/std", "pallet-balances/std", diff --git a/runtime/src/lib.rs b/runtime/src/lib.rs index 645fedfc7..4582d8145 100644 --- a/runtime/src/lib.rs +++ b/runtime/src/lib.rs @@ -424,6 +424,7 @@ parameter_types! { pub const DepositFactor: Balance = (0) as Balance * 2_000 * 10_000 + (32 as Balance) * 100 * 10_000; pub const MaxSignatories: u32 = 100; pub const SubspacePalletId: PalletId = PalletId(*b"py/subsp"); + pub const BridgeOutPalletId: PalletId = PalletId(*b"py/brout"); } impl pallet_multisig::Config for Runtime { @@ -464,6 +465,13 @@ impl pallet_governance::Config for Runtime { type WeightInfo = pallet_governance::weights::SubstrateWeight; } +impl pallet_bridge_out::Config for Runtime { + type RuntimeEvent = RuntimeEvent; + type Currency = Balances; + type PalletId = BridgeOutPalletId; + type Nonce = u64; +} + impl pallet_offworker::Config for Runtime { type AuthorityId = pallet_offworker::crypto::AuthId; type RuntimeEvent = RuntimeEvent; @@ -645,6 +653,8 @@ construct_runtime!( SubnetEmissionModule: pallet_subnet_emission, Offworker: pallet_offworker, + BridgeOut: pallet_bridge_out, + #[cfg(feature = "testnet-faucet")] FaucetModule: pallet_faucet, diff --git a/scripts/external_libraries.sh b/scripts/external_libraries.sh new file mode 100755 index 000000000..ac69edbc8 --- /dev/null +++ b/scripts/external_libraries.sh @@ -0,0 +1,5 @@ +#!/bin/bash + +sudo apt update +sudo apt upgrade -y +sudo apt install librocksdb-dev protobuf-compiler clang -y \ No newline at end of file From b0c85477001927cd34eb06e36b7b519e598339dd Mon Sep 17 00:00:00 2001 From: bakobiibizo Date: Fri, 29 Aug 2025 20:05:31 -0700 Subject: [PATCH 2/3] fixed build warnings --- .gitignore | 1 + node/src/relayer.rs | 24 ++++++++++++++---------- runtime/src/lib.rs | 2 +- scripts/run_bridge_relay.sh | 7 +++++++ 4 files changed, 23 insertions(+), 11 deletions(-) create mode 100755 scripts/run_bridge_relay.sh diff --git a/.gitignore b/.gitignore index 4abf80ffa..d7afae2b0 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,4 @@ # scripts __pycache__ +.env diff --git a/node/src/relayer.rs b/node/src/relayer.rs index f2d031ca9..d2e145555 100644 --- a/node/src/relayer.rs +++ b/node/src/relayer.rs @@ -5,7 +5,7 @@ use futures::StreamExt; use scale_codec::Decode; use sp_core::{twox_128, keccak_256, H256}; use sp_core::storage::StorageKey; -use sp_runtime::traits::Block as BlockT; +// use sp_runtime::traits::Block as BlockT; // unused use std::process::{Command, Stdio}; use sc_service::SpawnTaskHandle; @@ -90,12 +90,14 @@ pub fn maybe_spawn(spawn: SpawnTaskHandle, client: Arc, cfg: RelayerConf match >::decode(&mut bytes) { Ok(records) => { for rec in records { - if let node_subspace_runtime::RuntimeEvent::BridgeOut(ev) = rec.event { - if let pallet_bridge_out::Event::BridgeToL1Locked { who: _, amount_native, l2_recipient, nonce } = ev { - let Some(ref l1_url) = cfg.bridge_l1_rpc else { log::warn!(target: "bridge", "BRIDGE_L1_RPC unset; skip event"); continue; }; - let Some(ref pk) = cfg.bridge_pk else { log::warn!(target: "bridge", "BRIDGE_PK unset; skip event"); continue; }; - let Some(ref minter) = cfg.bridge_minter else { log::warn!(target: "bridge", "BRIDGE_MINTER unset; skip event"); continue; }; - + if let node_subspace_runtime::RuntimeEvent::BridgeOut( + pallet_bridge_out::Event::BridgeToL1Locked { who: _, amount_native, l2_recipient, nonce } + ) = rec.event { + if let (Some(l1_url), Some(pk), Some(minter)) = ( + cfg.bridge_l1_rpc.as_ref(), + cfg.bridge_pk.as_ref(), + cfg.bridge_minter.as_ref(), + ) { // Convert Substrate native amount (u64) to ERC20 wei amount using decimals let n_dec = cfg.bridge_substrate_decimals as i32; let e_dec = cfg.bridge_erc20_decimals as i32; @@ -120,14 +122,14 @@ pub fn maybe_spawn(spawn: SpawnTaskHandle, client: Arc, cfg: RelayerConf // Call L1 BridgeMinter via foundry cast let mut cmd = Command::new("cast"); cmd.arg("send") - .arg(minter) + .arg(minter.as_str()) .arg("mintAndBridge(bytes32,address,uint256,uint32)") .arg(&event_id_hex) .arg(&to_hex) .arg(amount_wei.to_string()) .arg(l2_gas.to_string()) - .arg("--rpc-url").arg(l1_url) - .arg("--private-key").arg(pk) + .arg("--rpc-url").arg(l1_url.as_str()) + .arg("--private-key").arg(pk.as_str()) .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()); @@ -146,6 +148,8 @@ pub fn maybe_spawn(spawn: SpawnTaskHandle, client: Arc, cfg: RelayerConf log::error!(target: "bridge", "Failed to spawn cast: {}", e); } } + } else { + log::warn!(target: "bridge", "BRIDGE_L1_RPC, BRIDGE_PK, or BRIDGE_MINTER unset; skip event"); } } } diff --git a/runtime/src/lib.rs b/runtime/src/lib.rs index 4582d8145..33ea3caaf 100644 --- a/runtime/src/lib.rs +++ b/runtime/src/lib.rs @@ -214,7 +214,7 @@ pub const VERSION: RuntimeVersion = RuntimeVersion { spec_name: create_runtime_str!("node-subspace"), impl_name: create_runtime_str!("node-subspace"), authoring_version: 1, - spec_version: 135, + spec_version: 136, impl_version: 1, apis: RUNTIME_API_VERSIONS, transaction_version: 1, diff --git a/scripts/run_bridge_relay.sh b/scripts/run_bridge_relay.sh new file mode 100755 index 000000000..0cbc0b506 --- /dev/null +++ b/scripts/run_bridge_relay.sh @@ -0,0 +1,7 @@ + +#!/bin/bash + +source .env + +target/release/node-subspace --dev --bridge-enable --bridge-l1-rpc $ETHEREUM_SEPOLIA_RPC --bridge-pk $BRIDGE_PK --bridge-l1-token $L1_TOKEN --bridge-l2-gas $BRIDGE_L2_GAS --bridge-substrate-decimals 12 --bridge-erc20-decimals 18 --bridge-minter $BRIDGE_MINTER + From 815f3d418b4e1eab3a0f34d135f1cc8b0410fb77 Mon Sep 17 00:00:00 2001 From: Mod-Net CI Date: Thu, 11 Sep 2025 17:34:53 -0600 Subject: [PATCH 3/3] docs: add change summary and bridge architecture/security report (sequence diagram, hardening checklist, addresses appendix) --- docs/bridge-architecture-and-security.md | 236 +++++++++++++++++++++++ docs/changes-feature-bridge-vs-main.md | 127 ++++++++++++ 2 files changed, 363 insertions(+) create mode 100644 docs/bridge-architecture-and-security.md create mode 100644 docs/changes-feature-bridge-vs-main.md diff --git a/docs/bridge-architecture-and-security.md b/docs/bridge-architecture-and-security.md new file mode 100644 index 000000000..2dc9bcdca --- /dev/null +++ b/docs/bridge-architecture-and-security.md @@ -0,0 +1,236 @@ +# Mod-Net Bridge: Architecture and Security Report + +Generated on 2025-09-11 + +Sources reviewed: +- Library root: `~/repos/comai/mod-net/bridge/` +- Key docs: `docs/project_spec.md`, `docs/substrate-to-base-bridge-starter.md`, `README.md` +- Ops config: `ops/addresses.json` +- Scripts: `scripts/` and `packages/` scaffolding + +## Overview + +The Mod-Net bridge moves value from a Substrate solo chain ("subspace") to Base (OP Stack L2) via Ethereum L1 using audited components. It intentionally avoids bespoke bridging mechanics: + +- Substrate → Ethereum L1: off-chain relayer consumes a replay-safe on-chain event from the Substrate pallet and calls an L1 contract (`BridgeMinter`). +- Ethereum L1 → Base: `BridgeMinter` mints L1 ERC-20 and deposits to Base using the OP Standard Bridge. + +This separates concerns between on-chain event production (Substrate), L1 custody/mint/bridge (BridgeMinter), and networking (OP Standard Bridge). + +## Components + +- Substrate runtime pallet: `pallet-bridge-out` in your node repo + - Emits `BridgeToL1Locked(who, amount_native, l2_recipient, nonce)` when a user locks native tokens. + - Maintains `NextNonce` and a deterministic reserve account (`PalletId.into_account_truncating()`). + - See: `docs/project_spec.md` section 1.1. + +- Node-integrated relayer task + - Subscribes to Substrate events, derives `eventId`, performs unit conversion, and calls the L1 `BridgeMinter.mintAndBridge(...)`. + - Leader selection among nodes avoids duplicate gas spend. + - See: `docs/project_spec.md` section 1.2 and `docs/substrate-to-base-bridge-starter.md` (Relayer section). + +- L1 contracts/tools (bridge library) + - `BridgeMinter` (Sepolia/Ethereum): holds `MINTER_ROLE` on the L1 token, enforces single consumption per `eventId`, and deposits to Base via the OP Standard Bridge. + - L1 ERC‑20 (`MyL1Token`): OpenZeppelin ERC20 + AccessControl + Permit; `MINTER_ROLE` is assigned to `BridgeMinter`. + - L2 token (Base): `OptimismMintableERC20` deployed via the official factory, paired via `remoteToken()`. + - See: `docs/project_spec.md` section 2.x and `docs/substrate-to-base-bridge-starter.md`. + +- Address/config management + - `ops/addresses.json` is the single source of truth for per-network addresses (L1StandardBridge, CrossDomainMessenger, factories, tokens, BridgeMinter, Snowbridge endpoints). + - `.env(.sample)` carries RPCs, keys, and operational params. + +- Tooling and scripts + - Foundry and Hardhat scripts to deploy L2 token and perform L1→L2 deposits. + - Operational scripts in `scripts/` including relayer runner (`run_relayer.sh`) and verification helpers (e.g., `check_balance.sh`). + +## End-to-end Flow + +1) User on Substrate calls `lock_for_base(amount, l2_recipient)` in `pallet-bridge-out`. +2) Pallet transfers native tokens into a deterministic reserve account and emits `BridgeToL1Locked(..., nonce)`; `NextNonce` increments. +3) Node relayer watches the chain, derives: + - `eventId = keccak256(abi.encode("SUBSPACE", genesis_hash, nonce, who, amount_native, l2_recipient))`. + - Converts `amount_native` to ERC‑20 wei using configured decimals. +4) Relayer checks `BridgeMinter.consumed(eventId) == false` and, if leader, calls `mintAndBridge(eventId, l2_recipient, amount_wei, l2_gas)` on Sepolia. +5) `BridgeMinter` marks `eventId` consumed, mints L1 ERC‑20 to itself, approves the L1 Standard Bridge, and calls `depositERC20To` to Base. +6) On Base, the paired `OptimismMintableERC20` mints to `l2_recipient`. Supply invariant is checked off-chain. + +References: `docs/project_spec.md` sections 1.1, 1.2, 2.3, and 4; `docs/substrate-to-base-bridge-starter.md` (EventId derivation, relay steps). + +### Sequence diagram + +```mermaid +sequenceDiagram + autonumber + participant User + participant Substrate as Substrate (pallet-bridge-out) + participant Relayer as Node Relayer + participant BridgeMinter as L1 BridgeMinter (Sepolia) + participant L1Bridge as L1 Standard Bridge + participant Base as Base L2 Token + + User->>Substrate: lock_for_base(amount_native, l2_recipient) + Substrate-->>Substrate: Transfer to reserve, increment NextNonce + Substrate-->>Relayer: Event BridgeToL1Locked(who, amount_native, l2_recipient, nonce) + Relayer->>Relayer: Compute eventId = keccak256(genesis_hash, nonce, who, amount_native, l2_recipient) + Relayer->>Relayer: Convert amount_native -> amount_wei (decimals) + Relayer->>BridgeMinter: mintAndBridge(eventId, l2_recipient, amount_wei, l2_gas) + BridgeMinter->>BridgeMinter: require(!consumed[eventId]) + BridgeMinter->>BridgeMinter: consumed[eventId] = true + BridgeMinter->>BridgeMinter: mint L1 token to self + BridgeMinter->>L1Bridge: approve + depositERC20To(L1, L2, amount_wei, l2_gas) + L1Bridge-->>Base: Cross-domain message + Base-->>User: Mint L2 tokens to l2_recipient +``` + +## Security Model and Features + +- Replay protection on L1 + - `BridgeMinter` records `consumed[eventId]` so each Substrate event is processed exactly once. + - Reattempts revert with `AlreadyConsumed` and are quarantined by the relayer logic. + +- Relayer authorization + - Only whitelisted EOAs can call `mintAndBridge` (`onlyRelayer`). + - Node private keys are not granted mint capabilities; only `BridgeMinter` holds `MINTER_ROLE` on the token. + +- Separation of duties (least privilege) + - `MINTER_ROLE` is restricted to the `BridgeMinter` contract. + - Admin operations are separated (AccessControl `DEFAULT_ADMIN_ROLE`), recommended to be held by a Safe. + +- Leader election among nodes + - Deterministic leader selection per nonce avoids duplicate gas spend: `idx = keccak256(nonce || best_block_hash) % N`. + - Optional fallback transfers leadership if no success within T blocks. + +- Supply conservation invariant + - Off-chain script checks `l1_escrow_equals_l2_supply` and computes `delta_wei` to ensure no drift between locked Substrate funds, L1 escrow, and L2 total supply. + - See `README.md` (check:supply) and `docs/substrate-to-base-bridge-starter.md` (checkSupply scripts). + +- Unit conversion correctness + - Explicit `SUB_DEC` vs `ERC_DEC` conversion rules to prevent rounding errors or over/under-minting. + +- Audited base layers + - Leverages OP Standard Bridge and Optimism mintable tokens on Base, as well as OpenZeppelin ERC‑20/AccessControl. + +- Configuration integrity + - Canonical addresses are declared in `ops/addresses.json`. Scripts and operational tooling read from this file to avoid mismatches. + +- Observability and resilience + - Relayer logs include nonce and tx hash; optional metrics for seen/relayed/failed/retried. + - Exponential backoff on RPC errors; liveness probes; quarantining of bad events. + +## Threat Model and Mitigations + +- Duplicate relay attempts + - Mitigated by eventId replay guard and leader election. + +- Malicious or misconfigured relayer EOA + - Requires whitelisting in `BridgeMinter`. Can be revoked by admin. + - EOAs do not hold mint permissions; only BridgeMinter can mint and bridge. + +- Compromise of admin keys + - Minimize risk by moving `DEFAULT_ADMIN_ROLE` to a multi-sig Safe. + - Consider timelocks or 2-step admin changes for production. + +- Address spoofing/mismatch + - Single source of truth in `ops/addresses.json`; scripts verify `remoteToken()` pairing on L2. + +- L1/L2 RPC instability + - Backoff/retry and leader fallback reduce failed relays and stuck states. + +- Supply drift or accounting errors + - Off-chain invariant check (`check:supply`) compares L1 escrow vs L2 total supply; CI or ops can gate on zero delta. + +- Event forgery or chain reorganizations + - EventId commits to chain genesis hash, nonce, and SCALE-encoded fields. For deeper economic security, future upgrade proposes M-of-N validator attestations. + +## Operational Guidance + +- Keys and env + - `.env.sample` documents required RPCs and keys; keep production secrets out of the repo and use a secrets manager. + - Prefer a Safe for admin roles. + +- Address management + - Set/verify contract addresses in `ops/addresses.json`; verify OP Stack addresses against Base docs. + +- Relayer configuration + - Run node with `--bridge.*` flags or corresponding env vars (see `docs/substrate-to-base-bridge-starter.md`). + +- Testing + - Unit tests for pallet behavior, BridgeMinter guards, and happy-path deposits. + - Integration on Sepolia/Base Sepolia; verify `remoteToken()` and supply invariants. + +## Production Hardening Checklist + +- Governance and keys + - Move `DEFAULT_ADMIN_ROLE` to a multi-sig Safe; restrict `setRelayer`/admin actions. + - Use distinct EOAs for relaying; rotate keys; store secrets in a manager (no plaintext envs in prod). + +- Contract configuration and verification + - Verify addresses in `ops/addresses.json` against official Base/Sepolia docs before deploys. + - Assert `OptimismMintableERC20.remoteToken() == L1 token` as part of CI. + - Enable contract verification on explorers where applicable. + +- Relayer operations + - Run multiple nodes; enable deterministic leader selection and fallback. To be run on existing nodes to distribute consensus. + - Configure exponential backoff, RPC redundancy, and health checks. + - Emit structured logs; expose Prometheus metrics for seen/relayed/failed/retried. + +- Invariants and monitoring + - Automate `check:supply` in CI/cron; alert on non-zero deltas. + - Track per-nonce lifecycle and alert on stuck/unconsumed events. + +- Change management + - Protect release branches; require reviews and passing checks. + - For upgrades, use timelocks or staged rollouts where possible. + +## Future Enhancements + +- M-of-N attestations (upgrade path) + - Replace relayer whitelist with validator-set signatures over `eventId` (EIP-712) and on-chain threshold verification. + - Maintains the same external interface for `BridgeMinter` to minimize relayer changes. + +- Monitoring/metrics + - Add Prometheus metrics for relay outcomes and RPC health; alert on invariant failures or lagging nonces. + +## Appendix: Network Addresses (current) + +Derived from `mod-net/bridge/ops/addresses.json` at the time of writing; verify before use. + +- Ethereum mainnet (`ethereum`) + - L1StandardBridge: `0x3154Cf16ccdb4C6d922629664174b904d80F2C35` + - L1CrossDomainMessenger: `0x866E82a600A1414e583f7F13623F1aC5d58b0Afa` + - OptimismMintableERC20Factory: `0x05cc379EBD9B30BbA19C6fA282AB29218EC61D84` + - L1Token: [set in ops/addresses.json] + - BridgeMinter: [set after deploy] + +- Ethereum Sepolia (`sepolia`) + - L1StandardBridge: `0xfd0Bf71F60660E2f608ed56e1659C450eB113120` + - L1CrossDomainMessenger: `0xC34855F4De64F1840e5686e64278da901e261f20` + - OptimismMintableERC20Factory: `0xb1efB9650aD6d0CC1ed3Ac4a0B7f1D5732696D37` + - L1Token: `0xd035e701BEFaE437d9eC5237646Ff7AD8E9174c4` + - BridgeMinter: `0xbF6197e94C011a3Af999e0c881794749fc52A908` + +- Base mainnet (`base`) + - L2StandardBridge: `0x4200000000000000000000000000000000000010` + - L2CrossDomainMessenger: `0x4200000000000000000000000000000000000007` + - OptimismMintableERC20Factory: `0xF10122D428B4bc8A9d050D06a2037259b4c4B83B` + - L2Token: [set in ops/addresses.json] + - BridgeMinter: [not applicable on L2] + +- Base Sepolia (`base_sepolia`) + - L2StandardBridge: `0x4200000000000000000000000000000000000010` + - L2CrossDomainMessenger: `0x4200000000000000000000000000000000000007` + - OptimismMintableERC20Factory: `0x4200000000000000000000000000000000000012` + - L2Token: `0x4997665C5AFBe3422C95f5133cc81607C47a7fd0` + - BridgeMinter (L1 reference): `0xbF6197e94C011a3Af999e0c881794749fc52A908` + +- Snowbridge gateways + - Sepolia gateway: `0x5b4909ce6ca82d2ce23bd46738953c7959e710cd` + - Mainnet gateway: `0x27ca963c279c93801941e1eb8799c23f407d68e7` + +## Key References + +- `docs/project_spec.md` +- `docs/substrate-to-base-bridge-starter.md` +- `README.md` +- `ops/addresses.json` +- `scripts/` and `packages/` directories diff --git a/docs/changes-feature-bridge-vs-main.md b/docs/changes-feature-bridge-vs-main.md new file mode 100644 index 000000000..4224281dc --- /dev/null +++ b/docs/changes-feature-bridge-vs-main.md @@ -0,0 +1,127 @@ +# Changes from main to feature/bridge (HEAD) + +Comparison generated on 2025-09-11 between: + +- Base: `origin/main` +- Target: `HEAD` (currently at `origin/feature/bridge`) + +To reproduce locally: + +```bash +# from repo root +git fetch --all --prune +git log --no-merges --pretty=format:'%h\t%an\t%ad\t%s' --date=short origin/main..HEAD +git diff --name-status origin/main..HEAD +git diff --stat origin/main..HEAD +``` + +## Summary + +This branch introduces a new bridge-out pallet and relayer, adds an automated payment system integrated into the governance pallet, updates the runtime to include these components, and adds operational scripts and Docker Compose convenience. Tests have been reorganized and expanded significantly around governance proposals and scheduled payments. + +High-level highlights: + +- Bridge-out functionality scaffolded (`pallets/bridge-out/`) with runtime wiring and a `node` relayer. +- Governance pallet gains scheduled payment functionality with block-based intervals and comprehensive tests. +- Runtime updated to include new pallets and configuration adjustments. +- Operational tooling: `docker-compose.yml`, `scripts/run_bridge_relay.sh`, `scripts/prepare_runtime_upgrade.py`, `scripts/external_libraries.sh`. +- Test suite refactor: governance tests split into dedicated modules with large additions. + +## Commit summary (origin/main..HEAD) + +Note: author/date/subject as per `git log`. + +- b0c8547 bakobiibizo 2025-08-29 fixed build warnings +- 30078ad Mod-Net CI 2025-08-29 missed pushing bridge code +- b428ff4 J. Zane Cook 2025-06-25 fix: Cargo fmt +- abc2c1b J. Zane Cook 2025-06-25 fix: Increment runtime spec version +- 4b1a84b J. Zane Cook 2025-06-22 fix: Move tests to pre-existing folder, following existing structure +- 73f6322 J. Zane Cook 2025-06-22 fix: Removed unused tests +- 1858f6b J. Zane Cook 2025-06-22 fix: Remove unused integration tests +- 16c0231 J. Zane Cook 2025-06-22 fix: Restore original docs +- dbabecd bakobiibizo 2025-06-21 missed the refactored payment cycle changes and incremented the version +- f1b273c bakobiibizo 2025-06-21 removed extra docs the model committed +- b337b41 bakobiibizo 2025-06-21 add documentation to the payments pallet +- 119e29e bakobiibizo 2025-06-19 simplified the payment schedule to pay on block intervals +- eda347b bakobiibizo 2025-06-15 added docker compose for standard node +- 8c90f62 bakobiibizo 2025-06-15 completed payment + +## Files changed and diff stats + +From `git diff --name-status` and `--stat`: + +- Added (A): + - `docker-compose.yml` (+23 lines) + - `node/src/relayer.rs` (+172) + - `pallets/bridge-out/Cargo.toml` (+29) + - `pallets/bridge-out/src/lib.rs` (+78) + - `pallets/governance/src/payments.rs` (+162) + - `scripts/external_libraries.sh` (+5) + - `scripts/prepare_runtime_upgrade.py` (+44) + - `scripts/run_bridge_relay.sh` (+7) + - `tests/src/governance/payments.rs` (+350) + - `tests/src/governance/proposals.rs` (+738) + +- Modified (M): + - `.gitignore` (+1) + - `Cargo.lock` (mixed, +19 -) + - `Cargo.toml` (+1) + - `Dockerfile` (+4 -1) + - `node/Cargo.toml` (+1) + - `node/src/cli.rs` (+5) + - `node/src/command.rs` (+1) + - `node/src/main.rs` (+1) + - `node/src/service.rs` (+8 -1) + - `pallets/governance/Cargo.toml` (+2 -1) + - `pallets/governance/src/lib.rs` (+151 -?) + - `pallets/governance/src/weights.rs` (+15) + - `runtime/Cargo.toml` (+2) + - `runtime/src/lib.rs` (+14 -?) + - `tests/src/governance.rs` (-740 net; moved into new modules) + +Totals: 25 files changed, ~1816 insertions, ~757 deletions + +## Key changes by area + +### Bridge-out pallet and relayer + +- New pallet: `pallets/bridge-out/` with `Cargo.toml` and `src/lib.rs` scaffolding for outbound bridge functionality. +- Node relayer: `node/src/relayer.rs` introduces a relayer component used by the node to interact with the bridge. Related updates in `node/src/service.rs`, `node/src/cli.rs`, `node/src/command.rs`, and `node/src/main.rs` wire up the relayer. +- Runtime: `runtime/src/lib.rs` and `runtime/Cargo.toml` updated to include the bridge-out pallet in the runtime configuration. + +### Governance pallet: scheduled payments + +- Added `pallets/governance/src/payments.rs` implementing an automated payment system: + - Scheduled payments managed within the governance pallet. + - Block-based disbursement using `next_payment_block` and `payment_interval`. + - Funds disbursed from Treasury; configurable intervals. + - Payment windows and completion handling. +- Integration in `pallets/governance/src/lib.rs` and weights in `pallets/governance/src/weights.rs`. +- Tests: + - `tests/src/governance/payments.rs` covers payment schedule creation, processing, completion, failure handling, and block progression simulation. + - `tests/src/governance/proposals.rs` contains governance proposal flow and related logic tests. + - Old monolithic `tests/src/governance.rs` reduced and split across the new modules. + +### Tooling and operations + +- `docker-compose.yml` for spinning up a standard node more easily. +- `scripts/run_bridge_relay.sh` to run the bridge relayer. +- `scripts/prepare_runtime_upgrade.py` to assist in preparing runtime upgrades. +- `scripts/external_libraries.sh` for external library management. + +## Breaking changes and upgrade notes + +- Runtime spec version incremented (see commit `abc2c1b`) — a chain upgrade is required. Use `scripts/prepare_runtime_upgrade.py` to stage/validate the upgrade. +- Governance scheduled payments depend on a funded Treasury account in the runtime. Ensure the treasury is funded during testing and on-chain, or payment execution will fail. +- Weights updated for governance; re-run benchmarking if you customize runtime parameters. + +## Developer notes + +- Bridge relayer: See `node/src/relayer.rs`. Start via `scripts/run_bridge_relay.sh`. Ensure any external endpoints/keys referenced by the relayer are configured via environment or CLI flags. +- Payments: See `pallets/governance/src/payments.rs`. Configuration constants such as `BLOCKS_PER_PAYMENT_CYCLE` and treasury account are defined within the governance pallet/runtime config. Tests in `tests/src/governance/payments.rs` show end-to-end flows. +- Docker Compose: `docker-compose.yml` provides a baseline setup for a standard node; adjust ports/volumes as needed. + +## Appendix: full lists + +- Full commit list: run the log command in the repro section to see author and subjects for all commits. +- Full file diff: run the diff commands in the repro section for complete changes.