Skip to content

Commit 1140ac3

Browse files
committed
fix(rpc): serialize numeric fields as hex strings
1 parent 1a8a109 commit 1140ac3

5 files changed

Lines changed: 256 additions & 67 deletions

File tree

crates/rpc/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ alloy-rlp = "0.3"
2727
alloy-rpc-types = { workspace = true }
2828
alloy-rpc-types-eth = { workspace = true }
2929
alloy-consensus = { workspace = true }
30+
alloy-serde = "1"
3031

3132
# Reth primitives for Ethereum compatibility
3233
reth-primitives = { workspace = true }

crates/rpc/src/adapters.rs

Lines changed: 8 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -1815,70 +1815,16 @@ impl NetworkApi for StubNetworkApi {
18151815
///
18161816
/// This is a standalone function for use by the node when broadcasting
18171817
/// new blocks to WebSocket subscribers via `eth_subscribe("newHeads")`.
1818+
///
1819+
/// Returns an `RpcBlock` which serializes all numeric fields as hex strings
1820+
/// following the Ethereum JSON-RPC specification. This ensures compatibility
1821+
/// with block explorers like Blockscout that require strict hex encoding.
18181822
pub fn storage_block_to_rpc_block(
18191823
storage_block: cipherbft_storage::blocks::Block,
1820-
full_txs: bool,
1821-
) -> Block {
1822-
use alloy_primitives::{Bloom, B64};
1823-
1824-
// Convert transaction hashes to B256
1825-
let tx_hashes: Vec<B256> = storage_block
1826-
.transaction_hashes
1827-
.iter()
1828-
.map(|h| B256::from(*h))
1829-
.collect();
1830-
1831-
// Build transactions field based on full_txs flag
1832-
let transactions = if full_txs {
1833-
// TODO: In the future, this should return full Transaction objects
1834-
BlockTransactions::Hashes(tx_hashes)
1835-
} else {
1836-
BlockTransactions::Hashes(tx_hashes)
1837-
};
1838-
1839-
// Build the consensus header
1840-
let consensus_header = alloy_consensus::Header {
1841-
parent_hash: B256::from(storage_block.parent_hash),
1842-
ommers_hash: B256::from(storage_block.ommers_hash),
1843-
beneficiary: Address::from(storage_block.beneficiary),
1844-
state_root: B256::from(storage_block.state_root),
1845-
transactions_root: B256::from(storage_block.transactions_root),
1846-
receipts_root: B256::from(storage_block.receipts_root),
1847-
logs_bloom: Bloom::from_slice(&storage_block.logs_bloom),
1848-
difficulty: U256::from_be_bytes(storage_block.difficulty),
1849-
number: storage_block.number,
1850-
gas_limit: storage_block.gas_limit,
1851-
gas_used: storage_block.gas_used,
1852-
timestamp: storage_block.timestamp,
1853-
extra_data: Bytes::from(storage_block.extra_data.clone()),
1854-
mix_hash: B256::from(storage_block.mix_hash),
1855-
nonce: B64::from(storage_block.nonce),
1856-
base_fee_per_gas: storage_block.base_fee_per_gas,
1857-
withdrawals_root: None,
1858-
blob_gas_used: None,
1859-
excess_blob_gas: None,
1860-
parent_beacon_block_root: None,
1861-
requests_hash: None,
1862-
};
1863-
1864-
// Build the RPC header with hash and total difficulty
1865-
let block_hash = B256::from(storage_block.hash);
1866-
let total_difficulty = U256::from_be_bytes(storage_block.total_difficulty);
1867-
1868-
let rpc_header = Header {
1869-
hash: block_hash,
1870-
inner: consensus_header,
1871-
total_difficulty: Some(total_difficulty),
1872-
size: None,
1873-
};
1874-
1875-
// Build the final RPC block
1876-
Block {
1877-
header: rpc_header,
1878-
uncles: Vec::new(),
1879-
transactions,
1880-
withdrawals: None,
1881-
}
1824+
_full_txs: bool,
1825+
) -> crate::types::RpcBlock {
1826+
// Use the RpcBlock's from_storage constructor for proper hex serialization
1827+
crate::types::RpcBlock::from_storage(storage_block)
18821828
}
18831829

18841830
#[cfg(test)]

crates/rpc/src/lib.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,7 @@ pub mod pubsub;
151151
pub mod server;
152152
pub mod traits;
153153
pub mod txpool;
154+
pub mod types;
154155
pub mod web3;
155156

156157
// Core types
@@ -170,6 +171,9 @@ pub use adapters::{EvmExecutionApi, MdbxRpcStorage, PoolMempoolApi, ProviderBase
170171
// Block conversion utilities for subscription broadcasting
171172
pub use adapters::storage_block_to_rpc_block;
172173

174+
// Custom RPC types with proper hex serialization
175+
pub use types::RpcBlock;
176+
173177
// RPC server traits (for method registration)
174178
pub use eth::EthRpcServer;
175179
pub use net::NetRpcServer;

crates/rpc/src/pubsub.rs

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@ use tokio::sync::broadcast;
1212
use tracing::{debug, trace, warn};
1313

1414
use alloy_primitives::B256;
15-
use alloy_rpc_types_eth::{Block, Filter, Log, SyncStatus, Transaction};
15+
use alloy_rpc_types_eth::{Filter, Log, SyncStatus, Transaction};
16+
17+
use crate::types::RpcBlock;
1618

1719
use crate::error::RpcError;
1820

@@ -88,7 +90,7 @@ pub struct Subscription {
8890
#[derive(Debug, Clone)]
8991
pub enum SubscriptionEvent {
9092
/// New block header.
91-
NewHead(Box<Block>),
93+
NewHead(Box<RpcBlock>),
9294
/// New log entry.
9395
Log(Box<Log>),
9496
/// New pending transaction hash.
@@ -106,7 +108,7 @@ pub struct SubscriptionManager {
106108
/// Counter for generating unique subscription IDs.
107109
next_id: AtomicU64,
108110
/// Broadcast channel for new block headers.
109-
block_tx: broadcast::Sender<Box<Block>>,
111+
block_tx: broadcast::Sender<Box<RpcBlock>>,
110112
/// Broadcast channel for logs.
111113
log_tx: broadcast::Sender<Box<Log>>,
112114
/// Broadcast channel for pending transaction hashes.
@@ -164,7 +166,7 @@ impl SubscriptionManager {
164166
}
165167

166168
/// Broadcast a new block header to all newHeads subscribers.
167-
pub fn broadcast_block(&self, block: Block) {
169+
pub fn broadcast_block(&self, block: RpcBlock) {
168170
let _ = self.block_tx.send(Box::new(block));
169171
}
170172

@@ -184,7 +186,7 @@ impl SubscriptionManager {
184186
}
185187

186188
/// Subscribe to new block headers channel.
187-
pub fn subscribe_blocks(&self) -> broadcast::Receiver<Box<Block>> {
189+
pub fn subscribe_blocks(&self) -> broadcast::Receiver<Box<RpcBlock>> {
188190
self.block_tx.subscribe()
189191
}
190192

crates/rpc/src/types.rs

Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
//! Custom RPC types with Ethereum-compatible hex serialization.
2+
//!
3+
//! This module provides custom block and header types that serialize numeric fields
4+
//! as hex strings (e.g., `"0x1fd"` instead of `509`) to comply with the Ethereum
5+
//! JSON-RPC specification and ensure compatibility with block explorers like Blockscout.
6+
//!
7+
//! # Why Custom Types?
8+
//!
9+
//! The standard `alloy_consensus::Header` uses `U64HexOrNumber` for numeric fields,
10+
//! which serializes as integers. While this is valid for deserializing, it causes
11+
//! compatibility issues with Elixir-based clients (like Blockscout) that expect
12+
//! strict Ethereum JSON-RPC format with hex-encoded quantities.
13+
14+
use alloy_primitives::{Address, Bloom, Bytes, B256, B64, U256};
15+
use serde::{Deserialize, Serialize};
16+
17+
/// RPC Block representation with proper hex serialization.
18+
///
19+
/// All numeric fields are serialized as hex strings following the Ethereum
20+
/// JSON-RPC "quantity" format (e.g., `"0x1fd"` for 509).
21+
#[derive(Debug, Clone, Serialize, Deserialize)]
22+
#[serde(rename_all = "camelCase")]
23+
pub struct RpcBlock {
24+
/// Block hash
25+
pub hash: B256,
26+
/// Parent block hash
27+
pub parent_hash: B256,
28+
/// Ommers/uncles hash (always empty hash in PoS)
29+
#[serde(rename = "sha3Uncles")]
30+
pub ommers_hash: B256,
31+
/// Coinbase/miner address
32+
pub miner: Address,
33+
/// State root
34+
pub state_root: B256,
35+
/// Transactions root
36+
pub transactions_root: B256,
37+
/// Receipts root
38+
pub receipts_root: B256,
39+
/// Logs bloom filter
40+
pub logs_bloom: Bloom,
41+
/// Difficulty (always 0 in PoS)
42+
#[serde(serialize_with = "serialize_u256_quantity")]
43+
pub difficulty: U256,
44+
/// Block number
45+
#[serde(serialize_with = "alloy_serde::quantity::serialize")]
46+
pub number: u64,
47+
/// Gas limit
48+
#[serde(serialize_with = "alloy_serde::quantity::serialize")]
49+
pub gas_limit: u64,
50+
/// Gas used
51+
#[serde(serialize_with = "alloy_serde::quantity::serialize")]
52+
pub gas_used: u64,
53+
/// Block timestamp
54+
#[serde(serialize_with = "alloy_serde::quantity::serialize")]
55+
pub timestamp: u64,
56+
/// Extra data
57+
pub extra_data: Bytes,
58+
/// Mix hash (used in PoW, random value in PoS)
59+
pub mix_hash: B256,
60+
/// Nonce (always zero bytes in PoS)
61+
pub nonce: B64,
62+
/// Total difficulty (sum of all block difficulties)
63+
#[serde(serialize_with = "serialize_u256_quantity")]
64+
pub total_difficulty: U256,
65+
/// Base fee per gas (EIP-1559)
66+
#[serde(
67+
default,
68+
skip_serializing_if = "Option::is_none",
69+
serialize_with = "alloy_serde::quantity::opt::serialize"
70+
)]
71+
pub base_fee_per_gas: Option<u64>,
72+
/// Block size in bytes (optional)
73+
#[serde(
74+
default,
75+
skip_serializing_if = "Option::is_none",
76+
serialize_with = "alloy_serde::quantity::opt::serialize"
77+
)]
78+
pub size: Option<u64>,
79+
/// Withdrawals root (EIP-4895)
80+
#[serde(default, skip_serializing_if = "Option::is_none")]
81+
pub withdrawals_root: Option<B256>,
82+
/// Blob gas used (EIP-4844)
83+
#[serde(
84+
default,
85+
skip_serializing_if = "Option::is_none",
86+
serialize_with = "alloy_serde::quantity::opt::serialize"
87+
)]
88+
pub blob_gas_used: Option<u64>,
89+
/// Excess blob gas (EIP-4844)
90+
#[serde(
91+
default,
92+
skip_serializing_if = "Option::is_none",
93+
serialize_with = "alloy_serde::quantity::opt::serialize"
94+
)]
95+
pub excess_blob_gas: Option<u64>,
96+
/// Parent beacon block root (EIP-4788)
97+
#[serde(default, skip_serializing_if = "Option::is_none")]
98+
pub parent_beacon_block_root: Option<B256>,
99+
/// Block transactions (hashes or full transactions)
100+
pub transactions: Vec<B256>,
101+
/// Uncle block hashes (always empty in PoS)
102+
pub uncles: Vec<B256>,
103+
/// Withdrawals (EIP-4895)
104+
#[serde(default, skip_serializing_if = "Option::is_none")]
105+
pub withdrawals: Option<Vec<()>>,
106+
}
107+
108+
/// Serialize U256 as a hex quantity string.
109+
///
110+
/// This follows the Ethereum JSON-RPC "quantity" format:
111+
/// - Values are encoded as hex with "0x" prefix
112+
/// - Leading zeros are removed (except for 0 which is "0x0")
113+
fn serialize_u256_quantity<S>(value: &U256, serializer: S) -> Result<S::Ok, S::Error>
114+
where
115+
S: serde::Serializer,
116+
{
117+
// Use alloy_primitives built-in serialization which already formats as hex
118+
value.serialize(serializer)
119+
}
120+
121+
impl RpcBlock {
122+
/// Create a new RpcBlock from storage block data.
123+
///
124+
/// This is the primary constructor for converting internal storage format
125+
/// to the RPC-compatible format with proper hex serialization.
126+
pub fn from_storage(storage_block: cipherbft_storage::blocks::Block) -> Self {
127+
Self {
128+
hash: B256::from(storage_block.hash),
129+
parent_hash: B256::from(storage_block.parent_hash),
130+
ommers_hash: B256::from(storage_block.ommers_hash),
131+
miner: Address::from(storage_block.beneficiary),
132+
state_root: B256::from(storage_block.state_root),
133+
transactions_root: B256::from(storage_block.transactions_root),
134+
receipts_root: B256::from(storage_block.receipts_root),
135+
logs_bloom: Bloom::from_slice(&storage_block.logs_bloom),
136+
difficulty: U256::from_be_bytes(storage_block.difficulty),
137+
number: storage_block.number,
138+
gas_limit: storage_block.gas_limit,
139+
gas_used: storage_block.gas_used,
140+
timestamp: storage_block.timestamp,
141+
extra_data: Bytes::from(storage_block.extra_data),
142+
mix_hash: B256::from(storage_block.mix_hash),
143+
nonce: B64::from(storage_block.nonce),
144+
total_difficulty: U256::from_be_bytes(storage_block.total_difficulty),
145+
base_fee_per_gas: storage_block.base_fee_per_gas,
146+
size: None,
147+
withdrawals_root: None,
148+
blob_gas_used: None,
149+
excess_blob_gas: None,
150+
parent_beacon_block_root: None,
151+
transactions: storage_block
152+
.transaction_hashes
153+
.iter()
154+
.map(|h| B256::from(*h))
155+
.collect(),
156+
uncles: Vec::new(),
157+
withdrawals: None,
158+
}
159+
}
160+
}
161+
162+
#[cfg(test)]
163+
mod tests {
164+
use super::*;
165+
166+
#[test]
167+
fn test_block_serializes_numbers_as_hex() {
168+
let block = RpcBlock {
169+
hash: B256::ZERO,
170+
parent_hash: B256::ZERO,
171+
ommers_hash: B256::ZERO,
172+
miner: Address::ZERO,
173+
state_root: B256::ZERO,
174+
transactions_root: B256::ZERO,
175+
receipts_root: B256::ZERO,
176+
logs_bloom: Bloom::ZERO,
177+
difficulty: U256::ZERO,
178+
number: 509,
179+
gas_limit: 30_000_000,
180+
gas_used: 21000,
181+
timestamp: 1706163600,
182+
extra_data: Bytes::new(),
183+
mix_hash: B256::ZERO,
184+
nonce: B64::ZERO,
185+
total_difficulty: U256::ZERO,
186+
base_fee_per_gas: Some(1_000_000_000),
187+
size: None,
188+
withdrawals_root: None,
189+
blob_gas_used: None,
190+
excess_blob_gas: None,
191+
parent_beacon_block_root: None,
192+
transactions: Vec::new(),
193+
uncles: Vec::new(),
194+
withdrawals: None,
195+
};
196+
197+
let json = serde_json::to_string(&block).unwrap();
198+
199+
// Verify numeric fields are hex-encoded
200+
assert!(
201+
json.contains("\"number\":\"0x1fd\""),
202+
"number should be hex: {}",
203+
json
204+
);
205+
assert!(
206+
json.contains("\"gasLimit\":\"0x1c9c380\""),
207+
"gasLimit should be hex: {}",
208+
json
209+
);
210+
assert!(
211+
json.contains("\"gasUsed\":\"0x5208\""),
212+
"gasUsed should be hex: {}",
213+
json
214+
);
215+
// 1706163600 = 0x65b1fd90
216+
assert!(
217+
json.contains("\"timestamp\":\"0x65b1fd90\""),
218+
"timestamp should be hex: {}",
219+
json
220+
);
221+
assert!(
222+
json.contains("\"baseFeePerGas\":\"0x3b9aca00\""),
223+
"baseFeePerGas should be hex: {}",
224+
json
225+
);
226+
227+
// Verify hash fields remain as full hex strings with 0x prefix
228+
assert!(
229+
json.contains(
230+
"\"hash\":\"0x0000000000000000000000000000000000000000000000000000000000000000\""
231+
),
232+
"hash should be full hex: {}",
233+
json
234+
);
235+
}
236+
}

0 commit comments

Comments
 (0)