Skip to main content

huff_neo_test_runner/
outcome.rs

1use crate::errors::RunnerError;
2use crate::runner::db_error;
3use crate::spec::{AccountValue, ExpectedSlot, ExpectedValue};
4use alloy_primitives::{U256, hex};
5use foundry_evm_core::decode::RevertDecoder;
6use huff_neo_utils::prelude::Contract;
7use revm::DatabaseRef;
8use serde::Serialize;
9
10/// Selector of Foundry's `CheatcodeError(string)`, the revert data of a failed cheatcode such as a
11/// failed `vm.assert*`
12const CHEATCODE_ERROR_SELECTOR: [u8; 4] = hex!("eeaa9e6f");
13
14/// How the test call ended
15#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
16#[serde(tag = "kind", rename_all = "snake_case")]
17pub enum Outcome {
18    /// The call stopped or returned, with the return data as hex
19    Return { data: String },
20    /// The call reverted, with the revert data as hex
21    Revert { data: String },
22    /// The call halted exceptionally, e.g. out of gas or on an invalid jump
23    Halt { reason: String },
24    /// The test was skipped, by a `skip` decorator or by `vm.skip(true)`, with the reason when given
25    Skip { reason: Option<String> },
26}
27
28/// An expectation the test did not meet
29#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
30pub struct Failure {
31    /// The decorator flag that set the expectation, or `outcome` for the default of returning
32    pub expectation: String,
33    /// What was expected and what happened instead
34    pub message: String,
35}
36
37/// How a test is expected to end
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub enum ExpectedOutcome {
40    /// Returns, with exactly this data when given
41    Return(Option<Vec<u8>>),
42    /// Reverts, with exactly this data when given
43    Revert(Option<Vec<u8>>),
44    /// Reverts with data that starts with this
45    PartialRevert(Vec<u8>),
46    /// Halts exceptionally, for this reason when given
47    Halt(Option<String>),
48}
49
50/// What a test's decorators expect of the test call
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct Expectations {
53    /// How the test must end; a test without an outcome decorator must return
54    pub outcome: ExpectedOutcome,
55    /// The decorator flag that set the outcome, used to label a failure
56    pub outcome_flag: Option<String>,
57    /// Lower bound of the reported gas
58    pub min_gas: Option<u64>,
59    /// Upper bound of the reported gas
60    pub max_gas: Option<u64>,
61    /// Storage slots and the values they must hold after the test
62    pub storage: Vec<ExpectedSlot>,
63    /// Balances accounts must hold after the test
64    pub balances: Vec<AccountValue<ExpectedValue>>,
65}
66
67impl Default for Expectations {
68    fn default() -> Self {
69        Self {
70            outcome: ExpectedOutcome::Return(None),
71            outcome_flag: None,
72            min_gas: None,
73            max_gas: None,
74            storage: Vec::new(),
75            balances: Vec::new(),
76        }
77    }
78}
79
80impl Expectations {
81    /// The expectations the test call did not meet
82    pub fn check(&self, outcome: &Outcome, gas: u64, contract: &Contract) -> Vec<Failure> {
83        let mut failures = Vec::new();
84        // A skipped test has nothing to check
85        if matches!(outcome, Outcome::Skip { .. }) {
86            return failures;
87        }
88
89        // A failed cheatcode, such as a failed `vm.assert*`, fails the test even where a revert is expected
90        if let Outcome::Revert { data } = outcome
91            && let data = decode_hex(data)
92            && data.starts_with(&CHEATCODE_ERROR_SELECTOR)
93        {
94            failures.push(Failure { expectation: String::from("cheatcode"), message: RevertDecoder::new().decode(&data, None) });
95        } else if let Some(message) = self.outcome_mismatch(outcome, contract) {
96            let expectation = self.outcome_flag.as_deref().unwrap_or("outcome");
97            failures.push(Failure { expectation: expectation.to_string(), message });
98        }
99        if let Some(min) = self.min_gas
100            && gas < min
101        {
102            failures.push(Failure { expectation: String::from("expect_gas"), message: format!("used {gas} gas, less than min {min}") });
103        }
104        if let Some(max) = self.max_gas
105            && gas > max
106        {
107            failures.push(Failure { expectation: String::from("expect_gas"), message: format!("used {gas} gas, more than max {max}") });
108        }
109
110        failures
111    }
112
113    /// The storage and balance expectations the state after the test does not meet
114    pub fn check_post_state<DB>(&self, db: &DB) -> Result<Vec<Failure>, RunnerError>
115    where
116        DB: DatabaseRef,
117        <DB as DatabaseRef>::Error: std::fmt::Debug,
118    {
119        let mut failures = Vec::new();
120
121        for ExpectedSlot { address, slot, expected } in &self.storage {
122            let actual = db.storage_ref(*address, *slot).map_err(db_error)?;
123            if !expected.matches(actual) {
124                failures.push(Failure {
125                    expectation: String::from("expect_storage"),
126                    message: format!("slot {slot:#x} at {address}: expected {}, got {actual:#x}", expected.describe(|v| format!("{v:#x}"))),
127                });
128            }
129        }
130        for AccountValue { address, value: expected } in &self.balances {
131            let actual = db.basic_ref(*address).map_err(db_error)?.map_or(U256::ZERO, |account| account.balance);
132            if !expected.matches(actual) {
133                failures.push(Failure {
134                    expectation: String::from("expect_balance"),
135                    message: format!("balance of {address}: expected {}, got {actual}", expected.describe(|v| v.to_string())),
136                });
137            }
138        }
139
140        Ok(failures)
141    }
142
143    /// Describes how the outcome differs from the expected one, if it does
144    fn outcome_mismatch(&self, outcome: &Outcome, contract: &Contract) -> Option<String> {
145        match (&self.outcome, outcome) {
146            // The test ended the expected way; compare the data or reason when one is expected
147            (ExpectedOutcome::Return(expected), Outcome::Return { data }) => {
148                let data = decode_hex(data);
149                match expected {
150                    Some(expected) if *expected != data => {
151                        Some(format!("expected return data {}, got {}", display_hex(expected), display_hex(&data)))
152                    }
153                    _ => None,
154                }
155            }
156            (ExpectedOutcome::Revert(expected), Outcome::Revert { data }) => {
157                let data = decode_hex(data);
158                match expected {
159                    Some(expected) if *expected != data => Some(format!(
160                        "expected revert data {}, got {}",
161                        describe_revert(expected, contract),
162                        describe_revert(&data, contract)
163                    )),
164                    _ => None,
165                }
166            }
167            (ExpectedOutcome::PartialRevert(prefix), Outcome::Revert { data }) => {
168                let data = decode_hex(data);
169                (!data.starts_with(prefix)).then(|| {
170                    format!(
171                        "expected revert data starting with {}, got {}",
172                        describe_revert(prefix, contract),
173                        describe_revert(&data, contract)
174                    )
175                })
176            }
177            (ExpectedOutcome::Halt(expected), Outcome::Halt { reason }) => match expected {
178                Some(expected) if !halt_reason_matches(expected, reason) => {
179                    Some(format!("expected the test to halt with {expected}, but it halted with {reason}"))
180                }
181                _ => None,
182            },
183
184            // The test ended another way
185            (ExpectedOutcome::Return(_), _) => Some(format!("expected the test to return, but it {}", describe_outcome(outcome, contract))),
186            (ExpectedOutcome::Revert(_) | ExpectedOutcome::PartialRevert(_), _) => {
187                Some(format!("expected a revert, but the test {}", describe_outcome(outcome, contract)))
188            }
189            (ExpectedOutcome::Halt(_), _) => Some(format!("expected the test to halt, but it {}", describe_outcome(outcome, contract))),
190        }
191    }
192}
193
194/// Whether a halt reason given in a decorator names the actual reason, so `OutOfGas` matches
195/// every kind of out-of-gas halt
196fn halt_reason_matches(expected: &str, actual: &str) -> bool {
197    actual == expected || actual.strip_prefix(expected).is_some_and(|rest| rest.starts_with('('))
198}
199
200/// The outcome as the end of a sentence, e.g. "reverted with 0x82b42900 (Unauthorized)"
201fn describe_outcome(outcome: &Outcome, contract: &Contract) -> String {
202    match outcome {
203        Outcome::Return { data } => format!("returned {}", display_hex(&decode_hex(data))),
204        Outcome::Revert { data } => format!("reverted with {}", describe_revert(&decode_hex(data), contract)),
205        Outcome::Halt { reason } => format!("halted with {reason}"),
206        Outcome::Skip { .. } => String::from("was skipped"),
207    }
208}
209
210/// Revert data as hex, named after the contract's error when its selector matches one, or with its
211/// message when it is a standard error such as `Error(string)` or a failed cheatcode
212fn describe_revert(data: &[u8], contract: &Contract) -> String {
213    let error = data.get(..4).and_then(|selector| contract.errors.iter().find(|e| e.selector == selector));
214    match error {
215        Some(error) => format!("{} ({})", display_hex(data), error.name),
216        None => match RevertDecoder::new().maybe_decode_known(data) {
217            Some(message) => format!("{} ({message})", display_hex(data)),
218            None => display_hex(data),
219        },
220    }
221}
222
223/// The bytes of an outcome's data, which the runner records as hex so it reads well in JSON
224fn decode_hex(data: &str) -> Vec<u8> {
225    hex::decode(data).unwrap_or_default()
226}
227
228/// Bytes as `0x`-prefixed hex, spelling out empty data so it is not mistaken for a missing value
229fn display_hex(data: &[u8]) -> String {
230    if data.is_empty() { String::from("0x (empty)") } else { format!("0x{}", hex::encode(data)) }
231}