Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion .github/workflows/docker.yml
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
name: Build and Push Docker Image

on:
push:
branches: [main]
workflow_dispatch:
Comment on lines 3 to 6

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Might be an overkill to build on every merge, but works for now.


permissions:
contents: read

jobs:
docker:
runs-on: ubuntu-latest
Expand All @@ -18,7 +23,7 @@ jobs:

- name: Extract commit digest
id: vars
run: echo "sha8=${GITHUB_SHA::8}" >> $GITHUB_OUTPUT
run: echo "sha8=${GITHUB_SHA::8}" >> "$GITHUB_OUTPUT"

- name: Build and Push Docker image
uses: docker/build-push-action@v6
Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,3 +125,11 @@ For a graceful key rotation (phased removal):
1. Treat JWT signer keys as sensitive credentials.

2. Rotate keys regularly for better security.

### Scroll mainnet

The mainnet deployment uses `--forward-gas-prices` to forward `eth_gasPrice` and `eth_maxPriorityFeePerGas` to the bootnode-backed upstream. The default remains zero for existing gas-free deployments. `--disable-withdraw-proofs` disables both withdrawal-proof RPCs with an explicit error; it is used on mainnet because that service is not deployed there. Without that flag, configure `withdraw_proofs_url` for deployments that provide the service.

The mainnet instance is deliberately limited to one replica: SIWE nonces are stored in process memory. A restart invalidates outstanding nonces. RPC access still requires the method's usual proxy authorization in addition to Cloudflare Access.

Every push to main publishes a Docker Hub image at `scrolltech/cloak-rpc-auth-proxy` tagged with the first eight characters of the commit SHA. Deployment is a separate reviewed Helm tag update. Docker Hub tags can be overwritten; verify the published digest and use a digest for subsequent reviewed promotions when reproducibility matters. See the Helm chart README for image selection and secret provisioning steps.
2 changes: 1 addition & 1 deletion src/auth/auth_middleware.rs
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ mod tests {
// ----------- Test with admin key -----------
let admin_key = &admin_keys[0];
let mut map = HeaderMap::new();
let value = HeaderValue::from_str(&format!("Bearer {}", admin_key)).unwrap();
let value = HeaderValue::from_str(&format!("Bearer {admin_key}")).unwrap();
map.insert("authorization", value);

// Should grant Full access
Expand Down
4 changes: 2 additions & 2 deletions src/auth/jwt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ impl JwtSigner {

// Ensure default_kid exists in the key map
if !map.contains_key(default_kid) {
anyhow::bail!("default_kid '{}' not found in jwt_signer_keys", default_kid);
anyhow::bail!("default_kid '{default_kid}' not found in jwt_signer_keys");
}

Ok(Self {
Expand Down Expand Up @@ -90,7 +90,7 @@ impl JwtSigner {
let entry = self
.keys
.get(&kid)
.ok_or_else(|| anyhow::anyhow!("JWT signing key kid {} not found", kid))?;
.ok_or_else(|| anyhow::anyhow!("JWT signing key kid {kid} not found"))?;

let token_data = decode(token.as_ref(), &entry.decoding, &Validation::default())?;
Ok(token_data.claims)
Expand Down
2 changes: 1 addition & 1 deletion src/auth/siwe.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// jsonrpsee 0.26 `#[rpc]` expands to async_trait code that trips this lint.
#![expect(clippy::double_must_use)]
#![allow(clippy::double_must_use)]

use alloy::primitives::Bytes;
use chrono::Utc;
Expand Down
36 changes: 27 additions & 9 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,14 @@ struct CliArgs {
/// Withdraw-proofs RPC endpoint to relay proxy requests to, e.g. http://cloak-withdraw-proofs:8545
#[arg(long)]
withdraw_proofs_url: Option<String>,

/// Forward gas price methods to the upstream instead of returning zero.
#[arg(long)]
forward_gas_prices: bool,

/// Disable withdrawal proof RPCs when no proof service is available.
#[arg(long)]
disable_withdraw_proofs: bool,
}

/// Structure of the config file
Expand All @@ -28,7 +36,9 @@ pub struct AppConfig {
pub bind_address: String,
#[serde(default = "default_validium_url")]
pub validium_url: String,
pub withdraw_proofs_url: String,
pub withdraw_proofs_url: Option<String>,
#[serde(default)]
pub forward_gas_prices: bool,
pub admin_keys: Vec<String>,
pub jwt_expiry_secs: usize,
pub default_kid: String,
Expand Down Expand Up @@ -62,7 +72,14 @@ pub fn load_config() -> anyhow::Result<AppConfig> {
cfg.validium_url = val;
}
if let Some(val) = args.withdraw_proofs_url {
cfg.withdraw_proofs_url = val;
cfg.withdraw_proofs_url = Some(val);
}

if args.forward_gas_prices {
cfg.forward_gas_prices = true;
}
if args.disable_withdraw_proofs {
cfg.withdraw_proofs_url = None;
}

// Validate bind_address format
Expand All @@ -84,13 +101,11 @@ pub fn load_config() -> anyhow::Result<AppConfig> {
}

// Validate withdraw_proofs_url format
if !cfg.withdraw_proofs_url.starts_with("http://")
&& !cfg.withdraw_proofs_url.starts_with("https://")
if let Some(url) = &cfg.withdraw_proofs_url
&& !url.starts_with("http://")
&& !url.starts_with("https://")
{
anyhow::bail!(
"Invalid withdraw_proofs_url: {}. Must start with http:// or https://",
cfg.withdraw_proofs_url
);
anyhow::bail!("Invalid withdraw_proofs_url: {url}. Must start with http:// or https://");
}

Ok(cfg)
Expand Down Expand Up @@ -132,7 +147,10 @@ mod tests {
// Check values
assert_eq!(cfg.bind_address, "127.0.0.1:12345");
assert_eq!(cfg.validium_url, "http://example.com:8545");
assert_eq!(cfg.withdraw_proofs_url, "http://example.com:8546");
assert_eq!(
cfg.withdraw_proofs_url.as_deref(),
Some("http://example.com:8546")
);
assert_eq!(
cfg.admin_keys,
vec![
Expand Down
14 changes: 9 additions & 5 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,14 @@ fn all_apis(
jwt: JwtSigner,
jwt_expiry_secs: usize,
validium_url: &str,
withdraw_proofs_url: &str,
withdraw_proofs_url: Option<&str>,
forward_gas_prices: bool,
) -> anyhow::Result<impl Into<Methods>> {
let auth_server = SiweAuthRpcImpl::new(jwt, jwt_expiry_secs);
let eth_proxy_server = RpcProxyImpl::new(validium_url, withdraw_proofs_url)?;
let scroll_proxy_server = RpcProxyImpl::new(validium_url, withdraw_proofs_url)?;
let eth_proxy_server =
RpcProxyImpl::new(validium_url, withdraw_proofs_url, forward_gas_prices)?;
let scroll_proxy_server =
RpcProxyImpl::new(validium_url, withdraw_proofs_url, forward_gas_prices)?;

let mut module = RpcModule::new(());
module.merge(SiweAuthRpcServer::into_rpc(auth_server))?;
Expand Down Expand Up @@ -72,13 +75,14 @@ pub async fn run_server() -> anyhow::Result<SocketAddr> {
let addr = server.local_addr()?;
info!("Server is listening on {addr}");
info!("Validium endpoint is {}", cfg.validium_url);
info!("Withdraw proofs endpoint is {}", cfg.withdraw_proofs_url);
info!("Withdraw proofs endpoint is {:?}", cfg.withdraw_proofs_url);

let handle = server.start(all_apis(
jwt,
cfg.jwt_expiry_secs,
&cfg.validium_url,
&cfg.withdraw_proofs_url,
cfg.withdraw_proofs_url.as_deref(),
cfg.forward_gas_prices,
)?);
handle.stopped().await;
Ok(addr)
Expand Down
2 changes: 1 addition & 1 deletion src/proxy/interface.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// jsonrpsee 0.26 `#[rpc]` expands to async_trait code that trips this lint.
#![expect(clippy::double_must_use)]
#![allow(clippy::double_must_use)]

use jsonrpsee::proc_macros::rpc;

Expand Down
115 changes: 100 additions & 15 deletions src/proxy/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,19 +63,24 @@ fn only_full_access(ext: &Extensions) -> RpcResult<()> {

pub struct RpcProxyImpl {
validium_client: HttpClient,
withdraw_proofs_client: HttpClient,
withdraw_proofs_client: Option<HttpClient>,
forward_gas_prices: bool,
}

impl RpcProxyImpl {
pub fn new(
validium_url: impl AsRef<str>,
withdraw_proofs_url: impl AsRef<str>,
withdraw_proofs_url: Option<&str>,
forward_gas_prices: bool,
) -> anyhow::Result<Self> {
let validium_client = HttpClient::builder().build(validium_url)?;
let withdraw_proofs_client = HttpClient::builder().build(withdraw_proofs_url)?;
let withdraw_proofs_client = withdraw_proofs_url
.map(|url| HttpClient::builder().build(url))
.transpose()?;
Ok(Self {
validium_client,
withdraw_proofs_client,
forward_gas_prices,
})
}
}
Expand Down Expand Up @@ -106,11 +111,14 @@ impl ScrollRpcProxyServer for RpcProxyImpl {
return Err(unauthorized());
}

let client = self
.withdraw_proofs_client
.as_ref()
.ok_or_else(|| internal_error("withdrawal proof RPCs are not configured"))?;
// proxy call
let ws =
ScrollRpcProxyClient::withdrawals_by_transaction(&self.withdraw_proofs_client, tx_hash)
.await
.map_err(proxy_call_failed)?;
let ws = ScrollRpcProxyClient::withdrawals_by_transaction(client, tx_hash)
.await
.map_err(proxy_call_failed)?;

if ws.is_empty() || access == &AccessLevel::Full {
return Ok(ws);
Expand Down Expand Up @@ -144,13 +152,14 @@ impl ScrollRpcProxyServer for RpcProxyImpl {
return Err(unauthorized());
}

let client = self
.withdraw_proofs_client
.as_ref()
.ok_or_else(|| internal_error("withdrawal proof RPCs are not configured"))?;
// proxy call
let maybe_w = ScrollRpcProxyClient::withdrawal_by_message_hash(
&self.withdraw_proofs_client,
message_hash,
)
.await
.map_err(proxy_call_failed)?;
let maybe_w = ScrollRpcProxyClient::withdrawal_by_message_hash(client, message_hash)
.await
.map_err(proxy_call_failed)?;

if maybe_w.is_none() || access == &AccessLevel::Full {
return Ok(maybe_w);
Expand Down Expand Up @@ -344,11 +353,19 @@ impl EthRpcProxyServer for RpcProxyImpl {
}

async fn gas_price(&self, _ext: &Extensions) -> RpcResult<U256> {
Ok(U256::ZERO) // gas is free
if self.forward_gas_prices {
proxy_call!(self.validium_client, gas_price)
} else {
Ok(U256::ZERO)
}
}

async fn max_priority_fee_per_gas(&self, _ext: &Extensions) -> RpcResult<U256> {
Ok(U256::ZERO) // gas is free
if self.forward_gas_prices {
proxy_call!(self.validium_client, max_priority_fee_per_gas)
} else {
Ok(U256::ZERO)
}
}

async fn fee_history(
Expand Down Expand Up @@ -406,13 +423,81 @@ impl EthRpcProxyServer for RpcProxyImpl {
#[cfg(test)]
mod tests {
use super::*;
use jsonrpsee::{RpcModule, server::ServerBuilder};

use alloy::consensus::{SignableTransaction, TxEip1559, TxEip4844, TxEnvelope, TxLegacy};
use alloy::eips::eip2718::Encodable2718;
use alloy::primitives::TxKind;
use alloy::signers::SignerSync;
use alloy::signers::local::PrivateKeySigner;
use jsonrpsee::types::error::INVALID_PARAMS_CODE;

#[tokio::test]
async fn gas_prices_follow_configured_mode() {
let server = ServerBuilder::default().build("127.0.0.1:0").await.unwrap();
let address = server.local_addr().unwrap();
let mut methods = RpcModule::new(());
methods
.register_method("eth_gasPrice", |_, _, _| "0x2a")
.unwrap();
methods
.register_method("eth_maxPriorityFeePerGas", |_, _, _| "0x7")
.unwrap();
let handle = server.start(methods);
let url = format!("http://{address}");
let free = RpcProxyImpl::new(&url, None, false).unwrap();
let forwarded = RpcProxyImpl::new(&url, None, true).unwrap();
let ext = Extensions::new();
assert_eq!(
EthRpcProxyServer::gas_price(&free, &ext).await.unwrap(),
U256::ZERO
);
assert_eq!(
EthRpcProxyServer::max_priority_fee_per_gas(&free, &ext)
.await
.unwrap(),
U256::ZERO
);
assert_eq!(
EthRpcProxyServer::gas_price(&forwarded, &ext)
.await
.unwrap(),
U256::from(42)
);
assert_eq!(
EthRpcProxyServer::max_priority_fee_per_gas(&forwarded, &ext)
.await
.unwrap(),
U256::from(7)
);
handle.stop().unwrap();
}

#[tokio::test]
async fn missing_withdrawal_service_is_explicit() {
let proxy = RpcProxyImpl::new("http://127.0.0.1:1", None, true).unwrap();
let mut ext = Extensions::new();
ext.insert(AccessLevel::Full);
let err = ScrollRpcProxyServer::withdrawals_by_transaction(&proxy, &ext, B256::ZERO)
.await
.unwrap_err();
assert!(
err.data()
.unwrap()
.get()
.contains("withdrawal proof RPCs are not configured")
);
let err = ScrollRpcProxyServer::withdrawal_by_message_hash(&proxy, &ext, B256::ZERO)
.await
.unwrap_err();
assert!(
err.data()
.unwrap()
.get()
.contains("withdrawal proof RPCs are not configured")
);
}

fn signed_eip1559(signer: &PrivateKeySigner) -> TxEnvelope {
let tx = TxEip1559 {
chain_id: 33333333,
Expand Down
Loading