Repository: matter-labs/zksync-era
Pull request #3726: feat(api): Support permissioned deployments
Author: login: RomanHiden
Labels: name: external-contribution
Created: 2025-03-18T16:00:29Z | Merged: 2025-04-09T13:57:44Z

## What ❔

This PR introduces support for permissioned contract deployments via the API layer. Specifically, it adds:
A configurable allowlist for contract deployers
Middleware to validate deployer permissions at the transaction level
Error feedback to users when deployment is not permitted
Integration with AllowListService to dynamically refresh allowed addresses

## Why ❔

To enable zkSync chains to enforce deployment restrictions, which are critical in:
Enterprise and consortium environments where only vetted contracts should be allowed
Testnets or appchains requiring controlled deployment access
Preventing malicious or spam deployments that could degrade network quality or security
These changes align with the principle of flexibility for sovereign chains, allowing operators to choose between open or permissioned deployment policies while still using the zkSync stack.

## Is this a breaking change?
- [ ] Yes
- [x] No

## Operational changes
<!-- Any config changes? Any new flags? Any changes to any scripts? -->
<!-- Please add anything that non-Matter Labs entities running their own ZK Chain may need to know -->

## Checklist

<!-- Check your PR fulfills the following items. -->
<!-- For draft PRs check the boxes as you complete them. -->

- [x] PR title corresponds to the body of PR (we generate changelog entries from PRs).
- [ ] Tests for the changes have been added / updated.
- [x] Documentation comments have been added / updated.
- [x] Code has been formatted via `zkstack dev fmt` and `zkstack dev lint`.



Diff:
diff --git a/core/Cargo.lock b/core/Cargo.lock
index 6459a462e4ce..da1d79059923 100644
--- a/core/Cargo.lock
+++ b/core/Cargo.lock
@@ -12496,6 +12496,7 @@ dependencies = [
  "once_cell",
  "pin-project-lite",
  "rand 0.8.5",
+ "reqwest 0.12.9",
  "serde",
  "serde_json",
  "strum",
diff --git a/core/bin/zksync_server/src/node_builder.rs b/core/bin/zksync_server/src/node_builder.rs
index bac005889e9e..8a49bd924853 100644
--- a/core/bin/zksync_server/src/node_builder.rs
+++ b/core/bin/zksync_server/src/node_builder.rs
@@ -72,7 +72,7 @@ use zksync_node_framework::{
             server::{Web3ServerLayer, Web3ServerOptionalConfig},
             tree_api_client::TreeApiClientLayer,
             tx_sender::{PostgresStorageCachesConfig, TxSenderLayer},
-            tx_sink::MasterPoolSinkLayer,
+            tx_sink::{whitelist::WhitelistedMasterPoolSinkLayer, MasterPoolSinkLayer},
         },
     },
     service::{ZkStackService, ZkStackServiceBuilder},
@@ -364,6 +364,7 @@ impl MainNodeBuilder {
     fn add_tx_sender_layer(mut self) -> anyhow::Result<Self> {
         let sk_config = try_load_config!(self.configs.state_keeper_config);
         let rpc_config = try_load_config!(self.configs.api_config).web3_json_rpc;
+        let deployment_allowlist = rpc_config.deployment_allowlist.clone();
 
         let postgres_storage_caches_config = PostgresStorageCachesConfig {
             factory_deps_cache_size: rpc_config.factory_deps_cache_size() as u64,
@@ -378,7 +379,13 @@ impl MainNodeBuilder {
             .unwrap_or_default();
 
         // On main node we always use master pool sink.
-        self.node.add_layer(MasterPoolSinkLayer);
+        if deployment_allowlist.is_enabled() {
+            self.node.add_layer(WhitelistedMasterPoolSinkLayer {
+                deployment_allowlist: deployment_allowlist.clone(),
+            });
+        } else {
+            self.node.add_layer(MasterPoolSinkLayer);
+        }
 
         let layer = TxSenderLayer::new(
             postgres_storage_caches_config,
diff --git a/core/lib/config/src/configs/api.rs b/core/lib/config/src/configs/api.rs
index 1321f25e7604..035a1e8b8f8a 100644
--- a/core/lib/config/src/configs/api.rs
+++ b/core/lib/config/src/configs/api.rs
@@ -224,6 +224,9 @@ pub struct Web3JsonRpcConfig {
     /// (hundreds or thousands RPS).
     #[serde(default)]
     pub extended_api_tracing: bool,
+    /// Configuration options for the deployment allow list
+    #[serde(default)]
+    pub deployment_allowlist: DeploymentAllowlist,
 }
 
 impl Web3JsonRpcConfig {
@@ -264,6 +267,7 @@ impl Web3JsonRpcConfig {
             whitelisted_tokens_for_aa: vec![],
             api_namespaces: None,
             extended_api_tracing: false,
+            deployment_allowlist: DeploymentAllowlist::default(),
         }
     }
 
@@ -408,6 +412,41 @@ impl MerkleTreeApiConfig {
     }
 }
 
+#[derive(Default, Debug, Deserialize, Clone, PartialEq)]
+pub struct DeploymentAllowlist {
+    /// If `Some(url)`, allowlisting is enabled. If `None`, it's disabled.
+    /// If the `String` is empty, treat it as invalid and effectively disable.
+    http_file_url: Option<String>,
+    /// Private field for the refresh interval (in seconds).
+    refresh_interval_secs: Option<u64>,
+}
+
+impl DeploymentAllowlist {
+    /// Create a new `DeploymentAllowlist` instance.
+    pub fn new(http_file_url: Option<String>, refresh_interval_secs: Option<u64>) -> Self {
+        Self {
+            http_file_url,
+            refresh_interval_secs,
+        }
+    }
+
+    /// Returns `true` if deployment allowlisting is enabled.
+    pub fn is_enabled(&self) -> bool {
+        self.http_file_url().is_some()
+    }
+
+    /// Returns the allowlist file URL, if present and non-empty.
+    pub fn http_file_url(&self) -> Option<&str> {
+        self.http_file_url.as_deref().filter(|s| !s.is_empty())
+    }
+
+    /// Returns the refresh interval used to reload the allowlist.
+    /// Defaults to 5 minutes if not set.
+    pub fn refresh_interval(&self) -> Duration {
+        Duration::from_secs(self.refresh_interval_secs.unwrap_or(300))
+    }
+}
+
 #[cfg(test)]
 mod tests {
     use super::*;
diff --git a/core/lib/config/src/testonly.rs b/core/lib/config/src/testonly.rs
index a5336dd7a9bd..35dd5dcbe605 100644
--- a/core/lib/config/src/testonly.rs
+++ b/core/lib/config/src/testonly.rs
@@ -17,6 +17,7 @@ use zksync_crypto_primitives::K256PrivateKey;
 use crate::{
     configs::{
         self,
+        api::DeploymentAllowlist,
         chain::TimestampAsserterConfig,
         da_client::{
             avail::{AvailClientConfig, AvailDefaultConfig},
@@ -115,6 +116,7 @@ impl Distribution<configs::api::Web3JsonRpcConfig> for EncodeDist {
             api_namespaces: self
                 .sample_opt(|| self.sample_range(rng).map(|_| self.sample(rng)).collect()),
             extended_api_tracing: self.sample(rng),
+            deployment_allowlist: DeploymentAllowlist::new(None, Some(300)),
         }
     }
 }
diff --git a/core/lib/env_config/src/api.rs b/core/lib/env_config/src/api.rs
index ecc2343d49f4..8dec72bad680 100644
--- a/core/lib/env_config/src/api.rs
+++ b/core/lib/env_config/src/api.rs
@@ -48,6 +48,8 @@ impl FromEnv for MerkleTreeApiConfig {
 mod tests {
     use std::num::{NonZeroU32, NonZeroUsize};
 
+    use zksync_config::configs::api::DeploymentAllowlist;
+
     use super::*;
     use crate::test_utils::{addr, EnvMutex};
 
@@ -97,6 +99,7 @@ mod tests {
                 ],
                 api_namespaces: Some(vec!["debug".to_string()]),
                 extended_api_tracing: true,
+                deployment_allowlist: DeploymentAllowlist::default(),
             },
             prometheus: PrometheusConfig {
                 listener_port: 3312,
diff --git a/core/lib/protobuf_config/src/api.rs b/core/lib/protobuf_config/src/api.rs
index 3db80c6d691a..d69f69df7885 100644
--- a/core/lib/protobuf_config/src/api.rs
+++ b/core/lib/protobuf_config/src/api.rs
@@ -3,7 +3,7 @@ use std::num::{NonZeroU32, NonZeroUsize};
 use anyhow::Context as _;
 use zksync_config::configs::{api, ApiConfig};
 use zksync_protobuf::{
-    repr::{read_required_repr, ProtoRepr},
+    repr::{read_optional_repr, read_required_repr, ProtoRepr},
     required,
 };
 
@@ -61,6 +61,7 @@ impl ProtoRepr for proto::Web3JsonRpc {
         } else {
             Some(self.api_namespaces.clone())
         };
+
         Ok(Self::Type {
             http_port: required(&self.http_port)
                 .and_then(|p| Ok((*p).try_into()?))
@@ -151,6 +152,8 @@ impl ProtoRepr for proto::Web3JsonRpc {
                 .context("whitelisted_tokens_for_aa")?,
             extended_api_tracing: self.extended_api_tracing.unwrap_or_default(),
             api_namespaces,
+            deployment_allowlist: read_optional_repr(&self.deployment_allowlist)?
+                .unwrap_or_default(),
         })
     }
 
@@ -217,6 +220,27 @@ impl ProtoRepr for proto::Web3JsonRpc {
                 .collect(),
             extended_api_tracing: Some(this.extended_api_tracing),
             api_namespaces: this.api_namespaces.clone().unwrap_or_default(),
+            deployment_allowlist: Some(proto::DeploymentAllowlist::build(
+                &this.deployment_allowlist,
+            )),
+        }
+    }
+}
+
+impl ProtoRepr for proto::DeploymentAllowlist {
+    type Type = zksync_config::configs::api::DeploymentAllowlist;
+
+    fn read(&self) -> anyhow::Result<Self::Type> {
+        Ok(Self::Type::new(
+            self.http_file_url.clone(),
+            self.refresh_interval_secs,
+        ))
+    }
+
+    fn build(this: &Self::Type) -> Self {
+        Self {
+            http_file_url: this.http_file_url().map(String::from),
+            refresh_interval_secs: Some(this.refresh_interval().as_secs()),
         }
     }
 }
diff --git a/core/lib/protobuf_config/src/proto/config/api.proto b/core/lib/protobuf_config/src/proto/config/api.proto
index 89ba0a6bcd2c..bd99e7ade2e7 100644
--- a/core/lib/protobuf_config/src/proto/config/api.proto
+++ b/core/lib/protobuf_config/src/proto/config/api.proto
@@ -42,12 +42,17 @@ message Web3JsonRpc {
   optional bool extended_api_tracing = 33; // optional, default false
   optional bool estimate_gas_optimize_search = 34; // optional, default false
   optional uint32 latest_values_max_block_lag = 35; // optional
+  optional DeploymentAllowlist deployment_allowlist = 36;
 
   reserved 15; reserved "l1_to_l2_transactions_compatibility_mode";
   reserved 11; reserved "request_timeout";
   reserved 12; reserved "account_pks";
 }
 
+message DeploymentAllowlist {
+  optional string http_file_url = 1;
+  optional uint64 refresh_interval_secs = 2;
+}
 
 
 message HealthCheck {
diff --git a/core/node/api_server/Cargo.toml b/core/node/api_server/Cargo.toml
index 2408f2c0b7b1..2e6c96a23eec 100644
--- a/core/node/api_server/Cargo.toml
+++ b/core/node/api_server/Cargo.toml
@@ -54,6 +54,7 @@ tower.workspace = true
 strum = { workspace = true, features = ["derive"] }
 tower-http = { workspace = true, features = ["cors", "metrics"] }
 lru.workspace = true
+reqwest.workspace = true
 
 [dev-dependencies]
 zk_evm_1_5_0.workspace = true
diff --git a/core/node/api_server/src/execution_sandbox/execute.rs b/core/node/api_server/src/execution_sandbox/execute.rs
index baf3d0d11fe6..5d802e8805ba 100644
--- a/core/node/api_server/src/execution_sandbox/execute.rs
+++ b/core/node/api_server/src/execution_sandbox/execute.rs
@@ -14,8 +14,8 @@ use zksync_multivm::{
         executor::{OneshotExecutor, TransactionValidator},
         storage::StorageWithOverrides,
         tracer::TimestampAsserterParams,
-        Call, ExecutionResult, OneshotEnv, OneshotTracingParams, TransactionExecutionMetrics,
-        TxExecutionArgs, VmEvent,
+        Call, DeduplicatedWritesMetrics, ExecutionResult, OneshotEnv, OneshotTracingParams,
+        TransactionExecutionMetrics, TxExecutionArgs, VmEvent,
     },
     utils::StorageWritesDeduplicator,
 };
@@ -70,7 +70,7 @@ impl SandboxAction {
 
 /// Output of [`SandboxExecutor::execute_in_sandbox()`].
 #[derive(Debug, Clone)]
-pub(crate) struct SandboxExecutionOutput {
+pub struct SandboxExecutionOutput {
     /// Output of the VM.
     pub result: ExecutionResult,
     /// Write logs produced by the VM.
@@ -85,6 +85,24 @@ pub(crate) struct SandboxExecutionOutput {
     pub are_published_bytecodes_ok: bool,
 }
 
+impl SandboxExecutionOutput {
+    pub fn mock_success() -> Self {
+        Self {
+            result: ExecutionResult::Success { output: Vec::new() },
+            write_logs: Vec::new(),
+            events: Vec::new(),
+            call_traces: Vec::new(),
+            metrics: TransactionExecutionMetrics {
+                writes: DeduplicatedWritesMetrics::default(),
+                vm: Default::default(),
+                gas_remaining: 0,
+                gas_refunded: 0,
+            },
+            are_published_bytecodes_ok: true,
+        }
+    }
+}
+
 type SandboxStorage = StorageWithOverrides<PostgresStorage<'static>>;
 
 /// Higher-level wrapper around a oneshot VM executor used in the API server.
diff --git a/core/node/api_server/src/tx_sender/master_pool_sink.rs b/core/node/api_server/src/tx_sender/master_pool_sink.rs
index 769d4e7908cb..c83f82eb1242 100644
--- a/core/node/api_server/src/tx_sender/master_pool_sink.rs
+++ b/core/node/api_server/src/tx_sender/master_pool_sink.rs
@@ -5,12 +5,12 @@ use std::{
 
 use tokio::sync::Mutex;
 use zksync_dal::{transactions_dal::L2TxSubmissionResult, ConnectionPool, Core, CoreDal, DalError};
-use zksync_multivm::interface::{tracer::ValidationTraces, TransactionExecutionMetrics};
+use zksync_multivm::interface::tracer::ValidationTraces;
 use zksync_shared_metrics::{TxStage, APP_METRICS};
 use zksync_types::{l2::L2Tx, Address, Nonce, H256};
 
 use super::{tx_sink::TxSink, SubmitTxError};
-use crate::web3::metrics::API_METRICS;
+use crate::{execution_sandbox::SandboxExecutionOutput, web3::metrics::API_METRICS};
 
 /// Guard for `address_and_nonce` keyed tx insertion.
 struct Guard {
@@ -64,7 +64,7 @@ impl TxSink for MasterPoolSink {
     async fn submit_tx(
         &self,
         tx: &L2Tx,
-        execution_metrics: TransactionExecutionMetrics,
+        execution_output: &SandboxExecutionOutput,
         validation_traces: ValidationTraces,
     ) -> Result<L2TxSubmissionResult, SubmitTxError> {
         let address_and_nonce = (tx.initiator_account(), tx.nonce());
@@ -96,7 +96,7 @@ impl TxSink for MasterPoolSink {
             .map_err(DalError::generalize)?;
         let result = connection
             .transactions_dal()
-            .insert_transaction_l2(tx, execution_metrics, validation_traces)
+            .insert_transaction_l2(tx, execution_output.metrics, validation_traces)
             .await
             .inspect(|submission_res_handle| {
                 APP_METRICS.processed_txs[&TxStage::Mempool(*submission_res_handle)].inc();
diff --git a/core/node/api_server/src/tx_sender/mod.rs b/core/node/api_server/src/tx_sender/mod.rs
index 2508f5e10b4b..bcfd816ac701 100644
--- a/core/node/api_server/src/tx_sender/mod.rs
+++ b/core/node/api_server/src/tx_sender/mod.rs
@@ -51,6 +51,7 @@ mod result;
 #[cfg(test)]
 pub(crate) mod tests;
 pub mod tx_sink;
+pub mod whitelist;
 
 pub async fn build_tx_sender(
     tx_sender_config: &TxSenderConfig,
@@ -395,7 +396,7 @@ impl TxSender {
         let submission_res_handle = self
             .0
             .tx_sink
-            .submit_tx(&tx, execution_output.metrics, validation_traces)
+            .submit_tx(&tx, &execution_output, validation_traces)
             .await?;
 
         match submission_res_handle {
diff --git a/core/node/api_server/src/tx_sender/proxy.rs b/core/node/api_server/src/tx_sender/proxy.rs
index bba462404cf0..c77cbf8af9f8 100644
--- a/core/node/api_server/src/tx_sender/proxy.rs
+++ b/core/node/api_server/src/tx_sender/proxy.rs
@@ -11,7 +11,7 @@ use zksync_dal::{
     helpers::wait_for_l1_batch, transactions_dal::L2TxSubmissionResult, Connection, ConnectionPool,
     Core, CoreDal, DalError,
 };
-use zksync_multivm::interface::{tracer::ValidationTraces, TransactionExecutionMetrics};
+use zksync_multivm::interface::tracer::ValidationTraces;
 use zksync_shared_metrics::{TxStage, APP_METRICS};
 use zksync_types::{api, l2::L2Tx, Address, Nonce, H256, U256};
 use zksync_web3_decl::{
@@ -21,6 +21,7 @@ use zksync_web3_decl::{
 };
 
 use super::{tx_sink::TxSink, SubmitTxError};
+use crate::execution_sandbox::SandboxExecutionOutput;
 
 /// In-memory transaction cache for a full node. Works like an ad-hoc mempool replacement, with the important limitation that
 /// it's not synchronized across the network.
@@ -308,7 +309,7 @@ impl TxSink for TxProxy {
     async fn submit_tx(
         &self,
         tx: &L2Tx,
-        _execution_metrics: TransactionExecutionMetrics,
+        _execution_output: &SandboxExecutionOutput,
         _validation_traces: ValidationTraces,
     ) -> Result<L2TxSubmissionResult, SubmitTxError> {
         // We're running an external node: we have to proxy the transaction to the main node.
@@ -419,7 +420,7 @@ mod tests {
         proxy
             .submit_tx(
                 &tx,
-                TransactionExecutionMetrics::default(),
+                &SandboxExecutionOutput::mock_success(),
                 ValidationTraces::default(),
             )
             .await
@@ -532,7 +533,7 @@ mod tests {
         proxy
             .submit_tx(
                 &tx,
-                TransactionExecutionMetrics::default(),
+                &SandboxExecutionOutput::mock_success(),
                 ValidationTraces::default(),
             )
             .await
@@ -596,7 +597,7 @@ mod tests {
         proxy
             .submit_tx(
                 &tx,
-                TransactionExecutionMetrics::default(),
+                &SandboxExecutionOutput::mock_success(),
                 ValidationTraces::default(),
             )
             .await
@@ -677,7 +678,7 @@ mod tests {
         proxy
             .submit_tx(
                 &tx,
-                TransactionExecutionMetrics::default(),
+                &SandboxExecutionOutput::mock_success(),
                 ValidationTraces::default(),
             )
             .await
@@ -685,7 +686,7 @@ mod tests {
         proxy
             .submit_tx(
                 &replacing_tx,
-                TransactionExecutionMetrics::default(),
+                &SandboxExecutionOutput::mock_success(),
                 ValidationTraces::default(),
             )
             .await
@@ -693,7 +694,7 @@ mod tests {
         proxy
             .submit_tx(
                 &future_tx,
-                TransactionExecutionMetrics::default(),
+                &SandboxExecutionOutput::mock_success(),
                 ValidationTraces::default(),
             )
             .await
diff --git a/core/node/api_server/src/tx_sender/result.rs b/core/node/api_server/src/tx_sender/result.rs
index 1b65db2a0369..e9adec6d535c 100644
--- a/core/node/api_server/src/tx_sender/result.rs
+++ b/core/node/api_server/src/tx_sender/result.rs
@@ -1,6 +1,6 @@
 use thiserror::Error;
 use zksync_multivm::interface::ExecutionResult;
-use zksync_types::{l2::error::TxCheckError, U256};
+use zksync_types::{l2::error::TxCheckError, Address, U256};
 use zksync_web3_decl::error::EnrichedClientError;
 
 use crate::execution_sandbox::{SandboxExecutionError, ValidationError};
@@ -69,6 +69,8 @@ pub enum SubmitTxError {
     /// Catch-all internal error (e.g., database error) that should not be exposed to the caller.
     #[error("internal error")]
     Internal(#[from] anyhow::Error),
+    #[error("contract deployer address {0} is not in the allow list")]
+    DeployerNotInAllowList(Address),
 }
 
 impl SubmitTxError {
@@ -99,6 +101,7 @@ impl SubmitTxError {
             Self::FailedBlockTimestampAssertion => "failed-block-timestamp-assertion",
             Self::ProxyError(_) => "proxy-error",
             Self::Internal(_) => "internal",
+            Self::DeployerNotInAllowList(_) => "deployer-not-in-allow-list",
         }
     }
 
diff --git a/core/node/api_server/src/tx_sender/tx_sink.rs b/core/node/api_server/src/tx_sender/tx_sink.rs
index 1a6a7a733ccd..1836e8aaa6b9 100644
--- a/core/node/api_server/src/tx_sender/tx_sink.rs
+++ b/core/node/api_server/src/tx_sender/tx_sink.rs
@@ -1,5 +1,5 @@
 use zksync_dal::{transactions_dal::L2TxSubmissionResult, Connection, Core};
-use zksync_multivm::interface::{tracer::ValidationTraces, TransactionExecutionMetrics};
+use zksync_multivm::interface::tracer::ValidationTraces;
 use zksync_types::{
     api::{Transaction, TransactionDetails, TransactionId},
     l2::L2Tx,
@@ -8,6 +8,7 @@ use zksync_types::{
 use zksync_web3_decl::error::Web3Error;
 
 use super::SubmitTxError;
+use crate::execution_sandbox::SandboxExecutionOutput;
 
 /// An abstraction of "destination" for transactions that should be propagated to the mempool.
 ///
@@ -27,7 +28,7 @@ pub trait TxSink: std::fmt::Debug + Send + Sync + 'static {
     async fn submit_tx(
         &self,
         tx: &L2Tx,
-        execution_metrics: TransactionExecutionMetrics,
+        execution_output: &SandboxExecutionOutput,
         validation_traces: ValidationTraces,
     ) -> Result<L2TxSubmissionResult, SubmitTxError>;
 
diff --git a/core/node/api_server/src/tx_sender/whitelist.rs b/core/node/api_server/src/tx_sender/whitelist.rs
new file mode 100644
index 000000000000..d51f85f3a13b
--- /dev/null
+++ b/core/node/api_server/src/tx_sender/whitelist.rs
@@ -0,0 +1,188 @@
+use std::{collections::HashSet, sync::Arc, time::Duration};
+
+use reqwest::Client;
+use serde::Deserialize;
+use tokio::sync::{watch, RwLock};
+use zksync_config::configs::api::DeploymentAllowlist;
+use zksync_dal::transactions_dal::L2TxSubmissionResult;
+use zksync_multivm::interface::{tracer::ValidationTraces, VmEvent};
+use zksync_types::{h256_to_address, l2::L2Tx, Address, CONTRACT_DEPLOYER_ADDRESS};
+
+use crate::{
+    execution_sandbox::SandboxExecutionOutput,
+    tx_sender::{master_pool_sink::MasterPoolSink, tx_sink::TxSink, SubmitTxError},
+};
+
+/// Wrapper that submits transactions to the mempool and enforces contract deployment allow-list
+/// by analyzing the VM events for contract deployments.
+#[derive(Debug)]
+pub struct WhitelistedDeployPoolSink {
+    master_pool_sink: MasterPoolSink,
+    shared_allow_list: SharedAllowList,
+}
+
+impl WhitelistedDeployPoolSink {
+    pub fn new(master_pool_sink: MasterPoolSink, shared_allow_list: SharedAllowList) -> Self {
+        Self {
+            master_pool_sink,
+            shared_allow_list,
+        }
+    }
+}
+
+#[async_trait::async_trait]
+impl TxSink for WhitelistedDeployPoolSink {
+    async fn submit_tx(
+        &self,
+        tx: &L2Tx,
+        execution_output: &SandboxExecutionOutput,
+        validation_traces: ValidationTraces,
+    ) -> Result<L2TxSubmissionResult, SubmitTxError> {
+        // Enforce the deployment allowlist by scanning for ContractDeployed events.
+        // Each such event should follow this format:
+        //   event ContractDeployed(address indexed deployerAddress, bytes32 indexed bytecodeHash, address indexed contractAddress);
+        // We extract the deployer address from topic[1] and verify it is whitelisted.
+
+        let deployer_addresses = execution_output.events.iter().filter_map(|event| {
+            let is_contract_deployed = event.address == CONTRACT_DEPLOYER_ADDRESS
+                && event.indexed_topics.first() == Some(&VmEvent::DEPLOY_EVENT_SIGNATURE);
+            if is_contract_deployed {
+                event.indexed_topics.get(1).map(h256_to_address)
+            } else {
+                None
+            }
+        });
+
+        for deployer_address in deployer_addresses {
+            if !self
+                .shared_allow_list
+                .is_address_allowed(&deployer_address)
+                .await
+            {
+                tracing::info!(
+                    "Blocking contract deployment in tx {:?}: deployer_address {:?} not whitelisted",
+                    tx.hash(),
+                    deployer_address
+                );
+                return Err(SubmitTxError::DeployerNotInAllowList(deployer_address));
+            }
+        }
+
+        // If all deployment events pass the allowlist check, forward the submission.
+        self.master_pool_sink
+            .submit_tx(tx, execution_output, validation_traces)
+            .await
+    }
+}
+
+#[derive(Debug, Deserialize)]
+struct WhitelistResponse {
+    addresses: Vec<Address>,
+}
+
+#[derive(Debug, Clone, Default)]
+pub struct SharedAllowList {
+    inner: Arc<RwLock<HashSet<Address>>>,
+}
+
+impl SharedAllowList {
+    fn writer(&self) -> &Arc<RwLock<HashSet<Address>>> {
+        &self.inner
+    }
+
+    async fn is_address_allowed(&self, address: &Address) -> bool {
+        self.inner.read().await.contains(address)
+    }
+}
+
+/// Task that periodically fetches and updates the allowlist from a remote HTTP source.
+#[derive(Debug, Clone)]
+pub struct AllowListTask {
+    url: String,
+    refresh_interval: Duration,
+    allowlist: SharedAllowList,
+    client: Client,
+}
+
+impl AllowListTask {
+    const REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
+    pub fn from_config(deployment_allowlist: DeploymentAllowlist) -> Self {
+        Self {
+            url: deployment_allowlist
+                .http_file_url()
+                .expect("DeploymentAllowlist must contain a URL")
+                .to_string(),
+            refresh_interval: deployment_allowlist.refresh_interval(),
+            allowlist: SharedAllowList::default(),
+            client: Client::new(),
+        }
+    }
+
+    fn refresh_interval(&self) -> Duration {
+        self.refresh_interval
+    }
+
+    pub fn shared(&self) -> SharedAllowList {
+        self.allowlist.clone()
+    }
+
+    async fn fetch(
+        &self,
+        current_etag: Option<&str>,
+    ) -> anyhow::Result<Option<(HashSet<Address>, Option<String>)>> {
+        let mut request = self.client.get(&self.url).timeout(Self::REQUEST_TIMEOUT);
+
+        if let Some(etag) = current_etag {
+            request = request.header("If-None-Match", etag);
+        }
+
+        let response = request.send().await?;
+
+        if response.status() == reqwest::StatusCode::NOT_MODIFIED {
+            tracing::debug!("Allowlist unchanged (304 Not Modified)");
+            return Ok(None);
+        }
+
+        let response = response.error_for_status()?;
+
+        let new_etag = match response.headers().get("ETag") {
+            Some(value) => match value.to_str() {
+                Ok(s) => Some(s.to_string()),
+                Err(_) => None,
+            },
+            None => None,
+        };
+        let list = response.json::<WhitelistResponse>().await?;
+        let addresses: HashSet<_> = list.addresses.into_iter().collect();
+
+        Ok(Some((addresses, new_etag)))
+    }
+
+    pub async fn run(self, mut stop_receiver: watch::Receiver<bool>) -> anyhow::Result<()> {
+        let mut etag: Option<String> = None;
+
+        while !*stop_receiver.borrow_and_update() {
+            match self.fetch(etag.as_deref()).await {
+                Ok(Some((new_list, new_etag))) => {
+                    let writer = self.allowlist.writer();
+                    let mut lock = writer.write().await;
+
+                    *lock = new_list;
+                    etag = new_etag;
+                    tracing::debug!("Allowlist updated. {} entries loaded.", lock.len());
+                }
+                Ok(None) => {
+                    tracing::debug!("Allowlist not updated (ETag matched).");
+                }
+                Err(err) => {
+                    tracing::warn!("Failed to refresh allowlist: {}", err);
+                }
+            }
+            let _ = tokio::time::timeout(self.refresh_interval(), stop_receiver.changed()).await;
+        }
+
+        tracing::info!("received a stop signal; allow list task is shut down");
+
+        Ok(())
+    }
+}
diff --git a/core/node/node_framework/src/implementations/layers/web3_api/tx_sink/mod.rs b/core/node/node_framework/src/implementations/layers/web3_api/tx_sink/mod.rs
index 61b9fb1d9e9e..c25a84f23258 100644
--- a/core/node/node_framework/src/implementations/layers/web3_api/tx_sink/mod.rs
+++ b/core/node/node_framework/src/implementations/layers/web3_api/tx_sink/mod.rs
@@ -2,3 +2,4 @@ pub use self::{master_pool_sink::MasterPoolSinkLayer, proxy_sink::ProxySinkLayer
 
 pub mod master_pool_sink;
 pub mod proxy_sink;
+pub mod whitelist;
diff --git a/core/node/node_framework/src/implementations/layers/web3_api/tx_sink/whitelist.rs b/core/node/node_framework/src/implementations/layers/web3_api/tx_sink/whitelist.rs
new file mode 100644
index 000000000000..32214f4e6399
--- /dev/null
+++ b/core/node/node_framework/src/implementations/layers/web3_api/tx_sink/whitelist.rs
@@ -0,0 +1,76 @@
+use async_trait::async_trait;
+use zksync_config::configs::api::DeploymentAllowlist;
+use zksync_node_api_server::tx_sender::{
+    master_pool_sink::MasterPoolSink,
+    whitelist::{AllowListTask, WhitelistedDeployPoolSink},
+};
+
+use crate::{
+    implementations::resources::{
+        pools::{MasterPool, PoolResource},
+        web3_api::TxSinkResource,
+    },
+    service::StopReceiver,
+    task::{Task, TaskId, TaskKind},
+    wiring_layer::{WiringError, WiringLayer},
+    FromContext, IntoContext,
+};
+
+/// Wiring layer for [`WhitelistedDeployPoolSink`] that wraps a `MasterPoolSink` and enables allowlist filtering.
+pub struct WhitelistedMasterPoolSinkLayer {
+    pub deployment_allowlist: DeploymentAllowlist,
+}
+
+#[derive(Debug, FromContext)]
+#[context(crate = crate)]
+pub struct Input {
+    pub master_pool: PoolResource<MasterPool>,
+}
+
+#[derive(Debug, IntoContext)]
+#[context(crate = crate)]
+pub struct Output {
+    pub tx_sink: TxSinkResource,
+    #[context(task)]
+    pub allow_list_task: AllowListTask,
+}
+
+#[async_trait]
+impl WiringLayer for WhitelistedMasterPoolSinkLayer {
+    type Input = Input;
+    type Output = Output;
+
+    fn layer_name(&self) -> &'static str {
+        "whitelisted_master_pool_sink_layer"
+    }
+
+    async fn wire(self, input: Self::Input) -> Result<Self::Output, WiringError> {
+        let pool = input.master_pool.get().await?;
+        let master_pool_sink = MasterPoolSink::new(pool);
+
+        let allow_list_task = AllowListTask::from_config(self.deployment_allowlist);
+
+        let tx_sink =
+            WhitelistedDeployPoolSink::new(master_pool_sink, allow_list_task.shared()).into();
+
+        Ok(Output {
+            tx_sink,
+            allow_list_task,
+        })
+    }
+}
+
+#[async_trait]
+impl Task for AllowListTask {
+    fn id(&self) -> TaskId {
+        "api_allowlist_task".into()
+    }
+
+    fn kind(&self) -> TaskKind {
+        TaskKind::Task
+    }
+
+    async fn run(self: Box<Self>, stop_receiver: StopReceiver) -> anyhow::Result<()> {
+        (*self).run(stop_receiver.0).await
+    }
+}
diff --git a/docs/src/guides/advanced/18_allowlist.md b/docs/src/guides/advanced/18_allowlist.md
new file mode 100644
index 000000000000..4d2e0c213e4e
--- /dev/null
+++ b/docs/src/guides/advanced/18_allowlist.md
@@ -0,0 +1,106 @@
+# Contract Deployment Allowlist
+
+This module restricts contract deployments on zkSync Era to a predefined list of approved addresses.
+
+## Configuration
+
+Configure via `api.web3_json_rpc.deployment_allowlist` in your node's YAML:
+
+```YAML
+api:
+  web3_json_rpc:
+    deployment_allowlist:
+      http_file_url: "https://example.com/allowlist.json"
+      refresh_interval_secs: 60
+```
+
+- `http_file_url` (required): HTTP(S) URL to the allowlist JSON.
+- `refresh_interval_secs` (optional): Refresh interval in seconds (default: 300).
+
+To disable, set:
+
+```YAML
+api:
+  web3_json_rpc:
+    deployment_allowlist: {}
+```
+
+---
+
+## Allowlist Format
+
+The JSON served at the specified URL file must contain:
+
+```JSON
+{
+  "addresses": [
+    "0x1234...abcd",
+    "0xbeef...dead"
+  ]
+}
+```
+
+- Served as `application/json`.
+- ETag headers supported (optional).
+
+---
+
+### How Allowlist Enforcement Works
+
+When a transaction is submitted, the `WhitelistedDeployPoolSink` inspects the virtual machine (VM) execution output to
+determine if it includes any contract deployments.
+
+#### Detection Flow
+
+1. The VM emits an event for each contract deployment:
+
+   - `ContractDeployed(address deployer, bytes32 bytecodeHash, address contractAddress)`
+
+2. These events are parsed from the `execution_output.events` field.
+
+3. If any deployment events are found:
+
+   - The deployer address (from topic[1]) is extracted.
+   - The address is checked against the current in-memory allowlist (`SharedAllowList`).
+
+4. If the deployer is **not** in the allowlist:
+
+   - The transaction is **rejected** with a `DeployerNotInAllowList` error.
+   - A log is written with the deployer address and transaction hash.
+
+5. If all deployments are allowed:
+   - The transaction is forwarded to the mempool via `MasterPoolSink`.
+
+#### Example Check (Simplified)
+
+- Deployment event detected → extract deployer address
+- Check: `allowlist.contains(deployer_address)`
+- If not found → reject transaction
+- Otherwise → forward to mempool
+
+## Behavior
+
+- On tx submission: contract deployer is checked against the allowlist.
+- If not listed → tx is rejected.
+- On refresh: allowlist is reloaded via HTTP. Skipped if `304 Not Modified`.
+
+### ETag Support
+
+To reduce unnecessary network usage, the allowlist fetcher supports HTTP **ETag** headers:
+
+- When the allowlist is first fetched, the response’s `ETag` header is cached.
+- On subsequent requests, the client includes the header:
+
+  If-None-Match: "<etag-value>"
+
+- If the allowlist has **not changed**, the server responds with:
+
+  304 Not Modified
+
+  and the in-memory list remains unchanged.
+
+#### Notes
+
+- ETag support is optional but recommended.
+- If the server does **not** return an `ETag`, the allowlist will be re-fetched and re-parsed every interval.
+- The ETag value is stored in memory only (not persisted across restarts).


Reviews:
user: login: dimazhornyk
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-19T14:49:07Z
user: login: RomanHiden
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-19T15:50:13Z
user: login: RomanHiden
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-19T15:52:00Z
user: login: RomanHiden
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-19T15:52:31Z
user: login: slowli
body: Is filtering supposed to include deployments made not only by the tx initiator, but by contracts inside a transaction? (The current approach will count these as deployments.)
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-19T16:17:46Z
user: login: RomanHiden
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-19T16:21:00Z
user: login: RomanHiden
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-19T16:28:56Z
user: login: RomanHiden
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-19T16:50:46Z
user: login: RomanHiden
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-19T17:17:42Z
user: login: slowli
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-20T15:54:17Z
user: login: Deniallugo
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-20T17:39:39Z
user: login: CryptoMan0x
state: COMMENTED
author_association: NONE
submitted_at: 2025-03-20T17:42:51Z
user: login: githubdoramon
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-21T09:20:44Z
user: login: CryptoMan0x
state: COMMENTED
author_association: NONE
submitted_at: 2025-03-21T15:07:48Z
user: login: RomanHiden
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-24T16:21:20Z
user: login: RomanHiden
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-25T18:22:57Z
user: login: slowli
body: I like the new approach significantly more than the Postgres-based one.
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-25T19:28:58Z
user: login: RomanHiden
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-25T19:29:50Z
user: login: RomanHiden
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-25T20:14:03Z
user: login: slowli
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-26T15:10:29Z
user: login: slowli
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-01T13:09:53Z
user: login: RomanHiden
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-01T16:12:04Z
user: login: RomanHiden
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-01T16:58:57Z
user: login: slowli
body: Looks better 👍 There's still a couple of architectural concers to be addressed.
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-02T13:59:10Z
user: login: slowli
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-04T11:20:24Z
user: login: RomanHiden
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-04T11:52:44Z
user: login: slowli
body: Other than questionable `Default` derivation, looks good 👍 @StanislavBreadless, @perekopskiy, @Deniallugo, @RomanBrodetski AFAIU, we're OK with this transaction filtering logic making into the server codebase, right?
state: DISMISSED
author_association: CONTRIBUTOR
submitted_at: 2025-04-08T13:22:22Z
user: login: dimazhornyk
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T13:15:02Z

Review comments:
diff_hunk: @@ -0,0 +1,7 @@
+syntax = "proto3";
+
+package zksync.config.tx_sink;
+
+message TxSink {
+  optional bool deployment_allowlist_sink = 1;
path: core/lib/protobuf_config/src/proto/config/tx_sink.proto
user: login: dimazhornyk
body: maybe it's worth naming it `use_deployment_allowlist_sink`
created_at: 2025-03-19T14:33:18Z
author_association: CONTRIBUTOR
diff_hunk: @@ -204,6 +220,7 @@ impl VmExecutionResultAndLogs {
             computational_gas_used: self.statistics.computational_gas_used,
             pubdata_published: self.statistics.pubdata_published,
             circuit_statistic: self.statistics.circuit_statistic,
+            contract_deployment_count: contract_deployment_count,
path: core/lib/vm_interface/src/types/outputs/execution_result.rs
user: login: dimazhornyk
body: ```suggestion
            contract_deployment_count,
```
created_at: 2025-03-19T14:45:40Z
author_association: CONTRIBUTOR
diff_hunk: @@ -217,6 +217,8 @@ pub struct VmExecutionMetrics {
     pub computational_gas_used: u32,
     pub pubdata_published: u32,
     pub circuit_statistic: CircuitStatistic,
+    #[serde(default)]
path: core/lib/vm_interface/src/types/outputs/statistic.rs
user: login: dimazhornyk
body: is the ` #[serde(default)]` necessary here?
created_at: 2025-03-19T14:46:34Z
author_association: CONTRIBUTOR
diff_hunk: @@ -69,6 +69,8 @@ pub enum SubmitTxError {
     Internal(#[from] anyhow::Error),
     #[error("transaction failed block.timestamp assertion")]
     FailedBlockTimestampAssertion,
+    #[error("sender address {0} is in allow list")]
path: core/node/api_server/src/tx_sender/result.rs
user: login: dimazhornyk
body: ```suggestion
    #[error("sender address {0} is not in allow list")]
```
created_at: 2025-03-19T14:46:57Z
author_association: CONTRIBUTOR
diff_hunk: @@ -99,6 +101,7 @@ impl SubmitTxError {
             Self::ProxyError(_) => "proxy-error",
             Self::Internal(_) => "internal",
             Self::FailedBlockTimestampAssertion => "failed-block-timestamp-assertion",
+            Self::SenderNotInAllowList(_) => "sender-not-allow-list",
path: core/node/api_server/src/tx_sender/result.rs
user: login: dimazhornyk
body: ```suggestion
            Self::SenderNotInAllowList(_) => "sender-not-in-allow-list",
```
created_at: 2025-03-19T14:47:11Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,67 @@
+use zksync_dal::{transactions_dal::L2TxSubmissionResult, ConnectionPool, Core, CoreDal, DalError};
+use zksync_multivm::interface::{tracer::ValidationTraces, TransactionExecutionMetrics};
+use zksync_types::l2::L2Tx;
+
+use super::{tx_sink::TxSink, SubmitTxError};
+use crate::tx_sender::master_pool_sink::MasterPoolSink;
+
+/// Wrapper for the master DB pool that allows submitting transactions to the mempool.
+#[derive(Debug)]
+pub struct WhitelistedDeployPoolSink {
+    master_pool_sink: MasterPoolSink,
+    master_pool: ConnectionPool<Core>,
+}
+
+impl WhitelistedDeployPoolSink {
+    pub fn new(master_pool_sink: MasterPoolSink, master_pool: ConnectionPool<Core>) -> Self {
+        Self {
+            master_pool_sink,
+            master_pool,
+        }
+    }
+}
+
+#[async_trait::async_trait]
+impl TxSink for WhitelistedDeployPoolSink {
+    async fn submit_tx(
+        &self,
+        tx: &L2Tx,
+        execution_metrics: TransactionExecutionMetrics,
+        validation_traces: ValidationTraces,
+    ) -> Result<L2TxSubmissionResult, SubmitTxError> {
+        let initiator = tx.initiator_account();
+
+        // Only enforce the allow-list check if VM actually deployed a contract.
+        if execution_metrics.vm.contract_deployment_count > 0 {
+            let mut connection = self
+                .master_pool
+                .connection_tagged("api")
+                .await
+                .map_err(DalError::generalize)?; // Removed `mut`, not needed
path: core/node/api_server/src/tx_sender/whitelisted_deploy_pool_sink.rs
user: login: dimazhornyk
body: could you please clarify which `mut` you are referring to?
created_at: 2025-03-19T14:48:38Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,7 @@
+syntax = "proto3";
+
+package zksync.config.tx_sink;
+
+message TxSink {
+  optional bool deployment_allowlist_sink = 1;
path: core/lib/protobuf_config/src/proto/config/tx_sink.proto
user: login: RomanHiden
body: definitely

created_at: 2025-03-19T15:50:10Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,67 @@
+use zksync_dal::{transactions_dal::L2TxSubmissionResult, ConnectionPool, Core, CoreDal, DalError};
+use zksync_multivm::interface::{tracer::ValidationTraces, TransactionExecutionMetrics};
+use zksync_types::l2::L2Tx;
+
+use super::{tx_sink::TxSink, SubmitTxError};
+use crate::tx_sender::master_pool_sink::MasterPoolSink;
+
+/// Wrapper for the master DB pool that allows submitting transactions to the mempool.
+#[derive(Debug)]
+pub struct WhitelistedDeployPoolSink {
+    master_pool_sink: MasterPoolSink,
+    master_pool: ConnectionPool<Core>,
+}
+
+impl WhitelistedDeployPoolSink {
+    pub fn new(master_pool_sink: MasterPoolSink, master_pool: ConnectionPool<Core>) -> Self {
+        Self {
+            master_pool_sink,
+            master_pool,
+        }
+    }
+}
+
+#[async_trait::async_trait]
+impl TxSink for WhitelistedDeployPoolSink {
+    async fn submit_tx(
+        &self,
+        tx: &L2Tx,
+        execution_metrics: TransactionExecutionMetrics,
+        validation_traces: ValidationTraces,
+    ) -> Result<L2TxSubmissionResult, SubmitTxError> {
+        let initiator = tx.initiator_account();
+
+        // Only enforce the allow-list check if VM actually deployed a contract.
+        if execution_metrics.vm.contract_deployment_count > 0 {
+            let mut connection = self
+                .master_pool
+                .connection_tagged("api")
+                .await
+                .map_err(DalError::generalize)?; // Removed `mut`, not needed
path: core/node/api_server/src/tx_sender/whitelisted_deploy_pool_sink.rs
user: login: RomanHiden
body: ahh I tried to refactor something  but that didn't work. 
created_at: 2025-03-19T15:52:00Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,7 @@
+syntax = "proto3";
+
+package zksync.config.tx_sink;
+
+message TxSink {
+  optional bool deployment_allowlist_sink = 1;
path: core/lib/protobuf_config/src/proto/config/tx_sink.proto
user: login: RomanHiden
body: definitely

created_at: 2025-03-19T15:52:31Z
author_association: CONTRIBUTOR
diff_hunk: @@ -95,6 +95,19 @@ impl VmEvent {
             })
             .map(|event| event.indexed_topics[1])
     }
+
+    /// Returns the number of `ContractDeployed` events in the given list of events.
+    pub fn count_contract_deployments(events: &[Self]) -> usize {
+        events
+            .iter()
+            .filter(|event| {
+                event
+                    .indexed_topics
+                    .get(0)
+                    .map_or(false, |&topic| topic == VmEvent::DEPLOY_EVENT_SIGNATURE)
path: core/lib/vm_interface/src/types/outputs/execution_result.rs
user: login: slowli
body: Shouldn't this check the `address` that emitted the event as well?
created_at: 2025-03-19T16:00:15Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,67 @@
+use zksync_dal::{transactions_dal::L2TxSubmissionResult, ConnectionPool, Core, CoreDal, DalError};
+use zksync_multivm::interface::{tracer::ValidationTraces, TransactionExecutionMetrics};
+use zksync_types::l2::L2Tx;
+
+use super::{tx_sink::TxSink, SubmitTxError};
+use crate::tx_sender::master_pool_sink::MasterPoolSink;
+
+/// Wrapper for the master DB pool that allows submitting transactions to the mempool.
+#[derive(Debug)]
+pub struct WhitelistedDeployPoolSink {
+    master_pool_sink: MasterPoolSink,
+    master_pool: ConnectionPool<Core>,
+}
+
+impl WhitelistedDeployPoolSink {
+    pub fn new(master_pool_sink: MasterPoolSink, master_pool: ConnectionPool<Core>) -> Self {
+        Self {
+            master_pool_sink,
+            master_pool,
+        }
+    }
+}
+
+#[async_trait::async_trait]
+impl TxSink for WhitelistedDeployPoolSink {
+    async fn submit_tx(
+        &self,
+        tx: &L2Tx,
+        execution_metrics: TransactionExecutionMetrics,
+        validation_traces: ValidationTraces,
+    ) -> Result<L2TxSubmissionResult, SubmitTxError> {
+        let initiator = tx.initiator_account();
+
+        // Only enforce the allow-list check if VM actually deployed a contract.
+        if execution_metrics.vm.contract_deployment_count > 0 {
+            let mut connection = self
+                .master_pool
+                .connection_tagged("api")
+                .await
+                .map_err(DalError::generalize)?; // Removed `mut`, not needed
+
+            let is_allowed = connection
+                .contracts_deploy_allow_list_dal()
+                .is_address_allowed(&initiator)
+                .await
+                .map_err(DalError::generalize)?;
path: core/node/api_server/src/tx_sender/whitelisted_deploy_pool_sink.rs
user: login: slowli
body: Just to understand better:

- Can the `contract_deploy_allow_list` table be updated at any moment? I.e., it would be unsound to read the allow list from the config at the node start or cache it in general, right?
- Is Postgres the only possible storage for the allow list? Adding a separate table for it will affect the Postgres schema for all nodes on all envs, where it'll be just deadweight. As potential alternatives, it could be read from a file or a server (both assuming that the list must be re-read on each request; see the question above).
created_at: 2025-03-19T16:12:11Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,15 @@
+use serde::Deserialize;
+
+#[derive(Debug, Deserialize, Clone, PartialEq)]
+pub struct TxSinkConfig {
path: core/lib/config/src/configs/tx_sink.rs
user: login: slowli
body: Bikeshedding: I don't think this merits a separate config; the only config param could be added to `Web3JsonRpcConfig`.
created_at: 2025-03-19T16:17:27Z
author_association: CONTRIBUTOR
diff_hunk: @@ -217,6 +217,8 @@ pub struct VmExecutionMetrics {
     pub computational_gas_used: u32,
     pub pubdata_published: u32,
     pub circuit_statistic: CircuitStatistic,
+    #[serde(default)]
path: core/lib/vm_interface/src/types/outputs/statistic.rs
user: login: RomanHiden
body: not needed
created_at: 2025-03-19T16:21:00Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,15 @@
+use serde::Deserialize;
+
+#[derive(Debug, Deserialize, Clone, PartialEq)]
+pub struct TxSinkConfig {
path: core/lib/config/src/configs/tx_sink.rs
user: login: RomanHiden
body: will move it there
created_at: 2025-03-19T16:28:55Z
author_association: CONTRIBUTOR
diff_hunk: @@ -95,6 +95,19 @@ impl VmEvent {
             })
             .map(|event| event.indexed_topics[1])
     }
+
+    /// Returns the number of `ContractDeployed` events in the given list of events.
+    pub fn count_contract_deployments(events: &[Self]) -> usize {
+        events
+            .iter()
+            .filter(|event| {
+                event
+                    .indexed_topics
+                    .get(0)
+                    .map_or(false, |&topic| topic == VmEvent::DEPLOY_EVENT_SIGNATURE)
path: core/lib/vm_interface/src/types/outputs/execution_result.rs
user: login: RomanHiden
body: it definitely should
created_at: 2025-03-19T16:50:45Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,67 @@
+use zksync_dal::{transactions_dal::L2TxSubmissionResult, ConnectionPool, Core, CoreDal, DalError};
+use zksync_multivm::interface::{tracer::ValidationTraces, TransactionExecutionMetrics};
+use zksync_types::l2::L2Tx;
+
+use super::{tx_sink::TxSink, SubmitTxError};
+use crate::tx_sender::master_pool_sink::MasterPoolSink;
+
+/// Wrapper for the master DB pool that allows submitting transactions to the mempool.
+#[derive(Debug)]
+pub struct WhitelistedDeployPoolSink {
+    master_pool_sink: MasterPoolSink,
+    master_pool: ConnectionPool<Core>,
+}
+
+impl WhitelistedDeployPoolSink {
+    pub fn new(master_pool_sink: MasterPoolSink, master_pool: ConnectionPool<Core>) -> Self {
+        Self {
+            master_pool_sink,
+            master_pool,
+        }
+    }
+}
+
+#[async_trait::async_trait]
+impl TxSink for WhitelistedDeployPoolSink {
+    async fn submit_tx(
+        &self,
+        tx: &L2Tx,
+        execution_metrics: TransactionExecutionMetrics,
+        validation_traces: ValidationTraces,
+    ) -> Result<L2TxSubmissionResult, SubmitTxError> {
+        let initiator = tx.initiator_account();
+
+        // Only enforce the allow-list check if VM actually deployed a contract.
+        if execution_metrics.vm.contract_deployment_count > 0 {
+            let mut connection = self
+                .master_pool
+                .connection_tagged("api")
+                .await
+                .map_err(DalError::generalize)?; // Removed `mut`, not needed
+
+            let is_allowed = connection
+                .contracts_deploy_allow_list_dal()
+                .is_address_allowed(&initiator)
+                .await
+                .map_err(DalError::generalize)?;
path: core/node/api_server/src/tx_sender/whitelisted_deploy_pool_sink.rs
user: login: RomanHiden
body: We want a permissioned chain from the start, and the allow list hopefully will grow large.

1. File-based approach  
   - Storing in memory is easy; 5MB RAM holds ~150K wallets.  
   - Periodic re-reads are fine; NVMe can load 5MB in ~0.002 sec.  
   - Not unsound if it's chain-specific.

2. HTTP endpoint  
   - Adds unnecessary delays to the sequencer.

3. Postgres + RocksDB  
   - I was thinking of having a RocksDB on top of Postgres - not worth it I think.  
 the table doesn't necessarily have to be present on all schema for all nodes. I understand the maintenance concern here too. 
 
 If you suggest file is better I will rework this to be a file based. 
created_at: 2025-03-19T17:17:42Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,67 @@
+use zksync_dal::{transactions_dal::L2TxSubmissionResult, ConnectionPool, Core, CoreDal, DalError};
+use zksync_multivm::interface::{tracer::ValidationTraces, TransactionExecutionMetrics};
+use zksync_types::l2::L2Tx;
+
+use super::{tx_sink::TxSink, SubmitTxError};
+use crate::tx_sender::master_pool_sink::MasterPoolSink;
+
+/// Wrapper for the master DB pool that allows submitting transactions to the mempool.
+#[derive(Debug)]
+pub struct WhitelistedDeployPoolSink {
+    master_pool_sink: MasterPoolSink,
+    master_pool: ConnectionPool<Core>,
+}
+
+impl WhitelistedDeployPoolSink {
+    pub fn new(master_pool_sink: MasterPoolSink, master_pool: ConnectionPool<Core>) -> Self {
+        Self {
+            master_pool_sink,
+            master_pool,
+        }
+    }
+}
+
+#[async_trait::async_trait]
+impl TxSink for WhitelistedDeployPoolSink {
+    async fn submit_tx(
+        &self,
+        tx: &L2Tx,
+        execution_metrics: TransactionExecutionMetrics,
+        validation_traces: ValidationTraces,
+    ) -> Result<L2TxSubmissionResult, SubmitTxError> {
+        let initiator = tx.initiator_account();
+
+        // Only enforce the allow-list check if VM actually deployed a contract.
+        if execution_metrics.vm.contract_deployment_count > 0 {
+            let mut connection = self
+                .master_pool
+                .connection_tagged("api")
+                .await
+                .map_err(DalError::generalize)?; // Removed `mut`, not needed
+
+            let is_allowed = connection
+                .contracts_deploy_allow_list_dal()
+                .is_address_allowed(&initiator)
+                .await
+                .map_err(DalError::generalize)?;
path: core/node/api_server/src/tx_sender/whitelisted_deploy_pool_sink.rs
user: login: slowli
body: Subjectively, a file is indeed better, provided that it satisfies all requirements. The path to it may be encoded in the config (i.e., if the path is present, allowlist filtering is enabled). Would love to hear other opinions though; cc @RomanBrodetski @Deniallugo 
created_at: 2025-03-20T15:54:16Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,67 @@
+use zksync_dal::{transactions_dal::L2TxSubmissionResult, ConnectionPool, Core, CoreDal, DalError};
+use zksync_multivm::interface::{tracer::ValidationTraces, TransactionExecutionMetrics};
+use zksync_types::l2::L2Tx;
+
+use super::{tx_sink::TxSink, SubmitTxError};
+use crate::tx_sender::master_pool_sink::MasterPoolSink;
+
+/// Wrapper for the master DB pool that allows submitting transactions to the mempool.
+#[derive(Debug)]
+pub struct WhitelistedDeployPoolSink {
+    master_pool_sink: MasterPoolSink,
+    master_pool: ConnectionPool<Core>,
+}
+
+impl WhitelistedDeployPoolSink {
+    pub fn new(master_pool_sink: MasterPoolSink, master_pool: ConnectionPool<Core>) -> Self {
+        Self {
+            master_pool_sink,
+            master_pool,
+        }
+    }
+}
+
+#[async_trait::async_trait]
+impl TxSink for WhitelistedDeployPoolSink {
+    async fn submit_tx(
+        &self,
+        tx: &L2Tx,
+        execution_metrics: TransactionExecutionMetrics,
+        validation_traces: ValidationTraces,
+    ) -> Result<L2TxSubmissionResult, SubmitTxError> {
+        let initiator = tx.initiator_account();
+
+        // Only enforce the allow-list check if VM actually deployed a contract.
+        if execution_metrics.vm.contract_deployment_count > 0 {
+            let mut connection = self
+                .master_pool
+                .connection_tagged("api")
+                .await
+                .map_err(DalError::generalize)?; // Removed `mut`, not needed
+
+            let is_allowed = connection
+                .contracts_deploy_allow_list_dal()
+                .is_address_allowed(&initiator)
+                .await
+                .map_err(DalError::generalize)?;
path: core/node/api_server/src/tx_sender/whitelisted_deploy_pool_sink.rs
user: login: Deniallugo
body: I agree. 
RocksDB looks like an overkill, but simple json/yaml file is good enough as an addon 
created_at: 2025-03-20T17:39:39Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,67 @@
+use zksync_dal::{transactions_dal::L2TxSubmissionResult, ConnectionPool, Core, CoreDal, DalError};
+use zksync_multivm::interface::{tracer::ValidationTraces, TransactionExecutionMetrics};
+use zksync_types::l2::L2Tx;
+
+use super::{tx_sink::TxSink, SubmitTxError};
+use crate::tx_sender::master_pool_sink::MasterPoolSink;
+
+/// Wrapper for the master DB pool that allows submitting transactions to the mempool.
+#[derive(Debug)]
+pub struct WhitelistedDeployPoolSink {
+    master_pool_sink: MasterPoolSink,
+    master_pool: ConnectionPool<Core>,
+}
+
+impl WhitelistedDeployPoolSink {
+    pub fn new(master_pool_sink: MasterPoolSink, master_pool: ConnectionPool<Core>) -> Self {
+        Self {
+            master_pool_sink,
+            master_pool,
+        }
+    }
+}
+
+#[async_trait::async_trait]
+impl TxSink for WhitelistedDeployPoolSink {
+    async fn submit_tx(
+        &self,
+        tx: &L2Tx,
+        execution_metrics: TransactionExecutionMetrics,
+        validation_traces: ValidationTraces,
+    ) -> Result<L2TxSubmissionResult, SubmitTxError> {
+        let initiator = tx.initiator_account();
+
+        // Only enforce the allow-list check if VM actually deployed a contract.
+        if execution_metrics.vm.contract_deployment_count > 0 {
+            let mut connection = self
+                .master_pool
+                .connection_tagged("api")
+                .await
+                .map_err(DalError::generalize)?; // Removed `mut`, not needed
+
+            let is_allowed = connection
+                .contracts_deploy_allow_list_dal()
+                .is_address_allowed(&initiator)
+                .await
+                .map_err(DalError::generalize)?;
path: core/node/api_server/src/tx_sender/whitelisted_deploy_pool_sink.rs
user: login: CryptoMan0x
body: Keep in mind that Sophon needs to be the admin of this whitelist file and needs a way to update it without involving Matter Labs.
created_at: 2025-03-20T17:42:51Z
author_association: NONE
diff_hunk: @@ -0,0 +1,67 @@
+use zksync_dal::{transactions_dal::L2TxSubmissionResult, ConnectionPool, Core, CoreDal, DalError};
+use zksync_multivm::interface::{tracer::ValidationTraces, TransactionExecutionMetrics};
+use zksync_types::l2::L2Tx;
+
+use super::{tx_sink::TxSink, SubmitTxError};
+use crate::tx_sender::master_pool_sink::MasterPoolSink;
+
+/// Wrapper for the master DB pool that allows submitting transactions to the mempool.
+#[derive(Debug)]
+pub struct WhitelistedDeployPoolSink {
+    master_pool_sink: MasterPoolSink,
+    master_pool: ConnectionPool<Core>,
+}
+
+impl WhitelistedDeployPoolSink {
+    pub fn new(master_pool_sink: MasterPoolSink, master_pool: ConnectionPool<Core>) -> Self {
+        Self {
+            master_pool_sink,
+            master_pool,
+        }
+    }
+}
+
+#[async_trait::async_trait]
+impl TxSink for WhitelistedDeployPoolSink {
+    async fn submit_tx(
+        &self,
+        tx: &L2Tx,
+        execution_metrics: TransactionExecutionMetrics,
+        validation_traces: ValidationTraces,
+    ) -> Result<L2TxSubmissionResult, SubmitTxError> {
+        let initiator = tx.initiator_account();
+
+        // Only enforce the allow-list check if VM actually deployed a contract.
+        if execution_metrics.vm.contract_deployment_count > 0 {
+            let mut connection = self
+                .master_pool
+                .connection_tagged("api")
+                .await
+                .map_err(DalError::generalize)?; // Removed `mut`, not needed
+
+            let is_allowed = connection
+                .contracts_deploy_allow_list_dal()
+                .is_address_allowed(&initiator)
+                .await
+                .map_err(DalError::generalize)?;
path: core/node/api_server/src/tx_sender/whitelisted_deploy_pool_sink.rs
user: login: githubdoramon
body: @slowli @Deniallugo 

If we store this as a json file on a CDN, it gives Sophon full control of it, so we don't need to bother you guys every time we want to change it. Can the sequencer just periodically fetch this file and store in memory?


Also, one important caveat, most likely we can just leave this file empty most of the time, because the idea is just to allow some developers to be able to deploy from L!, but in 99.99999999% of cases, they don't really need this, they can just deploy directly from L2 (FYI @CryptoMan0x).
created_at: 2025-03-21T09:20:44Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,67 @@
+use zksync_dal::{transactions_dal::L2TxSubmissionResult, ConnectionPool, Core, CoreDal, DalError};
+use zksync_multivm::interface::{tracer::ValidationTraces, TransactionExecutionMetrics};
+use zksync_types::l2::L2Tx;
+
+use super::{tx_sink::TxSink, SubmitTxError};
+use crate::tx_sender::master_pool_sink::MasterPoolSink;
+
+/// Wrapper for the master DB pool that allows submitting transactions to the mempool.
+#[derive(Debug)]
+pub struct WhitelistedDeployPoolSink {
+    master_pool_sink: MasterPoolSink,
+    master_pool: ConnectionPool<Core>,
+}
+
+impl WhitelistedDeployPoolSink {
+    pub fn new(master_pool_sink: MasterPoolSink, master_pool: ConnectionPool<Core>) -> Self {
+        Self {
+            master_pool_sink,
+            master_pool,
+        }
+    }
+}
+
+#[async_trait::async_trait]
+impl TxSink for WhitelistedDeployPoolSink {
+    async fn submit_tx(
+        &self,
+        tx: &L2Tx,
+        execution_metrics: TransactionExecutionMetrics,
+        validation_traces: ValidationTraces,
+    ) -> Result<L2TxSubmissionResult, SubmitTxError> {
+        let initiator = tx.initiator_account();
+
+        // Only enforce the allow-list check if VM actually deployed a contract.
+        if execution_metrics.vm.contract_deployment_count > 0 {
+            let mut connection = self
+                .master_pool
+                .connection_tagged("api")
+                .await
+                .map_err(DalError::generalize)?; // Removed `mut`, not needed
+
+            let is_allowed = connection
+                .contracts_deploy_allow_list_dal()
+                .is_address_allowed(&initiator)
+                .await
+                .map_err(DalError::generalize)?;
path: core/node/api_server/src/tx_sender/whitelisted_deploy_pool_sink.rs
user: login: CryptoMan0x
body: Small correction to what @githubdoramon wrote: Our plan is to 1) block all deployments from L1, and 2) whitelist L2 deployments. The whitelist file manages who can deploy on L2.
created_at: 2025-03-21T15:07:48Z
author_association: NONE
diff_hunk: @@ -0,0 +1,67 @@
+use zksync_dal::{transactions_dal::L2TxSubmissionResult, ConnectionPool, Core, CoreDal, DalError};
+use zksync_multivm::interface::{tracer::ValidationTraces, TransactionExecutionMetrics};
+use zksync_types::l2::L2Tx;
+
+use super::{tx_sink::TxSink, SubmitTxError};
+use crate::tx_sender::master_pool_sink::MasterPoolSink;
+
+/// Wrapper for the master DB pool that allows submitting transactions to the mempool.
+#[derive(Debug)]
+pub struct WhitelistedDeployPoolSink {
+    master_pool_sink: MasterPoolSink,
+    master_pool: ConnectionPool<Core>,
+}
+
+impl WhitelistedDeployPoolSink {
+    pub fn new(master_pool_sink: MasterPoolSink, master_pool: ConnectionPool<Core>) -> Self {
+        Self {
+            master_pool_sink,
+            master_pool,
+        }
+    }
+}
+
+#[async_trait::async_trait]
+impl TxSink for WhitelistedDeployPoolSink {
+    async fn submit_tx(
+        &self,
+        tx: &L2Tx,
+        execution_metrics: TransactionExecutionMetrics,
+        validation_traces: ValidationTraces,
+    ) -> Result<L2TxSubmissionResult, SubmitTxError> {
+        let initiator = tx.initiator_account();
+
+        // Only enforce the allow-list check if VM actually deployed a contract.
+        if execution_metrics.vm.contract_deployment_count > 0 {
+            let mut connection = self
+                .master_pool
+                .connection_tagged("api")
+                .await
+                .map_err(DalError::generalize)?; // Removed `mut`, not needed
+
+            let is_allowed = connection
+                .contracts_deploy_allow_list_dal()
+                .is_address_allowed(&initiator)
+                .await
+                .map_err(DalError::generalize)?;
path: core/node/api_server/src/tx_sender/whitelisted_deploy_pool_sink.rs
user: login: RomanHiden
body: @slowli  Alex, any further feedback from you? I have a wip file based approach. 
created_at: 2025-03-24T16:21:19Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,67 @@
+use zksync_dal::{transactions_dal::L2TxSubmissionResult, ConnectionPool, Core, CoreDal, DalError};
+use zksync_multivm::interface::{tracer::ValidationTraces, TransactionExecutionMetrics};
+use zksync_types::l2::L2Tx;
+
+use super::{tx_sink::TxSink, SubmitTxError};
+use crate::tx_sender::master_pool_sink::MasterPoolSink;
+
+/// Wrapper for the master DB pool that allows submitting transactions to the mempool.
+#[derive(Debug)]
+pub struct WhitelistedDeployPoolSink {
+    master_pool_sink: MasterPoolSink,
+    master_pool: ConnectionPool<Core>,
+}
+
+impl WhitelistedDeployPoolSink {
+    pub fn new(master_pool_sink: MasterPoolSink, master_pool: ConnectionPool<Core>) -> Self {
+        Self {
+            master_pool_sink,
+            master_pool,
+        }
+    }
+}
+
+#[async_trait::async_trait]
+impl TxSink for WhitelistedDeployPoolSink {
+    async fn submit_tx(
+        &self,
+        tx: &L2Tx,
+        execution_metrics: TransactionExecutionMetrics,
+        validation_traces: ValidationTraces,
+    ) -> Result<L2TxSubmissionResult, SubmitTxError> {
+        let initiator = tx.initiator_account();
+
+        // Only enforce the allow-list check if VM actually deployed a contract.
+        if execution_metrics.vm.contract_deployment_count > 0 {
+            let mut connection = self
+                .master_pool
+                .connection_tagged("api")
+                .await
+                .map_err(DalError::generalize)?; // Removed `mut`, not needed
+
+            let is_allowed = connection
+                .contracts_deploy_allow_list_dal()
+                .is_address_allowed(&initiator)
+                .await
+                .map_err(DalError::generalize)?;
path: core/node/api_server/src/tx_sender/whitelisted_deploy_pool_sink.rs
user: login: RomanHiden
body: I have pushed the file-based approach uses a background task that periodically fetches a JSON list of allowed addresses from a configured HTTP endpoint, storing them in memory. Any transaction attempting a contract deployment checks against this in-memory set, blocking deployments from addresses not present in the file.

this pull request will need cleaning form sql tables once this approach is approved. 
created_at: 2025-03-25T18:22:57Z
author_association: CONTRIBUTOR
path: core/lib/dal/src/contract_allow_list_dal.rs
user: login: slowli
body: Changes in DAL are obsolete now, right?
created_at: 2025-03-25T18:43:34Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,57 @@
+use std::{collections::HashSet, sync::Arc};
+
+use reqwest::Client;
+use serde::Deserialize;
+use tokio::{
+    sync::RwLock,
+    time::{interval, Duration},
+};
+use zksync_types::Address;
+
+#[derive(Debug, Deserialize)]
+struct Whitelist {
+    addresses: Vec<Address>,
+}
+
+#[derive(Clone, Debug)]
+pub struct AllowListService {
+    allowed_addresses: Arc<RwLock<HashSet<Address>>>,
+}
+
+impl AllowListService {
+    pub fn new(url: String, refresh_interval: Duration) -> Self {
+        let allowed_addresses = Arc::new(RwLock::new(HashSet::new()));
+        let addresses_clone = allowed_addresses.clone();
+
+        tokio::spawn(async move {
path: core/node/api_server/src/tx_sender/allow_list_service.rs
user: login: slowli
body: It would make sense to make this a [task in terms of the node framework](https://github.com/matter-labs/zksync-era/blob/ed4926f4cd0705ad6f1ed57ff5ee2c8c79af4987/core/node/node_framework/src/task/mod.rs#L71-L72). In this way, it would be cancel-aware, and fatal errors in the task would shut down the node (admittedly, there are no fatal errors currently, but this doesn't mean they cannot be added in the future). For this to happen:

- Define a `struct` for the task (internally, it would hold `allowed_addresses`).
- Add a `pub fn run(self, stop_receiver: watch::Receiver<bool>) -> anyhow::Result<()>` method for it.
- Add the task to the [`node_framework`](https://github.com/matter-labs/zksync-era/blob/main/core/node/node_framework/src/implementations/layers/web3_api/server/mod.rs) crate.
created_at: 2025-03-25T18:53:36Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,57 @@
+use std::{collections::HashSet, sync::Arc};
+
+use reqwest::Client;
+use serde::Deserialize;
+use tokio::{
+    sync::RwLock,
+    time::{interval, Duration},
+};
+use zksync_types::Address;
+
+#[derive(Debug, Deserialize)]
+struct Whitelist {
+    addresses: Vec<Address>,
+}
+
+#[derive(Clone, Debug)]
+pub struct AllowListService {
+    allowed_addresses: Arc<RwLock<HashSet<Address>>>,
+}
+
+impl AllowListService {
+    pub fn new(url: String, refresh_interval: Duration) -> Self {
+        let allowed_addresses = Arc::new(RwLock::new(HashSet::new()));
+        let addresses_clone = allowed_addresses.clone();
+
+        tokio::spawn(async move {
+            let client = Client::new();
+            let mut interval = interval(refresh_interval);
+
+            loop {
+                interval.tick().await;
+                match client.get(&url).send().await {
+                    Ok(response) => match response.json::<Whitelist>().await {
path: core/node/api_server/src/tx_sender/allow_list_service.rs
user: login: slowli
body: You'd probably want to check the response status via `error_for_status()` before parsing the body.
created_at: 2025-03-25T18:54:43Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,57 @@
+use std::{collections::HashSet, sync::Arc};
+
+use reqwest::Client;
+use serde::Deserialize;
+use tokio::{
+    sync::RwLock,
+    time::{interval, Duration},
+};
+use zksync_types::Address;
+
+#[derive(Debug, Deserialize)]
+struct Whitelist {
+    addresses: Vec<Address>,
+}
+
+#[derive(Clone, Debug)]
+pub struct AllowListService {
+    allowed_addresses: Arc<RwLock<HashSet<Address>>>,
+}
+
+impl AllowListService {
+    pub fn new(url: String, refresh_interval: Duration) -> Self {
+        let allowed_addresses = Arc::new(RwLock::new(HashSet::new()));
+        let addresses_clone = allowed_addresses.clone();
+
+        tokio::spawn(async move {
+            let client = Client::new();
+            let mut interval = interval(refresh_interval);
+
+            loop {
+                interval.tick().await;
+                match client.get(&url).send().await {
+                    Ok(response) => match response.json::<Whitelist>().await {
+                        Ok(data) => {
+                            let mut lock = addresses_clone.write().await;
+                            lock.clear();
+                            lock.extend(data.addresses);
+                            tracing::info!("Whitelist updated. {} entries loaded.", lock.len());
path: core/node/api_server/src/tx_sender/allow_list_service.rs
user: login: slowli
body: Please use a lesser log level here (`TRACE` will probably suffice) to not spam logs.
created_at: 2025-03-25T18:56:20Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,57 @@
+use std::{collections::HashSet, sync::Arc};
+
+use reqwest::Client;
+use serde::Deserialize;
+use tokio::{
+    sync::RwLock,
+    time::{interval, Duration},
+};
+use zksync_types::Address;
+
+#[derive(Debug, Deserialize)]
+struct Whitelist {
+    addresses: Vec<Address>,
+}
+
+#[derive(Clone, Debug)]
+pub struct AllowListService {
+    allowed_addresses: Arc<RwLock<HashSet<Address>>>,
+}
+
+impl AllowListService {
+    pub fn new(url: String, refresh_interval: Duration) -> Self {
+        let allowed_addresses = Arc::new(RwLock::new(HashSet::new()));
+        let addresses_clone = allowed_addresses.clone();
+
+        tokio::spawn(async move {
+            let client = Client::new();
+            let mut interval = interval(refresh_interval);
+
+            loop {
+                interval.tick().await;
+                match client.get(&url).send().await {
+                    Ok(response) => match response.json::<Whitelist>().await {
+                        Ok(data) => {
+                            let mut lock = addresses_clone.write().await;
+                            lock.clear();
+                            lock.extend(data.addresses);
path: core/node/api_server/src/tx_sender/allow_list_service.rs
user: login: slowli
body: Nit: Why not define `addresses` as `HashSet<_>` in the `Whitelist` DTO? A `HashSet` can be deserialized from a JSON array just fine. This would allow to simplify this to just `*addresses_clone.write().await = data.addresses`.
created_at: 2025-03-25T18:57:51Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,57 @@
+use std::{collections::HashSet, sync::Arc};
+
+use reqwest::Client;
+use serde::Deserialize;
+use tokio::{
+    sync::RwLock,
+    time::{interval, Duration},
+};
+use zksync_types::Address;
+
+#[derive(Debug, Deserialize)]
+struct Whitelist {
+    addresses: Vec<Address>,
+}
+
+#[derive(Clone, Debug)]
+pub struct AllowListService {
+    allowed_addresses: Arc<RwLock<HashSet<Address>>>,
+}
+
+impl AllowListService {
+    pub fn new(url: String, refresh_interval: Duration) -> Self {
+        let allowed_addresses = Arc::new(RwLock::new(HashSet::new()));
+        let addresses_clone = allowed_addresses.clone();
+
+        tokio::spawn(async move {
+            let client = Client::new();
+            let mut interval = interval(refresh_interval);
+
+            loop {
+                interval.tick().await;
+                match client.get(&url).send().await {
+                    Ok(response) => match response.json::<Whitelist>().await {
+                        Ok(data) => {
+                            let mut lock = addresses_clone.write().await;
+                            lock.clear();
+                            lock.extend(data.addresses);
+                            tracing::info!("Whitelist updated. {} entries loaded.", lock.len());
+                        }
+                        Err(e) => {
+                            tracing::warn!("Failed to parse whitelist JSON: {:?}", e);
path: core/node/api_server/src/tx_sender/allow_list_service.rs
user: login: slowli
body: Nit: `Display` formatting for the error (i.e., "Failed to parse whitelist JSON: {e}") looks more appropriate here since it's designed to be human-readable. Ditto below.
created_at: 2025-03-25T18:59:39Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,64 @@
+use zksync_dal::transactions_dal::L2TxSubmissionResult;
+use zksync_multivm::interface::{tracer::ValidationTraces, TransactionExecutionMetrics};
+use zksync_types::l2::L2Tx;
+
+use super::{allow_list_service::AllowListService, tx_sink::TxSink, SubmitTxError};
+use crate::tx_sender::master_pool_sink::MasterPoolSink;
+
+#[derive(Debug)]
+/// Wrapper that submits transactions to the mempool and enforces contract deployment allow-list.
+pub struct WhitelistedDeployPoolSink {
+    master_pool_sink: MasterPoolSink,
+    allowlist_service: AllowListService,
+}
+
+impl WhitelistedDeployPoolSink {
+    pub fn new(master_pool_sink: MasterPoolSink, allowlist_service: AllowListService) -> Self {
+        Self {
+            master_pool_sink,
+            allowlist_service,
+        }
+    }
+}
+
+#[async_trait::async_trait]
+impl TxSink for WhitelistedDeployPoolSink {
+    async fn submit_tx(
+        &self,
+        tx: &L2Tx,
+        execution_metrics: TransactionExecutionMetrics,
+        validation_traces: ValidationTraces,
+    ) -> Result<L2TxSubmissionResult, SubmitTxError> {
+        let initiator = tx.initiator_account();
+
+        // Only enforce the allow-list check if VM actually deployed a contract.
+        if execution_metrics.vm.contract_deployment_count > 0 {
path: core/node/api_server/src/tx_sender/whitelisted_deploy_pool_sink.rs
user: login: slowli
body: Highlighting my previous question:

> Is filtering supposed to include deployments made not only by the tx initiator, but by contracts inside a transaction? (The current approach will count these as deployments.)

To give an example, where this difference matters: suppose we have a contract factory

```solidity
contract Counter {
    // ...
}

contract CounterFactory {
    function createCounter() public {
        Counter counter = new Counter();
        // Do something with the counter...
    }
}
```

As currently implemented a call to `CounterFactory::createCounter()` (even an indirect one, if some other contract calls `CounterFactory`) will be counted in `contract_deployment_count`. This *may* be intended, but looks like spooky action at a distance.
created_at: 2025-03-25T19:07:43Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,64 @@
+use zksync_dal::transactions_dal::L2TxSubmissionResult;
+use zksync_multivm::interface::{tracer::ValidationTraces, TransactionExecutionMetrics};
+use zksync_types::l2::L2Tx;
+
+use super::{allow_list_service::AllowListService, tx_sink::TxSink, SubmitTxError};
+use crate::tx_sender::master_pool_sink::MasterPoolSink;
+
+#[derive(Debug)]
+/// Wrapper that submits transactions to the mempool and enforces contract deployment allow-list.
+pub struct WhitelistedDeployPoolSink {
+    master_pool_sink: MasterPoolSink,
+    allowlist_service: AllowListService,
+}
+
+impl WhitelistedDeployPoolSink {
+    pub fn new(master_pool_sink: MasterPoolSink, allowlist_service: AllowListService) -> Self {
+        Self {
+            master_pool_sink,
+            allowlist_service,
+        }
+    }
+}
+
+#[async_trait::async_trait]
+impl TxSink for WhitelistedDeployPoolSink {
+    async fn submit_tx(
+        &self,
+        tx: &L2Tx,
+        execution_metrics: TransactionExecutionMetrics,
+        validation_traces: ValidationTraces,
+    ) -> Result<L2TxSubmissionResult, SubmitTxError> {
+        let initiator = tx.initiator_account();
+
+        // Only enforce the allow-list check if VM actually deployed a contract.
+        if execution_metrics.vm.contract_deployment_count > 0 {
+            let initiator_allowed = self.allowlist_service.is_address_allowed(&initiator).await;
+
+            let contract_address_allowed = self
+                .allowlist_service
+                .is_address_allowed(&tx.execute.contract_address.unwrap_or_default())
path: core/node/api_server/src/tx_sender/whitelisted_deploy_pool_sink.rs
user: login: slowli
body: `unwrap_or_default()` looks hacky here; `contract_address == None` corresponds to EVM deployments. It'd be more straightforward to use `if let` here:

```rust
let contract_address_allowed = if let Some(addr) = &tx.execute.contract_address {
    self.allowlist_service.is_address_allowed(addr).await
} else {
    false // EVM deployments require the initiator to be whitelisted
};
```
created_at: 2025-03-25T19:11:20Z
author_association: CONTRIBUTOR
diff_hunk: @@ -35,8 +43,18 @@ impl WiringLayer for MasterPoolSinkLayer {
 
     async fn wire(self, input: Self::Input) -> Result<Self::Output, WiringError> {
         let pool = input.master_pool.get().await?;
-        Ok(Output {
-            tx_sink: MasterPoolSink::new(pool).into(),
-        })
+        let master_pool_sink = MasterPoolSink::new(pool.clone());
+
+        let tx_sink = if self.rpc_config.deployment_allowlist_sink {
+            let allowlist_service = AllowListService::new(
+                self.rpc_config.http_file_url.unwrap_or_default(),
path: core/node/node_framework/src/implementations/layers/web3_api/tx_sink/master_pool_sink.rs
user: login: slowli
body: `unwrap_or_default` doesn't look reasonable here and below; "" is not a valid URL, and `Duration::ZERO` is not a reasonable refresh interval.

- `http_file_url` can be used to signal whether allowlisting is enabled (if it's `Some(_)`, it is, if it's `None`, it isn't).
- `refresh_interval_secs` field should be private and have a public getter, as [some other config fields](https://github.com/matter-labs/zksync-era/blob/ed4926f4cd0705ad6f1ed57ff5ee2c8c79af4987/core/lib/config/src/configs/api.rs#L290).
created_at: 2025-03-25T19:15:59Z
author_association: CONTRIBUTOR
diff_hunk: @@ -224,6 +224,10 @@ pub struct Web3JsonRpcConfig {
     /// (hundreds or thousands RPS).
     #[serde(default)]
     pub extended_api_tracing: bool,
+    #[serde(default)]
+    pub deployment_allowlist_sink: bool,
+    pub http_file_url: Option<String>,
+    pub refresh_interval_secs: Option<u64>,
path: core/lib/config/src/configs/api.rs
user: login: slowli
body: These field names are too vague; e.g., nothing signals that `refresh_interval_secs` concerns the allowlist service specifically. I'd suggest to create a subconfig struct inside `Web3JsonRpcConfig` so that the allowlist params are well-structured:

```yaml
api:
  web3_json_rpc:
    deployment_allowlist:
      file_url: "http://example.com"
      refresh_interval_secs: 10
```
created_at: 2025-03-25T19:20:02Z
author_association: CONTRIBUTOR
diff_hunk: @@ -69,6 +69,8 @@ pub enum SubmitTxError {
     Internal(#[from] anyhow::Error),
     #[error("transaction failed block.timestamp assertion")]
     FailedBlockTimestampAssertion,
+    #[error("sender address {0} is not in allow list")]
+    SenderNotInAllowList(Address),
path: core/node/api_server/src/tx_sender/result.rs
user: login: slowli
body: Bikeshedding: Wouldn't the caller have the initiator address readily available as a part of the submitted transaction? So this info looks redundant. If it's retained, it's weird that the called contract address isn't included as well.
created_at: 2025-03-25T19:24:19Z
author_association: CONTRIBUTOR
path: core/lib/dal/src/contract_allow_list_dal.rs
user: login: RomanHiden
body: Yes, gonna clean everything
created_at: 2025-03-25T19:29:50Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,64 @@
+use zksync_dal::transactions_dal::L2TxSubmissionResult;
+use zksync_multivm::interface::{tracer::ValidationTraces, TransactionExecutionMetrics};
+use zksync_types::l2::L2Tx;
+
+use super::{allow_list_service::AllowListService, tx_sink::TxSink, SubmitTxError};
+use crate::tx_sender::master_pool_sink::MasterPoolSink;
+
+#[derive(Debug)]
+/// Wrapper that submits transactions to the mempool and enforces contract deployment allow-list.
+pub struct WhitelistedDeployPoolSink {
+    master_pool_sink: MasterPoolSink,
+    allowlist_service: AllowListService,
+}
+
+impl WhitelistedDeployPoolSink {
+    pub fn new(master_pool_sink: MasterPoolSink, allowlist_service: AllowListService) -> Self {
+        Self {
+            master_pool_sink,
+            allowlist_service,
+        }
+    }
+}
+
+#[async_trait::async_trait]
+impl TxSink for WhitelistedDeployPoolSink {
+    async fn submit_tx(
+        &self,
+        tx: &L2Tx,
+        execution_metrics: TransactionExecutionMetrics,
+        validation_traces: ValidationTraces,
+    ) -> Result<L2TxSubmissionResult, SubmitTxError> {
+        let initiator = tx.initiator_account();
+
+        // Only enforce the allow-list check if VM actually deployed a contract.
+        if execution_metrics.vm.contract_deployment_count > 0 {
path: core/node/api_server/src/tx_sender/whitelisted_deploy_pool_sink.rs
user: login: RomanHiden
body: I have considered the following scenarios
1) tx.initiator -> CONTRACT_DEPLOYER
2) tx.initiator -> factory1 -> CONTRACT_DEPLOYER


We want to be able to:
1) whitelists specific users to allow contract deployment - case 1. 
2) allow specific factories to allow contract deployments for any users. e.g `UniswapV2Factory` like - case 2. 


there is one more case:
3) tx.initiator -> factory2 -> factory1 -> CONTRACT_DEPLOYER
when we would allow any contract to call factory1 to deploy contracts. e.g contract calling `UniswapV2Factory` 
this is not a hard requirement for us. and its also harder to implement in current design for me. 
created_at: 2025-03-25T20:14:03Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,64 @@
+use zksync_dal::transactions_dal::L2TxSubmissionResult;
+use zksync_multivm::interface::{tracer::ValidationTraces, TransactionExecutionMetrics};
+use zksync_types::l2::L2Tx;
+
+use super::{allow_list_service::AllowListService, tx_sink::TxSink, SubmitTxError};
+use crate::tx_sender::master_pool_sink::MasterPoolSink;
+
+#[derive(Debug)]
+/// Wrapper that submits transactions to the mempool and enforces contract deployment allow-list.
+pub struct WhitelistedDeployPoolSink {
+    master_pool_sink: MasterPoolSink,
+    allowlist_service: AllowListService,
+}
+
+impl WhitelistedDeployPoolSink {
+    pub fn new(master_pool_sink: MasterPoolSink, allowlist_service: AllowListService) -> Self {
+        Self {
+            master_pool_sink,
+            allowlist_service,
+        }
+    }
+}
+
+#[async_trait::async_trait]
+impl TxSink for WhitelistedDeployPoolSink {
+    async fn submit_tx(
+        &self,
+        tx: &L2Tx,
+        execution_metrics: TransactionExecutionMetrics,
+        validation_traces: ValidationTraces,
+    ) -> Result<L2TxSubmissionResult, SubmitTxError> {
+        let initiator = tx.initiator_account();
+
+        // Only enforce the allow-list check if VM actually deployed a contract.
+        if execution_metrics.vm.contract_deployment_count > 0 {
path: core/node/api_server/src/tx_sender/whitelisted_deploy_pool_sink.rs
user: login: slowli
body: If you think that the current approach works reasonably well, OK. Strictly speaking though, it doesn't satisfy this req:

> allow specific factories to allow contract deployments for any users. e.g UniswapV2Factory like - case 2.

...because if the factory is called through indirection, it'll be incorrectly blacklisted. (A more esoteric violation is if the whitelisted contract can call other contracts; in this case, deployments from *these* contracts will go through even though they are not whitelisted.) For 100% correspondence to requirements, you'd need to analyze the deployer address in `ContractDeployed` events (which would also eliminate initiator / called contract distinction). Which looks easily doable if you change `TxSink::submit_tx()` signature to accept `&SandboxExecutionOutput` instead of `TransactionExecutionMetrics` (the former includes the latter, but also includes `events: Vec<VmEvent>` – all events emitted during execution).
created_at: 2025-03-26T15:10:29Z
author_association: CONTRIBUTOR
diff_hunk: @@ -224,6 +224,9 @@ pub struct Web3JsonRpcConfig {
     /// (hundreds or thousands RPS).
     #[serde(default)]
     pub extended_api_tracing: bool,
+    /// Configuration options for the deployment allow list
+    #[serde(default)]
+    pub deployment_allowlist: Option<DeploymentAllowlist>,
path: core/lib/config/src/configs/api.rs
user: login: slowli
body: Double `Option` (the config itself and its fields) looks unnecessary. Either `DeploymentAllowlist` should be made non-`Option`al, or its fields. IMO, the first approach is preferable since it's more flexible.
created_at: 2025-04-01T12:38:48Z
author_association: CONTRIBUTOR
path: core/node/api_server/src/tx_sender/shared_allow_list.rs
user: login: slowli
body: Bikeshedding: IMO, it's not necessary to put `SharedAllowList` in a separate module; it would look fine in the same module as other whitelist types.
created_at: 2025-04-01T12:46:00Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,27 @@
+use std::{collections::HashSet, sync::Arc};
+
+use tokio::sync::RwLock;
+use zksync_types::Address;
+
+#[derive(Debug, Clone)]
+pub struct SharedAllowList {
+    inner: Arc<RwLock<HashSet<Address>>>,
+}
+
+impl SharedAllowList {
+    pub fn new() -> Self {
+        Self {
+            inner: Arc::new(RwLock::new(HashSet::new())),
+        }
+    }
path: core/node/api_server/src/tx_sender/shared_allow_list.rs
user: login: slowli
body: Nit: You can `#[derive(Default)]` instead.
created_at: 2025-04-01T12:46:17Z
author_association: CONTRIBUTOR
path: core/node/node_framework/src/implementations/layers/web3_api/tx_sink/whitelisted_master_pool_sink_layer.rs
user: login: slowli
body: Bikeshedding: It's not necessary to name modules after the main type. Subjectively, `whitelist` would look better here. Ditto for the module in `api_server`.
created_at: 2025-04-01T12:48:19Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,183 @@
+use std::{collections::HashSet, time::Duration};
+
+use async_trait::async_trait;
+use reqwest::Client;
+use serde::Deserialize;
+use tokio::time::interval;
+use zksync_config::configs::api::DeploymentAllowlist;
+use zksync_node_api_server::tx_sender::{
+    master_pool_sink::MasterPoolSink, shared_allow_list::SharedAllowList,
+    whitelisted_deploy_pool_sink::WhitelistedDeployPoolSink,
+};
+use zksync_types::Address;
+
+use crate::{
+    implementations::resources::{
+        pools::{MasterPool, PoolResource},
+        web3_api::TxSinkResource,
+    },
+    service::StopReceiver,
+    task::{Task, TaskId, TaskKind},
+    wiring_layer::{WiringError, WiringLayer},
+    FromContext, IntoContext,
+};
+
+/// Wiring layer for [`WhitelistedDeployPoolSink`] that wraps a `MasterPoolSink` and enables allowlist filtering.
+pub struct WhitelistedMasterPoolSinkLayer {
+    pub deployment_allowlist: DeploymentAllowlist,
+}
+
+#[derive(Debug, FromContext)]
+#[context(crate = crate)]
+pub struct Input {
+    pub master_pool: PoolResource<MasterPool>,
+}
+
+#[derive(Debug, IntoContext)]
+#[context(crate = crate)]
+pub struct Output {
+    pub tx_sink: TxSinkResource,
+    #[context(task)]
+    pub allow_list_task: AllowListTask,
+}
+
+#[async_trait]
+impl WiringLayer for WhitelistedMasterPoolSinkLayer {
+    type Input = Input;
+    type Output = Output;
+
+    fn layer_name(&self) -> &'static str {
+        "whitelisted_master_pool_sink_layer"
+    }
+
+    async fn wire(self, input: Self::Input) -> Result<Self::Output, WiringError> {
+        let pool = input.master_pool.get().await?;
+        let master_pool_sink = MasterPoolSink::new(pool);
+
+        let shared_allowlist = SharedAllowList::new();
+
+        let allow_list_task = AllowListTask::new(
+            self.deployment_allowlist
+                .http_file_url()
+                .expect("DeploymentAllowlist must contain a URL")
+                .to_string(),
+            self.deployment_allowlist.refresh_interval(),
+            shared_allowlist.clone(),
+        );
+
+        let tx_sink = WhitelistedDeployPoolSink::new(master_pool_sink, shared_allowlist).into();
+
+        Ok(Output {
+            tx_sink,
+            allow_list_task,
+        })
+    }
+}
+
+/// Task that periodically fetches and updates the allowlist from a remote HTTP source.
+#[derive(Debug)]
+pub struct AllowListTask {
path: core/node/node_framework/src/implementations/layers/web3_api/tx_sink/whitelisted_master_pool_sink_layer.rs
user: login: slowli
body: `node_framework` should contain as little business logic as possible, focusing on wiring. Please move the task to the `api_server` crate correspondingly. Only the `impl Task for AllowListTask` wrapper should be left here.
created_at: 2025-04-01T12:49:29Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,183 @@
+use std::{collections::HashSet, time::Duration};
+
+use async_trait::async_trait;
+use reqwest::Client;
+use serde::Deserialize;
+use tokio::time::interval;
+use zksync_config::configs::api::DeploymentAllowlist;
+use zksync_node_api_server::tx_sender::{
+    master_pool_sink::MasterPoolSink, shared_allow_list::SharedAllowList,
+    whitelisted_deploy_pool_sink::WhitelistedDeployPoolSink,
+};
+use zksync_types::Address;
+
+use crate::{
+    implementations::resources::{
+        pools::{MasterPool, PoolResource},
+        web3_api::TxSinkResource,
+    },
+    service::StopReceiver,
+    task::{Task, TaskId, TaskKind},
+    wiring_layer::{WiringError, WiringLayer},
+    FromContext, IntoContext,
+};
+
+/// Wiring layer for [`WhitelistedDeployPoolSink`] that wraps a `MasterPoolSink` and enables allowlist filtering.
+pub struct WhitelistedMasterPoolSinkLayer {
+    pub deployment_allowlist: DeploymentAllowlist,
+}
+
+#[derive(Debug, FromContext)]
+#[context(crate = crate)]
+pub struct Input {
+    pub master_pool: PoolResource<MasterPool>,
+}
+
+#[derive(Debug, IntoContext)]
+#[context(crate = crate)]
+pub struct Output {
+    pub tx_sink: TxSinkResource,
+    #[context(task)]
+    pub allow_list_task: AllowListTask,
+}
+
+#[async_trait]
+impl WiringLayer for WhitelistedMasterPoolSinkLayer {
+    type Input = Input;
+    type Output = Output;
+
+    fn layer_name(&self) -> &'static str {
+        "whitelisted_master_pool_sink_layer"
+    }
+
+    async fn wire(self, input: Self::Input) -> Result<Self::Output, WiringError> {
+        let pool = input.master_pool.get().await?;
+        let master_pool_sink = MasterPoolSink::new(pool);
+
+        let shared_allowlist = SharedAllowList::new();
+
+        let allow_list_task = AllowListTask::new(
+            self.deployment_allowlist
+                .http_file_url()
+                .expect("DeploymentAllowlist must contain a URL")
+                .to_string(),
+            self.deployment_allowlist.refresh_interval(),
+            shared_allowlist.clone(),
+        );
+
+        let tx_sink = WhitelistedDeployPoolSink::new(master_pool_sink, shared_allowlist).into();
+
+        Ok(Output {
+            tx_sink,
+            allow_list_task,
+        })
+    }
+}
+
+/// Task that periodically fetches and updates the allowlist from a remote HTTP source.
+#[derive(Debug)]
+pub struct AllowListTask {
+    url: String,
+    refresh_interval: Duration,
+    allowlist: SharedAllowList,
+    client: Client,
+    etag: tokio::sync::Mutex<Option<String>>,
+}
+
+#[derive(Debug, Deserialize)]
+struct WhitelistResponse {
+    addresses: Vec<Address>,
+}
+
+impl AllowListTask {
+    const REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
+    pub fn new(url: String, refresh_interval: Duration, allowlist: SharedAllowList) -> Self {
+        Self {
+            url,
+            refresh_interval,
+            allowlist,
+            client: Client::new(),
+            etag: tokio::sync::Mutex::new(None),
+        }
+    }
+
+    async fn fetch(&self) -> anyhow::Result<Option<HashSet<Address>>> {
+        let etag_header = self.etag.lock().await.clone();
+        let response = self
+            .client
+            .get(&self.url)
+            .timeout(Self::REQUEST_TIMEOUT)
+            .header("If-None-Match", etag_header.unwrap_or_default())
+            .send()
+            .await?;
+
+        if response.status() == reqwest::StatusCode::NOT_MODIFIED {
+            tracing::debug!("Allowlist unchanged (304 Not Modified)");
+            return Ok(None);
+        }
+
+        let response = response.error_for_status()?;
+
+        if let Some(etag) = response.headers().get("ETag") {
+            *self.etag.lock().await = Some(etag.to_str()?.to_string());
+        }
+
+        let list = response.json::<WhitelistResponse>().await?;
+        Ok(Some(list.addresses.into_iter().collect()))
+    }
+}
+
+#[async_trait]
+impl Task for AllowListTask {
+    fn id(&self) -> TaskId {
+        "api_allowlist_task".into()
+    }
+
+    fn kind(&self) -> TaskKind {
+        TaskKind::UnconstrainedTask
+    }
+
+    async fn run(self: Box<Self>, mut stop_receiver: StopReceiver) -> anyhow::Result<()> {
+        let mut ticker = interval(self.refresh_interval);
+
+        loop {
+            tokio::select! {
path: core/node/node_framework/src/implementations/layers/web3_api/tx_sink/whitelisted_master_pool_sink_layer.rs
user: login: slowli
body: Nit: Other components [mostly use](https://github.com/matter-labs/zksync-era/blob/91e04fb6de072ec430b852bd565f6c560e46c610/core/node/consistency_checker/src/lib.rs#L643-L649) `tokio::time::timeout` in these situations since it is easier to read than `select!`.
created_at: 2025-04-01T12:53:37Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,183 @@
+use std::{collections::HashSet, time::Duration};
+
+use async_trait::async_trait;
+use reqwest::Client;
+use serde::Deserialize;
+use tokio::time::interval;
+use zksync_config::configs::api::DeploymentAllowlist;
+use zksync_node_api_server::tx_sender::{
+    master_pool_sink::MasterPoolSink, shared_allow_list::SharedAllowList,
+    whitelisted_deploy_pool_sink::WhitelistedDeployPoolSink,
+};
+use zksync_types::Address;
+
+use crate::{
+    implementations::resources::{
+        pools::{MasterPool, PoolResource},
+        web3_api::TxSinkResource,
+    },
+    service::StopReceiver,
+    task::{Task, TaskId, TaskKind},
+    wiring_layer::{WiringError, WiringLayer},
+    FromContext, IntoContext,
+};
+
+/// Wiring layer for [`WhitelistedDeployPoolSink`] that wraps a `MasterPoolSink` and enables allowlist filtering.
+pub struct WhitelistedMasterPoolSinkLayer {
+    pub deployment_allowlist: DeploymentAllowlist,
+}
+
+#[derive(Debug, FromContext)]
+#[context(crate = crate)]
+pub struct Input {
+    pub master_pool: PoolResource<MasterPool>,
+}
+
+#[derive(Debug, IntoContext)]
+#[context(crate = crate)]
+pub struct Output {
+    pub tx_sink: TxSinkResource,
+    #[context(task)]
+    pub allow_list_task: AllowListTask,
+}
+
+#[async_trait]
+impl WiringLayer for WhitelistedMasterPoolSinkLayer {
+    type Input = Input;
+    type Output = Output;
+
+    fn layer_name(&self) -> &'static str {
+        "whitelisted_master_pool_sink_layer"
+    }
+
+    async fn wire(self, input: Self::Input) -> Result<Self::Output, WiringError> {
+        let pool = input.master_pool.get().await?;
+        let master_pool_sink = MasterPoolSink::new(pool);
+
+        let shared_allowlist = SharedAllowList::new();
+
+        let allow_list_task = AllowListTask::new(
+            self.deployment_allowlist
+                .http_file_url()
+                .expect("DeploymentAllowlist must contain a URL")
+                .to_string(),
+            self.deployment_allowlist.refresh_interval(),
+            shared_allowlist.clone(),
+        );
+
+        let tx_sink = WhitelistedDeployPoolSink::new(master_pool_sink, shared_allowlist).into();
+
+        Ok(Output {
+            tx_sink,
+            allow_list_task,
+        })
+    }
+}
+
+/// Task that periodically fetches and updates the allowlist from a remote HTTP source.
+#[derive(Debug)]
+pub struct AllowListTask {
+    url: String,
+    refresh_interval: Duration,
+    allowlist: SharedAllowList,
+    client: Client,
+    etag: tokio::sync::Mutex<Option<String>>,
+}
+
+#[derive(Debug, Deserialize)]
+struct WhitelistResponse {
+    addresses: Vec<Address>,
+}
+
+impl AllowListTask {
+    const REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
+    pub fn new(url: String, refresh_interval: Duration, allowlist: SharedAllowList) -> Self {
+        Self {
+            url,
+            refresh_interval,
+            allowlist,
+            client: Client::new(),
+            etag: tokio::sync::Mutex::new(None),
+        }
+    }
+
+    async fn fetch(&self) -> anyhow::Result<Option<HashSet<Address>>> {
+        let etag_header = self.etag.lock().await.clone();
+        let response = self
+            .client
+            .get(&self.url)
+            .timeout(Self::REQUEST_TIMEOUT)
+            .header("If-None-Match", etag_header.unwrap_or_default())
+            .send()
+            .await?;
+
+        if response.status() == reqwest::StatusCode::NOT_MODIFIED {
+            tracing::debug!("Allowlist unchanged (304 Not Modified)");
+            return Ok(None);
+        }
+
+        let response = response.error_for_status()?;
+
+        if let Some(etag) = response.headers().get("ETag") {
+            *self.etag.lock().await = Some(etag.to_str()?.to_string());
+        }
+
+        let list = response.json::<WhitelistResponse>().await?;
+        Ok(Some(list.addresses.into_iter().collect()))
+    }
+}
+
+#[async_trait]
+impl Task for AllowListTask {
+    fn id(&self) -> TaskId {
+        "api_allowlist_task".into()
+    }
+
+    fn kind(&self) -> TaskKind {
+        TaskKind::UnconstrainedTask
+    }
+
+    async fn run(self: Box<Self>, mut stop_receiver: StopReceiver) -> anyhow::Result<()> {
+        let mut ticker = interval(self.refresh_interval);
+
+        loop {
+            tokio::select! {
+                _ = ticker.tick() => {
+                    if *stop_receiver.0.borrow_and_update() {
+                        tracing::info!("AllowListTask received shutdown signal");
+                        break;
+                    }
+
+                    match self.fetch().await {
+                        Ok(Some(new_list)) => {
+                            let writer = self.allowlist.writer();
+                            let mut lock = writer.write().await;
+
+                            if *lock != new_list {
+                                *lock = new_list;
+                                tracing::debug!("Allowlist updated. {} entries loaded.", lock.len());
+                            } else {
+                                tracing::debug!("Allowlist unchanged (same content).");
+                            }
+                        }
+                        Ok(None) => {
+                            // ETag said "not modified"
+                        }
+                        Err(err) => {
+                            tracing::warn!("Failed to refresh allowlist: {}", err);
+                        }
+                    }
+                }
+
+                _ = stop_receiver.0.changed() => {
+                    if *stop_receiver.0.borrow() {
path: core/node/node_framework/src/implementations/layers/web3_api/tx_sink/whitelisted_master_pool_sink_layer.rs
user: login: slowli
body: Nit: This check is redundant in practice since we only ever send `true` as a signal.
created_at: 2025-04-01T12:56:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,183 @@
+use std::{collections::HashSet, time::Duration};
+
+use async_trait::async_trait;
+use reqwest::Client;
+use serde::Deserialize;
+use tokio::time::interval;
+use zksync_config::configs::api::DeploymentAllowlist;
+use zksync_node_api_server::tx_sender::{
+    master_pool_sink::MasterPoolSink, shared_allow_list::SharedAllowList,
+    whitelisted_deploy_pool_sink::WhitelistedDeployPoolSink,
+};
+use zksync_types::Address;
+
+use crate::{
+    implementations::resources::{
+        pools::{MasterPool, PoolResource},
+        web3_api::TxSinkResource,
+    },
+    service::StopReceiver,
+    task::{Task, TaskId, TaskKind},
+    wiring_layer::{WiringError, WiringLayer},
+    FromContext, IntoContext,
+};
+
+/// Wiring layer for [`WhitelistedDeployPoolSink`] that wraps a `MasterPoolSink` and enables allowlist filtering.
+pub struct WhitelistedMasterPoolSinkLayer {
+    pub deployment_allowlist: DeploymentAllowlist,
+}
+
+#[derive(Debug, FromContext)]
+#[context(crate = crate)]
+pub struct Input {
+    pub master_pool: PoolResource<MasterPool>,
+}
+
+#[derive(Debug, IntoContext)]
+#[context(crate = crate)]
+pub struct Output {
+    pub tx_sink: TxSinkResource,
+    #[context(task)]
+    pub allow_list_task: AllowListTask,
+}
+
+#[async_trait]
+impl WiringLayer for WhitelistedMasterPoolSinkLayer {
+    type Input = Input;
+    type Output = Output;
+
+    fn layer_name(&self) -> &'static str {
+        "whitelisted_master_pool_sink_layer"
+    }
+
+    async fn wire(self, input: Self::Input) -> Result<Self::Output, WiringError> {
+        let pool = input.master_pool.get().await?;
+        let master_pool_sink = MasterPoolSink::new(pool);
+
+        let shared_allowlist = SharedAllowList::new();
+
+        let allow_list_task = AllowListTask::new(
+            self.deployment_allowlist
+                .http_file_url()
+                .expect("DeploymentAllowlist must contain a URL")
+                .to_string(),
+            self.deployment_allowlist.refresh_interval(),
+            shared_allowlist.clone(),
+        );
+
+        let tx_sink = WhitelistedDeployPoolSink::new(master_pool_sink, shared_allowlist).into();
+
+        Ok(Output {
+            tx_sink,
+            allow_list_task,
+        })
+    }
+}
+
+/// Task that periodically fetches and updates the allowlist from a remote HTTP source.
+#[derive(Debug)]
+pub struct AllowListTask {
+    url: String,
+    refresh_interval: Duration,
+    allowlist: SharedAllowList,
+    client: Client,
+    etag: tokio::sync::Mutex<Option<String>>,
+}
+
+#[derive(Debug, Deserialize)]
+struct WhitelistResponse {
+    addresses: Vec<Address>,
+}
+
+impl AllowListTask {
+    const REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
+    pub fn new(url: String, refresh_interval: Duration, allowlist: SharedAllowList) -> Self {
+        Self {
+            url,
+            refresh_interval,
+            allowlist,
+            client: Client::new(),
+            etag: tokio::sync::Mutex::new(None),
+        }
+    }
+
+    async fn fetch(&self) -> anyhow::Result<Option<HashSet<Address>>> {
+        let etag_header = self.etag.lock().await.clone();
+        let response = self
+            .client
+            .get(&self.url)
+            .timeout(Self::REQUEST_TIMEOUT)
+            .header("If-None-Match", etag_header.unwrap_or_default())
+            .send()
+            .await?;
+
+        if response.status() == reqwest::StatusCode::NOT_MODIFIED {
+            tracing::debug!("Allowlist unchanged (304 Not Modified)");
+            return Ok(None);
+        }
+
+        let response = response.error_for_status()?;
+
+        if let Some(etag) = response.headers().get("ETag") {
+            *self.etag.lock().await = Some(etag.to_str()?.to_string());
+        }
+
+        let list = response.json::<WhitelistResponse>().await?;
+        Ok(Some(list.addresses.into_iter().collect()))
+    }
+}
+
+#[async_trait]
+impl Task for AllowListTask {
+    fn id(&self) -> TaskId {
+        "api_allowlist_task".into()
+    }
+
+    fn kind(&self) -> TaskKind {
+        TaskKind::UnconstrainedTask
+    }
+
+    async fn run(self: Box<Self>, mut stop_receiver: StopReceiver) -> anyhow::Result<()> {
+        let mut ticker = interval(self.refresh_interval);
+
+        loop {
+            tokio::select! {
+                _ = ticker.tick() => {
+                    if *stop_receiver.0.borrow_and_update() {
+                        tracing::info!("AllowListTask received shutdown signal");
+                        break;
+                    }
+
+                    match self.fetch().await {
+                        Ok(Some(new_list)) => {
+                            let writer = self.allowlist.writer();
+                            let mut lock = writer.write().await;
+
+                            if *lock != new_list {
path: core/node/node_framework/src/implementations/layers/web3_api/tx_sink/whitelisted_master_pool_sink_layer.rs
user: login: slowli
body: This check looks redundant; if the server says that the allowlist is updated, what's the harm in trusting it?
created_at: 2025-04-01T12:57:01Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,183 @@
+use std::{collections::HashSet, time::Duration};
+
+use async_trait::async_trait;
+use reqwest::Client;
+use serde::Deserialize;
+use tokio::time::interval;
+use zksync_config::configs::api::DeploymentAllowlist;
+use zksync_node_api_server::tx_sender::{
+    master_pool_sink::MasterPoolSink, shared_allow_list::SharedAllowList,
+    whitelisted_deploy_pool_sink::WhitelistedDeployPoolSink,
+};
+use zksync_types::Address;
+
+use crate::{
+    implementations::resources::{
+        pools::{MasterPool, PoolResource},
+        web3_api::TxSinkResource,
+    },
+    service::StopReceiver,
+    task::{Task, TaskId, TaskKind},
+    wiring_layer::{WiringError, WiringLayer},
+    FromContext, IntoContext,
+};
+
+/// Wiring layer for [`WhitelistedDeployPoolSink`] that wraps a `MasterPoolSink` and enables allowlist filtering.
+pub struct WhitelistedMasterPoolSinkLayer {
+    pub deployment_allowlist: DeploymentAllowlist,
+}
+
+#[derive(Debug, FromContext)]
+#[context(crate = crate)]
+pub struct Input {
+    pub master_pool: PoolResource<MasterPool>,
+}
+
+#[derive(Debug, IntoContext)]
+#[context(crate = crate)]
+pub struct Output {
+    pub tx_sink: TxSinkResource,
+    #[context(task)]
+    pub allow_list_task: AllowListTask,
+}
+
+#[async_trait]
+impl WiringLayer for WhitelistedMasterPoolSinkLayer {
+    type Input = Input;
+    type Output = Output;
+
+    fn layer_name(&self) -> &'static str {
+        "whitelisted_master_pool_sink_layer"
+    }
+
+    async fn wire(self, input: Self::Input) -> Result<Self::Output, WiringError> {
+        let pool = input.master_pool.get().await?;
+        let master_pool_sink = MasterPoolSink::new(pool);
+
+        let shared_allowlist = SharedAllowList::new();
+
+        let allow_list_task = AllowListTask::new(
+            self.deployment_allowlist
+                .http_file_url()
+                .expect("DeploymentAllowlist must contain a URL")
+                .to_string(),
+            self.deployment_allowlist.refresh_interval(),
+            shared_allowlist.clone(),
+        );
+
+        let tx_sink = WhitelistedDeployPoolSink::new(master_pool_sink, shared_allowlist).into();
+
+        Ok(Output {
+            tx_sink,
+            allow_list_task,
+        })
+    }
+}
+
+/// Task that periodically fetches and updates the allowlist from a remote HTTP source.
+#[derive(Debug)]
+pub struct AllowListTask {
+    url: String,
+    refresh_interval: Duration,
+    allowlist: SharedAllowList,
+    client: Client,
+    etag: tokio::sync::Mutex<Option<String>>,
+}
+
+#[derive(Debug, Deserialize)]
+struct WhitelistResponse {
+    addresses: Vec<Address>,
+}
+
+impl AllowListTask {
+    const REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
+    pub fn new(url: String, refresh_interval: Duration, allowlist: SharedAllowList) -> Self {
+        Self {
+            url,
+            refresh_interval,
+            allowlist,
+            client: Client::new(),
+            etag: tokio::sync::Mutex::new(None),
+        }
+    }
+
+    async fn fetch(&self) -> anyhow::Result<Option<HashSet<Address>>> {
path: core/node/node_framework/src/implementations/layers/web3_api/tx_sink/whitelisted_master_pool_sink_layer.rs
user: login: slowli
body: You can use `&mut self` receiver here to avoid wrapping the `ETag` field into a `Mutex`. Alternatively, return a tag together with data, so that it's updated on the caller side (IMO, that would be a preferable option).
created_at: 2025-04-01T13:02:32Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,114 @@
+# Contract Deployment Allowlist
+
+This module restricts contract deployments on zkSync Era to a predefined list of approved addresses.
+
+It consists of:
+
+- `SharedAllowList`: in-memory, async-safe address set.
+- `AllowListTask`: periodically fetches the list from a remote URL.
+- `WhitelistedDeployPoolSink`: enforces the list during L2 transaction submission.
+
+---
+
+## Configuration
+
+Configure via `api.web3_json_rpc.deployment_allowlist` in your node's YAML:
+
+```api:
+  web3_json_rpc:
+    deployment_allowlist:
+      http_file_url: "https://example.com/allowlist.json"
+      refresh_interval_secs: 60
+```
+
+- `http_file_url` (required): HTTP(S) URL to the allowlist JSON.
+- `refresh_interval_secs` (optional): Refresh interval in seconds (default: 60).
+
+To disable, set:
+
+```
+api:
+  web3_json_rpc:
+    deployment_allowlist: {}
+```
+
+---
+
+## Allowlist Format
+
+The JSON file must contain:
+
+```
+{
+  "addresses": [
+    "0x1234...abcd",
+    "0xbeef...dead"
+  ]
+}
+```
+
+- Addresses must be checksummed.
path: docs/src/guides/advanced/18_allowlist.md
user: login: slowli
body: What does this mean? AFAICT, no checksum verification is performed on the app level.
created_at: 2025-04-01T13:04:16Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,114 @@
+# Contract Deployment Allowlist
+
+This module restricts contract deployments on zkSync Era to a predefined list of approved addresses.
+
+It consists of:
+
+- `SharedAllowList`: in-memory, async-safe address set.
+- `AllowListTask`: periodically fetches the list from a remote URL.
+- `WhitelistedDeployPoolSink`: enforces the list during L2 transaction submission.
+
+---
+
+## Configuration
+
+Configure via `api.web3_json_rpc.deployment_allowlist` in your node's YAML:
+
+```api:
+  web3_json_rpc:
+    deployment_allowlist:
+      http_file_url: "https://example.com/allowlist.json"
+      refresh_interval_secs: 60
+```
+
+- `http_file_url` (required): HTTP(S) URL to the allowlist JSON.
+- `refresh_interval_secs` (optional): Refresh interval in seconds (default: 60).
+
+To disable, set:
+
+```
+api:
+  web3_json_rpc:
+    deployment_allowlist: {}
+```
+
+---
+
+## Allowlist Format
+
+The JSON file must contain:
path: docs/src/guides/advanced/18_allowlist.md
user: login: slowli
body: Nit: "JSON file" -> "JSON served at the specified URL".
created_at: 2025-04-01T13:04:42Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,114 @@
+# Contract Deployment Allowlist
+
+This module restricts contract deployments on zkSync Era to a predefined list of approved addresses.
+
+It consists of:
+
+- `SharedAllowList`: in-memory, async-safe address set.
+- `AllowListTask`: periodically fetches the list from a remote URL.
+- `WhitelistedDeployPoolSink`: enforces the list during L2 transaction submission.
+
+---
+
+## Configuration
+
+Configure via `api.web3_json_rpc.deployment_allowlist` in your node's YAML:
+
+```api:
+  web3_json_rpc:
+    deployment_allowlist:
+      http_file_url: "https://example.com/allowlist.json"
+      refresh_interval_secs: 60
+```
+
+- `http_file_url` (required): HTTP(S) URL to the allowlist JSON.
+- `refresh_interval_secs` (optional): Refresh interval in seconds (default: 60).
path: docs/src/guides/advanced/18_allowlist.md
user: login: slowli
body: Nit: The default is 300s, AFAICT.
created_at: 2025-04-01T13:05:13Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,114 @@
+# Contract Deployment Allowlist
+
+This module restricts contract deployments on zkSync Era to a predefined list of approved addresses.
+
+It consists of:
+
+- `SharedAllowList`: in-memory, async-safe address set.
+- `AllowListTask`: periodically fetches the list from a remote URL.
+- `WhitelistedDeployPoolSink`: enforces the list during L2 transaction submission.
+
+---
+
+## Configuration
+
+Configure via `api.web3_json_rpc.deployment_allowlist` in your node's YAML:
+
+```api:
path: docs/src/guides/advanced/18_allowlist.md
user: login: slowli
body: The language tag should be `yaml`; `api` should be placed on a new line.
created_at: 2025-04-01T13:05:53Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,114 @@
+# Contract Deployment Allowlist
+
+This module restricts contract deployments on zkSync Era to a predefined list of approved addresses.
+
+It consists of:
+
+- `SharedAllowList`: in-memory, async-safe address set.
+- `AllowListTask`: periodically fetches the list from a remote URL.
+- `WhitelistedDeployPoolSink`: enforces the list during L2 transaction submission.
path: docs/src/guides/advanced/18_allowlist.md
user: login: slowli
body: Bikeshedding: IMO, this is unnecessary implementation detail; node operators could be more interested in how to configure this feature rather than implementation details. Ditto for the "How Allowlist Enforcement Works" section below. 
created_at: 2025-04-01T13:08:40Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,183 @@
+use std::{collections::HashSet, time::Duration};
+
+use async_trait::async_trait;
+use reqwest::Client;
+use serde::Deserialize;
+use tokio::time::interval;
+use zksync_config::configs::api::DeploymentAllowlist;
+use zksync_node_api_server::tx_sender::{
+    master_pool_sink::MasterPoolSink, shared_allow_list::SharedAllowList,
+    whitelisted_deploy_pool_sink::WhitelistedDeployPoolSink,
+};
+use zksync_types::Address;
+
+use crate::{
+    implementations::resources::{
+        pools::{MasterPool, PoolResource},
+        web3_api::TxSinkResource,
+    },
+    service::StopReceiver,
+    task::{Task, TaskId, TaskKind},
+    wiring_layer::{WiringError, WiringLayer},
+    FromContext, IntoContext,
+};
+
+/// Wiring layer for [`WhitelistedDeployPoolSink`] that wraps a `MasterPoolSink` and enables allowlist filtering.
+pub struct WhitelistedMasterPoolSinkLayer {
+    pub deployment_allowlist: DeploymentAllowlist,
+}
+
+#[derive(Debug, FromContext)]
+#[context(crate = crate)]
+pub struct Input {
+    pub master_pool: PoolResource<MasterPool>,
+}
+
+#[derive(Debug, IntoContext)]
+#[context(crate = crate)]
+pub struct Output {
+    pub tx_sink: TxSinkResource,
+    #[context(task)]
+    pub allow_list_task: AllowListTask,
+}
+
+#[async_trait]
+impl WiringLayer for WhitelistedMasterPoolSinkLayer {
+    type Input = Input;
+    type Output = Output;
+
+    fn layer_name(&self) -> &'static str {
+        "whitelisted_master_pool_sink_layer"
+    }
+
+    async fn wire(self, input: Self::Input) -> Result<Self::Output, WiringError> {
+        let pool = input.master_pool.get().await?;
+        let master_pool_sink = MasterPoolSink::new(pool);
+
+        let shared_allowlist = SharedAllowList::new();
+
+        let allow_list_task = AllowListTask::new(
+            self.deployment_allowlist
+                .http_file_url()
+                .expect("DeploymentAllowlist must contain a URL")
+                .to_string(),
+            self.deployment_allowlist.refresh_interval(),
+            shared_allowlist.clone(),
+        );
+
+        let tx_sink = WhitelistedDeployPoolSink::new(master_pool_sink, shared_allowlist).into();
+
+        Ok(Output {
+            tx_sink,
+            allow_list_task,
+        })
+    }
+}
+
+/// Task that periodically fetches and updates the allowlist from a remote HTTP source.
+#[derive(Debug)]
+pub struct AllowListTask {
+    url: String,
+    refresh_interval: Duration,
+    allowlist: SharedAllowList,
+    client: Client,
+    etag: tokio::sync::Mutex<Option<String>>,
+}
+
+#[derive(Debug, Deserialize)]
+struct WhitelistResponse {
+    addresses: Vec<Address>,
+}
+
+impl AllowListTask {
+    const REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
+    pub fn new(url: String, refresh_interval: Duration, allowlist: SharedAllowList) -> Self {
+        Self {
+            url,
+            refresh_interval,
+            allowlist,
+            client: Client::new(),
+            etag: tokio::sync::Mutex::new(None),
+        }
+    }
+
+    async fn fetch(&self) -> anyhow::Result<Option<HashSet<Address>>> {
+        let etag_header = self.etag.lock().await.clone();
+        let response = self
+            .client
+            .get(&self.url)
+            .timeout(Self::REQUEST_TIMEOUT)
+            .header("If-None-Match", etag_header.unwrap_or_default())
+            .send()
+            .await?;
+
+        if response.status() == reqwest::StatusCode::NOT_MODIFIED {
+            tracing::debug!("Allowlist unchanged (304 Not Modified)");
+            return Ok(None);
+        }
+
+        let response = response.error_for_status()?;
+
+        if let Some(etag) = response.headers().get("ETag") {
+            *self.etag.lock().await = Some(etag.to_str()?.to_string());
+        }
+
+        let list = response.json::<WhitelistResponse>().await?;
+        Ok(Some(list.addresses.into_iter().collect()))
+    }
+}
+
+#[async_trait]
+impl Task for AllowListTask {
+    fn id(&self) -> TaskId {
+        "api_allowlist_task".into()
+    }
+
+    fn kind(&self) -> TaskKind {
+        TaskKind::UnconstrainedTask
+    }
+
+    async fn run(self: Box<Self>, mut stop_receiver: StopReceiver) -> anyhow::Result<()> {
+        let mut ticker = interval(self.refresh_interval);
+
+        loop {
+            tokio::select! {
+                _ = ticker.tick() => {
+                    if *stop_receiver.0.borrow_and_update() {
+                        tracing::info!("AllowListTask received shutdown signal");
+                        break;
+                    }
+
+                    match self.fetch().await {
+                        Ok(Some(new_list)) => {
+                            let writer = self.allowlist.writer();
+                            let mut lock = writer.write().await;
+
+                            if *lock != new_list {
path: core/node/node_framework/src/implementations/layers/web3_api/tx_sink/whitelisted_master_pool_sink_layer.rs
user: login: RomanHiden
body: If the server doesn’t implement ETag, and the allowlist content hasn’t changed, the same list of wallets will be re-read and redundantly assigned to the in-memory allowlist.
It avoids pointless updates, keeps the logs clear, and doesn't mess with anything reading the allowlist.
created_at: 2025-04-01T16:12:03Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,183 @@
+use std::{collections::HashSet, time::Duration};
+
+use async_trait::async_trait;
+use reqwest::Client;
+use serde::Deserialize;
+use tokio::time::interval;
+use zksync_config::configs::api::DeploymentAllowlist;
+use zksync_node_api_server::tx_sender::{
+    master_pool_sink::MasterPoolSink, shared_allow_list::SharedAllowList,
+    whitelisted_deploy_pool_sink::WhitelistedDeployPoolSink,
+};
+use zksync_types::Address;
+
+use crate::{
+    implementations::resources::{
+        pools::{MasterPool, PoolResource},
+        web3_api::TxSinkResource,
+    },
+    service::StopReceiver,
+    task::{Task, TaskId, TaskKind},
+    wiring_layer::{WiringError, WiringLayer},
+    FromContext, IntoContext,
+};
+
+/// Wiring layer for [`WhitelistedDeployPoolSink`] that wraps a `MasterPoolSink` and enables allowlist filtering.
+pub struct WhitelistedMasterPoolSinkLayer {
+    pub deployment_allowlist: DeploymentAllowlist,
+}
+
+#[derive(Debug, FromContext)]
+#[context(crate = crate)]
+pub struct Input {
+    pub master_pool: PoolResource<MasterPool>,
+}
+
+#[derive(Debug, IntoContext)]
+#[context(crate = crate)]
+pub struct Output {
+    pub tx_sink: TxSinkResource,
+    #[context(task)]
+    pub allow_list_task: AllowListTask,
+}
+
+#[async_trait]
+impl WiringLayer for WhitelistedMasterPoolSinkLayer {
+    type Input = Input;
+    type Output = Output;
+
+    fn layer_name(&self) -> &'static str {
+        "whitelisted_master_pool_sink_layer"
+    }
+
+    async fn wire(self, input: Self::Input) -> Result<Self::Output, WiringError> {
+        let pool = input.master_pool.get().await?;
+        let master_pool_sink = MasterPoolSink::new(pool);
+
+        let shared_allowlist = SharedAllowList::new();
+
+        let allow_list_task = AllowListTask::new(
+            self.deployment_allowlist
+                .http_file_url()
+                .expect("DeploymentAllowlist must contain a URL")
+                .to_string(),
+            self.deployment_allowlist.refresh_interval(),
+            shared_allowlist.clone(),
+        );
+
+        let tx_sink = WhitelistedDeployPoolSink::new(master_pool_sink, shared_allowlist).into();
+
+        Ok(Output {
+            tx_sink,
+            allow_list_task,
+        })
+    }
+}
+
+/// Task that periodically fetches and updates the allowlist from a remote HTTP source.
+#[derive(Debug)]
+pub struct AllowListTask {
+    url: String,
+    refresh_interval: Duration,
+    allowlist: SharedAllowList,
+    client: Client,
+    etag: tokio::sync::Mutex<Option<String>>,
+}
+
+#[derive(Debug, Deserialize)]
+struct WhitelistResponse {
+    addresses: Vec<Address>,
+}
+
+impl AllowListTask {
+    const REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
+    pub fn new(url: String, refresh_interval: Duration, allowlist: SharedAllowList) -> Self {
+        Self {
+            url,
+            refresh_interval,
+            allowlist,
+            client: Client::new(),
+            etag: tokio::sync::Mutex::new(None),
+        }
+    }
+
+    async fn fetch(&self) -> anyhow::Result<Option<HashSet<Address>>> {
+        let etag_header = self.etag.lock().await.clone();
+        let response = self
+            .client
+            .get(&self.url)
+            .timeout(Self::REQUEST_TIMEOUT)
+            .header("If-None-Match", etag_header.unwrap_or_default())
+            .send()
+            .await?;
+
+        if response.status() == reqwest::StatusCode::NOT_MODIFIED {
+            tracing::debug!("Allowlist unchanged (304 Not Modified)");
+            return Ok(None);
+        }
+
+        let response = response.error_for_status()?;
+
+        if let Some(etag) = response.headers().get("ETag") {
+            *self.etag.lock().await = Some(etag.to_str()?.to_string());
+        }
+
+        let list = response.json::<WhitelistResponse>().await?;
+        Ok(Some(list.addresses.into_iter().collect()))
+    }
+}
+
+#[async_trait]
+impl Task for AllowListTask {
+    fn id(&self) -> TaskId {
+        "api_allowlist_task".into()
+    }
+
+    fn kind(&self) -> TaskKind {
+        TaskKind::UnconstrainedTask
+    }
+
+    async fn run(self: Box<Self>, mut stop_receiver: StopReceiver) -> anyhow::Result<()> {
+        let mut ticker = interval(self.refresh_interval);
+
+        loop {
+            tokio::select! {
+                _ = ticker.tick() => {
+                    if *stop_receiver.0.borrow_and_update() {
+                        tracing::info!("AllowListTask received shutdown signal");
+                        break;
+                    }
+
+                    match self.fetch().await {
+                        Ok(Some(new_list)) => {
+                            let writer = self.allowlist.writer();
+                            let mut lock = writer.write().await;
+
+                            if *lock != new_list {
path: core/node/node_framework/src/implementations/layers/web3_api/tx_sink/whitelisted_master_pool_sink_layer.rs
user: login: RomanHiden
body: if server doesn't implement ETag downloading takes more time than saving this nanoseconds. so I will remove `if`

created_at: 2025-04-01T16:58:57Z
author_association: CONTRIBUTOR
diff_hunk: @@ -151,6 +152,8 @@ impl ProtoRepr for proto::Web3JsonRpc {
                 .context("whitelisted_tokens_for_aa")?,
             extended_api_tracing: self.extended_api_tracing.unwrap_or_default(),
             api_namespaces,
+            deployment_allowlist: read_required_repr(&self.deployment_allowlist)
+                .context("deployment_allowlist")?,
path: core/lib/protobuf_config/src/api.rs
user: login: slowli
body: The `deployment_allowlist` shouldn't be required; if it's missing, `DeploymentAllowlist::default()` should be used.
created_at: 2025-04-02T13:44:47Z
author_association: CONTRIBUTOR
diff_hunk: @@ -408,6 +412,50 @@ impl MerkleTreeApiConfig {
     }
 }
 
+#[derive(Debug, Deserialize, Clone, PartialEq)]
+pub struct DeploymentAllowlist {
+    /// If `Some(url)`, allowlisting is enabled. If `None`, it's disabled.
+    /// If the `String` is empty, treat it as invalid and effectively disable.
+    http_file_url: Option<String>,
+    /// Private field for the refresh interval (in seconds).
+    refresh_interval_secs: Option<u64>,
+}
+
+impl DeploymentAllowlist {
+    /// Create a new `DeploymentAllowlist` instance.
+    pub fn new(http_file_url: Option<String>, refresh_interval_secs: Option<u64>) -> Self {
+        Self {
+            http_file_url,
+            refresh_interval_secs,
+        }
+    }
+
+    /// Returns `true` if deployment allowlisting is enabled.
+    pub fn is_enabled(&self) -> bool {
+        self.http_file_url().is_some()
+    }
+
+    /// Returns the allowlist file URL, if present and non-empty.
+    pub fn http_file_url(&self) -> Option<&str> {
+        self.http_file_url.as_deref().filter(|s| !s.is_empty())
+    }
+
+    /// Returns the refresh interval used to reload the allowlist.
+    /// Defaults to 5 minutes if not set.
+    pub fn refresh_interval(&self) -> Duration {
+        Duration::from_secs(self.refresh_interval_secs.unwrap_or(300))
+    }
+}
+
+impl Default for DeploymentAllowlist {
+    fn default() -> Self {
+        Self {
+            http_file_url: None,
+            refresh_interval_secs: None,
+        }
+    }
+}
path: core/lib/config/src/configs/api.rs
user: login: slowli
body: Nit: This can be `#[derive]`d.
created_at: 2025-04-02T13:45:01Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,148 @@
+use std::{collections::HashSet, sync::Arc, time::Duration};
+
+use reqwest::Client;
+use serde::Deserialize;
+use tokio::sync::RwLock;
+use zksync_config::configs::api::DeploymentAllowlist;
+use zksync_dal::transactions_dal::L2TxSubmissionResult;
+use zksync_multivm::interface::{tracer::ValidationTraces, VmEvent};
+use zksync_types::{h256_to_address, l2::L2Tx, Address, CONTRACT_DEPLOYER_ADDRESS, H160};
+
+use crate::{
+    execution_sandbox::SandboxExecutionOutput,
+    tx_sender::{master_pool_sink::MasterPoolSink, tx_sink::TxSink, SubmitTxError},
+};
+
+/// Wrapper that submits transactions to the mempool and enforces contract deployment allow-list
+/// by analyzing the VM events for contract deployments.
+#[derive(Debug)]
+pub struct WhitelistedDeployPoolSink {
+    master_pool_sink: MasterPoolSink,
+    allow_list_task: AllowListTask,
+}
+
+impl WhitelistedDeployPoolSink {
+    pub fn new(master_pool_sink: MasterPoolSink, allow_list_task: AllowListTask) -> Self {
+        Self {
+            master_pool_sink,
+            allow_list_task,
+        }
+    }
+}
+
+#[async_trait::async_trait]
+impl TxSink for WhitelistedDeployPoolSink {
+    async fn submit_tx(
+        &self,
+        tx: &L2Tx,
+        execution_outputs: &SandboxExecutionOutput,
+        validation_traces: ValidationTraces,
+    ) -> Result<L2TxSubmissionResult, SubmitTxError> {
+        // Enforce the deployment allowlist by scanning for ContractDeployed events.
+        // Each such event should follow this format:
+        //   event ContractDeployed(address indexed deployerAddress, bytes32 indexed bytecodeHash, address indexed contractAddress);
+        // We extract the deployer address from topic[1] and verify it is whitelisted.
+
+        let deployer_addresses = execution_outputs.events.iter().filter_map(|event| {
+            let is_contract_deployed = event.address == CONTRACT_DEPLOYER_ADDRESS
+                && event.indexed_topics.first() == Some(&VmEvent::DEPLOY_EVENT_SIGNATURE);
+            if is_contract_deployed {
+                event.indexed_topics.get(1).map(h256_to_address)
+            } else {
+                None
+            }
+        });
+
+        for deployer_address in deployer_addresses {
+            if !self
+                .allow_list_task
+                .is_address_allowed(&deployer_address)
+                .await
+            {
+                tracing::info!(
+                    "Blocking contract deployment in tx {:?}: deployer_address {:?} not whitelisted",
+                    tx.hash(),
+                    deployer_address
+                );
+                return Err(SubmitTxError::DeployerNotInAllowList(deployer_address));
+            }
+        }
+
+        // If all deployment events pass the allowlist check, forward the submission.
+        self.master_pool_sink
+            .submit_tx(tx, execution_outputs, validation_traces)
+            .await
+    }
+}
+
+#[derive(Debug, Deserialize)]
+struct WhitelistResponse {
+    addresses: Vec<Address>,
+}
+
+/// Task that periodically fetches and updates the allowlist from a remote HTTP source.
+#[derive(Debug, Default, Clone)]
path: core/node/api_server/src/tx_sender/whitelist.rs
user: login: slowli
body: Bikeshedding: `Default` doesn't make semantic sense for this type for the reasons we've discussed (i.e., `url == ""` and `refresh_interval == Duration::ZERO` not being valid). AFAIU, it's only used `..Default::default()` where it can be easily replaced.
created_at: 2025-04-02T13:47:27Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,148 @@
+use std::{collections::HashSet, sync::Arc, time::Duration};
+
+use reqwest::Client;
+use serde::Deserialize;
+use tokio::sync::RwLock;
+use zksync_config::configs::api::DeploymentAllowlist;
+use zksync_dal::transactions_dal::L2TxSubmissionResult;
+use zksync_multivm::interface::{tracer::ValidationTraces, VmEvent};
+use zksync_types::{h256_to_address, l2::L2Tx, Address, CONTRACT_DEPLOYER_ADDRESS, H160};
+
+use crate::{
+    execution_sandbox::SandboxExecutionOutput,
+    tx_sender::{master_pool_sink::MasterPoolSink, tx_sink::TxSink, SubmitTxError},
+};
+
+/// Wrapper that submits transactions to the mempool and enforces contract deployment allow-list
+/// by analyzing the VM events for contract deployments.
+#[derive(Debug)]
+pub struct WhitelistedDeployPoolSink {
+    master_pool_sink: MasterPoolSink,
+    allow_list_task: AllowListTask,
path: core/node/api_server/src/tx_sender/whitelist.rs
user: login: slowli
body: Sorry if I was misleading. Please don't inject a *task* here; the only part of it accessed by the sink logic is the allowlist itself, so you should inject just it (either in the original `Arc<RwLock<HashSet<Address>>>` form, or as a newtype wrapper with the `is_address_allowed()` method).
created_at: 2025-04-02T13:51:44Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,98 @@
+use async_trait::async_trait;
+use zksync_config::configs::api::DeploymentAllowlist;
+use zksync_node_api_server::tx_sender::{
+    master_pool_sink::MasterPoolSink,
+    whitelist::{AllowListTask, WhitelistedDeployPoolSink},
+};
+
+use crate::{
+    implementations::resources::{
+        pools::{MasterPool, PoolResource},
+        web3_api::TxSinkResource,
+    },
+    service::StopReceiver,
+    task::{Task, TaskId, TaskKind},
+    wiring_layer::{WiringError, WiringLayer},
+    FromContext, IntoContext,
+};
+
+/// Wiring layer for [`WhitelistedDeployPoolSink`] that wraps a `MasterPoolSink` and enables allowlist filtering.
+pub struct WhitelistedMasterPoolSinkLayer {
+    pub deployment_allowlist: DeploymentAllowlist,
+}
+
+#[derive(Debug, FromContext)]
+#[context(crate = crate)]
+pub struct Input {
+    pub master_pool: PoolResource<MasterPool>,
+}
+
+#[derive(Debug, IntoContext)]
+#[context(crate = crate)]
+pub struct Output {
+    pub tx_sink: TxSinkResource,
+    #[context(task)]
+    pub allow_list_task: AllowListTask,
+}
+
+#[async_trait]
+impl WiringLayer for WhitelistedMasterPoolSinkLayer {
+    type Input = Input;
+    type Output = Output;
+
+    fn layer_name(&self) -> &'static str {
+        "whitelisted_master_pool_sink_layer"
+    }
+
+    async fn wire(self, input: Self::Input) -> Result<Self::Output, WiringError> {
+        let pool = input.master_pool.get().await?;
+        let master_pool_sink = MasterPoolSink::new(pool);
+
+        let allow_list_task = AllowListTask::from_config(self.deployment_allowlist);
+
+        let tx_sink =
+            WhitelistedDeployPoolSink::new(master_pool_sink, allow_list_task.clone()).into();
+
+        Ok(Output {
+            tx_sink,
+            allow_list_task,
+        })
+    }
+}
+
+#[async_trait]
+impl Task for AllowListTask {
+    fn id(&self) -> TaskId {
+        "api_allowlist_task".into()
+    }
+
+    fn kind(&self) -> TaskKind {
+        TaskKind::UnconstrainedTask
+    }
+
+    async fn run(self: Box<Self>, mut stop_receiver: StopReceiver) -> anyhow::Result<()> {
path: core/node/node_framework/src/implementations/layers/web3_api/tx_sink/whitelist.rs
user: login: slowli
body: Please move `run()` to the `api_server` method as well as an inherent method taking `self` receiver. Here, it should just delegate to it [as is the case for other components](https://github.com/matter-labs/zksync-era/blob/d1bac66e339e46d2ecac183db8d33d9ee3e85940/core/node/node_framework/src/implementations/layers/consistency_checker.rs#L103-L105). This will allow for better encapsulation, like making most of `AllowListTask` methods private.
created_at: 2025-04-02T13:56:24Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,106 @@
+# Contract Deployment Allowlist
+
+This module restricts contract deployments on zkSync Era to a predefined list of approved addresses.
+
+## Configuration
+
+Configure via `api.web3_json_rpc.deployment_allowlist` in your node's YAML:
+
+```YAML
+api:
+  web3_json_rpc:fit
path: docs/src/guides/advanced/18_allowlist.md
user: login: slowli
body: What does "fit" mean? 🙃 It looks unnecessary.
created_at: 2025-04-02T13:57:33Z
author_association: CONTRIBUTOR
diff_hunk: @@ -151,6 +152,8 @@ impl ProtoRepr for proto::Web3JsonRpc {
                 .context("whitelisted_tokens_for_aa")?,
             extended_api_tracing: self.extended_api_tracing.unwrap_or_default(),
             api_namespaces,
+            deployment_allowlist: read_required_repr(&self.deployment_allowlist)
path: core/lib/protobuf_config/src/api.rs
user: login: slowli
body: This is incorrect at least semantically; `read_required_repr` implies that whatever it reads *is* required. (It may also be incorrect in practice if field conversions inside `DeploymentAllowlist` are fallible; in this case, a conversion error would be incorrectly swallowed.) Please use `read_optional_repr` instead.
created_at: 2025-04-04T11:04:25Z
author_association: CONTRIBUTOR
diff_hunk: @@ -64,7 +64,7 @@ impl TxSink for MasterPoolSink {
     async fn submit_tx(
         &self,
         tx: &L2Tx,
-        execution_metrics: TransactionExecutionMetrics,
+        execution_outputs: &SandboxExecutionOutput,
path: core/node/api_server/src/tx_sender/master_pool_sink.rs
user: login: slowli
body: Micro-nit: Since the type is named "..Output", it would make sense to name the arg `execution_output` (singular) for consistency.
created_at: 2025-04-04T11:07:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,188 @@
+use std::{collections::HashSet, sync::Arc, time::Duration};
+
+use reqwest::Client;
+use serde::Deserialize;
+use tokio::sync::{watch, RwLock};
+use zksync_config::configs::api::DeploymentAllowlist;
+use zksync_dal::transactions_dal::L2TxSubmissionResult;
+use zksync_multivm::interface::{tracer::ValidationTraces, VmEvent};
+use zksync_types::{h256_to_address, l2::L2Tx, Address, CONTRACT_DEPLOYER_ADDRESS, H160};
+
+use crate::{
+    execution_sandbox::SandboxExecutionOutput,
+    tx_sender::{master_pool_sink::MasterPoolSink, tx_sink::TxSink, SubmitTxError},
+};
+
+/// Wrapper that submits transactions to the mempool and enforces contract deployment allow-list
+/// by analyzing the VM events for contract deployments.
+#[derive(Debug)]
+pub struct WhitelistedDeployPoolSink {
+    master_pool_sink: MasterPoolSink,
+    shared_allow_list: SharedAllowList,
+}
+
+impl WhitelistedDeployPoolSink {
+    pub fn new(master_pool_sink: MasterPoolSink, shared_allow_list: SharedAllowList) -> Self {
+        Self {
+            master_pool_sink,
+            shared_allow_list,
+        }
+    }
+}
+
+#[async_trait::async_trait]
+impl TxSink for WhitelistedDeployPoolSink {
+    async fn submit_tx(
+        &self,
+        tx: &L2Tx,
+        execution_outputs: &SandboxExecutionOutput,
+        validation_traces: ValidationTraces,
+    ) -> Result<L2TxSubmissionResult, SubmitTxError> {
+        // Enforce the deployment allowlist by scanning for ContractDeployed events.
+        // Each such event should follow this format:
+        //   event ContractDeployed(address indexed deployerAddress, bytes32 indexed bytecodeHash, address indexed contractAddress);
+        // We extract the deployer address from topic[1] and verify it is whitelisted.
+
+        let deployer_addresses = execution_outputs.events.iter().filter_map(|event| {
+            let is_contract_deployed = event.address == CONTRACT_DEPLOYER_ADDRESS
+                && event.indexed_topics.first() == Some(&VmEvent::DEPLOY_EVENT_SIGNATURE);
+            if is_contract_deployed {
+                event.indexed_topics.get(1).map(h256_to_address)
+            } else {
+                None
+            }
+        });
+
+        for deployer_address in deployer_addresses {
+            if !self
+                .shared_allow_list
+                .is_address_allowed(&deployer_address)
+                .await
+            {
+                tracing::info!(
+                    "Blocking contract deployment in tx {:?}: deployer_address {:?} not whitelisted",
+                    tx.hash(),
+                    deployer_address
+                );
+                return Err(SubmitTxError::DeployerNotInAllowList(deployer_address));
+            }
+        }
+
+        // If all deployment events pass the allowlist check, forward the submission.
+        self.master_pool_sink
+            .submit_tx(tx, execution_outputs, validation_traces)
+            .await
+    }
+}
+
+#[derive(Debug, Deserialize)]
+struct WhitelistResponse {
+    addresses: Vec<Address>,
+}
+
+#[derive(Debug, Clone, Default)]
+pub struct SharedAllowList {
+    inner: Arc<RwLock<HashSet<Address>>>,
+}
+
+impl SharedAllowList {
+    pub fn writer(&self) -> Arc<RwLock<HashSet<Address>>> {
+        Arc::clone(&self.inner)
+    }
+
+    pub async fn is_address_allowed(&self, address: &Address) -> bool {
path: core/node/api_server/src/tx_sender/whitelist.rs
user: login: slowli
body: As I've said in the previous review, the benefit of allowlist logic locality is that methods like this can be made private. Ditto for `AllowListTask::{refresh_interval, fetch}`. 
created_at: 2025-04-04T11:11:17Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,188 @@
+use std::{collections::HashSet, sync::Arc, time::Duration};
+
+use reqwest::Client;
+use serde::Deserialize;
+use tokio::sync::{watch, RwLock};
+use zksync_config::configs::api::DeploymentAllowlist;
+use zksync_dal::transactions_dal::L2TxSubmissionResult;
+use zksync_multivm::interface::{tracer::ValidationTraces, VmEvent};
+use zksync_types::{h256_to_address, l2::L2Tx, Address, CONTRACT_DEPLOYER_ADDRESS, H160};
+
+use crate::{
+    execution_sandbox::SandboxExecutionOutput,
+    tx_sender::{master_pool_sink::MasterPoolSink, tx_sink::TxSink, SubmitTxError},
+};
+
+/// Wrapper that submits transactions to the mempool and enforces contract deployment allow-list
+/// by analyzing the VM events for contract deployments.
+#[derive(Debug)]
+pub struct WhitelistedDeployPoolSink {
+    master_pool_sink: MasterPoolSink,
+    shared_allow_list: SharedAllowList,
+}
+
+impl WhitelistedDeployPoolSink {
+    pub fn new(master_pool_sink: MasterPoolSink, shared_allow_list: SharedAllowList) -> Self {
+        Self {
+            master_pool_sink,
+            shared_allow_list,
+        }
+    }
+}
+
+#[async_trait::async_trait]
+impl TxSink for WhitelistedDeployPoolSink {
+    async fn submit_tx(
+        &self,
+        tx: &L2Tx,
+        execution_outputs: &SandboxExecutionOutput,
+        validation_traces: ValidationTraces,
+    ) -> Result<L2TxSubmissionResult, SubmitTxError> {
+        // Enforce the deployment allowlist by scanning for ContractDeployed events.
+        // Each such event should follow this format:
+        //   event ContractDeployed(address indexed deployerAddress, bytes32 indexed bytecodeHash, address indexed contractAddress);
+        // We extract the deployer address from topic[1] and verify it is whitelisted.
+
+        let deployer_addresses = execution_outputs.events.iter().filter_map(|event| {
+            let is_contract_deployed = event.address == CONTRACT_DEPLOYER_ADDRESS
+                && event.indexed_topics.first() == Some(&VmEvent::DEPLOY_EVENT_SIGNATURE);
+            if is_contract_deployed {
+                event.indexed_topics.get(1).map(h256_to_address)
+            } else {
+                None
+            }
+        });
+
+        for deployer_address in deployer_addresses {
+            if !self
+                .shared_allow_list
+                .is_address_allowed(&deployer_address)
+                .await
+            {
+                tracing::info!(
+                    "Blocking contract deployment in tx {:?}: deployer_address {:?} not whitelisted",
+                    tx.hash(),
+                    deployer_address
+                );
+                return Err(SubmitTxError::DeployerNotInAllowList(deployer_address));
+            }
+        }
+
+        // If all deployment events pass the allowlist check, forward the submission.
+        self.master_pool_sink
+            .submit_tx(tx, execution_outputs, validation_traces)
+            .await
+    }
+}
+
+#[derive(Debug, Deserialize)]
+struct WhitelistResponse {
+    addresses: Vec<Address>,
+}
+
+#[derive(Debug, Clone, Default)]
+pub struct SharedAllowList {
+    inner: Arc<RwLock<HashSet<Address>>>,
+}
+
+impl SharedAllowList {
+    pub fn writer(&self) -> Arc<RwLock<HashSet<Address>>> {
+        Arc::clone(&self.inner)
+    }
+
+    pub async fn is_address_allowed(&self, address: &Address) -> bool {
+        self.inner.read().await.contains(address)
+    }
+}
+
+/// Task that periodically fetches and updates the allowlist from a remote HTTP source.
+#[derive(Debug, Clone)]
+pub struct AllowListTask {
+    url: String,
+    refresh_interval: Duration,
+    allowlist: SharedAllowList,
+    client: Client,
+}
+
+impl AllowListTask {
+    const REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
+    pub fn from_config(deployment_allowlist: DeploymentAllowlist) -> Self {
+        Self {
+            url: deployment_allowlist
+                .http_file_url()
+                .expect("DeploymentAllowlist must contain a URL")
+                .to_string(),
+            refresh_interval: deployment_allowlist.refresh_interval(),
+            allowlist: SharedAllowList::default(),
+            client: Client::new(),
+        }
+    }
+
+    pub fn refresh_interval(&self) -> Duration {
+        self.refresh_interval
+    }
+
+    pub fn shared(&self) -> SharedAllowList {
+        self.allowlist.clone()
+    }
+
+    pub async fn fetch(
+        &self,
+        current_etag: Option<&str>,
+    ) -> anyhow::Result<Option<(HashSet<Address>, Option<String>)>> {
+        let mut request = self.client.get(&self.url).timeout(Self::REQUEST_TIMEOUT);
+
+        if let Some(etag) = current_etag {
+            request = request.header("If-None-Match", etag);
+        }
+
+        let response = request.send().await?;
+
+        if response.status() == reqwest::StatusCode::NOT_MODIFIED {
+            tracing::debug!("Allowlist unchanged (304 Not Modified)");
+            return Ok(None);
+        }
+
+        let response = response.error_for_status()?;
+
+        let new_etag = match response.headers().get("ETag") {
+            Some(value) => match value.to_str() {
+                Ok(s) => Some(s.to_string()),
+                Err(_) => None,
+            },
+            None => None,
+        };
+        let list = response.json::<WhitelistResponse>().await?;
+        let addresses: HashSet<H160> = list.addresses.into_iter().collect();
path: core/node/api_server/src/tx_sender/whitelist.rs
user: login: slowli
body: Nit: `H160` and `Address` are the same type; there's no need to use both (we mostly use `Address` in the codebase). Here specifically, it's possible to use a wildcard: `HashSet<_>`, since the compiler can infer the type automatically.
created_at: 2025-04-04T11:15:54Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,76 @@
+use async_trait::async_trait;
+use zksync_config::configs::api::DeploymentAllowlist;
+use zksync_node_api_server::tx_sender::{
+    master_pool_sink::MasterPoolSink,
+    whitelist::{AllowListTask, WhitelistedDeployPoolSink},
+};
+
+use crate::{
+    implementations::resources::{
+        pools::{MasterPool, PoolResource},
+        web3_api::TxSinkResource,
+    },
+    service::StopReceiver,
+    task::{Task, TaskId, TaskKind},
+    wiring_layer::{WiringError, WiringLayer},
+    FromContext, IntoContext,
+};
+
+/// Wiring layer for [`WhitelistedDeployPoolSink`] that wraps a `MasterPoolSink` and enables allowlist filtering.
+pub struct WhitelistedMasterPoolSinkLayer {
+    pub deployment_allowlist: DeploymentAllowlist,
+}
+
+#[derive(Debug, FromContext)]
+#[context(crate = crate)]
+pub struct Input {
+    pub master_pool: PoolResource<MasterPool>,
+}
+
+#[derive(Debug, IntoContext)]
+#[context(crate = crate)]
+pub struct Output {
+    pub tx_sink: TxSinkResource,
+    #[context(task)]
+    pub allow_list_task: AllowListTask,
+}
+
+#[async_trait]
+impl WiringLayer for WhitelistedMasterPoolSinkLayer {
+    type Input = Input;
+    type Output = Output;
+
+    fn layer_name(&self) -> &'static str {
+        "whitelisted_master_pool_sink_layer"
+    }
+
+    async fn wire(self, input: Self::Input) -> Result<Self::Output, WiringError> {
+        let pool = input.master_pool.get().await?;
+        let master_pool_sink = MasterPoolSink::new(pool);
+
+        let allow_list_task = AllowListTask::from_config(self.deployment_allowlist);
+
+        let tx_sink =
+            WhitelistedDeployPoolSink::new(master_pool_sink, allow_list_task.shared()).into();
+
+        Ok(Output {
+            tx_sink,
+            allow_list_task,
+        })
+    }
+}
+
+#[async_trait]
+impl Task for AllowListTask {
+    fn id(&self) -> TaskId {
+        "api_allowlist_task".into()
+    }
+
+    fn kind(&self) -> TaskKind {
+        TaskKind::UnconstrainedTask
path: core/node/node_framework/src/implementations/layers/web3_api/tx_sink/whitelist.rs
user: login: slowli
body: Why not `TaskKind::Task`? Unconstrained tasks are lower-level and can break more easily; [see the docs](https://github.com/matter-labs/zksync-era/blob/0d2bb67af441758690c195380d6f6d137967f19a/core/node/node_framework/src/task/mod.rs#L20).
created_at: 2025-04-04T11:18:35Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,188 @@
+use std::{collections::HashSet, sync::Arc, time::Duration};
+
+use reqwest::Client;
+use serde::Deserialize;
+use tokio::sync::{watch, RwLock};
+use zksync_config::configs::api::DeploymentAllowlist;
+use zksync_dal::transactions_dal::L2TxSubmissionResult;
+use zksync_multivm::interface::{tracer::ValidationTraces, VmEvent};
+use zksync_types::{h256_to_address, l2::L2Tx, Address, CONTRACT_DEPLOYER_ADDRESS, H160};
+
+use crate::{
+    execution_sandbox::SandboxExecutionOutput,
+    tx_sender::{master_pool_sink::MasterPoolSink, tx_sink::TxSink, SubmitTxError},
+};
+
+/// Wrapper that submits transactions to the mempool and enforces contract deployment allow-list
+/// by analyzing the VM events for contract deployments.
+#[derive(Debug)]
+pub struct WhitelistedDeployPoolSink {
+    master_pool_sink: MasterPoolSink,
+    shared_allow_list: SharedAllowList,
+}
+
+impl WhitelistedDeployPoolSink {
+    pub fn new(master_pool_sink: MasterPoolSink, shared_allow_list: SharedAllowList) -> Self {
+        Self {
+            master_pool_sink,
+            shared_allow_list,
+        }
+    }
+}
+
+#[async_trait::async_trait]
+impl TxSink for WhitelistedDeployPoolSink {
+    async fn submit_tx(
+        &self,
+        tx: &L2Tx,
+        execution_outputs: &SandboxExecutionOutput,
+        validation_traces: ValidationTraces,
+    ) -> Result<L2TxSubmissionResult, SubmitTxError> {
+        // Enforce the deployment allowlist by scanning for ContractDeployed events.
+        // Each such event should follow this format:
+        //   event ContractDeployed(address indexed deployerAddress, bytes32 indexed bytecodeHash, address indexed contractAddress);
+        // We extract the deployer address from topic[1] and verify it is whitelisted.
+
+        let deployer_addresses = execution_outputs.events.iter().filter_map(|event| {
+            let is_contract_deployed = event.address == CONTRACT_DEPLOYER_ADDRESS
+                && event.indexed_topics.first() == Some(&VmEvent::DEPLOY_EVENT_SIGNATURE);
+            if is_contract_deployed {
+                event.indexed_topics.get(1).map(h256_to_address)
+            } else {
+                None
+            }
+        });
+
+        for deployer_address in deployer_addresses {
+            if !self
+                .shared_allow_list
+                .is_address_allowed(&deployer_address)
+                .await
+            {
+                tracing::info!(
+                    "Blocking contract deployment in tx {:?}: deployer_address {:?} not whitelisted",
+                    tx.hash(),
+                    deployer_address
+                );
+                return Err(SubmitTxError::DeployerNotInAllowList(deployer_address));
+            }
+        }
+
+        // If all deployment events pass the allowlist check, forward the submission.
+        self.master_pool_sink
+            .submit_tx(tx, execution_outputs, validation_traces)
+            .await
+    }
+}
+
+#[derive(Debug, Deserialize)]
+struct WhitelistResponse {
+    addresses: Vec<Address>,
+}
+
+#[derive(Debug, Clone, Default)]
+pub struct SharedAllowList {
+    inner: Arc<RwLock<HashSet<Address>>>,
+}
+
+impl SharedAllowList {
+    pub fn writer(&self) -> Arc<RwLock<HashSet<Address>>> {
+        Arc::clone(&self.inner)
+    }
path: core/node/api_server/src/tx_sender/whitelist.rs
user: login: slowli
body: Nit: The only use of this method is local, so it can be made private. Also, why is cloning required? It seems that the only use would do fine without it.
created_at: 2025-04-04T11:19:52Z
author_association: CONTRIBUTOR
diff_hunk: @@ -151,6 +152,8 @@ impl ProtoRepr for proto::Web3JsonRpc {
                 .context("whitelisted_tokens_for_aa")?,
             extended_api_tracing: self.extended_api_tracing.unwrap_or_default(),
             api_namespaces,
+            deployment_allowlist: read_required_repr(&self.deployment_allowlist)
path: core/lib/protobuf_config/src/api.rs
user: login: RomanHiden
body: oh I see. I haven't realized this such function exists. 
created_at: 2025-04-04T11:52:44Z
author_association: CONTRIBUTOR
diff_hunk: @@ -69,8 +69,8 @@ impl SandboxAction {
 }
 
 /// Output of [`SandboxExecutor::execute_in_sandbox()`].
-#[derive(Debug, Clone)]
-pub(crate) struct SandboxExecutionOutput {
+#[derive(Debug, Clone, Default)]
path: core/node/api_server/src/execution_sandbox/execute.rs
user: login: slowli
body: Deriving `Default` is semantically incorrect here; for at least `are_published_bytecodes_ok`, the default value (`false`) means unsuccessful execution. Please use an inherent constructor instead (e.g. named `mock_success`). Would also allow to not derive `Default` for `ExecutionResult`, which I feel is semantically ambiguous (an empty return value is the default successful value for transactions, but not necessarily for calls).
created_at: 2025-04-08T13:15:47Z
author_association: CONTRIBUTOR

PR comments:
user: login: RomanHiden
created_at: 2025-03-30T19:01:02Z
body: I have addressed all review comments. tested and did self review. 

Please check again. @slowli 

author_association: CONTRIBUTOR
user: login: RomanHiden
created_at: 2025-03-30T19:05:49Z
body: anyone willing to test
```    
deployment_allowlist:
      http_file_url: "http://127.0.0.1:8000/whitelist.json"
      refresh_interval_secs: 10
```


whitelist.json 
```
{
    "addresses": [
      "0x618263CE921F7dd5F4f40C29f6c524Aaf97b9bbd",
      "0x0000000000000000000000000000000000000000",
      "0xE0486B081dAcC8e6af7f204abF508Ecc044B48A6"
    ]
  }
```
      
python3 -m http.server 8000

author_association: CONTRIBUTOR
user: login: RomanHiden
created_at: 2025-04-02T23:29:55Z
body: @slowli  please re-re-review 🧟
author_association: CONTRIBUTOR
user: login: RomanHiden
created_at: 2025-04-08T16:07:21Z
body: all CI fixed
author_association: CONTRIBUTOR