Skip to content

Commit 97d62d2

Browse files
committed
fix domain bug, harden implementation
1 parent 0707c1c commit 97d62d2

20 files changed

Lines changed: 133 additions & 130 deletions

File tree

examples/app-core/src/application/wallet.rs

Lines changed: 12 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -145,14 +145,8 @@ impl Application for WalletApp {
145145
});
146146
}
147147

148-
let max_fee = user_op.max_fee;
149-
// Users sign a cap (log-space exponent); sequencer executes against the committed frame fee.
150-
if max_fee < current_fee {
151-
return Err(InvalidReason::InvalidMaxFee {
152-
max_fee,
153-
base_fee: current_fee,
154-
});
155-
}
148+
// max_fee < current_fee is already checked by the trait default in
149+
// validate_and_execute_user_op. No need to repeat here.
156150

157151
let gas_cost = sequencer_core::fee::fee_to_linear(current_fee);
158152
let balance = self.balance_of(&sender);
@@ -279,6 +273,8 @@ mod tests {
279273

280274
#[test]
281275
fn validate_rejects_when_max_fee_below_current_fee() {
276+
use sequencer_core::application::{Application, ExecutionOutcome};
277+
282278
let mut app = WalletApp::new(WalletConfig::default());
283279
let sender = Address::from_slice(&[0x11; 20]);
284280
app.balances.insert(sender, U256::from(10_u64));
@@ -289,15 +285,17 @@ mod tests {
289285
data: Vec::<u8>::new().into(),
290286
};
291287

292-
let err = app
293-
.validate_user_op(sender, &user_op, 2)
294-
.expect_err("max_fee < current_fee should be invalid");
288+
// The max_fee < current_fee check now lives in the trait default
289+
// (validate_and_execute_user_op), not in validate_user_op directly.
290+
let result = app
291+
.validate_and_execute_user_op(sender, &user_op, 2)
292+
.expect("should return Ok(Invalid), not Err");
295293
assert_eq!(
296-
err,
297-
InvalidReason::InvalidMaxFee {
294+
result,
295+
ExecutionOutcome::Invalid(InvalidReason::InvalidMaxFee {
298296
max_fee: 1,
299297
base_fee: 2
300-
}
298+
})
301299
);
302300
}
303301

examples/canonical-app/src/scheduler/core.rs

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,8 @@ impl<A: Application> Scheduler<A> {
244244
for user_op in &frame.user_ops {
245245
if let Some(sender) = self.recover_sender(domain, user_op) {
246246
let plain = user_op.to_user_op();
247+
// Defense-in-depth: the trait default in validate_and_execute_user_op
248+
// now centralizes this check, but we keep it here as an extra guard.
247249
if plain.max_fee < frame.fee_price {
248250
eprintln!("scheduler skipped frame user-op due to max_fee < fee_price");
249251
continue;
@@ -326,13 +328,7 @@ fn has_elapsed_since(start_block: u64, wait_blocks: u64, current_block: u64) ->
326328
}
327329

328330
pub(super) fn input_domain(chain_id: u64, verifying_contract: Address) -> Eip712Domain {
329-
Eip712Domain {
330-
name: None,
331-
version: None,
332-
chain_id: Some(U256::from(chain_id)),
333-
verifying_contract: Some(verifying_contract),
334-
salt: None,
335-
}
331+
sequencer_core::build_input_domain(chain_id, verifying_contract)
336332
}
337333

338334
pub(super) fn block_to_u64(block: U256) -> u64 {

examples/canonical-test/src/main.rs

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -231,13 +231,7 @@ fn devnet_machine() -> Result<Machine, Box<dyn std::error::Error + Send + Sync>>
231231
}
232232

233233
fn input_domain() -> Eip712Domain {
234-
Eip712Domain {
235-
name: None,
236-
version: None,
237-
chain_id: Some(U256::from(TEST_CHAIN_ID)),
238-
verifying_contract: Some(TEST_DAPP_ADDRESS),
239-
salt: None,
240-
}
234+
sequencer_core::build_input_domain(TEST_CHAIN_ID, TEST_DAPP_ADDRESS)
241235
}
242236

243237
fn signing_key(byte: u8) -> SigningKey {

sequencer-core/src/application/mod.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,15 @@ pub trait Application: Send {
102102
user_op: &UserOp,
103103
current_fee: u16,
104104
) -> Result<ExecutionOutcome, AppError> {
105+
// Protocol invariant: max_fee must cover the current frame fee.
106+
// Enforced here so every Application impl inherits it.
107+
if user_op.max_fee < current_fee {
108+
return Ok(ExecutionOutcome::Invalid(InvalidReason::InvalidMaxFee {
109+
max_fee: user_op.max_fee,
110+
base_fee: current_fee,
111+
}));
112+
}
113+
105114
if let Err(reason) = self.validate_user_op(sender, user_op, current_fee) {
106115
return Ok(ExecutionOutcome::Invalid(reason));
107116
}

sequencer-core/src/batch.rs

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,6 @@
44
use crate::user_op::UserOp;
55
use ssz_derive::{Decode, Encode};
66

7-
/// Tag byte for InputBox payloads that are L1 app direct inputs (e.g. deposits).
8-
/// L1/app must post such inputs as `0x00 || body`. Only these are stored (body only) and executed.
9-
pub const INPUT_TAG_DIRECT_INPUT: u8 = 0x00;
10-
117
// ---------------------------------------------------------------------------
128
// Gas-economics-derived batch sizing
139
//

sequencer-core/src/lib.rs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
// (c) Cartesi and individual authors (see AUTHORS)
22
// SPDX-License-Identifier: Apache-2.0 (see LICENSE)
33

4+
use alloy_primitives::{Address, U256};
5+
use alloy_sol_types::Eip712Domain;
6+
47
pub mod api;
58
pub mod application;
69
pub mod batch;
@@ -12,3 +15,25 @@ pub mod user_op;
1215
/// Maximum number of L1 blocks a batch can wait before the scheduler considers it stale.
1316
/// Shared between the scheduler (canonical-app) and the sequencer (batch submitter, startup detection).
1417
pub const MAX_WAIT_BLOCKS: u64 = 1200;
18+
19+
/// EIP-712 domain name shared between sequencer and scheduler.
20+
pub const DOMAIN_NAME: &str = "CartesiAppSequencer";
21+
22+
/// EIP-712 domain version shared between sequencer and scheduler.
23+
pub const DOMAIN_VERSION: &str = "1";
24+
25+
/// Build the canonical EIP-712 domain for user-op signing and verification.
26+
///
27+
/// Both the sequencer (signature verification at ingress) and the scheduler
28+
/// (signature recovery during batch execution) MUST use this constructor.
29+
/// A mismatch in any field changes the domain separator and causes every
30+
/// signature to recover a different address.
31+
pub fn build_input_domain(chain_id: u64, verifying_contract: Address) -> Eip712Domain {
32+
Eip712Domain {
33+
name: Some(DOMAIN_NAME.into()),
34+
version: Some(DOMAIN_VERSION.into()),
35+
chain_id: Some(U256::from(chain_id)),
36+
verifying_contract: Some(verifying_contract),
37+
salt: None,
38+
}
39+
}

sequencer/src/ingress/api.rs

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -89,13 +89,23 @@ async fn submit_tx(
8989
}))
9090
}
9191

92-
/// Normalize JSON-extractor failures: 413 stays 413, everything else becomes
93-
/// 400. Keeps the public API contract stable across axum upgrades.
92+
/// Normalize JSON-extractor failures into fixed client-facing messages.
93+
/// Keeps the public API contract stable across axum upgrades and avoids
94+
/// reflecting parser internals (serde line/column, token excerpts) to callers.
9495
fn map_json_rejection(err: axum::extract::rejection::JsonRejection) -> ApiError {
96+
use axum::extract::rejection::JsonRejection;
97+
98+
tracing::debug!(error = %err, "JSON extraction failed");
99+
95100
if err.status() == StatusCode::PAYLOAD_TOO_LARGE {
96-
ApiError::payload_too_large(format!("request body too large: {err}"))
101+
ApiError::payload_too_large("request body too large")
97102
} else {
98-
ApiError::bad_request(format!("invalid JSON: {err}"))
103+
match err {
104+
JsonRejection::MissingJsonContentType(_) => {
105+
ApiError::bad_request("missing content type")
106+
}
107+
_ => ApiError::bad_request("invalid JSON"),
108+
}
99109
}
100110
}
101111

sequencer/src/ingress/inclusion_lane/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -242,7 +242,7 @@ impl<A: Application + 'static> InclusionLane<A> {
242242
self.storage
243243
.append_user_ops_chunk(head, included.as_slice())
244244
.map_err(|err| {
245-
Self::respond_internal_to_all(included, format!("db error: {err}"));
245+
Self::respond_internal_to_all(included, "internal storage error".to_string());
246246
InclusionLaneError::Storage(err)
247247
})
248248
}

sequencer/src/ingress/inclusion_lane/tests.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -241,7 +241,9 @@ fn make_pending_user_op(
241241
let (respond_to, recv) = oneshot::channel();
242242
let user_op = UserOp {
243243
nonce: 0,
244-
max_fee: 1,
244+
// Must be >= the DB default recommended_fee (1060) to pass the
245+
// protocol-level max_fee >= fee_price check in the trait default.
246+
max_fee: u16::MAX,
245247
data: vec![seed; 4].into(),
246248
};
247249
(

sequencer/src/l1/provider.rs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,15 @@ const COMPUTE_UNITS_PER_SEC: u64 = 500;
2020
fn create_client(url: &str) -> Result<RpcClient, String> {
2121
let url = Url::parse(url).map_err(|e| format!("invalid RPC URL: {e}"))?;
2222

23+
// Reject non-HTTPS for remote hosts to prevent accidental plaintext RPC.
24+
let host = url.host_str().unwrap_or("");
25+
if url.scheme() != "https" && !matches!(host, "localhost" | "127.0.0.1" | "::1") {
26+
return Err(format!(
27+
"remote RPC must use https, got {}://",
28+
url.scheme()
29+
));
30+
}
31+
2332
let http_client = reqwest::Client::builder()
2433
.timeout(REQUEST_TIMEOUT)
2534
.build()
@@ -50,7 +59,7 @@ pub fn create_provider(url: &str) -> Result<DynProvider, String> {
5059
pub fn create_signer_provider(url: &str, private_key: &str) -> Result<DynProvider, String> {
5160
let client = create_client(url)?;
5261
let signer =
53-
PrivateKeySigner::from_str(private_key).map_err(|e| format!("invalid private key: {e}"))?;
62+
PrivateKeySigner::from_str(private_key).map_err(|_| "invalid private key".to_string())?;
5463
let provider = ProviderBuilder::new().wallet(signer).connect_client(client);
5564
Ok(provider.erased())
5665
}

0 commit comments

Comments
 (0)