Skip to content

Commit 95f16ee

Browse files
authored
feat(interceptor): add some error criteria before txn submission (#7)
- check_tx_fee - valid rpc errors
1 parent 9650293 commit 95f16ee

File tree

6 files changed

+301
-15
lines changed

6 files changed

+301
-15
lines changed

interceptor/Cargo.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ version = "0.1.0"
44
edition = "2021"
55

66
[dependencies]
7-
alloy = { version = "0.3.1", features = ["full"] }
7+
alloy = { version = "0.4.2", features = ["full"] }
88
jsonrpsee = { version = "0.24.3", features = ["full"] }
99
tokio = { version = "1.0", features = ["full"] }
1010
anyhow = "1.0"
@@ -29,3 +29,4 @@ httpdate = { version = "1.0" }
2929
reqwest = "0.12.7"
3030
serde = "1.0.210"
3131
hyper-util = "0.1.8"
32+
alloy-primitives = "0.8.7"

interceptor/src/app.rs

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,53 @@
1-
use alloy::consensus::TxEnvelope;
1+
use crate::endpoint::{rpc_error, JsonRpcError};
2+
use crate::json_rpc_errors::JsonRpcErrorCode;
3+
use crate::json_rpc_errors::JsonRpcErrorCode::InvalidRequest;
4+
use crate::transaction;
5+
use alloy::consensus::{Transaction, TxEnvelope, TxType};
26
use alloy::primitives::private::alloy_rlp::Decodable;
37
use alloy::primitives::TxHash;
8+
use alloy_primitives::U256;
49
use bytes::Bytes;
510

611
/// Sends serialized and signed transaction `tx`.
7-
pub fn send_raw_transaction(tx: Bytes) -> anyhow::Result<TxHash> {
12+
pub fn send_raw_transaction(tx: Bytes) -> Result<TxHash, JsonRpcError<()>> {
13+
// TODO remove or move up to function comment
14+
// 1. Decoding
15+
// 2. Validation
16+
// 3. Submission/forwarding
17+
18+
// 1. Decoding:
819
let mut slice: &[u8] = tx.as_ref();
920
let tx = TxEnvelope::decode(&mut slice)?;
1021

11-
// TODO validate tx
22+
// 2. Validation:
23+
tx.recover_signer().map_err(|_e| {
24+
rpc_error(
25+
"Invalid signer on transaction",
26+
JsonRpcErrorCode::InvalidParams,
27+
None,
28+
)
29+
})?;
30+
31+
if tx.tx_type() == TxType::Legacy {
32+
// TODO(SEQ-179): introduce optional global tx cap config. See op-geth's checkTxFee() + RPCTxFeeCap for equivalent
33+
// skip check if unset
34+
let tx_cap_in_wei = U256::from(1_000_000_000_000_000_000u64); // 1e18wei = 1 ETH
35+
let gas_price = tx.gas_price().ok_or_else(|| {
36+
rpc_error("Legacy transaction missing gas price", InvalidRequest, None)
37+
})?;
38+
transaction::check_tx_fee(
39+
U256::try_from(gas_price)?,
40+
U256::try_from(tx.gas_limit())?,
41+
tx_cap_in_wei,
42+
)
43+
.map_err(|_e| {
44+
rpc_error(
45+
"Transaction fee exceeds the configured cap",
46+
JsonRpcErrorCode::InvalidInput,
47+
None,
48+
)
49+
})?;
50+
}
1251

1352
Ok(tx.tx_hash().to_owned())
1453
}

interceptor/src/endpoint.rs

Lines changed: 41 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
use crate::app;
2+
use crate::json_rpc_errors::JsonRpcErrorCode;
3+
use crate::json_rpc_errors::JsonRpcErrorCode::InvalidParams;
24
use alloy::hex;
35
use alloy::hex::ToHexExt;
4-
use anyhow::anyhow;
56
use bytes::Bytes;
67
use jsonrpsee::types::{ErrorObject, Params};
78
use serde::Serialize;
@@ -56,19 +57,48 @@ pub fn send_raw_transaction(
5657
_ctx: &(),
5758
_ext: &http::Extensions,
5859
) -> Result<String, JsonRpcError<()>> {
59-
let mut json: serde_json::Value = serde_json::from_str(params.as_str().unwrap())?;
60-
let arr = json
61-
.as_array_mut()
62-
.ok_or(anyhow!("Unexpected parameter format"))?;
63-
let item = arr.pop().ok_or(anyhow!("Missing parameter"))?;
64-
if !arr.is_empty() {
65-
Err(anyhow!("Expected 1 parameter, got {}", arr.len() + 1))?;
60+
let mut json: serde_json::Value =
61+
serde_json::from_str(params.as_str().unwrap()).map_err(|e| {
62+
rpc_error(
63+
&format!("failed to unmarshal params: {}", e),
64+
InvalidParams,
65+
None,
66+
)
67+
})?;
68+
let arr = json.as_array_mut().ok_or(rpc_error(
69+
"unexpected parameter format",
70+
InvalidParams,
71+
None,
72+
))?;
73+
if arr.len() != 1 {
74+
return Err(rpc_error(
75+
&format!("Expected 1 parameter, got {}", arr.len()),
76+
InvalidParams,
77+
None,
78+
));
6679
}
67-
let str = item
68-
.as_str()
69-
.ok_or(anyhow!("Expected hex encoded string"))?;
80+
let item = arr
81+
.pop()
82+
.ok_or(rpc_error("Missing parameter", InvalidParams, None))?;
83+
let str = item.as_str().ok_or(rpc_error(
84+
"Expected hex encoded string",
85+
InvalidParams,
86+
None,
87+
))?;
7088
let bytes = hex::decode(str)?;
7189
let bytes = Bytes::from(bytes);
7290

7391
Ok(app::send_raw_transaction(bytes)?.encode_hex_with_prefix())
7492
}
93+
94+
pub fn rpc_error<S: Serialize>(
95+
message: &str,
96+
error_code: JsonRpcErrorCode,
97+
data: Option<S>,
98+
) -> JsonRpcError<S> {
99+
JsonRpcError {
100+
code: error_code.code(),
101+
message: message.to_string(),
102+
data,
103+
}
104+
}

interceptor/src/json_rpc_errors.rs

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
// Source: https://github.com/MetaMask/rpc-errors/blob/main/src/errors.ts
2+
#[derive(Debug, Clone, PartialEq)]
3+
pub enum JsonRpcErrorCode {
4+
InvalidRequest = -32600,
5+
MethodNotFound = -32601,
6+
InvalidParams = -32602,
7+
InternalError = -32603,
8+
ParseError = -32700,
9+
InvalidInput = -32000,
10+
ResourceNotFound = -32001,
11+
ResourceUnavailable = -32002,
12+
TransactionRejected = -32003,
13+
MethodNotSupported = -32004,
14+
LimitExceeded = -32005,
15+
ServerError = -32099, // We'll use this as the base for server errors
16+
}
17+
18+
impl JsonRpcErrorCode {
19+
// TODO - bring back if needed
20+
// pub fn message(&self) -> &'static str {
21+
// match self {
22+
// Self::InvalidRequest => "Invalid request",
23+
// Self::MethodNotFound => "Method not found",
24+
// Self::InvalidParams => "Invalid params",
25+
// Self::InternalError => "Internal error",
26+
// Self::ParseError => "Parse error",
27+
// Self::InvalidInput => "Invalid input",
28+
// Self::ResourceNotFound => "Resource not found",
29+
// Self::ResourceUnavailable => "Resource unavailable",
30+
// Self::TransactionRejected => "Transaction rejected",
31+
// Self::MethodNotSupported => "Method not supported",
32+
// Self::LimitExceeded => "Limit exceeded",
33+
// Self::ServerError => "Server error",
34+
// }
35+
// }
36+
}
37+
38+
impl From<i32> for JsonRpcErrorCode {
39+
fn from(value: i32) -> Self {
40+
match value {
41+
-32700 => Self::ParseError,
42+
-32600 => Self::InvalidRequest,
43+
-32601 => Self::MethodNotFound,
44+
-32602 => Self::InvalidParams,
45+
-32603 => Self::InternalError,
46+
-32000 => Self::InvalidInput,
47+
-32001 => Self::ResourceNotFound,
48+
-32002 => Self::ResourceUnavailable,
49+
-32003 => Self::TransactionRejected,
50+
-32004 => Self::MethodNotSupported,
51+
-32005 => Self::LimitExceeded,
52+
_ if (-32099..=-32000).contains(&value) => Self::ServerError,
53+
_ => Self::InternalError, // Default case
54+
}
55+
}
56+
}
57+
58+
impl JsonRpcErrorCode {
59+
pub fn code(&self) -> i32 {
60+
self.to_owned() as i32
61+
}
62+
}

interceptor/src/main.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
mod app;
22
mod endpoint;
3+
mod json_rpc_errors;
34
mod server;
5+
mod transaction;
46

57
use tracing_subscriber::EnvFilter;
68

interceptor/src/transaction.rs

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
use alloy_primitives::U256;
2+
3+
/// Ported from op-geth/ethapi/api.go
4+
/// Check if the transaction fee is reasonable (under the cap).
5+
///
6+
/// # Arguments
7+
///
8+
/// * `gas_price` - The gas price in wei as U256.
9+
/// * `gas` - The gas amount as U256.
10+
/// * `cap_in_wei` - The fee cap in wei as U256.
11+
///
12+
/// # Returns
13+
///
14+
/// * `Ok(())` if the fee is under the cap, or if there is no cap.
15+
/// * `Err(String)` with an error message if the fee exceeds the cap.
16+
pub fn check_tx_fee(gas_price: U256, gas: U256, cap_in_wei: U256) -> Result<(), String> {
17+
// Short circuit if there is no cap for transaction fee at all.
18+
if cap_in_wei.is_zero() {
19+
return Ok(());
20+
}
21+
22+
let fee_wei = gas_price
23+
.checked_mul(gas)
24+
.ok_or_else(|| "fee calculation overflow".to_string())?;
25+
26+
if fee_wei > cap_in_wei {
27+
let gwei = U256::from(1_000_000_000u64); // 1 Gwei = 10^9 Wei
28+
let fee_gwei = fee_wei / gwei;
29+
let cap_gwei = cap_in_wei / gwei;
30+
Err(format!(
31+
"tx fee ({} gwei) exceeds the configured cap ({} gwei)",
32+
fee_gwei, cap_gwei
33+
))
34+
} else {
35+
Ok(())
36+
}
37+
}
38+
39+
#[cfg(test)]
40+
mod tests {
41+
use super::*;
42+
43+
const GWEI: U256 = U256::from_limbs([1_000_000_000, 0, 0, 0]);
44+
const ETHER: U256 = U256::from_limbs([1_000_000_000_000_000_000, 0, 0, 0]);
45+
46+
#[test]
47+
fn test_no_cap() {
48+
assert!(check_tx_fee(
49+
U256::from(1_000_000_000u64),
50+
U256::from(21000u64),
51+
U256::ZERO
52+
)
53+
.is_ok());
54+
}
55+
56+
#[test]
57+
fn test_under_cap() {
58+
assert!(check_tx_fee(U256::from(20_000_000_000u64), U256::from(21000u64), ETHER).is_ok());
59+
}
60+
61+
#[test]
62+
fn test_exact_cap() {
63+
let gas_price = U256::from(47619047619u64); // 47.619047619 Gwei
64+
let gas = U256::from(21000u64);
65+
let cap = gas_price * gas;
66+
assert!(check_tx_fee(gas_price, gas, cap).is_ok());
67+
}
68+
69+
#[test]
70+
fn test_over_cap() {
71+
let gas_price = U256::from(130u64);
72+
let gas = U256::from(20_123_000_123u64);
73+
let cap = GWEI * U256::from(1000u64); // 1000 Gwei
74+
let result = check_tx_fee(gas_price, gas, cap);
75+
assert!(result.is_err());
76+
assert!(result
77+
.unwrap_err()
78+
.contains("tx fee (2615 gwei) exceeds the configured cap (1000 gwei)"));
79+
}
80+
81+
#[test]
82+
fn test_large_values() {
83+
let gas_price = U256::MAX;
84+
let gas = U256::from(u64::MAX);
85+
let cap = U256::MAX;
86+
let result = check_tx_fee(gas_price, gas, cap);
87+
assert!(result.is_err()); // This will error due to overflow in fee calculation
88+
}
89+
90+
#[test]
91+
fn test_small_values() {
92+
assert!(check_tx_fee(U256::from(1u64), U256::from(1u64), U256::from(2u64)).is_ok());
93+
}
94+
95+
#[test]
96+
fn test_zero_gas_price() {
97+
assert!(check_tx_fee(U256::ZERO, U256::from(21000u64), ETHER).is_ok());
98+
}
99+
100+
#[test]
101+
fn test_zero_gas() {
102+
assert!(check_tx_fee(U256::from(20_000_000_000u64), U256::ZERO, ETHER).is_ok());
103+
}
104+
105+
#[test]
106+
fn test_precision() {
107+
let gas_price = U256::from(20_000_000_000u64); // 20 Gwei
108+
let gas = U256::from(21_000u64);
109+
let fee = gas_price * gas;
110+
let cap_slightly_over = fee + U256::from(1u64);
111+
let cap_slightly_under = fee - U256::from(1u64);
112+
113+
assert!(check_tx_fee(gas_price, gas, cap_slightly_over).is_ok());
114+
assert!(check_tx_fee(gas_price, gas, cap_slightly_under).is_err());
115+
}
116+
117+
#[test]
118+
fn test_very_small_cap() {
119+
assert!(check_tx_fee(U256::from(1u64), U256::from(1u64), U256::from(1u64)).is_ok());
120+
}
121+
122+
#[test]
123+
fn test_very_large_cap() {
124+
// This test now uses large but not maximum values to avoid overflow
125+
let gas_price = U256::from(u64::MAX);
126+
let gas = U256::from(u64::MAX);
127+
let cap = U256::MAX;
128+
assert!(check_tx_fee(gas_price, gas, cap).is_ok()); // Will not fail
129+
}
130+
131+
#[test]
132+
fn test_error_message_formatting() {
133+
let gas_price = U256::from(100u64);
134+
let gas = U256::from(1_600_000_000u64);
135+
let cap = GWEI * U256::from(150u64); // 150 Gwei
136+
let result = check_tx_fee(gas_price, gas, cap);
137+
assert!(result.is_err());
138+
assert!(result
139+
.unwrap_err()
140+
.contains("tx fee (160 gwei) exceeds the configured cap (150 gwei)"));
141+
}
142+
143+
#[test]
144+
fn test_fee_calculation_overflow() {
145+
let gas_price = U256::MAX;
146+
let gas = U256::from(2u64);
147+
let cap = U256::MAX;
148+
let result = check_tx_fee(gas_price, gas, cap);
149+
assert!(result.is_err());
150+
assert_eq!(result.unwrap_err(), "fee calculation overflow");
151+
}
152+
}

0 commit comments

Comments
 (0)