Skip to content

Conversation

0xrusowsky
Copy link
Contributor

@0xrusowsky 0xrusowsky commented Oct 8, 2025

Motivation

closes #10233

Solution

  1. leverage solar::sema::Compiler to collect all relevant AST literals found in the sources (excluding libs and scripts) and seed the FuzzerDictionary with them at initialization.
  2. modify the strategies to source from a new pool of values (AST literals), when available
  3. modify the string strategy to not always generate random strings, but also source from the string literals pool

TODO

  • fix unit tests, as they expect different amount of runs to break --> however it is better to get feedback on the impl before fixing all tests, and having to fix them again later

Future improvements

PR Checklist

  • Added Tests
  • Added Documentation
  • Breaking changes

@0xrusowsky 0xrusowsky marked this pull request as ready for review October 9, 2025 08:05
.prop_flat_map(move |(use_ast_index, select_index)| {
let dict = state_clone.dictionary_read();

// AST string literals available: use 30/70 allocation
Copy link
Contributor Author

@0xrusowsky 0xrusowsky Oct 9, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

arbitrary value, we could change it if you are opinionated.


// Seed dict with AST literals if analysis is available.
if let Some(literals) = analysis {
dictionary.ast_values = Some(literals);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMO better to keep this simpler and just insert / reuse the existing fuzz dict samples - insert_sample_values which stores values by type and use them during fuzz runs instead having new strategy / weights and ast_values. We probably need to make the samples limit configurable and bump the default value

/// Insert sample values that are reused across multiple runs.
/// The number of samples is limited to invariant run depth.
/// If collected samples limit is reached then values are inserted as regular values.
pub fn insert_sample_values(

@0xalpharush
Copy link
Contributor

0xalpharush commented Oct 9, 2025

Not blocking but I would recommend implementing constant folding to some degree i.e. evaluate 2 * 2 ether, evaluate bytes32 IMPLEMENTATION_SLOT = bytes32(uint256(keccak256('eip1967.proxy.implementation')) - 1);, evaluate uint(-2). Arguably, solar will need this and the code could live there

See crytic/echidna#636

@grandizzy
Copy link
Collaborator

Not blocking but I would recommend implementing constant folding to some degree i.e. evaluate 2 * 2 ether, perform the keccack hash of a string, evaluate uint(-2).

See crytic/echidna#636

thanks! One thing here - this means we should collect from tests too which we don't do in PR, is this correct?

@0xalpharush
Copy link
Contributor

0xalpharush commented Oct 9, 2025

AFAIK neither Echidna or slither's printer filters tests out. I think not including forge-std makes sense.

Also, I am not sure how the push/pop/log dictionary is managed currently in Foundry, but I think Echidna will always keep the constant pool around and eject the dynamically collected values after running a full sequence. For example, a user's balance that is emitted in one run may help within the same sequence but probably unlikely to help in a totally unrelated sequence.

@grandizzy
Copy link
Collaborator

AFAIK neither Echidna or slither's printer filters tests out. I think not including forge-std makes sense.

👍 @0xrusowsky let's include too

Also, I am not sure how the push/pop/log dictionary is managed currently in Foundry, but I think Echidna will always keep the constant pool around and eject the dynamically collected values after running a full sequence. For example, a user's balance that is emitted in one run may help within the same sequence but probably unlikely to help in a totally unrelated sequence.

  • The push / pop dictionary + db addresses / storage values are populated when test starts and used across all runs, without being evicted.
    These are defined as

    /// Number of state values initially collected from db.
    /// Used to revert new collected values at the end of each run.
    db_state_values: usize,
    /// Number of address values initially collected from db.
    /// Used to revert new collected addresses at the end of each run.
    db_addresses: usize,

    and collected when dict is created
    // Create fuzz dictionary and insert values from db state.
    let mut dictionary = FuzzDictionary::new(config);
    dictionary.insert_db_values(accs);

  • We also maintain a dict of so called sample values, dynamically collected from logs, return values & state changes of runs up to a limit (set rn to the test depth) - these are also reused across all runs

    /// Sample typed values that are collected from call result and used across invariant runs.
    sample_values: HashMap<DynSolType, B256IndexSet>,

  • Then there are the regular values dynamically collected from runs that are not shared between runs

    /// Collected state values.
    state_values: B256IndexSet,

Please let us know if you see any redundant data / ways to improve the dict. Thank you!

@0xrusowsky
Copy link
Contributor Author

^ note that AST literals are injected into sample_values

@0xrusowsky 0xrusowsky force-pushed the rusowsky/ast-fuzz-dict branch from 6f35d1b to fc4f3d4 Compare October 10, 2025 05:46
@0xrusowsky 0xrusowsky force-pushed the rusowsky/ast-fuzz-dict branch from a9373d6 to 3440429 Compare October 10, 2025 05:51
@0xrusowsky 0xrusowsky changed the title feat(test): ast-seeded fuzzer dictionary feat(fuzz): ast-seeded dictionary Oct 10, 2025
@0xrusowsky
Copy link
Contributor Author

0xrusowsky commented Oct 10, 2025

Not blocking but I would recommend implementing constant folding to some degree i.e. evaluate 2 * 2 ether, evaluate bytes32 IMPLEMENTATION_SLOT = bytes32(uint256(keccak256('eip1967.proxy.implementation')) - 1);, evaluate uint(-2). Arguably, solar will need this and the code could live there

See crytic/echidna#636

thanks for the advise! will be tackled next on a follow-up PR:

Copy link
Collaborator

@grandizzy grandizzy left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thank you, looks good! left some comments / nits, pls check

if let Some(config) = cheatcodes {
let mut cheatcodes = Cheatcodes::new(config);
// Set analysis capabilities if they are provided
if let Some(analysis) = analysis {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the analysis here is technically the compiler, not the analysis per se, should we process / analyze already, smth like

if let Some(compiler) = compiler {
    let ast_analysis = AstAnalysis::new(compiler);
    cheatcodes.set_struct_defs(ast_analysis.get_struct_defs().clone());
    stack.set_ast_analysis(ast_analysis);
}

and we consolidate AST analysis in single place instead have parts of it in cheatcodes, parts in stack? Then in EvmFuzzState::new we just pass AstAnalysis and populate dict with AstAnalysis words, strings and bytes - side note, in this way we could pass to fuzzer also the enums to be used for #6623 but that's different scope and complex (it affects mutations as well) as @DaniPopes pointed out

Copy link
Contributor Author

@0xrusowsky 0xrusowsky Oct 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i was also thinking about where to place the LiteralsCollector, and i guess it could also make sense to upstream it to the inspector stack so that other inspectors could benefit from it.

however, i wouldn't eagerly perform the analysis as you suggest here, as most of the times you won't need to use the analysis capabilities (i.e. only a small subset of tests will use the cheatcodes that require struct defs).

also, i expect each consumer (inspector) to have different needs, hence why i thought having a more granular approach and implementing the actual analysis capabilities on each inspector (i.e. crates/evm/fuzz/src/strategies/state.rs, crates/cheatcodes/src/inspector/analysis.rs) would make more sense 🤔

let's see what @DaniPopes prefers and we can do what majority thinks its best? haha

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we then analyze at build time and cache the values / write them to disk, then lazy loading what's needed in different components and only when / where needed? this will also mean we don't need to analyze each time we forge test

.prop_flat_map(move |(use_ast_index, select_index)| {
let dict = state_clone.dictionary_read();

// AST string literals available: use 30/70 allocation
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMO this should follow the sample rules, we already have logic / bias to select them

https://github.com/foundry-rs/foundry/pull/12015/files#diff-d37d278bbc4bfc5240900ba4963f1a0f562f98808670ca692658aed9e0fdf624R128-R130

maybe we could reuse same and return DynSolValues from ast analyzed String / bytes here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wdym exactly?

bias is a randomly generated bool (50-50) but allocating 50% to ast seeded literals feels like a lot (before it was 0-100).

my idea was that by using Index we can allocate a smaller pct to AST string literals, but we are already using them (30% of the time)

let max_int_plus1 = U256::from(1).wrapping_shl(n - 1);
let num = I256::from_raw(uint.wrapping_sub(max_int_plus1));
// Extract lower N bits
let uint_n = U256::from_be_bytes(value.0) % U256::from(1).wrapping_shl(n);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good catch, need to make some more tests to see how this affects overall perf

}

#[derive(Clone, Default, Debug)]
pub struct LiteralMaps {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

as in comment above, would be nice to have all AST analysis consolidated and performed only once, these could be good candidates to move there. Let's add comments to the enum / structs and their members too

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

Using AST to seed the fuzzer dictionary

3 participants