Skip to content

Commit 190355b

Browse files
committed
feat: add eth2util/helpers
1 parent 90d3eb4 commit 190355b

7 files changed

Lines changed: 534 additions & 89 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/charon-eth2/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ hex.workspace = true
1515
charon-testutil.workspace = true
1616
charon-k1util.workspace = true
1717
chrono.workspace = true
18+
regex.workspace = true
1819
# Deposit module dependencies
1920
serde = { workspace = true, features = ["derive"] }
2021
serde_json.workspace = true

crates/charon-eth2/src/deposit/domain.rs

Lines changed: 51 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ use super::{
99
constants::*,
1010
types::{DepositMessage, ForkData, SigningData},
1111
};
12-
use crate::network;
12+
use crate::{helpers, network};
1313

1414
use super::constants::{Domain, Root, Version};
1515

@@ -28,35 +28,34 @@ pub enum DomainError {
2828
#[error("Network error: {0}")]
2929
NetworkError(#[from] network::NetworkError),
3030

31-
/// Invalid address checksum
32-
#[error("Invalid address checksum: {0}")]
33-
InvalidChecksum(String),
31+
/// Helper error
32+
#[error("Address validation error: {0}")]
33+
HelperError(#[from] helpers::HelperError),
3434
}
3535

3636
/// Converts an Ethereum address to withdrawal credentials.
3737
///
3838
/// # Arguments
39-
/// * `addr` - Ethereum address (with or without 0x prefix)
39+
/// * `addr` - Ethereum address with 0x prefix (format validation only, checksum not enforced)
4040
/// * `compounding` - Whether to use EIP-7251 compounding withdrawal credentials
4141
///
4242
/// # Returns
4343
/// 32-byte withdrawal credentials
4444
///
4545
/// # Errors
46-
/// Returns error if address is invalid or checksum fails
47-
/// NOTE: Done
46+
/// Returns error if address format is invalid
47+
/// NOTE: Done - Uses helpers::checksum_address to match Go's eth2util.ChecksumAddress behavior
48+
/// Go's ChecksumAddress accepts any valid hex without validating existing EIP-55 checksums
4849
pub(crate) fn withdrawal_creds_from_addr(
4950
addr: &str,
5051
compounding: bool,
5152
) -> Result<[u8; 32], DomainError> {
52-
// Validate checksum
53-
validate_checksum(addr)?;
53+
// Validate address format and get checksummed version
54+
// This matches Go's eth2util.ChecksumAddress: validates format but doesn't validate checksums
55+
helpers::checksum_address(addr)?;
5456

55-
// Remove 0x prefix if present
56-
let addr_hex = addr.strip_prefix("0x").unwrap_or(addr);
57-
58-
// Decode address bytes
59-
let addr_bytes = hex::decode(addr_hex)?;
57+
// Decode address bytes (we already validated format, so this should succeed)
58+
let addr_bytes = hex::decode(&addr[2..])?;
6059

6160
let mut creds = [0u8; 32];
6261

@@ -79,58 +78,6 @@ pub(crate) fn withdrawal_creds_from_addr(
7978
Ok(creds)
8079
}
8180

82-
/// Validates Ethereum address checksum using EIP-55.
83-
///
84-
/// # Arguments
85-
/// * `addr` - Ethereum address to validate
86-
///
87-
/// # Errors
88-
/// Returns error if checksum validation fails
89-
/// NOTE: Consider to use alloy-primitive
90-
/// NOTE: or create eth2util/helper
91-
fn validate_checksum(addr: &str) -> Result<(), DomainError> {
92-
let addr_no_prefix = addr.strip_prefix("0x").unwrap_or(addr);
93-
94-
// Check length
95-
if addr_no_prefix.len() != 40 {
96-
return Err(DomainError::InvalidAddress(format!(
97-
"Address must be 40 hex characters, got {}",
98-
addr_no_prefix.len()
99-
)));
100-
}
101-
102-
// If all lowercase or all uppercase, skip checksum validation
103-
let has_uppercase = addr_no_prefix.chars().any(|c| c.is_uppercase());
104-
let has_lowercase = addr_no_prefix.chars().any(|c| c.is_lowercase());
105-
106-
if !has_uppercase || !has_lowercase {
107-
// Mixed case not present, skip validation
108-
return Ok(());
109-
}
110-
111-
// Compute checksum using Keccak256
112-
use sha3::{Digest, Keccak256};
113-
let hash = Keccak256::digest(addr_no_prefix.to_lowercase().as_bytes());
114-
115-
for (i, ch) in addr_no_prefix.chars().enumerate() {
116-
if ch.is_alphabetic() {
117-
let hash_byte = hash[i / 2];
118-
let hash_nibble = if i % 2 == 0 {
119-
hash_byte >> 4
120-
} else {
121-
hash_byte & 0x0f
122-
};
123-
124-
let should_be_uppercase = hash_nibble >= 8;
125-
126-
if ch.is_uppercase() != should_be_uppercase {
127-
return Err(DomainError::InvalidChecksum(addr.to_string()));
128-
}
129-
}
130-
}
131-
132-
Ok(())
133-
}
13481

13582
/// Returns the deposit domain for the given fork version.
13683
///
@@ -225,46 +172,63 @@ mod tests {
225172

226173
#[test]
227174
fn test_withdrawal_creds_without_prefix() {
175+
// Address without 0x prefix should fail (matching Go's behavior)
228176
let addr = "321dcb529f3945bc94fecea9d3bc5caf35253b94";
229-
let creds = withdrawal_creds_from_addr(addr, false).unwrap();
230-
assert_eq!(creds[0], ETH1_ADDRESS_WITHDRAWAL_PREFIX);
177+
let err = withdrawal_creds_from_addr(addr, false).unwrap_err();
178+
// Error is HelperError wrapped in DomainError
179+
assert!(matches!(err, DomainError::HelperError(_)));
231180
}
232181

233182
#[test]
234183
fn test_invalid_address_length() {
235184
let addr = "0x321dcb5"; // Too short
236185
let err = withdrawal_creds_from_addr(addr, false).unwrap_err();
237-
assert!(matches!(err, DomainError::InvalidAddress(_)));
186+
// Error is HelperError wrapped in DomainError
187+
assert!(matches!(err, DomainError::HelperError(_)));
238188
}
239189

240190
#[test]
241-
fn test_validate_checksum_all_lowercase() {
242-
// All lowercase should pass
243-
assert!(validate_checksum("0x321dcb529f3945bc94fecea9d3bc5caf35253b94").is_ok());
191+
fn test_address_parsing_all_lowercase() {
192+
// All lowercase with 0x prefix should pass (matching Go's lenient behavior)
193+
let addr = "0x321dcb529f3945bc94fecea9d3bc5caf35253b94";
194+
assert!(helpers::checksum_address(addr).is_ok());
195+
assert!(withdrawal_creds_from_addr(addr, false).is_ok());
244196
}
245197

246198
#[test]
247-
fn test_validate_checksum_all_uppercase() {
248-
// All uppercase should pass
249-
assert!(validate_checksum("0x321DCB529F3945BC94FECEA9D3BC5CAF35253B94").is_ok());
199+
fn test_address_parsing_all_uppercase() {
200+
// All uppercase with 0x prefix should pass (matching Go's lenient behavior)
201+
let addr = "0x321DCB529F3945BC94FECEA9D3BC5CAF35253B94";
202+
assert!(helpers::checksum_address(addr).is_ok());
203+
assert!(withdrawal_creds_from_addr(addr, false).is_ok());
250204
}
251205

252206
#[test]
253-
fn test_validate_checksum_valid_mixed_case() {
254-
// Valid EIP-55 checksummed address
207+
fn test_address_parsing_valid_checksum() {
208+
// Valid EIP-55 checksummed address should pass
255209
let addr = "0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed";
256-
assert!(validate_checksum(addr).is_ok());
210+
assert!(helpers::checksum_address(addr).is_ok());
211+
assert!(withdrawal_creds_from_addr(addr, false).is_ok());
257212
}
258213

259214
#[test]
260-
fn test_validate_checksum_invalid() {
261-
// Invalid checksum (wrong case for some letters)
262-
let addr = "0x5aaeb6053f3e94c9b9a09f33669435e7ef1beaed"; // Should have some uppercase
263-
// This is all lowercase, so it passes (no checksum validation)
264-
assert!(validate_checksum(addr).is_ok());
265-
266-
// But this should fail (mixed case but wrong checksum)
267-
let addr_wrong = "0x5aAeb6053f3E94C9b9A09f33669435E7Ef1BeAed"; // Wrong case
268-
assert!(validate_checksum(addr_wrong).is_err());
215+
fn test_address_parsing_invalid_checksum_accepted() {
216+
// Mixed case with WRONG checksum is ACCEPTED (matching Go's lenient behavior)
217+
// Go doesn't validate checksums, just accepts valid hex
218+
let addr_wrong = "0x5aAeb6053f3E94C9b9A09f33669435E7Ef1BeAed";
219+
assert!(helpers::checksum_address(addr_wrong).is_ok());
220+
assert!(withdrawal_creds_from_addr(addr_wrong, false).is_ok());
269221
}
222+
223+
#[test]
224+
fn test_address_requires_prefix() {
225+
// Address without 0x prefix should fail (matching Go's behavior)
226+
let addr = "321dcb529f3945bc94fecea9d3bc5caf35253b94";
227+
assert!(withdrawal_creds_from_addr(addr, false).is_err());
228+
229+
// With prefix should work
230+
let addr_with_prefix = "0x321dcb529f3945bc94fecea9d3bc5caf35253b94";
231+
assert!(withdrawal_creds_from_addr(addr_with_prefix, false).is_ok());
232+
}
233+
270234
}

crates/charon-eth2/src/deposit/files.rs

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -378,4 +378,157 @@ mod tests {
378378
assert_eq!(deposit_set.len(), 2);
379379
}
380380
}
381+
382+
/// Comprehensive test for deposit file path formatting to match Go's strconv.FormatFloat(eth, 'f', -1, 64).
383+
///
384+
/// Verifies that Rust's format!("{}", f64) produces identical output to Go for:
385+
/// - Whole numbers (no ".0" suffix)
386+
/// - Decimals (no trailing zeros)
387+
/// - Small numbers (fixed-point, not scientific notation)
388+
/// - Large numbers (no scientific notation)
389+
/// - Problematic floats (0.3, 0.7)
390+
///
391+
/// If this test fails, implement explicit Go-compatible formatting.
392+
#[test]
393+
fn test_deposit_file_path_go_formatting_compatibility() {
394+
use std::path::Path;
395+
396+
let data_dir = Path::new("/tmp/deposits");
397+
398+
struct TestCase {
399+
gwei: u64,
400+
expected_filename: &'static str,
401+
description: &'static str,
402+
}
403+
404+
let test_cases = vec![
405+
// Legacy filename for default amount
406+
TestCase {
407+
gwei: 32_000_000_000,
408+
expected_filename: "deposit-data.json",
409+
description: "32 ETH - legacy filename",
410+
},
411+
// Whole numbers - no decimal point or trailing zeros
412+
TestCase {
413+
gwei: 1_000_000_000,
414+
expected_filename: "deposit-data-1eth.json",
415+
description: "1 ETH - no trailing .0",
416+
},
417+
TestCase {
418+
gwei: 16_000_000_000,
419+
expected_filename: "deposit-data-16eth.json",
420+
description: "16 ETH - no trailing .0",
421+
},
422+
TestCase {
423+
gwei: 100_000_000_000,
424+
expected_filename: "deposit-data-100eth.json",
425+
description: "100 ETH - no trailing .0",
426+
},
427+
TestCase {
428+
gwei: 1_000_000_000_000_000,
429+
expected_filename: "deposit-data-1000000eth.json",
430+
description: "1M ETH - large number, no scientific notation",
431+
},
432+
// Decimals - no trailing zeros
433+
TestCase {
434+
gwei: 100_000_000,
435+
expected_filename: "deposit-data-0.1eth.json",
436+
description: "0.1 ETH - one decimal",
437+
},
438+
TestCase {
439+
gwei: 500_000_000,
440+
expected_filename: "deposit-data-0.5eth.json",
441+
description: "0.5 ETH - one decimal",
442+
},
443+
TestCase {
444+
gwei: 1_500_000_000,
445+
expected_filename: "deposit-data-1.5eth.json",
446+
description: "1.5 ETH - one decimal",
447+
},
448+
TestCase {
449+
gwei: 1_250_000_000,
450+
expected_filename: "deposit-data-1.25eth.json",
451+
description: "1.25 ETH - two decimals",
452+
},
453+
TestCase {
454+
gwei: 125_000_000,
455+
expected_filename: "deposit-data-0.125eth.json",
456+
description: "0.125 ETH - three decimals",
457+
},
458+
TestCase {
459+
gwei: 62_500_000,
460+
expected_filename: "deposit-data-0.0625eth.json",
461+
description: "0.0625 ETH - four decimals",
462+
},
463+
TestCase {
464+
gwei: 24_500_000_000,
465+
expected_filename: "deposit-data-24.5eth.json",
466+
description: "24.5 ETH - non-standard amount",
467+
},
468+
TestCase {
469+
gwei: 32_100_000_000,
470+
expected_filename: "deposit-data-32.1eth.json",
471+
description: "32.1 ETH - slightly more than default",
472+
},
473+
TestCase {
474+
gwei: 10_100_000_000,
475+
expected_filename: "deposit-data-10.1eth.json",
476+
description: "10.1 ETH - decimal precision",
477+
},
478+
// Problematic floats (binary representation issues)
479+
TestCase {
480+
gwei: 300_000_000,
481+
expected_filename: "deposit-data-0.3eth.json",
482+
description: "0.3 ETH - known problematic float",
483+
},
484+
TestCase {
485+
gwei: 700_000_000,
486+
expected_filename: "deposit-data-0.7eth.json",
487+
description: "0.7 ETH - known problematic float",
488+
},
489+
// Very small amounts - must use fixed-point notation, not scientific
490+
TestCase {
491+
gwei: 1_000,
492+
expected_filename: "deposit-data-0.000001eth.json",
493+
description: "0.000001 ETH - 6 decimals, no scientific notation",
494+
},
495+
TestCase {
496+
gwei: 10,
497+
expected_filename: "deposit-data-0.00000001eth.json",
498+
description: "10 Gwei - 8 decimals, no scientific notation",
499+
},
500+
TestCase {
501+
gwei: 1,
502+
expected_filename: "deposit-data-0.000000001eth.json",
503+
description: "1 Gwei - 9 decimals, no scientific notation",
504+
},
505+
];
506+
507+
for test_case in test_cases {
508+
let amount = Gwei::new(test_case.gwei);
509+
let path = get_deposit_file_path(data_dir, amount);
510+
let actual_filename = path.file_name().unwrap().to_str().unwrap();
511+
512+
// Check exact filename match
513+
assert_eq!(
514+
actual_filename, test_case.expected_filename,
515+
"Failed for {}: expected '{}', got '{}'",
516+
test_case.description, test_case.expected_filename, actual_filename
517+
);
518+
519+
// For non-legacy filenames, verify no scientific notation in the number part
520+
if test_case.gwei != 32_000_000_000 {
521+
let number_part = actual_filename
522+
.strip_prefix("deposit-data-")
523+
.and_then(|s| s.strip_suffix("eth.json"))
524+
.expect("filename should have correct prefix/suffix");
525+
526+
assert!(
527+
!number_part.contains('e') && !number_part.contains('E'),
528+
"Number part '{}' should not use scientific notation for {}",
529+
number_part, test_case.description
530+
);
531+
}
532+
}
533+
}
381534
}

crates/charon-eth2/src/deposit/mod.rs

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -185,8 +185,14 @@ pub fn marshal_deposit_data(
185185
// Sort by pubkey
186186
dd_list.sort_by(|a, b| a.pubkey.cmp(&b.pubkey));
187187

188-
// Serialize to JSON with pretty printing
189-
let bytes = serde_json::to_vec_pretty(&dd_list)?;
188+
let bytes = {
189+
use serde::Serialize;
190+
let mut buf = Vec::new();
191+
let formatter = serde_json::ser::PrettyFormatter::with_indent(b" "); // Single space
192+
let mut ser = serde_json::Serializer::with_formatter(&mut buf, formatter);
193+
dd_list.serialize(&mut ser)?;
194+
buf
195+
};
190196

191197
Ok(bytes)
192198
}

0 commit comments

Comments
 (0)