Skip to content

Commit 880abfe

Browse files
cburgdorfsbillig
authored andcommitted
Extend #[test(should_revert)] with panic and selector matching
Add two new optional arguments to the test attribute: - `#[test(should_revert, panic = 0x11)]` — match Panic(uint256) with specific code (verifies selector 0x4e487b71 + ABI-encoded code) - `#[test(should_revert, selector = 0x4e487b71)]` — match only the 4-byte error selector This enables end-to-end verification that `revert_error()` produces the correct Solidity-compatible revert payloads.
1 parent ecfe9b8 commit 880abfe

22 files changed

Lines changed: 350 additions & 44 deletions

crates/codegen/src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,5 +13,5 @@ pub use sonatina::{
1313
pub use yul::{
1414
EmitModuleError, ExpectedRevert, TestMetadata, TestModuleOutput, YulError, emit_ingot_yul,
1515
emit_ingot_yul_with_layout, emit_module_yul, emit_module_yul_with_layout, emit_test_module_yul,
16-
emit_test_module_yul_with_layout,
16+
emit_test_module_yul_with_layout, parse_expected_revert,
1717
};

crates/codegen/src/sonatina/tests.rs

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ use sonatina_ir::{
2424
};
2525
use sonatina_verifier::{VerificationLevel, VerifierConfig};
2626

27-
use crate::{ExpectedRevert, OptLevel, TestMetadata, TestModuleOutput};
27+
use crate::{ExpectedRevert, OptLevel, TestMetadata, TestModuleOutput, parse_expected_revert};
2828

2929
use super::{ContractObjectSelection, LowerError, ModuleLowerer};
3030

@@ -165,18 +165,15 @@ fn collect_tests(
165165
};
166166
let attrs = ItemKind::from(hir_func).attrs(db)?;
167167
let test_attr = attrs.get_attr(db, "test")?;
168-
169-
let expected_revert = if test_attr.has_arg(db, "should_revert") {
170-
Some(ExpectedRevert::Any)
171-
} else {
172-
None
173-
};
174-
175168
let hir_name = hir_func
176169
.name(db)
177170
.to_opt()
178171
.map(|n| n.data(db).to_string())
179172
.unwrap_or_else(|| "<anonymous>".to_string());
173+
let expected_revert = match parse_expected_revert(db, &hir_name, test_attr) {
174+
Ok(expected_revert) => expected_revert,
175+
Err(err) => return Some(Err(LowerError::Unsupported(err))),
176+
};
180177
let initial_balance = match parse_test_balance_arg(db, &hir_name, test_attr) {
181178
Ok(balance) => balance,
182179
Err(err) => return Some(Err(err)),

crates/codegen/src/yul/emitter/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ use crate::yul::errors::YulError;
55
pub use module::{
66
ExpectedRevert, TestMetadata, TestModuleOutput, emit_ingot_yul, emit_ingot_yul_with_layout,
77
emit_module_yul, emit_module_yul_with_layout, emit_test_module_yul,
8-
emit_test_module_yul_with_layout,
8+
emit_test_module_yul_with_layout, parse_expected_revert,
99
};
1010

1111
mod control_flow;

crates/codegen/src/yul/emitter/module.rs

Lines changed: 124 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -59,9 +59,11 @@ pub struct TestMetadata {
5959
pub enum ExpectedRevert {
6060
/// Test should revert with any data.
6161
Any,
62-
// Future phases:
63-
// ExactData(Vec<u8>),
64-
// Selector([u8; 4]),
62+
/// Test should revert with data starting with the given 4-byte selector.
63+
Selector([u8; 4]),
64+
/// Test should revert with a Panic(uint256) whose code matches.
65+
/// Stores the expected full revert payload: selector (4 bytes) + ABI-encoded code (32 bytes).
66+
PanicCode(Vec<u8>),
6567
}
6668

6769
/// Output returned by `emit_test_module_yul`.
@@ -860,19 +862,15 @@ fn collect_test_infos(
860862
};
861863
let attrs = ItemKind::from(hir_func).attrs(db)?;
862864
let test_attr = attrs.get_attr(db, "test")?;
863-
864-
// Check for #[test(should_revert)]
865-
let expected_revert = if test_attr.has_arg(db, "should_revert") {
866-
Some(ExpectedRevert::Any)
867-
} else {
868-
None
869-
};
870-
871865
let hir_name = hir_func
872866
.name(db)
873867
.to_opt()
874868
.map(|n| n.data(db).to_string())
875869
.unwrap_or_else(|| "<anonymous>".to_string());
870+
let expected_revert = match parse_expected_revert(db, &hir_name, test_attr) {
871+
Ok(expected_revert) => expected_revert,
872+
Err(err) => return Some(Err(EmitModuleError::Yul(YulError::Unsupported(err)))),
873+
};
876874
// Check for #[test(balance = N)]
877875
let initial_balance = match parse_test_balance_arg(db, &hir_name, test_attr) {
878876
Ok(balance) => balance,
@@ -931,6 +929,121 @@ fn parse_test_balance_arg<'db>(
931929

932930
Ok(None)
933931
}
932+
/// Parses the expected revert behavior from a `#[test(...)]` attribute.
933+
///
934+
/// Supported forms:
935+
/// - `#[test(should_revert)]` — any revert
936+
/// - `#[test(should_revert, selector = 0x4e487b71)]` — revert with matching 4-byte selector
937+
/// - `#[test(should_revert, panic = 0x11)]` — revert with Panic(uint256) and matching code
938+
pub fn parse_expected_revert<'db>(
939+
db: &'db dyn HirDb,
940+
test_name: &str,
941+
test_attr: &hir::hir_def::attr::NormalAttr<'db>,
942+
) -> Result<Option<ExpectedRevert>, String> {
943+
let should_revert = test_attr.has_arg(db, "should_revert");
944+
let has_panic = has_test_attr_key(db, test_attr, "panic");
945+
let has_selector = has_test_attr_key(db, test_attr, "selector");
946+
947+
if !should_revert {
948+
if has_panic && has_selector {
949+
return Err(format!(
950+
"invalid #[test] function `{test_name}`: `panic = ...` and `selector = ...` require `should_revert`"
951+
));
952+
}
953+
if has_panic {
954+
return Err(format!(
955+
"invalid #[test] function `{test_name}`: `panic = ...` requires `should_revert`"
956+
));
957+
}
958+
if has_selector {
959+
return Err(format!(
960+
"invalid #[test] function `{test_name}`: `selector = ...` requires `should_revert`"
961+
));
962+
}
963+
return Ok(None);
964+
}
965+
966+
let panic = parse_test_attr_int_arg(db, test_name, test_attr, "panic", "u256", 32)?;
967+
let selector = parse_test_attr_int_arg(db, test_name, test_attr, "selector", "u32", 4)?;
968+
969+
if panic.is_some() && selector.is_some() {
970+
return Err(format!(
971+
"invalid #[test] function `{test_name}`: #[test(should_revert)] cannot combine `panic = ...` and `selector = ...`"
972+
));
973+
}
974+
975+
if let Some(code) = panic {
976+
let panic_selector: [u8; 4] = [0x4e, 0x48, 0x7b, 0x71];
977+
let mut payload = Vec::with_capacity(36);
978+
payload.extend_from_slice(&panic_selector);
979+
// ABI-encode the uint256 code: pad to 32 bytes big-endian
980+
let code_bytes = code.to_bytes_be();
981+
let mut padded = [0u8; 32];
982+
let start = 32 - code_bytes.len();
983+
padded[start..].copy_from_slice(&code_bytes);
984+
payload.extend_from_slice(&padded);
985+
return Ok(Some(ExpectedRevert::PanicCode(payload)));
986+
}
987+
988+
if let Some(sel) = selector {
989+
let bytes = sel.to_bytes_be();
990+
let mut selector = [0u8; 4];
991+
let start = 4 - bytes.len();
992+
selector[start..].copy_from_slice(&bytes);
993+
return Ok(Some(ExpectedRevert::Selector(selector)));
994+
}
995+
996+
Ok(Some(ExpectedRevert::Any))
997+
}
998+
999+
fn has_test_attr_key<'db>(
1000+
db: &'db dyn HirDb,
1001+
test_attr: &hir::hir_def::attr::NormalAttr<'db>,
1002+
key: &str,
1003+
) -> bool {
1004+
test_attr
1005+
.args
1006+
.iter()
1007+
.any(|arg| arg.key_str(db) == Some(key))
1008+
}
1009+
1010+
fn parse_test_attr_int_arg<'db>(
1011+
db: &'db dyn HirDb,
1012+
test_name: &str,
1013+
test_attr: &hir::hir_def::attr::NormalAttr<'db>,
1014+
key: &str,
1015+
type_name: &str,
1016+
max_bytes: usize,
1017+
) -> Result<Option<BigUint>, String> {
1018+
for arg in &test_attr.args {
1019+
if arg.key_str(db) != Some(key) {
1020+
continue;
1021+
}
1022+
1023+
let Some(value) = arg.value.as_ref() else {
1024+
return Err(format!(
1025+
"invalid #[test] function `{test_name}`: #[test(should_revert, {key} = ...)] expects an integer literal"
1026+
));
1027+
};
1028+
let hir::hir_def::attr::AttrArgValue::Lit(hir::hir_def::LitKind::Int(int_id)) = value
1029+
else {
1030+
return Err(format!(
1031+
"invalid #[test] function `{test_name}`: #[test(should_revert, {key} = ...)] expects an integer literal"
1032+
));
1033+
};
1034+
1035+
let value = int_id.data(db).clone();
1036+
if value.to_bytes_be().len() > max_bytes {
1037+
return Err(format!(
1038+
"invalid #[test] function `{test_name}`: #[test(should_revert, {key} = ...)] must fit in {type_name}"
1039+
));
1040+
}
1041+
return Ok(Some(value));
1042+
}
1043+
1044+
Ok(None)
1045+
}
1046+
9341047
fn test_info_matches_filter(test: &TestInfo, filter: Option<&str>) -> bool {
9351048
let Some(pattern) = filter else {
9361049
return true;

crates/codegen/src/yul/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,6 @@ mod state;
66
pub use emitter::{
77
EmitModuleError, ExpectedRevert, TestMetadata, TestModuleOutput, emit_ingot_yul,
88
emit_ingot_yul_with_layout, emit_module_yul, emit_module_yul_with_layout, emit_test_module_yul,
9-
emit_test_module_yul_with_layout,
9+
emit_test_module_yul_with_layout, parse_expected_revert,
1010
};
1111
pub use errors::YulError;

crates/fe/src/test/mod.rs

Lines changed: 59 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3124,23 +3124,66 @@ fn execute_test(
31243124
gas_profile,
31253125
)
31263126
}
3127-
// Expected revert: execution reverted (success)
3128-
(Err(contract_harness::HarnessError::Revert(_)), Some(ExpectedRevert::Any)) => (
3129-
TestResult {
3130-
name: name.to_string(),
3131-
passed: true,
3132-
error_message: None,
3133-
gas_used: None,
3134-
deploy_gas_used: Some(deploy_gas_used),
3135-
total_gas_used: None,
3136-
},
3137-
Vec::new(),
3138-
trace,
3139-
step_count,
3140-
gas_profile,
3141-
),
3127+
// Expected revert: execution reverted — check revert data against expectation
3128+
(Err(contract_harness::HarnessError::Revert(data)), Some(expected)) => {
3129+
let mismatch = match expected {
3130+
ExpectedRevert::Any => None,
3131+
ExpectedRevert::Selector(sel) => {
3132+
if data.0.len() >= 4 && data.0[..4] == sel[..] {
3133+
None
3134+
} else {
3135+
Some(format!(
3136+
"Expected revert with selector 0x{}, but got revert data: {}",
3137+
hex::encode(sel),
3138+
data,
3139+
))
3140+
}
3141+
}
3142+
ExpectedRevert::PanicCode(expected_bytes) => {
3143+
if data.0 == *expected_bytes {
3144+
None
3145+
} else {
3146+
Some(format!(
3147+
"Expected revert data 0x{}, but got: {}",
3148+
hex::encode(expected_bytes),
3149+
data,
3150+
))
3151+
}
3152+
}
3153+
};
3154+
match mismatch {
3155+
None => (
3156+
TestResult {
3157+
name: name.to_string(),
3158+
passed: true,
3159+
error_message: None,
3160+
gas_used: None,
3161+
deploy_gas_used: Some(deploy_gas_used),
3162+
total_gas_used: None,
3163+
},
3164+
Vec::new(),
3165+
trace,
3166+
step_count,
3167+
gas_profile,
3168+
),
3169+
Some(msg) => (
3170+
TestResult {
3171+
name: name.to_string(),
3172+
passed: false,
3173+
error_message: Some(msg),
3174+
gas_used: None,
3175+
deploy_gas_used: Some(deploy_gas_used),
3176+
total_gas_used: None,
3177+
},
3178+
Vec::new(),
3179+
trace,
3180+
step_count,
3181+
gas_profile,
3182+
),
3183+
}
3184+
}
31423185
// Expected revert: execution failed for a different reason (failure)
3143-
(Err(err), Some(ExpectedRevert::Any)) => {
3186+
(Err(err), Some(_)) => {
31443187
let gas_used = harness_error_gas_used(&err);
31453188
let total_gas_used = gas_used.map(|call_gas| deploy_gas_used.saturating_add(call_gas));
31463189
(

crates/fe/tests/fixtures/fe_test_runner/custom_error_revert.fe

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,33 @@ pub struct InsufficientBalance {
44
pub required: u256,
55
}
66

7+
// --- should_revert (any) ---
8+
79
#[test(should_revert)]
8-
fn test_revert_error_with_custom_error() uses (evm: mut Evm) {
10+
fn test_revert_error_any() uses (evm: mut Evm) {
911
revert_error(InsufficientBalance { balance: 100, required: 200 })
1012
}
1113

12-
#[test(should_revert)]
13-
fn test_revert_error_with_panic() uses (evm: mut Evm) {
14+
// --- should_revert with selector ---
15+
16+
#[test(should_revert, selector = 0x4e487b71)]
17+
fn test_revert_panic_selector() uses (evm: mut Evm) {
1418
revert_error(Panic { code: 0x11 })
1519
}
20+
21+
// --- should_revert with panic code ---
22+
23+
#[test(should_revert, panic = 0x11)]
24+
fn test_revert_panic_overflow() uses (evm: mut Evm) {
25+
revert_error(Panic { code: 0x11 })
26+
}
27+
28+
#[test(should_revert, panic = 0x12)]
29+
fn test_revert_panic_division_by_zero() uses (evm: mut Evm) {
30+
revert_error(Panic { code: 0x12 })
31+
}
32+
33+
#[test(should_revert, panic = 0x01)]
34+
fn test_revert_panic_assert() uses (evm: mut Evm) {
35+
revert_error(Panic { code: 0x01 })
36+
}

crates/fe/tests/fixtures/fe_test_runner/custom_error_revert.snap

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,12 @@ expression: output
44
input_file: tests/fixtures/fe_test_runner/custom_error_revert.fe
55
---
66
=== STDOUT ===
7-
PASS [<time>] test_revert_error_with_custom_error
8-
PASS [<time>] test_revert_error_with_panic
7+
PASS [<time>] test_revert_error_any
8+
PASS [<time>] test_revert_panic_selector
9+
PASS [<time>] test_revert_panic_overflow
10+
PASS [<time>] test_revert_panic_division_by_zero
11+
PASS [<time>] test_revert_panic_assert
912

10-
test result: ok. 2 passed; 0 failed
13+
test result: ok. 5 passed; 0 failed
1114

1215
=== EXIT CODE: 0 ===
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
#[test(should_revert, panic = 0x10000000000000000000000000000000000000000000000000000000000000000)]
2+
fn test_panic_code_too_wide() {
3+
let code: u256 = 1
4+
revert(code)
5+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
source: crates/fe/tests/cli_output.rs
3+
expression: output
4+
input_file: tests/fixtures/fe_test_runner/invalid_revert_panic_attr.fe
5+
---
6+
=== STDOUT ===
7+
Failed to emit test Sonatina: unsupported: invalid #[test] function `test_panic_code_too_wide`: #[test(should_revert, panic = ...)] must fit in u256
8+
ERROR [<time>] Failed to emit test Sonatina: unsupported: invalid #[test] function `test_panic_code_too_wide`: #[test(should_revert, panic = ...)] must fit in u256
9+
10+
test result: FAILED. 0 passed; 1 failed
11+
12+
failures:
13+
invalid_revert_panic_attr::codegen
14+
15+
=== EXIT CODE: 1 ===

0 commit comments

Comments
 (0)