diff --git a/Cargo.lock b/Cargo.lock index 771758a..e7b29be 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -135,65 +135,6 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" -[[package]] -name = "alloy-json-abi" -version = "1.5.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9dbe713da0c737d9e5e387b0ba790eb98b14dd207fe53eef50e19a5a8ec3dac" -dependencies = [ - "alloy-primitives", - "alloy-sol-type-parser", - "serde", - "serde_json", -] - -[[package]] -name = "alloy-primitives" -version = "1.5.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de3b431b4e72cd8bd0ec7a50b4be18e73dab74de0dba180eef171055e5d5926e" -dependencies = [ - "alloy-rlp", - "bytes", - "cfg-if", - "const-hex", - "derive_more", - "foldhash 0.2.0", - "hashbrown 0.16.1", - "indexmap", - "itoa", - "k256", - "keccak-asm", - "paste", - "proptest", - "rand 0.9.2", - "rapidhash", - "ruint", - "rustc-hash", - "serde", - "sha3", -] - -[[package]] -name = "alloy-rlp" -version = "0.3.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e93e50f64a77ad9c5470bf2ad0ca02f228da70c792a8f06634801e202579f35e" -dependencies = [ - "arrayvec", - "bytes", -] - -[[package]] -name = "alloy-sol-type-parser" -version = "1.5.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6df77fea9d6a2a75c0ef8d2acbdfd92286cc599983d3175ccdc170d3433d249" -dependencies = [ - "serde", - "winnow 0.7.15", -] - [[package]] name = "anchor-lang-idl" version = "0.1.2" @@ -226,17 +167,6 @@ dependencies = [ "libc", ] -[[package]] -name = "annotate-snippets" -version = "0.12.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74fc7650eedcb2fee505aad48491529e408f0e854c2d9f63eb86c1361b9b3f93" -dependencies = [ - "anstyle", - "memchr", - "unicode-width", -] - [[package]] name = "ansi_term" version = "0.12.1" @@ -246,21 +176,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "anstream" -version = "0.6.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" -dependencies = [ - "anstyle", - "anstyle-parse 0.2.7", - "anstyle-query", - "anstyle-wincon", - "colorchoice", - "is_terminal_polyfill", - "utf8parse", -] - [[package]] name = "anstream" version = "1.0.0" @@ -268,7 +183,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" dependencies = [ "anstyle", - "anstyle-parse 1.0.0", + "anstyle-parse", "anstyle-query", "anstyle-wincon", "colorchoice", @@ -282,15 +197,6 @@ version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" -[[package]] -name = "anstyle-parse" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" -dependencies = [ - "utf8parse", -] - [[package]] name = "anstyle-parse" version = "1.0.0" @@ -386,24 +292,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "ark-ff" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b3235cc41ee7a12aaaf2c575a2ad7b46713a8a50bda2fc3b003a04845c05dd6" -dependencies = [ - "ark-ff-asm 0.3.0", - "ark-ff-macros 0.3.0", - "ark-serialize 0.3.0", - "ark-std 0.3.0", - "derivative", - "num-bigint 0.4.6", - "num-traits", - "paste", - "rustc_version 0.3.3", - "zeroize", -] - [[package]] name = "ark-ff" version = "0.4.2" @@ -420,7 +308,7 @@ dependencies = [ "num-bigint 0.4.6", "num-traits", "paste", - "rustc_version 0.4.1", + "rustc_version", "zeroize", ] @@ -444,16 +332,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "ark-ff-asm" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db02d390bf6643fb404d3d22d31aee1c4bc4459600aef9113833d17e786c6e44" -dependencies = [ - "quote", - "syn 1.0.109", -] - [[package]] name = "ark-ff-asm" version = "0.4.2" @@ -474,18 +352,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "ark-ff-macros" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db2fd794a08ccb318058009eefdf15bcaaaaf6f8161eb3345f907222bac38b20" -dependencies = [ - "num-bigint 0.4.6", - "num-traits", - "quote", - "syn 1.0.109", -] - [[package]] name = "ark-ff-macros" version = "0.4.2" @@ -540,16 +406,6 @@ dependencies = [ "hashbrown 0.15.5", ] -[[package]] -name = "ark-serialize" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d6c2b318ee6e10f8c2853e73a83adc0ccb88995aa978d8a3408d492ab2ee671" -dependencies = [ - "ark-std 0.3.0", - "digest 0.9.0", -] - [[package]] name = "ark-serialize" version = "0.4.2" @@ -597,16 +453,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "ark-std" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1df2c09229cbc5a028b1d70e00fdb2acee28b1055dfb5ca73eea49c5a25c4e7c" -dependencies = [ - "num-traits", - "rand 0.8.5", -] - [[package]] name = "ark-std" version = "0.4.0" @@ -851,17 +697,6 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" -[[package]] -name = "auto_impl" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffdcb70bdbc4d478427380519163274ac86e52916e10f0a8889adf0f96d3fee7" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "autocfg" version = "1.5.0" @@ -1018,18 +853,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "bitvec" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" -dependencies = [ - "funty", - "radium", - "tap", - "wyz", -] - [[package]] name = "blake3" version = "1.8.5" @@ -1115,12 +938,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "boxcar" -version = "0.2.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36f64beae40a84da1b4b26ff2761a5b895c12adc41dc25aaee1c4f2bbfe97a6e" - [[package]] name = "bs58" version = "0.5.1" @@ -1146,12 +963,6 @@ dependencies = [ "serde", ] -[[package]] -name = "byte-slice-cast" -version = "1.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7575182f7272186991736b70173b0ea045398f984bf5ebbb3804736ce1330c9d" - [[package]] name = "bytecount" version = "0.6.9" @@ -1189,18 +1000,6 @@ name = "bytes" version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" -dependencies = [ - "serde", -] - -[[package]] -name = "castaway" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" -dependencies = [ - "rustversion", -] [[package]] name = "cc" @@ -1275,7 +1074,7 @@ version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" dependencies = [ - "anstream 1.0.0", + "anstream", "anstyle", "clap_lex", "strsim", @@ -1333,20 +1132,6 @@ dependencies = [ "unreachable", ] -[[package]] -name = "compact_str" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fdb1325a1cece981e8a296ab8f0f9b63ae357bd0784a9faaf548cc7b480707a" -dependencies = [ - "castaway", - "cfg-if", - "itoa", - "rustversion", - "ryu", - "static_assertions", -] - [[package]] name = "concurrent-queue" version = "2.5.0" @@ -1356,44 +1141,12 @@ dependencies = [ "crossbeam-utils", ] -[[package]] -name = "const-hex" -version = "1.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "531185e432bb31db1ecda541e9e7ab21468d4d844ad7505e0546a49b4945d49b" -dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "proptest", - "serde_core", -] - [[package]] name = "const-oid" version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" -[[package]] -name = "const_format" -version = "0.2.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7faa7469a93a566e9ccc1c73fe783b4a65c274c5ace346038dca9c39fe0030ad" -dependencies = [ - "const_format_proc_macros", -] - -[[package]] -name = "const_format_proc_macros" -version = "0.2.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d57c2eccfb16dbac1f4e61e206105db5820c9d26c3c472bc17c774259ef7744" -dependencies = [ - "proc-macro2", - "quote", - "unicode-xid", -] - [[package]] name = "constant_time_eq" version = "0.4.2" @@ -1453,25 +1206,6 @@ dependencies = [ "libc", ] -[[package]] -name = "crossbeam-deque" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" -dependencies = [ - "crossbeam-epoch", - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-epoch" -version = "0.9.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" -dependencies = [ - "crossbeam-utils", -] - [[package]] name = "crossbeam-utils" version = "0.8.21" @@ -1574,7 +1308,7 @@ dependencies = [ "digest 0.10.7", "fiat-crypto", "rand_core 0.6.4", - "rustc_version 0.4.1", + "rustc_version", "serde", "subtle", "zeroize", @@ -1660,20 +1394,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "dashmap" -version = "6.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" -dependencies = [ - "cfg-if", - "crossbeam-utils", - "hashbrown 0.14.5", - "lock_api", - "once_cell", - "parking_lot_core", -] - [[package]] name = "data-encoding" version = "2.10.0" @@ -1690,15 +1410,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "deranged" -version = "0.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" -dependencies = [ - "powerfmt", -] - [[package]] name = "derivation-path" version = "0.2.0" @@ -1734,9 +1445,8 @@ dependencies = [ "convert_case", "proc-macro2", "quote", - "rustc_version 0.4.1", + "rustc_version", "syn 2.0.117", - "unicode-xid", ] [[package]] @@ -1839,12 +1549,6 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" -[[package]] -name = "dunce" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" - [[package]] name = "dyn-clone" version = "1.0.20" @@ -2060,28 +1764,6 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" -[[package]] -name = "fastrlp" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139834ddba373bbdd213dffe02c8d110508dcf1726c2be27e8d1f7d7e1856418" -dependencies = [ - "arrayvec", - "auto_impl", - "bytes", -] - -[[package]] -name = "fastrlp" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce8dba4714ef14b8274c371879b175aa55b16b30f269663f19d576f380018dc4" -dependencies = [ - "arrayvec", - "auto_impl", - "bytes", -] - [[package]] name = "fd-lock" version = "4.0.4" @@ -2156,30 +1838,12 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "059c31d7d36c43fe39d89e55711858b4da8be7eb6dabac23c7289b1a19489406" -[[package]] -name = "fixed-hash" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "835c052cb0c08c1acf6ffd71c022172e18723949c8282f2b9f27efbc51e64534" -dependencies = [ - "byteorder", - "rand 0.8.5", - "rustc-hex", - "static_assertions", -] - [[package]] name = "fixedbitset" version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" -[[package]] -name = "fixedbitset" -version = "0.5.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" - [[package]] name = "fluent-uri" version = "0.4.1" @@ -2243,12 +1907,6 @@ dependencies = [ "num 0.4.3", ] -[[package]] -name = "funty" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" - [[package]] name = "futures" version = "0.3.32" @@ -2472,12 +2130,6 @@ dependencies = [ "ahash", ] -[[package]] -name = "hashbrown" -version = "0.14.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" - [[package]] name = "hashbrown" version = "0.15.5" @@ -2497,8 +2149,6 @@ dependencies = [ "allocator-api2", "equivalent", "foldhash 0.2.0", - "serde", - "serde_core", ] [[package]] @@ -2522,12 +2172,6 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" -[[package]] -name = "hex" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" - [[package]] name = "hmac" version = "0.12.1" @@ -2896,35 +2540,19 @@ dependencies = [ "anyhow", "clap", "colored", - "crossterm", "dirs", - "ilold-core", "ilold-help", "ilold-mcp", "ilold-render", "ilold-solana-core", "ilold-web", "nu-ansi-term", - "ratatui", "reedline", "reqwest", "serde_json", - "terminal_size", "tokio", ] -[[package]] -name = "ilold-core" -version = "0.1.0" -dependencies = [ - "ilold-session-core", - "petgraph 0.8.3", - "serde", - "serde_json", - "solar-compiler", - "thiserror 2.0.18", -] - [[package]] name = "ilold-help" version = "0.1.0" @@ -2956,7 +2584,6 @@ name = "ilold-render" version = "0.1.0" dependencies = [ "colored", - "ilold-core", "ilold-session-core", "ilold-solana-core", "serde_json", @@ -3005,7 +2632,6 @@ dependencies = [ "anyhow", "axum", "futures-util", - "ilold-core", "ilold-session-core", "ilold-solana-core", "portable-pty", @@ -3021,32 +2647,6 @@ dependencies = [ "tower-http", ] -[[package]] -name = "impl-codec" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba6a270039626615617f3f36d15fc827041df3b78c439da2cadfa47455a77f2f" -dependencies = [ - "parity-scale-codec", -] - -[[package]] -name = "impl-trait-for-tuples" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0eb5a3343abf848c0984fe4604b2b105da9539376e24fc0a3b0007411ae4fd9" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "index_vec" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44faf5bb8861a9c72e20d3fb0fdbd59233e43056e2b80475ab0aacdc2e781355" - [[package]] name = "indexmap" version = "2.13.0" @@ -3059,15 +2659,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "indoc" -version = "2.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" -dependencies = [ - "rustversion", -] - [[package]] name = "inout" version = "0.1.4" @@ -3077,32 +2668,6 @@ dependencies = [ "generic-array", ] -[[package]] -name = "instability" -version = "0.3.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" -dependencies = [ - "darling 0.23.0", - "indoc", - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "inturn" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2efbe120e37f17bb33fcdc82bc1c65087242608be37ace3cf7ebf49f3164e37" -dependencies = [ - "boxcar", - "bumpalo", - "dashmap", - "hashbrown 0.14.5", - "thread_local", -] - [[package]] name = "ioctl-rs" version = "0.1.6" @@ -3238,17 +2803,6 @@ dependencies = [ "signature", ] -[[package]] -name = "kasuari" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899" -dependencies = [ - "hashbrown 0.16.1", - "portable-atomic", - "thiserror 2.0.18", -] - [[package]] name = "keccak" version = "0.1.6" @@ -3258,16 +2812,6 @@ dependencies = [ "cpufeatures 0.2.17", ] -[[package]] -name = "keccak-asm" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa468878266ad91431012b3e5ef1bf9b170eab22883503a318d46857afa4579a" -dependencies = [ - "digest 0.10.7", - "sha3-asm", -] - [[package]] name = "kv-log-macro" version = "1.0.7" @@ -3288,7 +2832,7 @@ dependencies = [ "ena", "itertools 0.11.0", "lalrpop-util", - "petgraph 0.6.5", + "petgraph", "pico-args", "regex", "regex-syntax", @@ -3332,12 +2876,6 @@ version = "0.2.183" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" -[[package]] -name = "libm" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" - [[package]] name = "libredox" version = "0.1.15" @@ -3417,15 +2955,6 @@ dependencies = [ "thiserror 1.0.69", ] -[[package]] -name = "line-clipping" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f50e8f47623268b5407192d26876c4d7f89d686ca130fdc53bced4814cd29f8" -dependencies = [ - "bitflags 2.11.0", -] - [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -3526,15 +3055,6 @@ dependencies = [ "value-bag", ] -[[package]] -name = "lru" -version = "0.16.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1dc47f592c06f33f8e3aea9591776ec7c9f9e4124778ff8a3c3b87159f7e593" -dependencies = [ - "hashbrown 0.16.1", -] - [[package]] name = "matchers" version = "0.2.0" @@ -3648,12 +3168,6 @@ dependencies = [ "pin-utils", ] -[[package]] -name = "normalize-path" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5438dd2b2ff4c6df6e1ce22d825ed2fa93ee2922235cc45186991717f0a892d" - [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -3737,12 +3251,6 @@ dependencies = [ "num-traits", ] -[[package]] -name = "num-conv" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" - [[package]] name = "num-derive" version = "0.4.2" @@ -3804,16 +3312,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", - "libm", -] - -[[package]] -name = "num_threads" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" -dependencies = [ - "libc", ] [[package]] @@ -3828,18 +3326,6 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" -[[package]] -name = "once_map" -version = "0.4.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29eefd5038c9eee9e788d90966d6b5578dd3f88363a91edaec117a7ae0adc2d5" -dependencies = [ - "ahash", - "hashbrown 0.16.1", - "parking_lot", - "stable_deref_trait", -] - [[package]] name = "opaque-debug" version = "0.3.1" @@ -3902,34 +3388,6 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" -[[package]] -name = "parity-scale-codec" -version = "3.7.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "799781ae679d79a948e13d4824a40970bfa500058d245760dd857301059810fa" -dependencies = [ - "arrayvec", - "bitvec", - "byte-slice-cast", - "const_format", - "impl-trait-for-tuples", - "parity-scale-codec-derive", - "rustversion", - "serde", -] - -[[package]] -name = "parity-scale-codec-derive" -version = "3.7.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34b4653168b563151153c9e4c08ebed57fb8262bebfa79711552fa983c623e7a" -dependencies = [ - "proc-macro-crate", - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "parking" version = "2.2.1" @@ -3995,38 +3453,16 @@ dependencies = [ "num 0.2.1", ] -[[package]] -name = "pest" -version = "2.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" -dependencies = [ - "memchr", - "ucd-trie", -] - [[package]] name = "petgraph" version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" dependencies = [ - "fixedbitset 0.4.2", + "fixedbitset", "indexmap", ] -[[package]] -name = "petgraph" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" -dependencies = [ - "fixedbitset 0.5.7", - "hashbrown 0.15.5", - "indexmap", - "serde", -] - [[package]] name = "phf_shared" version = "0.11.3" @@ -4107,12 +3543,6 @@ dependencies = [ "universal-hash", ] -[[package]] -name = "portable-atomic" -version = "1.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" - [[package]] name = "portable-pty" version = "0.8.1" @@ -4143,12 +3573,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "powerfmt" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" - [[package]] name = "ppv-lite86" version = "0.2.21" @@ -4174,17 +3598,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "primitive-types" -version = "0.12.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b34d9fd68ae0b74a41b21c03c2f62847aa0ffea044eee893b4c140b37e244e2" -dependencies = [ - "fixed-hash", - "impl-codec", - "uint", -] - [[package]] name = "proc-macro-crate" version = "3.5.0" @@ -4203,25 +3616,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "proptest" -version = "1.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" -dependencies = [ - "bit-set 0.8.0", - "bit-vec 0.8.0", - "bitflags 2.11.0", - "num-traits", - "rand 0.9.2", - "rand_chacha 0.9.0", - "rand_xorshift", - "regex-syntax", - "rusty-fork", - "tempfile", - "unarray", -] - [[package]] name = "qstring" version = "0.7.2" @@ -4242,12 +3636,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "quick-error" -version = "1.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" - [[package]] name = "quote" version = "1.0.45" @@ -4269,12 +3657,6 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" -[[package]] -name = "radium" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" - [[package]] name = "rand" version = "0.7.3" @@ -4307,7 +3689,6 @@ checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" dependencies = [ "rand_chacha 0.9.0", "rand_core 0.9.5", - "serde", ] [[package]] @@ -4351,131 +3732,29 @@ dependencies = [ [[package]] name = "rand_core" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" -dependencies = [ - "getrandom 0.2.17", -] - -[[package]] -name = "rand_core" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" -dependencies = [ - "getrandom 0.3.4", - "serde", -] - -[[package]] -name = "rand_hc" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca3129af7b92a17112d59ad498c6f81eaf463253766b90396d39ea7a39d6613c" -dependencies = [ - "rand_core 0.5.1", -] - -[[package]] -name = "rand_xorshift" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" -dependencies = [ - "rand_core 0.9.5", -] - -[[package]] -name = "rapidhash" -version = "4.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5e48930979c155e2f33aa36ab3119b5ee81332beb6482199a8ecd6029b80b59" -dependencies = [ - "rustversion", -] - -[[package]] -name = "ratatui" -version = "0.30.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1ce67fb8ba4446454d1c8dbaeda0557ff5e94d39d5e5ed7f10a65eb4c8266bc" -dependencies = [ - "instability", - "ratatui-core", - "ratatui-crossterm", - "ratatui-widgets", -] - -[[package]] -name = "ratatui-core" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ef8dea09a92caaf73bff7adb70b76162e5937524058a7e5bff37869cbbec293" -dependencies = [ - "bitflags 2.11.0", - "compact_str", - "hashbrown 0.16.1", - "indoc", - "itertools 0.14.0", - "kasuari", - "lru", - "strum 0.27.2", - "thiserror 2.0.18", - "unicode-segmentation", - "unicode-truncate", - "unicode-width", -] - -[[package]] -name = "ratatui-crossterm" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "577c9b9f652b4c121fb25c6a391dd06406d3b092ba68827e6d2f09550edc54b3" -dependencies = [ - "cfg-if", - "crossterm", - "instability", - "ratatui-core", -] - -[[package]] -name = "ratatui-widgets" -version = "0.3.0" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7dbfa023cd4e604c2553483820c5fe8aa9d71a42eea5aa77c6e7f35756612db" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "bitflags 2.11.0", - "hashbrown 0.16.1", - "indoc", - "instability", - "itertools 0.14.0", - "line-clipping", - "ratatui-core", - "strum 0.27.2", - "time", - "unicode-segmentation", - "unicode-width", + "getrandom 0.2.17", ] [[package]] -name = "rayon" -version = "1.11.0" +name = "rand_core" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" dependencies = [ - "either", - "rayon-core", + "getrandom 0.3.4", ] [[package]] -name = "rayon-core" -version = "1.13.0" +name = "rand_hc" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +checksum = "ca3129af7b92a17112d59ad498c6f81eaf463253766b90396d39ea7a39d6613c" dependencies = [ - "crossbeam-deque", - "crossbeam-utils", + "rand_core 0.5.1", ] [[package]] @@ -4522,8 +3801,8 @@ dependencies = [ "nu-ansi-term", "serde", "strip-ansi-escapes", - "strum 0.26.3", - "strum_macros 0.26.4", + "strum", + "strum_macros", "thiserror 2.0.18", "unicase", "unicode-segmentation", @@ -4660,16 +3939,6 @@ dependencies = [ "windows-sys 0.52.0", ] -[[package]] -name = "rlp" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb919243f34364b6bd2fc10ef797edbfa75f33c252e7998527479c6d6b47e1ec" -dependencies = [ - "bytes", - "rustc-hex", -] - [[package]] name = "rmcp" version = "1.6.0" @@ -4705,74 +3974,19 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "ruint" -version = "1.17.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c141e807189ad38a07276942c6623032d3753c8859c146104ac2e4d68865945a" -dependencies = [ - "alloy-rlp", - "ark-ff 0.3.0", - "ark-ff 0.4.2", - "ark-ff 0.5.0", - "bytes", - "fastrlp 0.3.1", - "fastrlp 0.4.0", - "num-bigint 0.4.6", - "num-integer", - "num-traits", - "parity-scale-codec", - "primitive-types", - "proptest", - "rand 0.8.5", - "rand 0.9.2", - "rlp", - "ruint-macro", - "serde_core", - "valuable", - "zeroize", -] - -[[package]] -name = "ruint-macro" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48fd7bd8a6377e15ad9d42a8ec25371b94ddc67abe7c8b9127bec79bebaaae18" - [[package]] name = "rustc-demangle" version = "0.1.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" -[[package]] -name = "rustc-hash" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" - -[[package]] -name = "rustc-hex" -version = "2.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e75f6a532d0fd9f7f13144f392b6ad56a32696bfcd9c78f797f16bbb6f072d6" - -[[package]] -name = "rustc_version" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0dfe2087c51c460008730de8b57e6a320782fbfb312e1f4d520e6c6fae155ee" -dependencies = [ - "semver 0.11.0", -] - [[package]] name = "rustc_version" version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" dependencies = [ - "semver 1.0.27", + "semver", ] [[package]] @@ -4827,18 +4041,6 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" -[[package]] -name = "rusty-fork" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" -dependencies = [ - "fnv", - "quick-error", - "tempfile", - "wait-timeout", -] - [[package]] name = "ryu" version = "1.0.23" @@ -4889,12 +4091,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "scoped-tls" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" - [[package]] name = "scopeguard" version = "1.2.0" @@ -4938,30 +4134,12 @@ dependencies = [ "libc", ] -[[package]] -name = "semver" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f301af10236f6df4160f7c3f04eec6dbc70ace82d23326abad5edee88801c6b6" -dependencies = [ - "semver-parser", -] - [[package]] name = "semver" version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" -[[package]] -name = "semver-parser" -version = "0.10.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9900206b54a3527fdc7b8a938bffd94a568bac4f4aa8113b209df75a09c0dec2" -dependencies = [ - "pest", -] - [[package]] name = "serde" version = "1.0.228" @@ -5183,16 +4361,6 @@ dependencies = [ "keccak", ] -[[package]] -name = "sha3-asm" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59cbb88c189d6352cc8ae96a39d19c7ecad8f7330b29461187f2587fdc2988d5" -dependencies = [ - "cc", - "cfg-if", -] - [[package]] name = "sharded-slab" version = "0.1.7" @@ -6657,152 +5825,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "solar-ast" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b6aaf98d032ba3be85dca5f969895ade113a9137bb5956f80c5faf14689de59" -dependencies = [ - "alloy-primitives", - "bumpalo", - "either", - "num-rational 0.4.2", - "semver 1.0.27", - "solar-data-structures", - "solar-interface", - "solar-macros", - "strum 0.27.2", -] - -[[package]] -name = "solar-compiler" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95e792060bcbb007a6b9b060292945fb34ff854c7d93a9628f81b6c809eb4360" -dependencies = [ - "alloy-primitives", - "solar-ast", - "solar-config", - "solar-data-structures", - "solar-interface", - "solar-macros", - "solar-parse", - "solar-sema", -] - -[[package]] -name = "solar-config" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff16d692734c757edd339f5db142ba91b42772f8cbe1db1ce3c747f1e777185f" -dependencies = [ - "colorchoice", - "strum 0.27.2", -] - -[[package]] -name = "solar-data-structures" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dea34e58332c7d6a8cde1f1740186d31682b7be46e098b8cc16fcb7ffd98bf5" -dependencies = [ - "bumpalo", - "index_vec", - "indexmap", - "parking_lot", - "rayon", - "rustc-hash", - "smallvec", -] - -[[package]] -name = "solar-interface" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d6163af2e773f4d455212fa9ba2c0664506029dd26232eb406f5046092ac311" -dependencies = [ - "annotate-snippets", - "anstream 0.6.21", - "anstyle", - "derive_more", - "dunce", - "inturn", - "itertools 0.14.0", - "itoa", - "normalize-path", - "once_map", - "rayon", - "scoped-tls", - "serde", - "serde_json", - "solar-config", - "solar-data-structures", - "solar-macros", - "thiserror 2.0.18", - "tracing", - "unicode-width", -] - -[[package]] -name = "solar-macros" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44a98045888d75d17f52e7b76f6098844b76078b5742a450c3ebcdbdb02da124" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "solar-parse" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19b77a9cbb07948e4586cdcf64f0a483424197308816ebd57a4cf06130b68562" -dependencies = [ - "alloy-primitives", - "bitflags 2.11.0", - "bumpalo", - "itertools 0.14.0", - "memchr", - "num-bigint 0.4.6", - "num-rational 0.4.2", - "num-traits", - "ruint", - "smallvec", - "solar-ast", - "solar-data-structures", - "solar-interface", - "tracing", -] - -[[package]] -name = "solar-sema" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd033af43a38da316a04b25bbd20b121ce5d728b61e6988fd8fd6e2f1e68d0a1" -dependencies = [ - "alloy-json-abi", - "alloy-primitives", - "bitflags 2.11.0", - "bumpalo", - "derive_more", - "either", - "once_map", - "paste", - "rayon", - "serde", - "serde_json", - "solar-ast", - "solar-data-structures", - "solar-interface", - "solar-macros", - "solar-parse", - "strum 0.27.2", - "thread_local", - "tracing", -] - [[package]] name = "spki" version = "0.7.3" @@ -6819,12 +5841,6 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" -[[package]] -name = "static_assertions" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" - [[package]] name = "string_cache" version = "0.8.9" @@ -6858,15 +5874,6 @@ version = "0.26.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" -[[package]] -name = "strum" -version = "0.27.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" -dependencies = [ - "strum_macros 0.27.2", -] - [[package]] name = "strum_macros" version = "0.26.4" @@ -6880,18 +5887,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "strum_macros" -version = "0.27.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" -dependencies = [ - "heck 0.5.0", - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "subtle" version = "2.6.1" @@ -6961,12 +5956,6 @@ dependencies = [ "libc", ] -[[package]] -name = "tap" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" - [[package]] name = "tempfile" version = "3.27.0" @@ -6991,16 +5980,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "terminal_size" -version = "0.4.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" -dependencies = [ - "rustix", - "windows-sys 0.61.2", -] - [[package]] name = "termios" version = "0.2.2" @@ -7059,27 +6038,6 @@ dependencies = [ "cfg-if", ] -[[package]] -name = "time" -version = "0.3.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" -dependencies = [ - "deranged", - "libc", - "num-conv", - "num_threads", - "powerfmt", - "serde_core", - "time-core", -] - -[[package]] -name = "time-core" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" - [[package]] name = "tiny-keccak" version = "2.0.2" @@ -7217,7 +6175,7 @@ dependencies = [ "indexmap", "toml_datetime", "toml_parser", - "winnow 1.0.0", + "winnow", ] [[package]] @@ -7226,7 +6184,7 @@ version = "1.1.0+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2334f11ee363607eb04df9b8fc8a13ca1715a72ba8662a26ac285c98aabb4011" dependencies = [ - "winnow 1.0.0", + "winnow", ] [[package]] @@ -7394,30 +6352,6 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" -[[package]] -name = "ucd-trie" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" - -[[package]] -name = "uint" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76f64bba2c53b04fcab63c01a7d7427eadc821e3bc48c34dc9ba29c501164b52" -dependencies = [ - "byteorder", - "crunchy", - "hex", - "static_assertions", -] - -[[package]] -name = "unarray" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" - [[package]] name = "unicase" version = "2.9.0" @@ -7442,17 +6376,6 @@ version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" -[[package]] -name = "unicode-truncate" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" -dependencies = [ - "itertools 0.14.0", - "unicode-segmentation", - "unicode-width", -] - [[package]] name = "unicode-width" version = "0.2.2" @@ -7585,15 +6508,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "wait-timeout" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" -dependencies = [ - "libc", -] - [[package]] name = "walkdir" version = "2.5.0" @@ -7729,7 +6643,7 @@ dependencies = [ "bitflags 2.11.0", "hashbrown 0.15.5", "indexmap", - "semver 1.0.27", + "semver", ] [[package]] @@ -7950,15 +6864,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "winnow" -version = "0.7.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" -dependencies = [ - "memchr", -] - [[package]] name = "winnow" version = "1.0.0" @@ -8057,7 +6962,7 @@ dependencies = [ "id-arena", "indexmap", "log", - "semver 1.0.27", + "semver", "serde", "serde_derive", "serde_json", @@ -8071,15 +6976,6 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" -[[package]] -name = "wyz" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" -dependencies = [ - "tap", -] - [[package]] name = "yoke" version = "0.8.2" diff --git a/Cargo.toml b/Cargo.toml index e39b8f2..e4f1a1c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,6 @@ [workspace] resolver = "2" members = [ - "crates/ilold-core", "crates/ilold-cli", "crates/ilold-web", "crates/ilold-session-core", @@ -18,11 +17,9 @@ authors = ["scab24 "] license = "AGPL-3.0-only" repository = "https://github.com/scab24/ilold" homepage = "https://github.com/scab24/ilold" -description = "Execution path analyzer and interactive security workbench for smart contracts" +description = "Solana program execution path analyzer and interactive security workbench" [workspace.dependencies] -solar = { version = "=0.1.8", package = "solar-compiler", default-features = false } -petgraph = "0.8" serde = { version = "1", features = ["derive"] } serde_json = "1" thiserror = "2" diff --git a/README.md b/README.md index 6624ef3..6057464 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,14 @@ # ilold -Execution path analyzer and interactive security workbench for smart contracts. It maps every possible path through a protocol (every branch, function combination, and state mutation) and lets users navigate them visually, branch by branch, with an LLM reasoning over each path. +Solana program execution path analyzer and interactive security workbench. It maps every reachable instruction path through an Anchor program, executes them on a real LiteSVM, and lets the auditor (or an LLM agent) navigate branches, fork scenarios, and ship a Markdown deliverable. ![ilold](images/Ilold.jpg) ## What it does -ilold loads a smart contract project, builds an in-memory model, and drops the auditor into an interactive REPL backed by a live web canvas. Each command answers a question about the protocol: list entry points, add a call to a session, inspect state changes, trace execution flow, slice data dependencies, record findings, export a report. The canvas reflects the same state in a visual graph that the user navigates by clicking, expanding and forking. +ilold loads an Anchor workspace, builds an in-memory model from the IDL, boots a LiteSVM with the compiled `.so`, and drops the auditor into an interactive REPL backed by a live web canvas. Each command answers a question about the program: list instructions, add a call to a scenario, inspect decoded account state, fork the timeline, record a finding, export the report. The canvas reflects the same state in a visual graph that the user navigates by clicking, expanding and forking. -Two backends, one shell: - -| Backend | Input | Execution model | -| --- | --- | --- | -| Solana | Anchor project (`Anchor.toml` + IDL + `.so`) | Concrete execution on LiteSVM with scenario forking | -| Solidity | `.sol` sources | Static analysis: parser, CFG, path tree, slicer | - -The Solana backend also ships a Model Context Protocol (MCP) server so an LLM agent can drive the audit end to end while the canvas reflects every step in real time. +ilold also ships a Model Context Protocol (MCP) server so any LLM client (Claude Code, Cursor, Continue) can drive the audit end to end while the canvas reflects every step in real time. ## Quick start @@ -27,7 +20,7 @@ cd ilold cargo build --release ``` -The binary is at `target/release/ilold` with four subcommands: `analyze`, `context`, `serve`, and `explore`. +The binary is at `target/release/ilold` with three subcommands: `serve`, `explore`, and `mcp`. ## Solana backend @@ -52,7 +45,7 @@ ilold[staking]> finding High "..." ilold[staking]> export ``` -### LLM-driven audit (Solana via MCP) +### LLM-driven audit (MCP) Register the MCP server once in your client: @@ -67,37 +60,6 @@ Then ask the LLM in natural language: The LLM picks the right tools, the backend executes them in LiteSVM, and the canvas reflects each step in real time. The MCP server is agnostic to the active program: a single registration works against multi-program workspaces, the LLM switches with `ilold_use `. -## Solidity backend - -Contracts are parsed into a typed model. Per-function CFGs and path trees drive the interactive analysis commands: `info`, `trace`, `slice`, `timeline`, and sequence narratives. - -![Solidity canvas](images/diagram_solidity.png) - -``` -./target/release/ilold explore tests/fixtures/staking.sol -``` - -``` -ilold[Staking]> f -ilold[Staking]> c deposit -ilold[Staking → deposit]> s -ilold[Staking → deposit]> who totalStaked -ilold[Staking → deposit]> sl deposit totalStaked -ilold[Staking → deposit]> tr deposit -``` - -For one-shot static analysis without the REPL: - -``` -./target/release/ilold analyze tests/fixtures/staking.sol --max-seq-depth 5 -``` - -## Trace and slice (Solidity) - -`trace` builds the full execution tree of a function with modifier bodies inlined, requires highlighted, state writes flagged, and external calls marked. `slice` produces backward and forward dataflow slices for any variable in any function. Both are static and only available on the Solidity side today; Solana coverage is reconstructed at runtime via `coverage` and the runtime overlay until the AST + CFG layer lands (see [Roadmap](docs/guide/src/roadmap/solana.md)). - -![Solidity trace command output](images/tr_menu.png) - ## Documentation The published book lives at **[scab24.github.io/ilold](https://scab24.github.io/ilold/)**. Sources are in [`docs/guide/`](docs/guide/src/SUMMARY.md); build it locally with: @@ -111,7 +73,6 @@ Key pages: - [Introduction](docs/guide/src/introduction.md) - [Getting Started](docs/guide/src/getting-started.md) - [Solana Backend](docs/guide/src/solana/overview.md) -- [Solidity Backend](docs/guide/src/solidity/overview.md) - [MCP server](docs/guide/src/reference/mcp.md) - [Roadmap](docs/guide/src/roadmap/solana.md) @@ -119,19 +80,20 @@ Key pages: MVP scope shipped: -- Typed graph from Anchor IDL (Solana) and `solar-compiler` AST (Solidity) +- Typed graph from Anchor IDL with discriminator + admin-gating + coupling metadata - Executable scenarios with fork from any step, save/load, runtime overlay - LLM-aware REPL with structured `?` help per command - MCP server with 30 typed tools agnostic to the active program -- Source viewer parity Solana - Solidity with "open in IDE" deep links +- Source viewer with "open in IDE" deep links - Markdown audit deliverable with severity matrix and methodology Documented future work (see [Roadmap](docs/guide/src/roadmap/solana.md)): -- AST + CFG layer on the Solana side via Elozer, our in-house static analyzer +- AST + CFG layer for Anchor handlers via Elozer, our in-house static analyzer - Detector engine measured against the public sealevel-attacks corpus - Pre-built attack-pattern query catalog -- MCP server for the Solidity backend + +For the legacy Solidity backend, see the standalone [`ilold-evm`](https://github.com/scab24/ilold-evm) repo. ## Project layout @@ -139,16 +101,15 @@ Single Rust monorepo: ``` crates/ - ilold-cli Interactive shell + analyze/context CLI + ilold-cli Interactive shell + serve/explore/mcp CLI ilold-solana-core Solana engine (Anchor IDL + LiteSVM + runtime overlay) - ilold-core Solidity engine (parser, CFG, path tree, slicer) ilold-session-core Shared session, scenarios, findings, export ilold-web REST + WebSocket server (Svelte frontend inside) ilold-mcp MCP server for LLM agent integration ilold-render Pretty printers shared by CLI and MCP ilold-help Tool registry shared by REPL help and MCP tests/ - fixtures/ Anchor + Solidity Foundry fixtures (binaries committed) + fixtures/solana/ Anchor fixtures (binaries committed) scenarios/ Bash + Python end-to-end suite docs/guide/ Public mdbook documentation ``` diff --git a/crates/ilold-cli/Cargo.toml b/crates/ilold-cli/Cargo.toml index 002b150..80c3c6d 100644 --- a/crates/ilold-cli/Cargo.toml +++ b/crates/ilold-cli/Cargo.toml @@ -5,14 +5,13 @@ edition.workspace = true authors.workspace = true license.workspace = true repository.workspace = true -description = "CLI for Ilold: smart contract execution path analyzer" +description = "CLI for Ilold: Solana program execution path analyzer" [[bin]] name = "ilold" path = "src/main.rs" [dependencies] -ilold-core = { path = "../ilold-core" } ilold-solana-core = { path = "../ilold-solana-core" } ilold-web = { path = "../ilold-web" } ilold-mcp = { path = "../ilold-mcp" } @@ -27,6 +26,3 @@ nu-ansi-term = "0.50" reqwest = { version = "0.12", features = ["json"] } serde_json = { workspace = true } dirs = "6" -terminal_size = "0.4" -ratatui = { version = "0.30", default-features = false, features = ["crossterm_0_29"] } -crossterm = "0.29" diff --git a/crates/ilold-cli/src/analyze.rs b/crates/ilold-cli/src/analyze.rs deleted file mode 100644 index 361b910..0000000 --- a/crates/ilold-cli/src/analyze.rs +++ /dev/null @@ -1,251 +0,0 @@ -use std::collections::HashMap; -use std::path::PathBuf; - -use anyhow::{Context, Result}; -use colored::Colorize; - -use ilold_core::callgraph::builder::build_call_graph; -use ilold_core::callgraph::types::CallKind; -use ilold_core::cfg::builder::CfgBuilder; -use ilold_core::classify::entry_points::classify_function; -use ilold_core::model::contract::{ContractDef, ContractKind}; -use ilold_core::model::function::Visibility; -use ilold_core::model::project::Project; -use ilold_core::parse::solar_frontend::SolarParser; -use ilold_core::parse::ProjectParser; -use ilold_core::pathtree::config::PruningConfig; -use ilold_core::pathtree::types::PathTree; -use ilold_core::pathtree::walker::build_path_tree; -use ilold_core::sequence::analysis::{analyze_project, analyze_sequences, SequenceAnalysis}; -use ilold_core::sequence::builder::build_sequence_tree; - -use crate::colors::*; - -pub fn run( - path: &PathBuf, - contract_filter: Option<&str>, - max_seq_depth: usize, - verbose: bool, -) -> Result<()> { - let paths = crate::collect_sol_files(path)?; - if paths.is_empty() { - anyhow::bail!("No .sol files found at {}", path.display()); - } - - let parser = SolarParser; - let mut project = parser - .parse(&paths) - .context(format!("Failed to parse {}", path.display()))?; - project.rebuild_index(); - - println!("Parsed {} file(s), {} contract(s)\n", - project.source_files.len(), project.contracts.len()); - - let config = PruningConfig::default(); - let mut all_analyses: HashMap = HashMap::new(); - for contract in &project.contracts { - let combined_state_vars = project.inherited_state_vars(contract); - let mut pt_map: HashMap<(String, String), PathTree> = HashMap::new(); - for func in &contract.functions { - if let Ok(cfg) = CfgBuilder::build_with_project(func, contract, Some(&project)) { - let pt = build_path_tree(&cfg, &contract.name, &func.name, &combined_state_vars, &config); - pt_map.insert((contract.name.clone(), func.name.clone()), pt); - } - } - let analysis = analyze_sequences(&pt_map, &contract.name); - all_analyses.insert(contract.name.clone(), analysis); - } - analyze_project(&project, &mut all_analyses); - - for contract in &project.contracts { - if let Some(filter) = contract_filter { - if contract.name != filter { continue; } - } - print_contract(&project, contract, max_seq_depth, verbose, &all_analyses); - } - - Ok(()) -} - -fn print_contract( - project: &Project, - contract: &ContractDef, - max_seq_depth: usize, - verbose: bool, - all_analyses: &HashMap, -) { - let kind_str = match contract.kind { - ContractKind::Contract => "contract", - ContractKind::Interface => "interface", - ContractKind::Library => "library", - ContractKind::Abstract => "abstract", - }; - - println!("{} {} ({} functions, {} state vars)", - kind_str.dimmed(), contract.name.bold(), contract.functions.len(), contract.state_vars.len()); - - if !contract.inherits.is_empty() { - println!(" {} {}", "inherits:".dimmed(), contract.inherits.join(", ")); - } - - let config = PruningConfig::default(); - let mut path_trees: Vec = Vec::new(); - let combined_state_vars = project.inherited_state_vars(contract); - - for func in &contract.functions { - let display_name = if func.name.is_empty() { - format!("{:?}", func.kind).to_lowercase() - } else { - func.name.clone() - }; - - let access = classify_function(func, contract); - let vis = match func.visibility { - Visibility::Public => "public", - Visibility::External => "external", - Visibility::Internal => "internal", - Visibility::Private => "private", - }; - - match CfgBuilder::build(func, contract) { - Ok(cfg) => { - let pt = build_path_tree( - &cfg, &contract.name, &func.name, &combined_state_vars, &config, - ); - - println!(" {} {} {} — {} blocks, {} edges, {} paths ({} happy, {} revert)", - access_colored(&access), c_muted(vis), c_bright(&display_name), - cfg.node_count(), cfg.edge_count(), - pt.stats.total_paths, - c_ok(&pt.stats.happy_paths.to_string()), - c_danger(&pt.stats.revert_paths.to_string())); - - if verbose { - for node in cfg.node_indices() { - let block = &cfg[node]; - println!(" {} {:?} {}", - c_muted(&format!("[{}]", block.id)), - block.kind, - c_muted(&format!("({} stmts)", block.statements.len()))); - } - for edge in cfg.edge_indices() { - let (src, dst) = cfg.edge_endpoints(edge).unwrap(); - println!(" {} {} {} {}", - cfg[src].id, c_muted("→"), cfg[dst].id, c_muted(&format!("{:?}", cfg[edge]))); - } - println!(); - } - - path_trees.push(pt); - } - Err(e) => { - println!(" {} {} {} — {}", - access_colored(&access), c_muted(vis), display_name, c_danger(&format!("CFG error: {e}"))); - } - } - } - - if verbose { - let cg = build_call_graph(project, contract); - let edges: Vec<_> = cg.edge_indices().collect(); - if !edges.is_empty() { - println!(" {}", c_muted("Call graph:")); - for edge_idx in edges { - let (src, dst) = cg.edge_endpoints(edge_idx).unwrap(); - let edge = &cg[edge_idx]; - let kind_color = match edge.kind { - CallKind::Internal => c_muted("internal"), - CallKind::External => c_danger("external"), - CallKind::Inherited => c_muted("inherited"), - }; - println!(" {} {} {} {}", - c_bright(&cg[src].function), c_muted("→"), - c_accent(&format!("{}.{}", cg[dst].contract, cg[dst].function)), - kind_color); - } - println!(); - } - } - - if contract.kind != ContractKind::Interface { - let st = build_sequence_tree(contract, &path_trees, max_seq_depth); - if !st.functions.is_empty() { - let state_changing = st.functions.iter().filter(|f| !f.read_only).count(); - let read_only = st.functions.iter().filter(|f| f.read_only).count(); - - println!(" {} {} total ({} functions: {} state-changing, {} read-only)", - c_muted(&format!("Sequences (depth {}):", max_seq_depth)), - st.sequences.len(), - st.functions.len(), state_changing, read_only); - - if verbose { - for d in 1..=max_seq_depth { - let count = st.sequences.iter().filter(|s| s.depth == d).count(); - println!(" {} {} sequences", c_muted(&format!("depth {}:", d)), count); - } - println!(); - } - } - - let default_analysis; - let analysis = match all_analyses.get(&contract.name) { - Some(a) => a, - None => { - default_analysis = analyze_sequences(&HashMap::new(), &contract.name); - &default_analysis - } - }; - - if verbose { - println!(" {}", c_muted("Function behaviors:")); - let func_count = analysis.functions.len(); - for (i, func) in analysis.functions.iter().enumerate() { - let is_last_func = i == func_count - 1; - let branch = if is_last_func { "└── " } else { "├── " }; - let pipe = if is_last_func { " " } else { "│ " }; - - let view_tag = if func.read_only { c_muted(" (view)").to_string() } else { String::new() }; - println!(" {}{}{}", c_muted(branch), c_bright(&func.name), view_tag); - - if !func.preconditions.is_empty() { - println!(" {}├── {} {}", c_muted(pipe), c_muted("requires:"), c_warn(&func.preconditions.join(", "))); - } - if !func.state_writes.is_empty() { - println!(" {}├── {} {}", c_muted(pipe), c_muted("writes:"), c_accent(&func.state_writes.join(", "))); - } - if !func.external_calls.is_empty() { - println!(" {}├── {} {}", c_muted(pipe), c_muted("calls:"), c_danger(&func.external_calls.join(", "))); - } - if !func.events.is_empty() { - println!(" {}├── {} {}", c_muted(pipe), c_muted("emits:"), func.events.join(", ")); - } - - let outgoing: Vec<_> = analysis.transitions.iter() - .filter(|t| t.from == func.name && (!t.shared_state.is_empty() || !t.conditions_affected.is_empty())) - .collect(); - - if !outgoing.is_empty() { - println!(" {}└── {}", c_muted(pipe), c_muted("transitions:")); - let out_count = outgoing.len(); - for (j, t) in outgoing.iter().enumerate() { - let is_last_t = j == out_count - 1; - let t_branch = if is_last_t { "└── " } else { "├── " }; - let t_pipe = if is_last_t { " " } else { "│ " }; - - println!(" {} {}→ {}", c_muted(pipe), c_muted(t_branch), c_bright(&t.to)); - - if !t.shared_state.is_empty() { - println!(" {} {}{} {}", c_muted(pipe), c_muted(t_pipe), c_muted("shared:"), c_accent(&t.shared_state.join(", "))); - } - for cond in &t.conditions_affected { - println!(" {} {}{}", c_muted(pipe), c_muted(t_pipe), c_warn(cond)); - } - } - } - } - println!(); - } - } - - println!(); -} diff --git a/crates/ilold-cli/src/colors.rs b/crates/ilold-cli/src/colors.rs index 10a86fc..6f3d750 100644 --- a/crates/ilold-cli/src/colors.rs +++ b/crates/ilold-cli/src/colors.rs @@ -1 +1,43 @@ pub use ilold_render::colors::*; + +use colored::Colorize; + +pub fn header_box(lines: &[&str]) -> String { + let max_content = lines.iter().map(|l| l.chars().count()).max().unwrap_or(0); + let inner_width = max_content + 2; + + let mut out = String::new(); + out.push_str(&format!( + " {}{}{}\n", + "╭".truecolor(60, 70, 90), + "─".repeat(inner_width).truecolor(60, 70, 90), + "╮".truecolor(60, 70, 90), + )); + for line in lines { + let visible_len = line.chars().count(); + let pad = max_content.saturating_sub(visible_len); + out.push_str(&format!( + " {} {}{} {}\n", + "│".truecolor(60, 70, 90), + line, + " ".repeat(pad), + "│".truecolor(60, 70, 90), + )); + } + out.push_str(&format!( + " {}{}{}", + "╰".truecolor(60, 70, 90), + "─".repeat(inner_width).truecolor(60, 70, 90), + "╯".truecolor(60, 70, 90), + )); + out +} + +pub fn pad_right(s: &str, width: usize) -> String { + let len = s.chars().count(); + if len >= width { + s.to_string() + } else { + format!("{}{}", s, " ".repeat(width - len)) + } +} diff --git a/crates/ilold-cli/src/context.rs b/crates/ilold-cli/src/context.rs deleted file mode 100644 index d7b4a03..0000000 --- a/crates/ilold-cli/src/context.rs +++ /dev/null @@ -1,293 +0,0 @@ -use std::collections::HashMap; -use std::path::PathBuf; - -use anyhow::{Context, Result}; - -use ilold_core::cfg::builder::CfgBuilder; -use ilold_core::cfg::types::CfgGraph; -use ilold_core::classify::entry_points::{classify_all, AccessLevel}; -use ilold_core::model::contract::ContractDef; -use ilold_core::model::project::Project; -use ilold_core::narrative::function::build_function_narrative; -use ilold_core::narrative::sequence::build_sequence_narrative; -use ilold_core::narrative::types::*; -use ilold_core::parse::solar_frontend::SolarParser; -use ilold_core::parse::ProjectParser; -use ilold_core::pathtree::config::PruningConfig; -use ilold_core::pathtree::types::{PathTree, TerminalKind}; -use ilold_core::pathtree::walker::build_path_tree; -use ilold_core::sequence::analysis::{analyze_project, analyze_sequences}; - -use crate::colors::*; - -pub fn run( - path: &PathBuf, - contract_filter: Option<&str>, - function_filter: Option<&str>, - sequence_filter: Option<&str>, - list: bool, -) -> Result<()> { - let paths = crate::collect_sol_files(path)?; - if paths.is_empty() { anyhow::bail!("No .sol files found at {}", path.display()); } - - let parser = SolarParser; - let mut project = parser.parse(&paths).context("Failed to parse")?; - project.rebuild_index(); - - let contract = find_contract(&project, contract_filter)?; - let classifications = classify_all(contract); - - if list { - print_list(contract, &classifications); - return Ok(()); - } - - let config = PruningConfig::default(); - let mut cfgs: HashMap<(String, String), CfgGraph> = HashMap::new(); - let mut pt_map: HashMap<(String, String), PathTree> = HashMap::new(); - let combined_state_vars = project.inherited_state_vars(contract); - for func in &contract.functions { - if let Ok(cfg) = CfgBuilder::build_with_project(func, contract, Some(&project)) { - let pt = build_path_tree(&cfg, &contract.name, &func.name, &combined_state_vars, &config); - let key = (contract.name.clone(), func.name.clone()); - cfgs.insert(key.clone(), cfg); - pt_map.insert(key, pt); - } - } - let mut all_sequence_analyses: HashMap = HashMap::new(); - for c in &project.contracts { - let combined = project.inherited_state_vars(c); - let mut c_pt_map: HashMap<(String, String), PathTree> = HashMap::new(); - for func in &c.functions { - if let Ok(cfg) = CfgBuilder::build_with_project(func, c, Some(&project)) { - let pt = build_path_tree(&cfg, &c.name, &func.name, &combined, &config); - c_pt_map.insert((c.name.clone(), func.name.clone()), pt); - } - } - let a = analyze_sequences(&c_pt_map, &c.name); - all_sequence_analyses.insert(c.name.clone(), a); - } - analyze_project(&project, &mut all_sequence_analyses); - let analysis = all_sequence_analyses - .get(&contract.name) - .cloned() - .unwrap_or_else(|| analyze_sequences(&pt_map, &contract.name)); - - if let Some(seq_str) = sequence_filter { - let names: Vec<&str> = seq_str.split(',').map(|s| s.trim()).collect(); - let narrative = build_sequence_narrative( - &contract.name, &names, &analysis.functions, &analysis.transitions, &classifications, - ); - print_sequence(&narrative); - return Ok(()); - } - - if let Some(func_name) = function_filter { - let func = contract.functions.iter().find(|f| f.name == func_name) - .ok_or_else(|| anyhow::anyhow!("Function '{}' not found. Use --list to see available functions.", func_name))?; - let key = (contract.name.clone(), func_name.to_string()); - let cfg = cfgs.get(&key).ok_or_else(|| anyhow::anyhow!("No CFG for {}", func_name))?; - let pt = pt_map.get(&key).ok_or_else(|| anyhow::anyhow!("No paths for {}", func_name))?; - let narrative = build_function_narrative(contract, func, pt, cfg, &analysis.functions, &project, &all_sequence_analyses); - print_function(&narrative); - return Ok(()); - } - - let narratives: Vec<_> = contract.functions.iter().filter_map(|func| { - let key = (contract.name.clone(), func.name.clone()); - let cfg = cfgs.get(&key)?; - let pt = pt_map.get(&key)?; - Some(build_function_narrative(contract, func, pt, cfg, &analysis.functions, &project, &all_sequence_analyses)) - }).collect(); - print_overview(contract, &narratives); - Ok(()) -} - -fn find_contract<'a>(project: &'a Project, filter: Option<&str>) -> Result<&'a ContractDef> { - project.find_contract(filter).map_err(|e| anyhow::anyhow!(e)) -} - -fn print_list(contract: &ContractDef, classifications: &[(String, AccessLevel)]) { - println!("\n {} — {} functions\n", - c_bright(&contract.name), contract.functions.len()); - - for (name, access) in classifications { - if name.is_empty() { continue; } - let vis = contract.functions.iter().find(|f| f.name == *name) - .map(|f| format!("{:?}", f.visibility).to_lowercase()).unwrap_or_default(); - println!(" {} {:<20} {}", access_colored(access), c_bright(name), c_muted(&vis)); - } - - println!("\n {}", c_muted("Usage:")); - println!(" ilold context --function "); - println!(" ilold context --sequence \"fn1,fn2\""); - - let names: Vec<&str> = classifications.iter() - .filter(|(n, _)| !n.is_empty()).map(|(n, _)| n.as_str()).take(2).collect(); - if names.len() >= 2 { - println!("\n {}", c_muted("Example:")); - println!(" ilold context --function {}", names[0]); - println!(" ilold context --sequence \"{},{}\"", names[0], names[1]); - } - println!(); -} - -fn print_function(n: &FunctionNarrative) { - println!("\n {} {}\n", c_bright(&n.name), c_muted(&format!("({})", n.access))); - - if !n.modifiers.is_empty() { - println!(" {} {}", c_muted("Modifiers:"), n.modifiers.join(", ")); - } - println!(" {} {} ({} return, {} revert)", - c_muted("Paths:"), n.total_paths, c_ok(&n.happy_paths.to_string()), c_danger(&n.revert_paths.to_string())); - if !n.state_writes.is_empty() { - println!(" {} {}", c_muted("Writes:"), c_warn(&n.state_writes.join(", "))); - } - if !n.external_calls.is_empty() { - println!(" {} {}", c_muted("Calls:"), c_danger(&n.external_calls.join(", "))); - } - - for path in &n.paths { - println!(); - let terminal = match path.terminal { - TerminalKind::Return => c_ok("Return"), - TerminalKind::Revert => c_danger("Revert"), - TerminalKind::DepthCutoff => c_muted("DepthCutoff"), - TerminalKind::LoopCutoff => c_muted("LoopCutoff"), - }; - println!(" {} #{} → {}", c_muted("Path"), path.id, terminal); - - for (i, step) in path.steps.iter().enumerate() { - let is_last = i == path.steps.len() - 1; - let connector = if is_last { " └── " } else { " ├── " }; - - let branch_str = match step.branch { - Some(BranchDirection::True) => format!(" {}", c_ok("✓")), - Some(BranchDirection::False) => format!(" {}", c_danger("✗")), - None => String::new(), - }; - - let desc = match step.step_type { - StepType::Entry => c_bright(&step.description).to_string(), - StepType::Condition => c_warn(&step.description).to_string(), - StepType::ExternalCall => c_danger(&step.description).to_string(), - StepType::InternalCall => c_muted(&step.description).to_string(), - StepType::StateWrite => c_accent(&step.description).to_string(), - StepType::StateRead => c_muted(&step.description).to_string(), - StepType::EthTransfer => c_danger(&step.description).to_string(), - StepType::Event => c_muted(&step.description).to_string(), - StepType::Return => c_ok(&step.description).to_string(), - StepType::Revert => c_danger(&step.description).to_string(), - StepType::Assembly => c_muted(&step.description).to_string(), - }; - - println!("{}{} {}{}", - c_muted(connector), step.step_type.icon(), desc, branch_str); - } - } - - if !n.observations.is_empty() { - println!("\n {}", c_muted("Observations:")); - for obs in &n.observations { - println!(" {} {}: {}", - c_warn("⚠"), c_muted(&obs.kind.to_string()), obs.description); - } - } - println!(); -} - -fn print_sequence(n: &SequenceNarrative) { - let names: Vec<&str> = n.steps.iter().map(|s| s.function.as_str()).collect(); - println!("\n {} {}\n", - c_muted("Sequence:"), c_bright(&names.join(" → "))); - - for (i, step) in n.steps.iter().enumerate() { - let is_last_step = i == n.steps.len() - 1; - let step_conn = if is_last_step { "└─" } else { "├─" }; - let pipe = if is_last_step { " " } else { "│ " }; - - println!(" {} {} {}", - c_muted(step_conn), - c_bright(&format!("Step {}: {}", i + 1, step.function)), - c_muted(&format!("({})", step.access))); - - let mut lines: Vec<(String, String)> = Vec::new(); - - for req in &step.requires { - lines.push(("require".into(), c_warn(req).to_string())); - } - for eff in &step.effects { - lines.push(("writes".into(), c_accent(eff).to_string())); - } - for call in &step.external_calls { - lines.push(("calls".into(), c_danger(call).to_string())); - } - for ev in &step.events { - lines.push(("emits".into(), c_muted(ev).to_string())); - } - for dep in &step.dependencies { - lines.push(("depends".into(), c_warn(&dep.relationship).to_string())); - } - - if lines.is_empty() { - println!(" {} {} {}", c_muted(pipe), c_muted("·"), c_muted("read-only")); - } else { - for (j, (label, value)) in lines.iter().enumerate() { - let is_last = j == lines.len() - 1; - let conn = if is_last { "└─" } else { "├─" }; - println!(" {} {} {} {}", - c_muted(pipe), c_muted(conn), c_muted(&format!("{:<7}", label)), value); - } - } - - if !is_last_step { - println!(" {}", c_muted("│")); - } - } - - if !n.observations.is_empty() { - println!("\n {}", c_muted("Observations:")); - for obs in &n.observations { - println!(" {} {}: {}", - c_warn("⚠"), c_muted(&obs.kind.to_string()), obs.description); - } - } - println!(); -} - -fn print_overview(contract: &ContractDef, narratives: &[FunctionNarrative]) { - let total_paths: usize = narratives.iter().map(|n| n.total_paths).sum(); - println!("\n {} — {} functions, {} paths\n", - c_bright(&contract.name), narratives.len(), total_paths); - - for n in narratives { - if n.name.is_empty() { continue; } - let access = access_colored(&n.access); - let paths = format!("{} paths", n.total_paths); - let mut extras = Vec::new(); - if !n.state_writes.is_empty() { - extras.push(format!("writes: {}", n.state_writes.join(", "))); - } - if !n.external_calls.is_empty() { - extras.push(format!("calls: {}", n.external_calls.join(", "))); - } - let extra_str = if extras.is_empty() { String::new() } - else { format!(" {}", c_muted(&extras.join(" | "))) }; - - println!(" {} {:<20} {}{}", - access, c_bright(&n.name), c_muted(&paths), extra_str); - } - - let obs: Vec<&Observation> = narratives.iter().flat_map(|n| n.observations.iter()).collect(); - if !obs.is_empty() { - println!("\n {}", c_muted("Observations:")); - let mut seen = std::collections::HashSet::new(); - for o in &obs { - if seen.insert(&o.description) { - println!(" {} {}: {}", - c_warn("⚠"), c_muted(&o.kind.to_string()), o.description); - } - } - } - println!(); -} diff --git a/crates/ilold-cli/src/explore.rs b/crates/ilold-cli/src/explore.rs index b1e5fb7..7f7989a 100644 --- a/crates/ilold-cli/src/explore.rs +++ b/crates/ilold-cli/src/explore.rs @@ -1,123 +1,41 @@ use std::borrow::Cow; -use std::collections::HashMap; use std::path::PathBuf; use anyhow::Result; -use colored::Colorize; use reedline::{ Completer, DefaultHinter, FileBackedHistory, Prompt, PromptEditMode, PromptHistorySearch, PromptHistorySearchStatus, Reedline, Signal, Span, Suggestion, }; -use ilold_core::classify::entry_points::AccessLevel; -use ilold_core::exploration::commands::CommandResult; use ilold_solana_core::exploration::SolanaCommandResult; use ilold_solana_core::view::ProgramView; use crate::colors::*; -use crate::fmt; -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum BackendKind { - Solidity, - Solana, - Attached, -} - -fn detect_backend_kind(state: &Option>) -> BackendKind { - match state { - None => BackendKind::Attached, - Some(s) => { - if s.solana().is_some() { - BackendKind::Solana - } else { - BackendKind::Solidity - } - } - } -} - -pub async fn run(paths: Vec, port: u16, max_seq_depth: usize, attach: Option) -> Result<()> { - if let Some(url) = attach { - let client = reqwest::Client::new(); - let map_resp = client - .get(format!("{url}/api/project/map")) - .send() - .await - .map_err(|e| anyhow::anyhow!("Cannot reach server at {url}: {e}"))?; - if !map_resp.status().is_success() { - anyhow::bail!("Server at {url} returned {}", map_resp.status()); - } - let project_map: serde_json::Value = map_resp.json().await?; - let kind = project_map["kind"].as_str().unwrap_or("solidity"); - - if kind == "solana" { - return run_solana_attach(url, client, project_map).await; - } - - let resp = client.get(format!("{url}/api/project")) - .send().await - .map_err(|e| anyhow::anyhow!("Cannot reach server at {url}: {e}"))?; - if !resp.status().is_success() { - anyhow::bail!("Server at {url} returned {}", resp.status()); - } - let project_info: serde_json::Value = resp.json().await?; - - let contracts_arr = project_info["contracts"].as_array(); - let contract_name = contracts_arr - .and_then(|arr| arr.iter().rev().find(|c| c["kind"].as_str() == Some("Contract"))) - .or_else(|| contracts_arr.and_then(|arr| arr.last())) - .and_then(|c| c["name"].as_str()) - .unwrap_or("unknown") - .to_string(); - - let mut functions_by_contract = HashMap::>::new(); - let contract_names_raw: Vec = contracts_arr - .map(|arr| arr.iter().filter_map(|c| c["name"].as_str().map(String::from)).collect()) - .unwrap_or_default(); - for cname in &contract_names_raw { - if let Ok(resp) = client.get(format!("{url}/api/contract/{cname}")).send().await { - if let Ok(detail) = resp.json::().await { - let funcs: Vec = detail["functions"].as_array() - .map(|fs| fs.iter().filter_map(|f| f["name"].as_str().map(String::from)).collect()) - .unwrap_or_default(); - functions_by_contract.insert(cname.clone(), funcs); - } - } - } - - let function_names = functions_by_contract.get(&contract_name).cloned().unwrap_or_default(); - let contract_names: Vec = functions_by_contract.keys().cloned().collect(); - let func_count = function_names.len(); - - let banner = fmt::header_box(&[ - &format!("ilold explore — {} (attached)", contract_name), - &format!("{} functions | Type ? for help", func_count), - &format!("Server: {}", url), - ]); - println!("{}\n", banner); - - let base_url = url; - let handle = tokio::runtime::Handle::current(); - let repl_thread = std::thread::spawn(move || { - repl_loop( - handle, - contract_name, - function_names, - contract_names, - None, - base_url, - Some(functions_by_contract), - BackendKind::Attached, - ); - }); - repl_thread.join().map_err(|_| anyhow::anyhow!("REPL thread panicked"))?; - return Ok(()); - } - - println!("Analyzing {} file(s)...", paths.len()); - let (state, actual_port) = ilold_web::start_server(paths, port, max_seq_depth).await?; - run_with_state(state, actual_port).await +pub async fn run( + _paths: Vec, + _port: u16, + _max_seq_depth: usize, + attach: Option, +) -> Result<()> { + let url = attach.ok_or_else(|| { + anyhow::anyhow!("explore::run requires --attach ; use explore::run_solana for local projects") + })?; + let client = reqwest::Client::new(); + let map_resp = client + .get(format!("{url}/api/project/map")) + .send() + .await + .map_err(|e| anyhow::anyhow!("Cannot reach server at {url}: {e}"))?; + if !map_resp.status().is_success() { + anyhow::bail!("Server at {url} returned {}", map_resp.status()); + } + let project_map: serde_json::Value = map_resp.json().await?; + let kind = project_map["kind"].as_str().unwrap_or("solana"); + if kind != "solana" { + anyhow::bail!("Only Solana servers are supported (got kind={kind})"); + } + run_solana_attach(url, client, project_map).await } async fn run_solana_attach( @@ -148,7 +66,7 @@ async fn run_solana_attach( }) .unwrap_or_default(); - let banner = fmt::header_box(&[ + let banner = header_box(&[ &format!("ilold explore — {} (solana, attached)", program_name), &format!("{} instructions | Type ? for help", function_names.len()), &format!("Server: {}", url), @@ -157,16 +75,7 @@ async fn run_solana_attach( let handle = tokio::runtime::Handle::current(); let repl_thread = std::thread::spawn(move || { - repl_loop( - handle, - program_name, - function_names, - program_names, - None, - url, - None, - BackendKind::Solana, - ); + repl_loop(handle, program_name, function_names, program_names, None, url); }); repl_thread .join() @@ -187,67 +96,24 @@ async fn run_with_state( state: std::sync::Arc, actual_port: u16, ) -> Result<()> { - let backend = detect_backend_kind(&Some(state.clone())); - - let (contract_name, function_names, contract_names, header_label) = match backend { - BackendKind::Solana => { - let s = state.solana().expect("solana backend"); - let program = s.project.programs.first(); - let name = program - .map(|p| p.name.clone()) - .unwrap_or_else(|| "unknown".into()); - let ix_names: Vec = program - .map(|p| p.instructions.iter().map(|i| i.name.clone()).collect()) - .unwrap_or_default(); - let program_names: Vec = s - .project - .programs - .iter() - .map(|p| p.name.clone()) - .collect(); - let label = format!("ilold explore — {} (solana)", name); - (name, ix_names, program_names, label) - } - _ => { - let s = state.unwrap_solidity(); - let name = s - .project - .find_contract(None) - .map(|c| c.name.clone()) - .unwrap_or_else(|_| "unknown".into()); - let function_names: Vec = s - .project - .contracts - .iter() - .find(|c| c.name == name) - .map(|c| { - s.project - .accessible_functions(c) - .iter() - .map(|af| af.function.name.clone()) - .collect() - }) - .unwrap_or_default(); - let contract_names: Vec = s - .project - .contracts - .iter() - .map(|c| c.name.clone()) - .filter(|n| !n.is_empty()) - .collect(); - let label = format!("ilold explore — {}", name); - (name, function_names, contract_names, label) - } - }; + let s = state.solana().expect("solana backend required"); + let program = s.project.programs.first(); + let program_name = program + .map(|p| p.name.clone()) + .unwrap_or_else(|| "unknown".into()); + let function_names: Vec = program + .map(|p| p.instructions.iter().map(|i| i.name.clone()).collect()) + .unwrap_or_default(); + let program_names: Vec = s + .project + .programs + .iter() + .map(|p| p.name.clone()) + .collect(); - let func_count = function_names.len(); - let func_label = match backend { - BackendKind::Solana => "instructions", - _ => "functions", - }; - let banner = fmt::header_box(&[ - &header_label, - &format!("{} {} | Type ? for help", func_count, func_label), + let banner = header_box(&[ + &format!("ilold explore — {} (solana)", program_name), + &format!("{} instructions | Type ? for help", function_names.len()), &format!("Web UI: http://localhost:{}", actual_port), ]); println!("{}\n", banner); @@ -258,17 +124,17 @@ async fn run_with_state( let repl_thread = std::thread::spawn(move || { repl_loop( handle, - contract_name, + program_name, function_names, - contract_names, + program_names, Some(state_for_thread), base_url, - None, - backend, ); }); - repl_thread.join().map_err(|_| anyhow::anyhow!("REPL thread panicked"))?; + repl_thread + .join() + .map_err(|_| anyhow::anyhow!("REPL thread panicked"))?; Ok(()) } @@ -277,11 +143,9 @@ fn repl_loop( handle: tokio::runtime::Handle, mut contract: String, mut functions: Vec, - contract_names: Vec, + program_names: Vec, state: Option>, base_url: String, - functions_by_contract: Option>>, - backend: BackendKind, ) { let history_path = dirs::home_dir() .map(|h| h.join(".ilold").join("history")) @@ -297,7 +161,7 @@ fn repl_loop( let completer = std::sync::Arc::new(std::sync::Mutex::new(IloldCompleter { functions: functions.clone(), - contracts: contract_names.clone(), + contracts: program_names.clone(), scenarios: vec!["main".to_string()], })); @@ -318,44 +182,41 @@ fn repl_loop( scenario: scenario_name.clone(), }; - if state.is_none() { - if let Some(server_steps) = sync_steps(&handle, &client, &base_url, &contract, backend) { + let attached = state.is_none(); + if attached { + if let Some(server_steps) = sync_steps(&handle, &client, &base_url, &contract) { steps = server_steps; prompt.steps = steps.clone(); } - if backend == BackendKind::Solana { - if let Some(active) = sync_active_scenario(&handle, &client, &base_url, &contract) { - scenario_name = active; - prompt.scenario = scenario_name.clone(); - } - if let Some(scns) = sync_scenarios(&handle, &client, &base_url, &contract) { - if let Ok(mut comp) = completer.lock() { - comp.scenarios = scns; - } + if let Some(active) = sync_active_scenario(&handle, &client, &base_url, &contract) { + scenario_name = active; + prompt.scenario = scenario_name.clone(); + } + if let Some(scns) = sync_scenarios(&handle, &client, &base_url, &contract) { + if let Ok(mut comp) = completer.lock() { + comp.scenarios = scns; } } } loop { - if state.is_none() { - if let Some(server_steps) = sync_steps(&handle, &client, &base_url, &contract, backend) { + if attached { + if let Some(server_steps) = sync_steps(&handle, &client, &base_url, &contract) { if server_steps != steps { steps = server_steps; prompt.steps = steps.clone(); } } - if backend == BackendKind::Solana { - if let Some(active) = sync_active_scenario(&handle, &client, &base_url, &contract) { - if active != scenario_name { - scenario_name = active; - prompt.scenario = scenario_name.clone(); - } + if let Some(active) = sync_active_scenario(&handle, &client, &base_url, &contract) { + if active != scenario_name { + scenario_name = active; + prompt.scenario = scenario_name.clone(); } - if let Some(scns) = sync_scenarios(&handle, &client, &base_url, &contract) { - if let Ok(mut comp) = completer.lock() { - if comp.scenarios != scns { - comp.scenarios = scns; - } + } + if let Some(scns) = sync_scenarios(&handle, &client, &base_url, &contract) { + if let Ok(mut comp) = completer.lock() { + if comp.scenarios != scns { + comp.scenarios = scns; } } } @@ -364,11 +225,19 @@ fn repl_loop( match editor.read_line(&prompt) { Ok(Signal::Success(line)) => { let line = line.trim(); - if line.is_empty() { continue; } - - match handle_input( - line, &handle, &client, &base_url, &contract, - &mut steps, &mut scenario_name, &completer, &state, backend, + if line.is_empty() { + continue; + } + + match handle_solana_input( + line, + &handle, + &client, + &base_url, + &contract, + &mut steps, + &mut scenario_name, + &completer, ) { InputResult::Continue => {} InputResult::Quit => break, @@ -381,47 +250,17 @@ fn repl_loop( steps.clear(); scenario_name = "main".into(); if let Some(state) = state.as_ref() { - match backend { - BackendKind::Solana => { - if let Some(s) = state.solana() { - if let Some(p) = - s.project.programs.iter().find(|p| p.name == new_name) - { - functions = p - .instructions - .iter() - .map(|i| i.name.clone()) - .collect(); - if let Ok(mut comp) = completer.lock() { - comp.functions = functions.clone(); - } - } - } - } - _ => { - let s = state.unwrap_solidity(); - if let Some(c) = - s.project.contracts.iter().find(|c| c.name == new_name) - { - functions = s - .project - .accessible_functions(c) - .iter() - .map(|af| af.function.name.clone()) - .collect(); - if let Ok(mut comp) = completer.lock() { - comp.functions = functions.clone(); - } + if let Some(s) = state.solana() { + if let Some(p) = + s.project.programs.iter().find(|p| p.name == new_name) + { + functions = + p.instructions.iter().map(|i| i.name.clone()).collect(); + if let Ok(mut comp) = completer.lock() { + comp.functions = functions.clone(); } } } - } else if let Some(fbc) = functions_by_contract.as_ref() { - if let Some(funcs) = fbc.get(&new_name) { - functions = funcs.clone(); - if let Ok(mut comp) = completer.lock() { - comp.functions = functions.clone(); - } - } } prompt.contract = contract.clone(); prompt.steps = Vec::new(); @@ -452,12 +291,28 @@ struct CompleterWrapper(std::sync::Arc>); impl Completer for CompleterWrapper { fn complete(&mut self, line: &str, pos: usize) -> Vec { - self.0.lock().map(|mut c| c.complete(line, pos)).unwrap_or_default() + self.0 + .lock() + .map(|mut c| c.complete(line, pos)) + .unwrap_or_default() + } +} + +fn inline_help_target(cmd: &str, arg: &str) -> Option { + if cmd == "?" || cmd == "help" || cmd == "h" { + return None; + } + if cmd.ends_with('?') && cmd.len() > 1 { + return Some(cmd[..cmd.len() - 1].to_string()); } + if arg.trim() == "?" { + return Some(cmd.to_string()); + } + None } #[allow(clippy::too_many_arguments)] -fn handle_input( +fn handle_solana_input( line: &str, handle: &tokio::runtime::Handle, client: &reqwest::Client, @@ -466,800 +321,145 @@ fn handle_input( steps: &mut Vec, scenario_name: &mut String, completer: &std::sync::Arc>, - state: &Option>, - backend: BackendKind, ) -> InputResult { - if backend == BackendKind::Solana { - return handle_solana_input( - line, - handle, - client, - base_url, - contract, - steps, - scenario_name, - completer, - ); - } let normalized = split_numeric_suffix(line); let parts: Vec<&str> = normalized.splitn(2, ' ').collect(); let cmd = parts[0].to_lowercase(); let arg = parts.get(1).map(|s| s.trim()).unwrap_or(""); - if cmd.ends_with('?') && cmd.len() > 1 { - let base = &cmd[..cmd.len() - 1]; - print_inline_help(base); + if let Some(base) = inline_help_target(&cmd, arg) { + match crate::help::render_solana_help_block(&base) { + Some(text) => print!("{text}"), + None => println!( + " {} no help registered for {}", + c_danger("✗"), + c_accent(&base) + ), + } return InputResult::Continue; } match cmd.as_str() { - "?" | "h" | "help" => { print_help(); InputResult::Continue } - "q" | "quit" | "exit" => InputResult::Quit, - "browser" => { - println!(" {}", c_muted("Web UI not yet available in explore mode.")); - println!(" API running at {base_url}/api/"); + "?" | "help" | "h" => { + print_solana_help(); InputResult::Continue } - "sc" | "scen" | "scenario" => { - let sub_parts: Vec<&str> = arg.splitn(2, ' ').collect(); - let sub = sub_parts.first().copied().unwrap_or("").trim(); - let name_arg = sub_parts.get(1).map(|s| s.trim()).unwrap_or(""); - - use ilold_core::exploration::commands::ScenarioAction; - - let parse_fork = |raw: &str| -> Result { - let parts: Vec<&str> = raw.split_whitespace().collect(); - match parts.as_slice() { - [name] => Ok(ScenarioAction::Fork { - name: name.to_string(), - at_step: None, - }), - [name, "at", n_str] => n_str - .parse::() - .map(|n| ScenarioAction::Fork { - name: name.to_string(), - at_step: Some(n), - }) - .map_err(|_| format!( - "Invalid at-step: '{n_str}' is not a non-negative integer" - )), - _ => Err("Usage: scenario fork [at ]".to_string()), - } - }; - - let action: Option = match sub { - "new" if !name_arg.is_empty() => Some(ScenarioAction::New { name: name_arg.to_string() }), - "list" | "ls" => Some(ScenarioAction::List), - "switch" if !name_arg.is_empty() => Some(ScenarioAction::Switch { name: name_arg.to_string() }), - "fork" if !name_arg.is_empty() => match parse_fork(name_arg) { - Ok(a) => Some(a), - Err(msg) => { - eprintln!(" {}", c_danger(&msg)); - return InputResult::Continue; - } - }, - "delete" | "rm" if !name_arg.is_empty() => Some(ScenarioAction::Delete { name: name_arg.to_string() }), - _ => None, - }; - - let Some(action) = action else { - println!(" Usage: scenario new|list|switch|fork|delete "); + "quit" | "q" | "exit" => InputResult::Quit, + "funcs" | "functions" | "f" => dispatch_solana( + handle, + client, + base_url, + contract, + serde_json::json!("Funcs"), + steps, + ), + "funcs-all" | "fa" => dispatch_solana( + handle, + client, + base_url, + contract, + serde_json::json!("Funcs"), + steps, + ), + "info" | "i" => { + if arg.is_empty() { + println!(" Usage: info "); return InputResult::Continue; - }; - - let body = serde_json::json!({ - "contract": contract, - "command": { "Scenario": { "sub": action } } - }); - match send_command(handle, client, base_url, &body) { - Ok(result) => { - let mut did_update_scenario = false; - match &result { - CommandResult::ScenarioCreated { name } => { - if let Ok(mut comp) = completer.lock() { - if !comp.scenarios.iter().any(|s| s == name) { - comp.scenarios.push(name.clone()); - } - } - } - CommandResult::ScenarioForked { to, .. } => { - if let Ok(mut comp) = completer.lock() { - if !comp.scenarios.iter().any(|s| s == to) { - comp.scenarios.push(to.clone()); - } - } - } - CommandResult::ScenarioSwitched { from, to } if from != to => { - *scenario_name = to.clone(); - did_update_scenario = true; - } - CommandResult::ScenarioDeleted { name } => { - if let Ok(mut comp) = completer.lock() { - comp.scenarios.retain(|s| s != name); - } - } - CommandResult::ScenarioList { items } => { - if let Ok(mut comp) = completer.lock() { - comp.scenarios = items.iter().map(|i| i.name.clone()).collect(); - } - } - _ => {} - } - print_result(&result, steps); - if did_update_scenario { - return InputResult::UpdatePrompt; - } - } - Err(e) => eprintln!(" {}", c_danger(&e)), } - InputResult::Continue + let body = serde_json::json!({"Info": {"ix": arg}}); + dispatch_solana(handle, client, base_url, contract, body, steps) } - - "ct" | "contracts" | "programs" | "progs" => { - if let Some(state) = state { - match backend { - BackendKind::Solana => print_programs(state, contract), - _ => print_contracts(state, contract), + "vars" | "v" | "vars-all" | "va" => dispatch_solana( + handle, + client, + base_url, + contract, + serde_json::json!("Vars"), + steps, + ), + "coupling" | "cp" => dispatch_solana( + handle, + client, + base_url, + contract, + serde_json::json!("Coupling"), + steps, + ), + "coverage" | "cov" => dispatch_solana( + handle, + client, + base_url, + contract, + serde_json::json!("Coverage"), + steps, + ), + "state" => dispatch_solana( + handle, + client, + base_url, + contract, + serde_json::json!("State"), + steps, + ), + "session" | "s" => { + let r = dispatch_solana( + handle, + client, + base_url, + contract, + serde_json::json!("Session"), + steps, + ); + *scenario_name = sync_active_scenario(handle, client, base_url, contract) + .unwrap_or_else(|| scenario_name.clone()); + r + } + "back" => dispatch_solana( + handle, + client, + base_url, + contract, + serde_json::json!("Back"), + steps, + ), + "clear" => dispatch_solana( + handle, + client, + base_url, + contract, + serde_json::json!("Clear"), + steps, + ), + "users" => { + if arg.starts_with("new") { + let rest = arg.trim_start_matches("new").trim(); + if rest.is_empty() { + println!(" Usage: users new []"); + return InputResult::Continue; } + let parts: Vec<&str> = rest.split_whitespace().collect(); + let name = parts[0].to_string(); + let lamports: u64 = parts + .get(1) + .and_then(|s| s.parse().ok()) + .unwrap_or(10_000_000_000); + let body = serde_json::json!({"UsersNew": {"name": name, "lamports": lamports}}); + dispatch_solana(handle, client, base_url, contract, body, steps) } else { - match handle.block_on(async { - let resp = client.get(format!("{base_url}/api/project")).send().await?; - resp.json::().await - }) { - Ok(info) => { - if let Some(arr) = info["contracts"].as_array() { - println!(); - for c in arr { - let name = c["name"].as_str().unwrap_or("?"); - let marker = if name == contract { c_ok(" ← current").to_string() } else { String::new() }; - println!(" {} {}{}", c_accent("[C]"), name, marker); - } - println!(); - } - } - Err(e) => eprintln!(" {}", c_danger(&format!("Failed to fetch contracts: {e}"))), - } + dispatch_solana( + handle, + client, + base_url, + contract, + serde_json::json!("Users"), + steps, + ) } - InputResult::Continue } - "use" => { - if arg.is_empty() { - println!(" Usage: use "); - return InputResult::Continue; - } - if let Some(state) = state { - match backend { - BackendKind::Solana => { - let s = state.solana().expect("solana backend"); - match s.project.find_program(arg) { - Some(p) => { - let name = p.name.clone(); - if name == contract { - println!(" Already using {}", c_accent(&name)); - return InputResult::Continue; - } - println!(" {} Now using: {}", c_ok("✓"), c_accent(&name)); - if !steps.is_empty() { - println!( - " {}", - c_muted(&format!( - "Cleared {} step(s) from previous program", - steps.len() - )) - ); - } - InputResult::SwitchContract(name) - } - None => { - eprintln!(" {}", c_danger(&format!("program '{arg}' not found"))); - InputResult::Continue - } - } - } - _ => { - let s = state.unwrap_solidity(); - match s.project.find_contract(Some(arg)) { - Ok(c) => { - let name = c.name.clone(); - if name == contract { - println!(" Already using {}", c_accent(&name)); - return InputResult::Continue; - } - println!(" {} Now using: {}", c_ok("✓"), c_accent(&name)); - if !steps.is_empty() { - println!( - " {}", - c_muted(&format!( - "Cleared {} step(s) from previous contract", - steps.len() - )) - ); - } - InputResult::SwitchContract(name) - } - Err(e) => { - eprintln!(" {}", c_danger(&e)); - InputResult::Continue - } - } - } - } - } else { - let name = arg.to_string(); - if name == contract { - println!(" Already using {}", c_accent(&name)); - return InputResult::Continue; - } - println!(" {} Now using: {}", c_ok("✓"), c_accent(&name)); - if !steps.is_empty() { - println!(" {}", c_muted(&format!("Cleared {} step(s) from previous contract", steps.len()))); - } - InputResult::SwitchContract(name) - } - } - - "c" | "call" => { - if arg.is_empty() { - println!(" Usage: call "); - return InputResult::Continue; - } - let body = serde_json::json!({ - "contract": contract, - "command": { "Call": { "func": arg } } - }); - match send_command(handle, client, base_url, &body) { - Ok(result) => { - if let CommandResult::StepAdded { function, .. } = &result { - steps.push(function.clone()); - } - print_result(&result, steps); - if matches!(&result, CommandResult::StepAdded { .. }) { - return InputResult::UpdatePrompt; - } - } - Err(e) => eprintln!(" {}", c_danger(&e)), - } - InputResult::Continue - } - "b" | "back" => { - let body = serde_json::json!({ - "contract": contract, - "command": "Back" - }); - match send_command(handle, client, base_url, &body) { - Ok(result) => { - if matches!(&result, CommandResult::StepRemoved { .. }) { - steps.pop(); - } - print_result(&result, steps); - if matches!(&result, CommandResult::StepRemoved { .. }) { - return InputResult::UpdatePrompt; - } - } - Err(e) => eprintln!(" {}", c_danger(&e)), - } - InputResult::Continue - } - "cl" | "clear" => { - if !steps.is_empty() { - println!(" Clear {} steps? (y/n)", steps.len()); - let mut input = String::new(); - match std::io::stdin().read_line(&mut input) { - Ok(_) if input.trim().eq_ignore_ascii_case("y") => { /* proceed */ } - _ => { - println!(" Cancelled."); - return InputResult::Continue; - } - } - } - let body = serde_json::json!({ - "contract": contract, - "command": "Clear" - }); - match send_command(handle, client, base_url, &body) { - Ok(result) => { - print_result(&result, steps); - steps.clear(); - return InputResult::UpdatePrompt; - } - Err(e) => eprintln!(" {}", c_danger(&e)), - } - InputResult::Continue - } - "s" | "state" => { - send_and_print(handle, client, base_url, contract, "State", steps); - InputResult::Continue - } - "f" | "functions" => { - send_and_print(handle, client, base_url, contract, "Functions", steps); - InputResult::Continue - } - "fa" | "funcs-all" => { - send_and_print(handle, client, base_url, contract, "FunctionsAll", steps); - InputResult::Continue - } - "va" | "vars-all" => { - send_and_print(handle, client, base_url, contract, "StateVarsAll", steps); - InputResult::Continue - } - "ss" | "session" => { - send_and_print(handle, client, base_url, contract, "Session", steps); - InputResult::Continue - } - "n" | "note" => { - if arg.is_empty() { - println!(" Usage: note "); - return InputResult::Continue; - } - let body = serde_json::json!({ - "contract": contract, - "command": { "Note": { "text": arg } } - }); - match send_command(handle, client, base_url, &body) { - Ok(result) => print_result(&result, steps), - Err(e) => eprintln!(" {}", c_danger(&e)), - } - InputResult::Continue - } - "fi" | "finding" => { - if arg.is_empty() { - handle_finding_interactive(handle, client, base_url, contract, steps); - } else { - let finding_parts: Vec<&str> = arg.splitn(2, ' ').collect(); - if finding_parts.len() < 2 { - println!(" Usage: fi "); - println!(" Or just: fi (interactive mode)"); - } else { - let severity_input = finding_parts[0]; - let rest = finding_parts[1]; - match normalize_severity(severity_input) { - Some(severity) => { - let body = serde_json::json!({ - "contract": contract, - "command": { - "Finding": { - "severity": severity, - "title": rest, - "description": "" - } - } - }); - match send_command(handle, client, base_url, &body) { - Ok(result) => print_result(&result, steps), - Err(e) => eprintln!(" {}", c_danger(&e)), - } - } - None => { - println!(" {}", c_danger("Invalid severity. Valid: critical, high, medium, low, info")); - } - } - } - } - InputResult::Continue - } - "status" => { - let status_parts: Vec<&str> = arg.splitn(2, ' ').collect(); - if status_parts.len() < 2 { - println!(" Usage: status <function> <reviewed|suspicious|vulnerable|clean|inprogress|notreviewed>"); - return InputResult::Continue; - } - let normalized = match normalize_status(status_parts[1]) { - Some(s) => s, - None => { - println!(" {}", c_danger("Invalid status. Valid: reviewed, suspicious, vulnerable, clean, inprogress, notreviewed")); - return InputResult::Continue; - } - }; - let body = serde_json::json!({ - "contract": contract, - "command": { "Status": { "func": status_parts[0], "status": normalized } } - }); - match send_command(handle, client, base_url, &body) { - Ok(result) => print_result(&result, steps), - Err(e) => eprintln!(" {}", c_danger(&e)), - } - InputResult::Continue - } - "w" | "who" => { - if arg.is_empty() { - println!(" Usage: who <variable>"); - return InputResult::Continue; - } - let body = serde_json::json!({ - "contract": contract, - "command": { "Who": { "variable": arg } } - }); - match send_command(handle, client, base_url, &body) { - Ok(result) => print_result(&result, steps), - Err(e) => eprintln!(" {}", c_danger(&e)), - } - InputResult::Continue - } - - "v" | "vars" => { - match send_get(handle, client, &format!("{base_url}/api/contract/{contract}")) { - Ok(val) => print_vars(&val), - Err(e) => eprintln!(" {}", c_danger(&e)), - } - InputResult::Continue - } - - "i" | "info" => { - if arg.is_empty() { - println!(" Usage: info <function>"); - return InputResult::Continue; - } - match send_get(handle, client, &format!("{base_url}/api/session/function/{contract}/{arg}")) { - Ok(val) => print_narrative(&val), - Err(e) => eprintln!(" {}", c_danger(&e)), - } - InputResult::Continue - } - "tr" | "trace" => { - if arg.is_empty() { - println!(" Usage: trace <function> [--depth N] [--reverts] [+N...] [-i]"); - println!(" trace step <N>"); - return InputResult::Continue; - } - let parsed = parse_trace_args(arg); - let target = match parsed.target { - Some(t) => t, - None => { - println!(" Usage: trace <function> [--depth N] [--reverts] [+N...] [-i]"); - println!(" trace step <N>"); - return InputResult::Continue; - } - }; - let url = match target { - TraceTarget::Function(func_name) => { - let mut url = format!("{base_url}/api/session/trace/{contract}/{func_name}"); - let mut sep = '?'; - let effective_depth = parsed.depth - .or(if parsed.interactive { Some(4) } else { None }); - if let Some(d) = effective_depth { - url.push_str(&format!("{sep}depth={d}")); - sep = '&'; - } - if parsed.reverts { - url.push_str(&format!("{sep}reverts=true")); - sep = '&'; - } - if !parsed.expand.is_empty() { - let csv = parsed.expand.iter() - .map(|n| n.to_string()) - .collect::<Vec<_>>() - .join(","); - url.push_str(&format!("{sep}expand={csv}")); - } - url - } - TraceTarget::SessionStep(idx) => { - format!("{base_url}/api/session/step/{idx}/trace") - } - }; - match send_get(handle, client, &url) { - Ok(val) => match serde_json::from_value::<ilold_core::narrative::trace::FlowTree>(val) { - Ok(tree) => { - if parsed.interactive { - if let Err(e) = crate::interactive::run_trace_viewer(tree) { - eprintln!(" {} interactive viewer: {}", c_danger("✗"), e); - } - } else { - print!("{}", fmt::render_flow_tree(&tree)); - } - } - Err(e) => eprintln!(" {} Parse FlowTree: {}", c_danger("✗"), e), - }, - Err(e) => eprintln!(" {}", c_danger(&e)), - } - InputResult::Continue - } - "seq" | "sequence" => { - match send_get(handle, client, &format!("{base_url}/api/session/sequence")) { - Ok(val) => print_sequence_narrative(&val), - Err(e) => eprintln!(" {}", c_danger(&e)), - } - InputResult::Continue - } - "tl" | "timeline" => { - if arg.is_empty() { - println!(" Usage: timeline <variable>"); - return InputResult::Continue; - } - match send_get(handle, client, &format!("{base_url}/api/session/timeline/{arg}")) { - Ok(val) => match serde_json::from_value::<ilold_core::exploration::timeline::VariableTimeline>(val) { - Ok(tl) => print!("{}", fmt::render_variable_timeline(&tl)), - Err(e) => eprintln!(" {} Parse VariableTimeline: {}", c_danger("✗"), e), - }, - Err(e) => eprintln!(" {}", c_danger(&e)), - } - InputResult::Continue - } - "sl" | "slice" => { - let parts: Vec<&str> = arg.split_whitespace().collect(); - let mut positionals: Vec<&str> = Vec::new(); - let mut direction: Option<&str> = None; - for part in &parts { - match *part { - "--backward" | "-b" => direction = Some("backward"), - "--forward" | "-f" => direction = Some("forward"), - "--both" => direction = Some("both"), - _ => positionals.push(part), - } - } - if positionals.len() < 2 { - println!(" Usage: slice <function> <variable> [--backward|--forward|--both]"); - return InputResult::Continue; - } - let func_name = positionals[0]; - let var_name = positionals[1]; - let mut url = format!("{base_url}/api/session/slice/{func_name}/{var_name}"); - if let Some(d) = direction { - url.push_str(&format!("?direction={d}")); - } - match send_get(handle, client, &url) { - Ok(val) => match serde_json::from_value::<ilold_core::slicing::SliceResult>(val) { - Ok(res) => print!("{}", fmt::render_slice_result(&res)), - Err(e) => eprintln!(" {} Parse SliceResult: {}", c_danger("✗"), e), - }, - Err(e) => eprintln!(" {}", c_danger(&e)), - } - InputResult::Continue - } - "st" | "step" => { - if arg.is_empty() { - println!(" Usage: step <index>"); - return InputResult::Continue; - } - match send_get(handle, client, &format!("{base_url}/api/session/step/{arg}/narrative")) { - Ok(val) => print_narrative(&val), - Err(e) => eprintln!(" {}", c_danger(&e)), - } - InputResult::Continue - } - - "fl" | "findings" => { - print_findings_list(handle, client, base_url, contract); - InputResult::Continue - } - "ex" | "export" => { - let body = serde_json::json!({ "contract": contract, "command": "Export" }); - match send_command(handle, client, base_url, &body) { - Ok(CommandResult::Exported { markdown }) => { - let filename = format!("ilold-report-{}.md", contract); - match std::fs::write(&filename, &markdown) { - Ok(_) => println!(" {} Exported to {}", c_ok("✓"), c_accent(&filename)), - Err(e) => eprintln!(" {} Failed to write: {}", c_danger("✗"), e), - } - } - Ok(other) => print_result(&other, steps), - Err(e) => eprintln!(" {}", c_danger(&e)), - } - InputResult::Continue - } - - "save" => { - if arg.is_empty() { - println!(" Usage: save <name>"); - return InputResult::Continue; - } - let body = serde_json::json!({ "contract": contract, "command": "SaveSession" }); - match send_command(handle, client, base_url, &body) { - Ok(CommandResult::SessionSaved { json }) => { - let dir = dirs::home_dir() - .map(|h| h.join(".ilold").join("sessions")) - .unwrap_or_else(|| std::path::PathBuf::from(".ilold/sessions")); - std::fs::create_dir_all(&dir).ok(); - let path = dir.join(format!("{}.json", arg)); - match std::fs::write(&path, &json) { - Ok(_) => println!(" {} Saved to {}", c_ok("✓"), c_accent(&path.display().to_string())), - Err(e) => eprintln!(" {} Write failed: {}", c_danger("✗"), e), - } - } - Ok(other) => print_result(&other, steps), - Err(e) => eprintln!(" {}", c_danger(&e)), - } - InputResult::Continue - } - "load" => { - if arg.is_empty() { - println!(" Usage: load <name>"); - return InputResult::Continue; - } - let dir = dirs::home_dir() - .map(|h| h.join(".ilold").join("sessions")) - .unwrap_or_else(|| std::path::PathBuf::from(".ilold/sessions")); - let path = dir.join(format!("{}.json", arg)); - let json = match std::fs::read_to_string(&path) { - Ok(j) => j, - Err(e) => { - eprintln!(" {} File not found: {} ({})", c_danger("✗"), path.display(), e); - return InputResult::Continue; - } - }; - let body = serde_json::json!({ "contract": contract, "command": { "LoadSession": { "json": json } } }); - match send_command(handle, client, base_url, &body) { - Ok(CommandResult::SessionLoaded { steps: loaded_steps, .. }) => { - *steps = loaded_steps; - println!(" {} Session loaded ({} steps)", c_ok("✓"), steps.len()); - return InputResult::UpdatePrompt; - } - Ok(other) => print_result(&other, steps), - Err(e) => eprintln!(" {}", c_danger(&e)), - } - InputResult::Continue - } - - _ => { - println!(" Unknown command: {}. Type {} for help.", c_danger(cmd.as_str()), c_accent("?")); - InputResult::Continue - } - } -} - -fn inline_help_target(cmd: &str, arg: &str) -> Option<String> { - if cmd == "?" || cmd == "help" || cmd == "h" { - return None; - } - if cmd.ends_with('?') && cmd.len() > 1 { - return Some(cmd[..cmd.len() - 1].to_string()); - } - if arg.trim() == "?" { - return Some(cmd.to_string()); - } - None -} - -#[allow(clippy::too_many_arguments)] -fn handle_solana_input( - line: &str, - handle: &tokio::runtime::Handle, - client: &reqwest::Client, - base_url: &str, - contract: &str, - steps: &mut Vec<String>, - scenario_name: &mut String, - completer: &std::sync::Arc<std::sync::Mutex<IloldCompleter>>, -) -> InputResult { - let parts: Vec<&str> = line.splitn(2, ' ').collect(); - let cmd = parts[0].to_lowercase(); - let arg = parts.get(1).map(|s| s.trim()).unwrap_or(""); - - if let Some(base) = inline_help_target(&cmd, arg) { - match crate::help::render_solana_help_block(&base) { - Some(text) => print!("{text}"), - None => println!( - " {} no help registered for {}", - c_danger("✗"), - c_accent(&base) - ), - } - return InputResult::Continue; - } - - match cmd.as_str() { - "?" | "help" | "h" => { - print_solana_help(); - InputResult::Continue - } - "quit" | "q" | "exit" => InputResult::Quit, - "funcs" | "functions" | "f" => { - dispatch_solana( - handle, - client, - base_url, - contract, - serde_json::json!("Funcs"), - steps, - ) - } - "funcs-all" | "fa" => dispatch_solana( - handle, - client, - base_url, - contract, - serde_json::json!("Funcs"), - steps, - ), - "info" | "i" => { - if arg.is_empty() { - println!(" Usage: info <instruction>"); - return InputResult::Continue; - } - let body = serde_json::json!({"Info": {"ix": arg}}); - dispatch_solana(handle, client, base_url, contract, body, steps) - } - "vars" | "v" | "vars-all" | "va" => dispatch_solana( - handle, - client, - base_url, - contract, - serde_json::json!("Vars"), - steps, - ), - "coupling" | "cp" => dispatch_solana( - handle, - client, - base_url, - contract, - serde_json::json!("Coupling"), - steps, - ), - "coverage" | "cov" => dispatch_solana( - handle, - client, - base_url, - contract, - serde_json::json!("Coverage"), - steps, - ), - "state" => dispatch_solana( - handle, - client, - base_url, - contract, - serde_json::json!("State"), - steps, - ), - "session" | "s" => { - let r = dispatch_solana( - handle, - client, - base_url, - contract, - serde_json::json!("Session"), - steps, - ); - *scenario_name = sync_active_scenario(handle, client, base_url, contract) - .unwrap_or_else(|| scenario_name.clone()); - r - } - "back" => dispatch_solana( - handle, - client, - base_url, - contract, - serde_json::json!("Back"), - steps, - ), - "clear" => dispatch_solana( - handle, - client, - base_url, - contract, - serde_json::json!("Clear"), - steps, - ), - "users" => { - if arg.starts_with("new") { - let rest = arg.trim_start_matches("new").trim(); - if rest.is_empty() { - println!(" Usage: users new <name> [<lamports>]"); - return InputResult::Continue; - } - let parts: Vec<&str> = rest.split_whitespace().collect(); - let name = parts[0].to_string(); - let lamports: u64 = parts - .get(1) - .and_then(|s| s.parse().ok()) - .unwrap_or(10_000_000_000); - let body = serde_json::json!({"UsersNew": {"name": name, "lamports": lamports}}); - dispatch_solana(handle, client, base_url, contract, body, steps) - } else { - dispatch_solana( - handle, - client, - base_url, - contract, - serde_json::json!("Users"), - steps, - ) - } - } - "airdrop" | "air" => { - let parts: Vec<&str> = arg.split_whitespace().collect(); - if parts.len() != 2 { - println!(" Usage: airdrop <name> <lamports>"); + "airdrop" | "air" => { + let parts: Vec<&str> = arg.split_whitespace().collect(); + if parts.len() != 2 { + println!(" Usage: airdrop <name> <lamports>"); return InputResult::Continue; } let lamports: u64 = match parts[1].parse() { @@ -1303,9 +503,7 @@ fn handle_solana_input( "call" | "c" => { let parts: Vec<&str> = arg.splitn(2, ' ').collect(); if parts.is_empty() || parts[0].is_empty() { - println!( - " Usage: call <ix> [arg=val ...] [account=user_or_pubkey ...]" - ); + println!(" Usage: call <ix> [arg=val ...] [account=user_or_pubkey ...]"); println!(" or: call <ix> {{json}} for full control"); return InputResult::Continue; } @@ -1361,6 +559,13 @@ fn handle_solana_input( } InputResult::Continue } + "use" => { + if arg.is_empty() { + println!(" Usage: use <program>"); + return InputResult::Continue; + } + InputResult::SwitchContract(arg.to_string()) + } "sc" | "scenario" => { let parts: Vec<&str> = arg.split_whitespace().collect(); let sub = parts.first().copied().unwrap_or(""); @@ -1385,9 +590,7 @@ fn handle_solana_input( let action = match action { Some(a) => a, None => { - println!( - " Usage: scenario new|list|switch|fork|delete <name> [step]" - ); + println!(" Usage: scenario new|list|switch|fork|delete <name> [step]"); return InputResult::Continue; } }; @@ -1449,21 +652,16 @@ fn handle_solana_input( }); dispatch_solana(handle, client, base_url, contract, body, steps) } - "seq" | "sequence" => { - dispatch_solana( - handle, - client, - base_url, - contract, - serde_json::json!("Session"), - steps, - ) - } + "seq" | "sequence" => dispatch_solana( + handle, + client, + base_url, + contract, + serde_json::json!("Session"), + steps, + ), "browser" => { - println!( - " {} Web UI not yet available in explore mode.", - c_muted("·") - ); + println!(" {} Web UI not yet available in explore mode.", c_muted("·")); println!(" {} API running at {}/api/", c_muted("·"), base_url); InputResult::Continue } @@ -1489,9 +687,7 @@ fn handle_solana_input( if tok == "--with-keypairs" { with_keypairs = true; } else if tok.starts_with("--") { - println!( - " Unknown flag: {tok}. Use --with-keypairs (or no flags)." - ); + println!(" Unknown flag: {tok}. Use --with-keypairs (or no flags)."); return InputResult::Continue; } else if name.is_none() { name = Some(tok); @@ -1597,11 +793,16 @@ fn handle_solana_input( let mut version: Option<String> = None; let mut date: Option<String> = None; for tok in arg.split_whitespace() { - if let Some(v) = tok.strip_prefix("--auditor=") { auditor = Some(strip_quotes(v).to_string()); } - else if let Some(v) = tok.strip_prefix("--version=") { version = Some(strip_quotes(v).to_string()); } - else if let Some(v) = tok.strip_prefix("--date=") { date = Some(strip_quotes(v).to_string()); } - else { - println!(" Unknown flag: {tok}. Use --auditor= / --version= / --date= (or no flags)"); + if let Some(v) = tok.strip_prefix("--auditor=") { + auditor = Some(strip_quotes(v).to_string()); + } else if let Some(v) = tok.strip_prefix("--version=") { + version = Some(strip_quotes(v).to_string()); + } else if let Some(v) = tok.strip_prefix("--date=") { + date = Some(strip_quotes(v).to_string()); + } else { + println!( + " Unknown flag: {tok}. Use --auditor= / --version= / --date= (or no flags)" + ); return InputResult::Continue; } } @@ -1648,8 +849,7 @@ fn handle_solana_input( return InputResult::Continue; } }; - let body = - serde_json::json!({"Status": {"ix": parts[0], "status": st}}); + let body = serde_json::json!({"Status": {"ix": parts[0], "status": st}}); dispatch_solana(handle, client, base_url, contract, body, steps) } _ => { @@ -1976,82 +1176,14 @@ fn sync_steps( client: &reqwest::Client, base_url: &str, contract: &str, - backend: BackendKind, -) -> Option<Vec<String>> { - let body = serde_json::json!({ - "contract": contract, - "command": "Session" - }); - if backend == BackendKind::Solana { - match send_solana_command(handle, client, base_url, &body) { - Ok(SolanaCommandResult::SessionView { steps, .. }) => Some(steps), - _ => None, - } - } else { - match send_command(handle, client, base_url, &body) { - Ok(CommandResult::SessionView { steps, .. }) => Some(steps), - _ => None, - } - } -} - -fn send_command( - handle: &tokio::runtime::Handle, - client: &reqwest::Client, - base_url: &str, - body: &serde_json::Value, -) -> Result<CommandResult, String> { - handle.block_on(async { - client.post(format!("{base_url}/api/cmd")) - .json(body) - .send() - .await - .map_err(|e| format!("Request failed: {e}"))? - .json::<CommandResult>() - .await - .map_err(|e| format!("Parse failed: {e}")) - }) -} - -fn send_get( - handle: &tokio::runtime::Handle, - client: &reqwest::Client, - url: &str, -) -> Result<serde_json::Value, String> { - handle.block_on(async { - let resp = client.get(url) - .send() - .await - .map_err(|e| format!("Request failed: {e}"))?; - - if !resp.status().is_success() { - let status = resp.status(); - let body = resp.text().await.unwrap_or_default(); - return Err(if body.is_empty() { - format!("Server error: {status}") - } else { - body - }); - } - - resp.json::<serde_json::Value>() - .await - .map_err(|e| format!("Parse failed: {e}")) - }) -} - -fn send_and_print( - handle: &tokio::runtime::Handle, - client: &reqwest::Client, - base_url: &str, - contract: &str, - command: &str, - steps: &[String], -) { - let body = serde_json::json!({ "contract": contract, "command": command }); - match send_command(handle, client, base_url, &body) { - Ok(result) => print_result(&result, steps), - Err(e) => eprintln!(" {}", c_danger(&e)), +) -> Option<Vec<String>> { + let body = serde_json::json!({ + "contract": contract, + "command": "Session" + }); + match send_solana_command(handle, client, base_url, &body) { + Ok(SolanaCommandResult::SessionView { steps, .. }) => Some(steps), + _ => None, } } @@ -2073,64 +1205,6 @@ fn split_numeric_suffix(line: &str) -> String { line.to_string() } -enum TraceTarget { - Function(String), - SessionStep(usize), -} - -struct TraceArgs { - target: Option<TraceTarget>, - depth: Option<usize>, - reverts: bool, - expand: Vec<usize>, - interactive: bool, -} - -fn parse_trace_args(arg: &str) -> TraceArgs { - let tokens: Vec<&str> = arg.split_whitespace().collect(); - let mut target: Option<TraceTarget> = None; - let mut depth: Option<usize> = None; - let mut reverts = false; - let mut expand: Vec<usize> = Vec::new(); - let mut interactive = false; - let mut i = 0; - while i < tokens.len() { - let t = tokens[i]; - if t == "--depth" { - if let Some(v) = tokens.get(i + 1).and_then(|s| s.parse::<usize>().ok()) { - depth = Some(v); - i += 2; - continue; - } - i += 1; - } else if t == "--reverts" { - reverts = true; - i += 1; - } else if t == "-i" || t == "--interactive" { - interactive = true; - i += 1; - } else if let Some(rest) = t.strip_prefix('+') { - if let Ok(id) = rest.parse::<usize>() { - expand.push(id); - } - i += 1; - } else if t == "step" - && target.is_none() - && tokens.get(i + 1).and_then(|s| s.parse::<usize>().ok()).is_some() - { - let idx = tokens[i + 1].parse::<usize>().unwrap(); - target = Some(TraceTarget::SessionStep(idx)); - i += 2; - } else if target.is_none() { - target = Some(TraceTarget::Function(t.to_string())); - i += 1; - } else { - i += 1; - } - } - TraceArgs { target, depth, reverts, expand, interactive } -} - fn strip_quotes(s: &str) -> &str { let s = s.trim(); if (s.starts_with('"') && s.ends_with('"') && s.len() >= 2) @@ -2153,74 +1227,6 @@ fn normalize_severity(input: &str) -> Option<&'static str> { } } -fn normalize_status(input: &str) -> Option<&'static str> { - match input.to_lowercase().as_str() { - "reviewed" => Some("Reviewed"), - "suspicious" => Some("Suspicious"), - "vulnerable" => Some("Vulnerable"), - "clean" => Some("Clean"), - "inprogress" => Some("InProgress"), - "notreviewed" => Some("NotReviewed"), - _ => None, - } -} - -fn read_prompt(label: &str) -> Option<String> { - println!(" {} {}", label, c_muted("(empty to cancel)")); - print!(" > "); - std::io::Write::flush(&mut std::io::stdout()).ok(); - let mut input = String::new(); - match std::io::stdin().read_line(&mut input) { - Ok(0) | Err(_) => None, - Ok(_) => { - let trimmed = input.trim(); - if trimmed.is_empty() { None } else { Some(trimmed.to_string()) } - } - } -} - -fn handle_finding_interactive( - handle: &tokio::runtime::Handle, - client: &reqwest::Client, - base_url: &str, - contract: &str, - steps: &[String], -) { - let severity_input = match read_prompt("Severity (critical/high/medium/low/info):") { - Some(s) => s, - None => { println!(" {}", c_muted("Cancelled.")); return; } - }; - let severity = match normalize_severity(&severity_input) { - Some(s) => s.to_string(), - None => { - println!(" {}", c_danger("Invalid severity. Valid: critical, high, medium, low, info")); - return; - } - }; - - let title = match read_prompt("Title:") { - Some(t) => t, - None => { println!(" {}", c_muted("Cancelled.")); return; } - }; - - let description = read_prompt("Description (optional):").unwrap_or_default(); - - let body = serde_json::json!({ - "contract": contract, - "command": { - "Finding": { - "severity": severity, - "title": title, - "description": description - } - } - }); - match send_command(handle, client, base_url, &body) { - Ok(result) => print_result(&result, steps), - Err(e) => eprintln!(" {}", c_danger(&e)), - } -} - fn print_remote_programs(map: &serde_json::Value, current: &str) { let arr = match map.get("programs").and_then(|v| v.as_array()) { Some(a) => a, @@ -2260,769 +1266,75 @@ fn print_remote_programs(map: &serde_json::Value, current: &str) { println!(); } -fn print_programs(state: &std::sync::Arc<ilold_web::state::AppState>, current: &str) { - let s = match state.solana() { - Some(s) => s, - None => return, - }; - println!(); - if s.project.programs.is_empty() { - println!(" {}", c_muted("No programs detected")); - println!(); - return; - } - for p in &s.project.programs { - let marker = if p.name == current { - c_ok(" ← current").to_string() - } else { - String::new() - }; - println!( - " {} {} {} {}{}", - c_accent("[P]"), - p.name, - c_muted(&format!("({} ix)", p.instructions.len())), - c_muted(&p.program_id.to_string()), - marker - ); - } - println!(); -} - -fn print_contracts(state: &std::sync::Arc<ilold_web::state::AppState>, current: &str) { - use ilold_core::model::contract::ContractKind; - let s = state.unwrap_solidity(); - println!(); - let max_name = s.project.contracts.iter() - .filter(|c| !c.name.is_empty()) - .map(|c| c.name.chars().count()) - .max().unwrap_or(0); - - for c in &s.project.contracts { - if c.name.is_empty() { continue; } - let badge = match c.kind { - ContractKind::Contract => c_accent("[C]"), - ContractKind::Interface => c_muted("[I]"), - ContractKind::Library => c_muted("[L]"), - ContractKind::Abstract => c_warn("[A]"), - }; - let marker = if c.name == current { - c_ok(" ← current").to_string() - } else { - String::new() - }; - let padded = fmt::pad_right(&c.name, max_name); - let details = format!( - "{} functions, {} state vars", - c.functions.iter().filter(|f| !f.name.is_empty()).count(), - c.state_vars.len(), - ); - let inherits = if c.inherits.is_empty() { - String::new() - } else { - format!(", inherits {}", c.inherits.join(", ")) - }; - println!(" {} {} {}{}{}", badge, c_accent(&padded), c_muted(&details), c_muted(&inherits), marker); - } - println!(); -} - -fn print_findings_list( - handle: &tokio::runtime::Handle, - client: &reqwest::Client, - base_url: &str, - contract: &str, -) { - let body = serde_json::json!({ "contract": contract, "command": "Session" }); - match send_command(handle, client, base_url, &body) { - Ok(CommandResult::SessionView { findings_count, .. }) => { - if findings_count == 0 { - println!(" No findings recorded yet."); - } else { - println!(" {} finding(s) recorded. Use {} to export.", findings_count, c_accent("export")); - } - } - _ => println!(" Could not retrieve findings."), - } -} - -fn print_result(result: &CommandResult, steps: &[String]) { - match result { - CommandResult::StepAdded { step_index, function, access, state_changed } => { - let badge = access_colored(access); - println!(); - println!(" {} Step {}: {} {} {}", c_ok("+"), step_index, c_bright(function), badge, format_access_detail(access)); - if !state_changed.is_empty() { - println!(" {}:", c_muted("State writes")); - for var in state_changed { - println!(" {} {}", c_muted("·"), c_warn(var)); - } - } - let seq_str = steps.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(" → "); - println!(" {}: {}", c_muted("Sequence"), c_accent(&seq_str)); - println!(); - } - CommandResult::StepRemoved { remaining } => { - println!(); - println!(" {} Step removed. {} remaining.", c_warn("-"), remaining); - if !steps.is_empty() { - let seq_str = steps.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(" → "); - println!(" {}: {}", c_muted("Sequence"), c_accent(&seq_str)); - } - println!(); - } - CommandResult::Cleared => { - println!(" {}", c_ok("Session cleared.")); - } - CommandResult::StateView { summary } => { - if summary.is_empty() { - println!(" No state changes yet. Use {} to add steps.", c_accent("call <func>")); - return; - } - println!(); - println!("{}", fmt::separator("STATE")); - for var in summary { - println!(" {} {}", c_bright(&var.variable), ""); - for change in &var.changes { - println!(" {}", c_muted(change)); - } - } - println!(); - } - CommandResult::FunctionList { functions } => { - println!(); - let max_name = functions.iter() - .filter(|f| !f.name.is_empty()) - .map(|f| f.name.chars().count()) - .max().unwrap_or(0); - for entry in functions { - if entry.name.is_empty() { continue; } - let badge = access_colored(&entry.access); - let padded_name = fmt::pad_right(&entry.name, max_name); - let mut tags: Vec<&str> = Vec::new(); - if entry.writes_state { tags.push("writes state"); } - if entry.has_external_calls { tags.push("external calls"); } - if entry.is_read_only { tags.push("view"); } - let tag_str = if tags.is_empty() { - String::new() - } else { - format!(" {}", c_muted(&tags.join(", "))) - }; - println!(" {} {}{}", badge, c_accent(&padded_name), tag_str); - } - println!(); - } - CommandResult::FunctionListAll { functions } => { - println!(); - let max_name = functions.iter() - .filter(|f| !f.name.is_empty()) - .map(|f| f.name.chars().count()) - .max().unwrap_or(0); - - let own: Vec<_> = functions.iter().filter(|f| !f.is_inherited).collect(); - let inherited: Vec<_> = functions.iter().filter(|f| f.is_inherited).collect(); - - for entry in &own { - if entry.name.is_empty() { continue; } - let badge = access_colored(&entry.access); - let padded_name = fmt::pad_right(&entry.name, max_name); - let mut tags: Vec<&str> = Vec::new(); - if entry.writes_state { tags.push("writes state"); } - if entry.has_external_calls { tags.push("external calls"); } - if entry.is_read_only { tags.push("view"); } - let tag_str = if tags.is_empty() { - String::new() - } else { - format!(" {}", c_muted(&tags.join(", "))) - }; - println!(" {} {}{}", badge, c_accent(&padded_name), tag_str); - } - - if !inherited.is_empty() { - println!(); - println!(" {}", c_muted("inherited:")); - for entry in &inherited { - let badge = access_colored(&entry.access); - let padded_name = fmt::pad_right(&entry.name, max_name); - let origin = format!("from {}", entry.origin); - println!(" {} {} {}", badge, c_muted(&padded_name), c_muted(&origin)); - } - } - println!(); - } - CommandResult::StateVarListAll { state_vars } => { - println!(); - let max_name = state_vars.iter() - .map(|v| v.name.chars().count()) - .max().unwrap_or(0); - let max_tag = 9; - - let own: Vec<_> = state_vars.iter().filter(|v| !v.is_inherited).collect(); - let inherited: Vec<_> = state_vars.iter().filter(|v| v.is_inherited).collect(); - - let render_tag = |is_const: bool, is_immut: bool| -> String { - let text = if is_const { "const" } - else if is_immut { "immutable" } - else { "mutable" }; - let padded = fmt::pad_right(text, max_tag); - if is_const || is_immut { - c_muted(&padded).to_string() - } else { - c_warn(&padded).to_string() - } - }; - - for entry in &own { - let tag = render_tag(entry.is_constant, entry.is_immutable); - let padded_name = fmt::pad_right(&entry.name, max_name); - println!(" {} {} {}", tag, c_accent(&padded_name), c_muted(&entry.type_name)); - } - - if !inherited.is_empty() { - println!(); - println!(" {}", c_muted("inherited:")); - for entry in &inherited { - let tag = render_tag(entry.is_constant, entry.is_immutable); - let padded_name = fmt::pad_right(&entry.name, max_name); - let origin = format!("from {}", entry.origin); - println!(" {} {} {} {}", - tag, - c_muted(&padded_name), - c_muted(&entry.type_name), - c_muted(&origin), - ); - } - } - println!(); - } - CommandResult::FindingAdded { id } => { - println!(" {} Finding {} added", c_ok("✓"), c_accent(id)); - } - CommandResult::NoteAdded => { - println!(" {} Note added", c_ok("✓")); - } - CommandResult::StatusUpdated => { - println!(" {} Status updated", c_ok("✓")); - } - CommandResult::SessionView { contract, steps: session_steps, findings_count } => { - println!(); - println!(" Contract: {}", c_bright(contract)); - println!(" Steps: {}", if session_steps.is_empty() { - c_muted("(empty)").to_string() - } else { - session_steps.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(" → ") - }); - println!(" Findings: {}", findings_count); - println!(); - } - CommandResult::VariableInfo { variable, writers, readers } => { - println!(); - println!(" {} {}", c_bright("who:"), c_accent(variable)); - if !writers.is_empty() { - println!(" {}:", c_warn("Writers")); - for (name, access) in writers { - println!(" {} {}", access_colored(access), c_accent(name)); - } - } - if !readers.is_empty() { - println!(" {}:", c_muted("Readers")); - for (name, access) in readers { - println!(" {} {}", access_colored(access), c_muted(name)); - } - } - if !writers.is_empty() { - let slice_hints: Vec<String> = writers.iter() - .take(4) - .map(|(name, _)| format!("sl {} {}", name, variable)) - .collect(); - let suffix = if writers.len() > 4 { - format!(" (+{})", writers.len() - 4) - } else { - String::new() - }; - println!(" {}{}", c_muted(&format!("→ {}", slice_hints.join(", "))), c_muted(&suffix)); - } - println!(" {}", c_muted(&format!("→ tl {}", variable))); - println!(); - } - CommandResult::Exported { markdown } => { - println!(" {} chars exported", markdown.len()); - } - CommandResult::SessionSaved { json } => { - println!(" Session serialized ({} bytes)", json.len()); - } - CommandResult::SessionLoaded { contract, steps: loaded_steps } => { - println!(" Session loaded: {} ({} steps)", contract, loaded_steps.len()); - } - CommandResult::Error { message } => { - println!(" {}", c_danger(message)); - } - CommandResult::ScenarioList { items } => { - print!("{}", fmt::render_scenario_list(items)); - } - CommandResult::ScenarioCreated { name } => { - println!("{}", fmt::render_scenario_created(name)); - } - CommandResult::ScenarioSwitched { from, to } => { - println!("{}", fmt::render_scenario_switched(from, to)); - } - CommandResult::ScenarioForked { from, to, at_step } => { - println!("{}", fmt::render_scenario_forked(from, to, *at_step)); - } - CommandResult::ScenarioDeleted { name } => { - println!("{}", fmt::render_scenario_deleted(name)); - } - } -} - -fn format_access_detail(access: &AccessLevel) -> String { - match access { - AccessLevel::Public => "external".truecolor(110, 120, 140).to_string(), - AccessLevel::Restricted { role } => format!("{}", c_warn(&format!("restricted({role})"))), - AccessLevel::Internal => "internal".truecolor(110, 120, 140).to_string(), - AccessLevel::Special { kind } => format!("{}", c_muted(&format!("special({kind})"))), - } -} - -fn print_vars(val: &serde_json::Value) { - let vars = match val.get("state_vars").and_then(|v| v.as_array()) { - Some(v) => v, - None => { println!(" No state variables found."); return; } - }; - let max_name = vars.iter() - .filter_map(|v| v.get("name").and_then(|n| n.as_str())) - .map(|n| n.chars().count()) - .max().unwrap_or(0); - let max_tag = 9; // "immutable" is the longest - println!(); - for v in vars { - let name = v.get("name").and_then(|n| n.as_str()).unwrap_or("?"); - let type_name = v.get("type_name").and_then(|n| n.as_str()).unwrap_or("?"); - let is_const = v.get("is_constant").and_then(|n| n.as_bool()).unwrap_or(false); - let is_immut = v.get("is_immutable").and_then(|n| n.as_bool()).unwrap_or(false); - - let tag_text = if is_const { "const" } - else if is_immut { "immutable" } - else { "mutable" }; - let padded_tag = fmt::pad_right(tag_text, max_tag); - let tag = if is_const || is_immut { - c_muted(&padded_tag).to_string() - } else { - c_warn(&padded_tag).to_string() - }; - - let padded_name = fmt::pad_right(name, max_name); - println!(" {} {} {}", tag, c_accent(&padded_name), c_muted(type_name)); - } - println!(); -} - -fn print_narrative(val: &serde_json::Value) { - println!(); - if let Some(name) = val.get("name").and_then(|v| v.as_str()) { - let access = val.get("access").and_then(|v| v.as_str()).unwrap_or(""); - let mods = val.get("modifiers").and_then(|v| v.as_array()) - .map(|arr| arr.iter().filter_map(|m| m.as_str()).collect::<Vec<_>>().join(", ")) - .unwrap_or_default(); - let mod_str = if mods.is_empty() { String::new() } else { format!(" — {}", c_muted(&mods)) }; - println!(" {} [{}]{}", c_bright(name), c_accent(access), mod_str); - } - - #[derive(Default)] - struct TransitiveGroup { - writes: Vec<String>, - reads: Vec<String>, - external: Vec<String>, - events: Vec<String>, - } - - enum Section<'a> { - Paths { total: u64, happy: u64, revert: u64 }, - StringList { label: &'a str, label_color: SectionColor, items: Vec<String> }, - Transitive(Vec<(String, TransitiveGroup)>), - Observations(Vec<String>), - } - enum SectionColor { Muted, Danger, Warn, Accent } - - let color = |c: &SectionColor, s: &str| -> String { - match c { - SectionColor::Muted => c_muted(s).to_string(), - SectionColor::Danger => c_danger(s).to_string(), - SectionColor::Warn => c_warn(s).to_string(), - SectionColor::Accent => c_accent(s).to_string(), - } - }; - - let mut sections: Vec<Section> = Vec::new(); - - if let Some(total) = val.get("total_paths").and_then(|v| v.as_u64()) { - let happy = val.get("happy_paths").and_then(|v| v.as_u64()).unwrap_or(0); - let revert = val.get("revert_paths").and_then(|v| v.as_u64()).unwrap_or(0); - sections.push(Section::Paths { total, happy, revert }); - } - - let collect_strs = |key: &str| -> Vec<String> { - val.get(key) - .and_then(|v| v.as_array()) - .map(|arr| arr.iter().filter_map(|s| s.as_str().map(|s| s.to_string())).collect()) - .unwrap_or_default() - }; - - let reads = collect_strs("state_reads"); - if !reads.is_empty() { - sections.push(Section::StringList { label: "State reads", label_color: SectionColor::Muted, items: reads }); - } - let writes = collect_strs("state_writes"); - if !writes.is_empty() { - sections.push(Section::StringList { label: "State writes", label_color: SectionColor::Danger, items: writes }); - } - let internal = collect_strs("internal_calls"); - if !internal.is_empty() { - sections.push(Section::StringList { label: "Internal calls", label_color: SectionColor::Accent, items: internal }); - } - let externals = collect_strs("external_calls"); - if !externals.is_empty() { - sections.push(Section::StringList { label: "External calls", label_color: SectionColor::Warn, items: externals }); - } - let events = collect_strs("events"); - if !events.is_empty() { - sections.push(Section::StringList { label: "Events", label_color: SectionColor::Accent, items: events }); - } - - let collect_transitive = |key: &str| -> Vec<(Vec<String>, String)> { - val.get(key) - .and_then(|v| v.as_array()) - .map(|arr| { - arr.iter() - .filter_map(|e| { - let via = e.get("via")?.as_array()? - .iter().filter_map(|s| s.as_str().map(|s| s.to_string())) - .collect::<Vec<_>>(); - let item = e.get("item")?.as_str()?.to_string(); - Some((via, item)) - }) - .collect() - }) - .unwrap_or_default() - }; - - let t_writes = collect_transitive("transitive_state_writes"); - let t_reads = collect_transitive("transitive_state_reads"); - let t_external = collect_transitive("transitive_external_calls"); - let t_events = collect_transitive("transitive_events"); - - if !t_writes.is_empty() || !t_reads.is_empty() || !t_external.is_empty() || !t_events.is_empty() { - use std::collections::BTreeMap; - let mut groups: BTreeMap<String, TransitiveGroup> = BTreeMap::new(); - let join_chain = |via: &[String]| via.join(" → "); - for (via, item) in t_writes { groups.entry(join_chain(&via)).or_default().writes.push(item); } - for (via, item) in t_reads { groups.entry(join_chain(&via)).or_default().reads.push(item); } - for (via, item) in t_external { groups.entry(join_chain(&via)).or_default().external.push(item); } - for (via, item) in t_events { groups.entry(join_chain(&via)).or_default().events.push(item); } - let ordered: Vec<(String, TransitiveGroup)> = groups.into_iter().collect(); - sections.push(Section::Transitive(ordered)); - } - - let obs_items: Vec<String> = val - .get("observations") - .and_then(|v| v.as_array()) - .map(|arr| { - arr.iter() - .filter_map(|o| o.get("description").and_then(|v| v.as_str()).map(|s| s.to_string())) - .collect() - }) - .unwrap_or_default(); - if !obs_items.is_empty() { - sections.push(Section::Observations(obs_items)); - } - - let total = sections.len(); - for (i, section) in sections.iter().enumerate() { - let last = i == total - 1; - let branch = if last { "└──" } else { "├──" }; - let cont = if last { " " } else { "│ " }; - match section { - Section::Paths { total, happy, revert } => { - println!( - " {} {} path(s): {} happy, {} revert", - c_muted(branch), total, c_ok(&happy.to_string()), c_danger(&revert.to_string()) - ); - } - Section::StringList { label, label_color, items } => { - println!(" {} {}:", c_muted(branch), color(label_color, label)); - for (j, item) in items.iter().enumerate() { - let leaf = if j == items.len() - 1 { "└──" } else { "├──" }; - println!(" {} {} {}", c_muted(cont), c_muted(leaf), c_muted(item)); - } - } - Section::Transitive(groups) => { - println!(" {} {}:", c_muted(branch), c_warn("Transitive effects")); - let gtotal = groups.len(); - for (gi, (chain, g)) in groups.iter().enumerate() { - let glast = gi == gtotal - 1; - let gbranch = if glast { "└──" } else { "├──" }; - let gcont = if glast { " " } else { "│ " }; - println!(" {} {} {} {}", c_muted(cont), c_muted(gbranch), c_muted("via"), c_muted(chain)); - - let mut parts: Vec<(&str, &Vec<String>)> = Vec::new(); - if !g.writes.is_empty() { parts.push(("writes", &g.writes)); } - if !g.reads.is_empty() { parts.push(("reads", &g.reads)); } - if !g.external.is_empty() { parts.push(("external", &g.external)); } - if !g.events.is_empty() { parts.push(("emits", &g.events)); } - let ptotal = parts.len(); - for (pi, (plabel, pitems)) in parts.iter().enumerate() { - let plast = pi == ptotal - 1; - let pbranch = if plast { "└──" } else { "├──" }; - println!( - " {} {} {} {}: {}", - c_muted(cont), c_muted(gcont), c_muted(pbranch), - c_muted(plabel), c_muted(&pitems.join(", ")) - ); - } - } - } - Section::Observations(items) => { - println!(" {} {}:", c_muted(branch), c_danger("Observations")); - for (j, item) in items.iter().enumerate() { - let leaf = if j == items.len() - 1 { "└──" } else { "├──" }; - println!(" {} {} {}", c_muted(cont), c_muted(leaf), c_danger(item)); - } - } - } - } - if let Some(name) = val.get("name").and_then(|v| v.as_str()) { - println!(" {}", c_muted(&format!("→ c {} | tr {}", name, name))); - } - println!(); -} - -fn print_sequence_narrative(val: &serde_json::Value) { - println!(); - if let Some(steps) = val.get("steps").and_then(|v| v.as_array()) { - let names: Vec<&str> = steps.iter() - .filter_map(|s| s.get("function").and_then(|f| f.as_str())) - .collect(); - if !names.is_empty() { - println!(" {}", c_bright(&names.join(" → "))); - } - } - if let Some(deps) = val.get("dependencies").and_then(|v| v.as_array()) { - if !deps.is_empty() { - println!(" {}:", c_warn("Dependencies")); - for dep in deps { - if let Some(desc) = dep.get("description").and_then(|v| v.as_str()) { - println!(" • {}", c_muted(desc)); - } - } - } - } - if let Some(obs) = val.get("observations").and_then(|v| v.as_array()) { - if !obs.is_empty() { - println!(" {}:", c_danger("Observations")); - for o in obs { - if let Some(desc) = o.get("description").and_then(|v| v.as_str()) { - println!(" ! {}", c_danger(desc)); - } else if let Some(desc) = o.as_str() { - println!(" ! {}", c_danger(desc)); - } - } - } - } - if let Some(steps) = val.get("steps").and_then(|v| v.as_array()) { - let any_summary = steps.iter().any(|s| !s.get("flow_summary").map(|v| v.is_null()).unwrap_or(true)); - if any_summary { - println!(" {}:", c_warn("Flow summaries")); - for (i, step) in steps.iter().enumerate() { - let summary = match step.get("flow_summary") { - Some(s) if !s.is_null() => s, - _ => continue, - }; - let func = step.get("function").and_then(|v| v.as_str()).unwrap_or("?"); - let total = summary.get("total_steps").and_then(|v| v.as_u64()).unwrap_or(0); - let muts = summary.get("mutation_count").and_then(|v| v.as_u64()).unwrap_or(0); - let ext = summary.get("external_call_count").and_then(|v| v.as_u64()).unwrap_or(0); - let int_n = summary.get("internal_call_count").and_then(|v| v.as_u64()).unwrap_or(0); - let dl = summary.get("depth_limited_count").and_then(|v| v.as_u64()).unwrap_or(0); - println!( - " {} {} {} ops | {} mutations | {} ext calls | {} internal{}", - c_accent(&format!("step {}", i)), - c_bright(func), - total, muts, ext, int_n, - if dl > 0 { format!(" ({} depth-limited)", dl) } else { String::new() }, - ); - } - } - } - println!(); -} - -fn print_help() { - print_help_for(BackendKind::Solidity); -} - fn print_solana_help() { - print_help_for(BackendKind::Solana); -} - -fn print_help_for(backend: BackendKind) { - let groups: &[(&str, &[(&str, &str, &str)])] = match backend { - BackendKind::Solana => &[ - ("Session", &[ - ("c", "call <ix> arg=val acc=user", "Concise: keys auto-distributed; signers auto from IDL"), - ("", "call <ix> {json}", "Full control with explicit args/accounts/signers"), - ("", " --signer=a,b", "Add signers (override IDL defaults)"), - ("", " --no-signer=a", "Remove a signer (simulate negative cases)"), - ("b", "back", "Remove last step from active scenario"), - ("cl", "clear", "Reset active scenario steps"), - ("", "state", "Decoded view of accounts mutated this session"), - ("s", "session", "Active scenario summary (steps + findings)"), - ]), - ("Programs", &[ - ("ct", "programs (alias contracts)", "List programs in workspace"), - ("", "use <program>", "Switch active program"), - ("f", "funcs (alias functions)", "List instructions of active program"), - ("fa", "funcs-all", "Instructions with arg/account/signer/pda counts"), - ("i", "info <ix>", "Detail an instruction: args (typed), accounts (flags), discriminator"), - ("v", "vars", "List declared account types with discriminators"), - ("va", "vars-all", "Account types with their decoded field layout"), - ]), - ("Solana runtime", &[ - ("", "users", "List keypairs in active scenario"), - ("", "users new <name> [lamports]", "Create keypair + airdrop (default 10 SOL)"), - ("", "airdrop <user> <lamports>", "Top up an existing keypair"), - ("tw", "time-warp <secs>", "Advance Clock unix_timestamp + slot"), - ("", "pda <ix>", "List PDAs declared by an instruction (symbolic)"), - ("", "inspect <pubkey>", "Read VM account, decode by Anchor discriminator"), - ]), - ("Analysis", &[ - ("st", "step <index>", "Re-inspect a step: CU, logs, diffs"), - ("", "who <query>", "Resolve query: AccountType | Instruction | Field"), - ("tl", "timeline <pubkey>", "Cross-step mutation history of an account, decoded"), - ("cp", "coupling", "List ix pairs that share writable accounts"), - ("cov","coverage", "Aggregated runtime metrics for the active scenario"), - ]), - ("Findings", &[ - ("fi", "finding <sev> <title>", "Record a security finding"), - ("fl", "findings", "List recorded findings"), - ("n", "note <text>", "Add annotation to active sequence"), - ("", "status <ix> <s>", "Set review status: open|reviewed|finding"), - ("ex", "export", "Markdown report: sequence + findings + program info"), - ]), - ("Workspace", &[ - ("sc", "scenario <sub>", "new|list|switch|fork|delete <name> [step]"), - ("", "save <name>", "Save active scenario JSON to disk"), - ("", "load <name>", "Load scenario JSON from disk"), - ("?", "help", "Print this help (append ? to any cmd for full reference)"), - ("q", "quit/exit", "Exit"), - ]), - ], - _ => &[ + let groups: &[(&str, &[(&str, &str, &str)])] = &[ ("Session", &[ - ("c", "call <func>", "Add function to sequence"), - ("b", "back", "Remove last step"), - ("cl", "clear", "Reset sequence"), - ("s", "state", "Show accumulated state"), - ("seq", "sequence", "Sequence narrative with dependencies"), - ("st", "step <index>", "Re-inspect a specific step"), - ("ss", "session", "Full session state"), + ("c", "call <ix> arg=val acc=user", "Concise: keys auto-distributed; signers auto from IDL"), + ("", "call <ix> {json}", "Full control with explicit args/accounts/signers"), + ("", " --signer=a,b", "Add signers (override IDL defaults)"), + ("", " --no-signer=a", "Remove a signer (simulate negative cases)"), + ("b", "back", "Remove last step from active scenario"), + ("cl", "clear", "Reset active scenario steps"), + ("", "state", "Decoded view of accounts mutated this session"), + ("s", "session", "Active scenario summary (steps + findings)"), ]), - ("Analysis", &[ - ("w", "who <var>", "Who reads/writes a variable"), - ("i", "info <func>", "Function detail"), - ("tr", "trace <func>", "Execution flow tree (inlined)"), - ("", "trace <func> -i", "Interactive trace viewer"), - ("", "trace step <N>", "Re-render persisted step trace"), - ("tl", "timeline <var>", "Cross-step variable mutation history"), - ("sl", "slice <fn> <var>", "Dataflow slice for a variable"), + ("Programs", &[ + ("ct", "programs (alias contracts)", "List programs in workspace"), + ("", "use <program>", "Switch active program"), + ("f", "funcs (alias functions)", "List instructions of active program"), + ("fa", "funcs-all", "Instructions with arg/account/signer/pda counts"), + ("i", "info <ix>", "Detail an instruction: args (typed), accounts (flags), discriminator"), + ("v", "vars", "List declared account types with discriminators"), + ("va", "vars-all", "Account types with their decoded field layout"), ]), - ("Contract", &[ - ("f", "functions", "List callable functions"), - ("fa", "funcs-all", "List all accessible (incl. inherited)"), - ("v", "vars", "List state variables"), - ("va", "vars-all", "List all accessible (incl. inherited)"), - ("ct", "contracts", "List project contracts"), - ("", "use <contract>", "Switch active contract"), + ("Solana runtime", &[ + ("", "users", "List keypairs in active scenario"), + ("", "users new <name> [lamports]", "Create keypair + airdrop (default 10 SOL)"), + ("", "airdrop <user> <lamports>", "Top up an existing keypair"), + ("tw", "time-warp <secs>", "Advance Clock unix_timestamp + slot"), + ("", "pda <ix>", "List PDAs declared by an instruction (symbolic)"), + ("", "inspect <pubkey>", "Read VM account, decode by Anchor discriminator"), + ]), + ("Analysis", &[ + ("st", "step <index>", "Re-inspect a step: CU, logs, diffs"), + ("", "who <query>", "Resolve query: AccountType | Instruction | Field"), + ("tl", "timeline <pubkey>", "Cross-step mutation history of an account, decoded"), + ("cp", "coupling", "List ix pairs that share writable accounts"), + ("cov","coverage", "Aggregated runtime metrics for the active scenario"), ]), ("Findings", &[ - ("fi", "finding [sev] [t]","Record a finding"), - ("n", "note <text>", "Add note to current step"), - ("sc", "scenario <name>", "Name the current sequence"), - ("", "status <f> <s>", "Change review status"), - ("fl", "findings", "List recorded findings"), - ("ex", "export", "Export findings as markdown"), + ("fi", "finding <sev> <title>", "Record a security finding"), + ("fl", "findings", "List recorded findings"), + ("n", "note <text>", "Add annotation to active sequence"), + ("", "status <ix> <s>", "Set review status: open|reviewed|finding"), + ("ex", "export", "Markdown report: sequence + findings + program info"), ]), ("Workspace", &[ - ("", "save <name>", "Save session to disk"), - ("", "load <name>", "Load session from disk"), - ("", "browser", "Open web UI"), - ("q", "quit/exit", "Exit"), + ("sc", "scenario <sub>", "new|list|switch|fork|delete <name> [step]"), + ("", "save <name>", "Save active scenario JSON to disk"), + ("", "load <name>", "Load scenario JSON from disk"), + ("?", "help", "Print this help (append ? to any cmd for full reference)"), + ("q", "quit/exit", "Exit"), ]), - ], - }; + ]; println!(); - println!(" {} {}", c_bright("ilold explore"), c_muted("— append ? to any command for inline help (e.g. sl?)")); + println!(" {} {}", c_bright("ilold explore"), c_muted("— append ? to any command for inline help (e.g. call?)")); println!(); for (group_name, cmds) in groups { println!(" {}", c_warn(group_name)); for (shortcut, name, desc) in *cmds { let sc = if shortcut.is_empty() { - format!(" {} ", fmt::pad_right("", 3)) + format!(" {} ", pad_right("", 3)) } else { - format!(" {} {}", c_accent(&fmt::pad_right(shortcut, 3)), c_muted("|")) + format!(" {} {}", c_accent(&pad_right(shortcut, 3)), c_muted("|")) }; - println!(" {} {} {}", sc, c_accent(&fmt::pad_right(name, 22)), c_muted(desc)); + println!(" {} {} {}", sc, c_accent(&pad_right(name, 22)), c_muted(desc)); } println!(); } } -fn print_inline_help(cmd: &str) { - let entries: &[(&[&str], &str, &str)] = &[ - (&["c", "call"], "call <func>", "Add function call to session. Example: c deposit"), - (&["b", "back"], "back", "Remove last step from the session sequence."), - (&["cl", "clear"], "clear", "Reset the entire session (all steps removed)."), - (&["s", "state"], "state", "Show accumulated state mutations across all steps."), - (&["f", "functions"], "functions", "List callable functions in the active contract."), - (&["v", "vars"], "vars", "List state variables of the active contract."), - (&["w", "who"], "who <variable>", "Show which functions read/write a variable. Example: who totalStaked"), - (&["i", "info"], "info <func>", "Function detail: paths, reads, writes, calls. Example: i deposit"), - (&["tr", "trace"], "trace <func> [--depth N] [-i]", "Execution flow tree. -i for interactive TUI. Example: tr swap --depth 3"), - (&["seq", "sequence"],"sequence", "Show the narrative of the current call sequence."), - (&["tl", "timeline"], "timeline <variable>", "Cross-step mutation history. Example: tl totalStaked"), - (&["sl", "slice"], "slice <func> <var> [--backward]", "Dataflow slice. Example: sl deposit totalStaked --backward"), - (&["st", "step"], "step <index>", "Re-inspect a specific session step. Example: st 0"), - (&["ss", "session"], "session", "Full session state with all steps."), - (&["fi", "finding"], "finding [severity] [text]", "Record a security finding for the current step."), - (&["n", "note"], "note <text>", "Attach a note to the current step."), - (&["sc", "scenario"],"scenario <name>", "Name the current sequence. Example: sc reentrancy-attack"), - (&["fl", "findings"], "findings", "List all recorded findings."), - (&["ex", "export"], "export", "Export findings as a markdown report."), - (&["fa", "funcs-all"],"funcs-all", "List all accessible functions including inherited."), - (&["va", "vars-all"], "vars-all", "List all accessible state variables including inherited."), - (&["ct", "contracts"],"contracts", "List all contracts in the project."), - (&["use"], "use <contract>", "Switch the active contract. Example: use Staking"), - (&["status"], "status <func> <status>", "Change review status. Example: status deposit reviewed"), - (&["save"], "save <name>", "Save session to disk. Example: save my-audit"), - (&["load"], "load <name>", "Load session from disk. Example: load my-audit"), - (&["browser"], "browser", "Open the web UI in a browser."), - (&["users"], "users [new <name> [<lamports>]]", "List or create keypairs in active scenario."), - (&["airdrop", "air"], "airdrop <name> <lamports>", "Top up a user's SOL balance."), - (&["tw", "time-warp"],"time-warp <delta_seconds>", "Warp the Clock sysvar (positive forward, negative back)."), - (&["pda"], "pda <instruction>", "List declared PDAs of an instruction (Anchor IDL)."), - (&["inspect", "acc"], "inspect <pubkey>", "Decode an account by Anchor discriminator."), - (&["programs", "progs"], "programs", "List programs in the workspace (Solana)."), - ]; - - for (aliases, usage, desc) in entries { - if aliases.iter().any(|a| *a == cmd) { - println!(" {} {}", c_accent(usage), c_muted(desc)); - return; - } - } - println!(" {} unknown command: {}", c_danger("✗"), cmd); -} - struct IloldPrompt { contract: String, steps: Vec<String>, @@ -3045,7 +1357,9 @@ impl Prompt for IloldPrompt { if self.steps.is_empty() { Cow::Owned(format!("ilold[{}]", label)) } else if self.steps.len() <= 3 { - let path = self.steps.iter() + let path = self + .steps + .iter() .map(|s| s.as_str()) .collect::<Vec<_>>() .join(" → "); @@ -3054,15 +1368,27 @@ impl Prompt for IloldPrompt { let skipped = self.steps.len() - 2; Cow::Owned(format!( "ilold[{} → {} → ...{} more → {}]", - label, self.steps[0], skipped, self.steps.last().unwrap() + label, + self.steps[0], + skipped, + self.steps.last().unwrap() )) } } - fn render_prompt_right(&self) -> Cow<'_, str> { Cow::Borrowed("") } - fn render_prompt_indicator(&self, _: PromptEditMode) -> Cow<'_, str> { Cow::Borrowed("> ") } - fn render_prompt_multiline_indicator(&self) -> Cow<'_, str> { Cow::Borrowed(".. ") } - fn render_prompt_history_search_indicator(&self, search: PromptHistorySearch) -> Cow<'_, str> { + fn render_prompt_right(&self) -> Cow<'_, str> { + Cow::Borrowed("") + } + fn render_prompt_indicator(&self, _: PromptEditMode) -> Cow<'_, str> { + Cow::Borrowed("> ") + } + fn render_prompt_multiline_indicator(&self) -> Cow<'_, str> { + Cow::Borrowed(".. ") + } + fn render_prompt_history_search_indicator( + &self, + search: PromptHistorySearch, + ) -> Cow<'_, str> { match search.status { PromptHistorySearchStatus::Passing => Cow::Borrowed("(search) "), PromptHistorySearchStatus::Failing => Cow::Borrowed("(search failed) "), @@ -3084,22 +1410,16 @@ impl Completer for IloldCompleter { || line_lower.starts_with("call ") || line_lower.starts_with("i ") || line_lower.starts_with("info ") - || line_lower.starts_with("tr ") - || line_lower.starts_with("trace ") || line_lower.starts_with("w ") || line_lower.starts_with("who ") - || line_lower.starts_with("status ") - || (line_lower.starts_with("sl ") && line[..pos].matches(' ').count() == 1) - || (line_lower.starts_with("slice ") && line[..pos].matches(' ').count() == 1); + || line_lower.starts_with("status "); let needs_contract = line_lower.starts_with("use "); let needs_scenario = line_lower.starts_with("scenario switch ") || line_lower.starts_with("scenario delete ") || line_lower.starts_with("sc switch ") - || line_lower.starts_with("sc delete ") - || line_lower.starts_with("scen switch ") - || line_lower.starts_with("scen delete "); + || line_lower.starts_with("sc delete "); if !needs_func && !needs_contract && !needs_scenario { return Vec::new(); @@ -3116,7 +1436,8 @@ impl Completer for IloldCompleter { &self.functions }; - source.iter() + source + .iter() .filter(|f| f.starts_with(partial)) .map(|f| Suggestion { value: f.clone(), diff --git a/crates/ilold-cli/src/fmt.rs b/crates/ilold-cli/src/fmt.rs deleted file mode 100644 index fcb4e51..0000000 --- a/crates/ilold-cli/src/fmt.rs +++ /dev/null @@ -1,607 +0,0 @@ -use colored::Colorize; - -use ilold_core::exploration::commands::ScenarioInfo; -use ilold_core::exploration::session::MutationScope; -use ilold_core::exploration::timeline::{TimelineEntry, VariableTimeline}; -use ilold_core::narrative::trace::{FlowKind, FlowNode, FlowTree}; -use ilold_core::slicing::{SliceDirection, SliceEntry, SliceResult, StatementOrigin}; - -use crate::colors::{c_accent, c_bright, c_danger, c_muted, c_ok, c_warn}; - -pub fn term_width() -> usize { - terminal_size::terminal_size() - .map(|(terminal_size::Width(w), _)| w as usize) - .unwrap_or(80) -} - -pub fn separator(label: &str) -> String { - let width = term_width().min(100); - let inner = format!("[ {} ]", label); - let remaining = width.saturating_sub(inner.len() + 2); - let left = remaining / 2; - let right = remaining - left; - format!( - " {}{}{}", - "═".repeat(left).truecolor(60, 70, 90), - inner.truecolor(190, 200, 215), - "═".repeat(right).truecolor(60, 70, 90), - ) -} - -pub fn header_box(lines: &[&str]) -> String { - let max_content = lines.iter().map(|l| l.chars().count()).max().unwrap_or(0); - let inner_width = max_content + 2; - - let mut out = String::new(); - out.push_str(&format!( - " {}{}{}\n", - "╭".truecolor(60, 70, 90), - "─".repeat(inner_width).truecolor(60, 70, 90), - "╮".truecolor(60, 70, 90), - )); - for line in lines { - let visible_len = line.chars().count(); - let pad = max_content.saturating_sub(visible_len); - out.push_str(&format!( - " {} {}{} {}\n", - "│".truecolor(60, 70, 90), - line, - " ".repeat(pad), - "│".truecolor(60, 70, 90), - )); - } - out.push_str(&format!( - " {}{}{}", - "╰".truecolor(60, 70, 90), - "─".repeat(inner_width).truecolor(60, 70, 90), - "╯".truecolor(60, 70, 90), - )); - out -} - -pub fn pad_right(s: &str, width: usize) -> String { - let len = s.chars().count(); - if len >= width { - s.to_string() - } else { - format!("{}{}", s, " ".repeat(width - len)) - } -} - -pub fn render_flow_tree(tree: &FlowTree) -> String { - let mut out = String::new(); - - let title = format!("{}::{}", tree.contract, tree.signature); - let mods = if tree.modifiers.is_empty() { - "modifiers: (none)".to_string() - } else { - format!("modifiers: {}", tree.modifiers.join(", ")) - }; - let depth = format!("max inlining depth: {}", tree.max_depth); - let lines: Vec<&str> = vec![&title, &mods, &depth]; - out.push_str(&header_box(&lines)); - out.push('\n'); - out.push('\n'); - - let mut step: usize = 1; - render_flow_root(&tree.root, &mut step, &mut out); - - append_expand_hint(tree, &mut out); - - let mut raw_vars: Vec<&str> = Vec::new(); - collect_written_vars(&tree.root, &mut raw_vars); - let mut base_vars: Vec<String> = raw_vars.iter() - .map(|v| v.split('[').next().unwrap_or(v).to_string()) - .collect(); - base_vars.sort_unstable(); - base_vars.dedup(); - if !base_vars.is_empty() { - let hints = base_vars.iter() - .take(5) - .map(|v| format!("sl {} {}", tree.function, v)) - .collect::<Vec<_>>() - .join(", "); - let suffix = if base_vars.len() > 5 { - format!(" (+{} more)", base_vars.len() - 5) - } else { - String::new() - }; - out.push_str(&format!(" {}{}\n", c_muted(&format!("→ {}", hints)), c_muted(&suffix))); - } - - out -} - -fn collect_written_vars<'a>(node: &'a FlowNode, out: &mut Vec<&'a str>) { - match &node.kind { - FlowKind::Write { target, .. } | FlowKind::StateWrite { variable: target } => { - out.push(target.as_str()); - } - _ => {} - } - for child in &node.children { - collect_written_vars(child, out); - } -} - -fn append_expand_hint(tree: &FlowTree, out: &mut String) { - let mut candidates: Vec<usize> = Vec::new(); - collect_depth_limited(&tree.root, &mut candidates); - if candidates.is_empty() { - return; - } - - let total = candidates.len(); - candidates.truncate(10); - let csv = candidates.iter() - .map(|n| n.to_string()) - .collect::<Vec<_>>() - .join(", "); - let suffix = if total > 10 { - format!(" (… {} more)", total - 10) - } else { - String::new() - }; - - out.push('\n'); - out.push_str(&format!( - " {} {}{}\n", - c_muted("tip: expand with `tr <func> +N` — candidates:"), - c_accent(&csv), - c_muted(&suffix), - )); -} - -fn collect_depth_limited(node: &FlowNode, out: &mut Vec<usize>) { - if let FlowKind::InternalCall { depth_limited: true, .. } = &node.kind { - out.push(node.step_id); - } - for child in &node.children { - collect_depth_limited(child, out); - } -} - -fn render_flow_root(root: &FlowNode, step: &mut usize, out: &mut String) { - let (icon, text) = format_flow_label(&root.kind); - let gutter = format_gutter(*step); - *step += 1; - out.push_str(&format!( - "{} {} {}\n", - gutter, - c_accent(icon), - c_bright(&text), - )); - - let n = root.children.len(); - for (i, child) in root.children.iter().enumerate() { - render_flow_node(child, "", i == n - 1, step, out); - } -} - -fn render_flow_node( - node: &FlowNode, - parent_prefix: &str, - is_last: bool, - step: &mut usize, - out: &mut String, -) { - let connector = if is_last { "└─ " } else { "├─ " }; - let (icon, text) = format_flow_label(&node.kind); - let gutter = format_gutter(*step); - *step += 1; - - let suffix = match &node.from_modifier { - Some(name) => format!(" {}", c_warn(&format!("[from: {}]", name))), - None => String::new(), - }; - - let colored_icon = color_for_kind(&node.kind, icon); - let colored_text = color_for_kind_text(&node.kind, &text); - - out.push_str(&format!( - "{} {}{}{} {}{}\n", - gutter, - c_muted(parent_prefix), - c_muted(connector), - colored_icon, - colored_text, - suffix, - )); - - let extension = if is_last { " " } else { "│ " }; - let new_prefix = format!("{}{}", parent_prefix, extension); - let n = node.children.len(); - for (i, child) in node.children.iter().enumerate() { - render_flow_node(child, &new_prefix, i == n - 1, step, out); - } -} - -fn format_gutter(step: usize) -> String { - format!(" {} {}", c_accent(&format!("{:03}", step)), c_muted("│")) -} - -pub(crate) fn format_flow_label(kind: &FlowKind) -> (&'static str, String) { - match kind { - FlowKind::Entry { signature } => ("▶", signature.clone()), - FlowKind::Require { condition, message } => { - let text = match message { - Some(m) => format!("require({}, {})", condition, m), - None => format!("require({})", condition), - }; - ("◇", text) - } - FlowKind::Assert { condition } => ("◇", format!("assert({})", condition)), - FlowKind::Write { target, value, op } => { - ("✏", format!("{} {} {}", target, op.as_str(), value)) - } - FlowKind::StateWrite { variable } => ("✏", format!("write {}", variable)), - FlowKind::StateRead { variable } => ("▸", format!("read {}", variable)), - FlowKind::InternalCall { - function, - origin, - depth_limited, - ops_count, - } => { - let mut text = function.clone(); - if !origin.is_empty() { - text.push_str(&format!(" [from: {}]", origin)); - } - if *depth_limited { - text.push_str(&format!(" [+{} ops, depth limited]", ops_count)); - } - ("○", text) - } - FlowKind::ExternalCall { target, function } => { - ("→", format!("{}.{}", target, function)) - } - FlowKind::EmitEvent { name } => ("◆", format!("emit {}", name)), - FlowKind::BranchTrue { condition } => ("?", format!("if ({}) == true", condition)), - FlowKind::BranchFalse { condition } => ("?", format!("if ({}) == false", condition)), - FlowKind::LoopHeader { kind } => ("↻", format!("loop ({})", kind)), - FlowKind::Return => ("✓", "return".to_string()), - FlowKind::Revert { reason } => { - let text = match reason { - Some(r) => format!("revert({})", r), - None => "revert".to_string(), - }; - ("✗", text) - } - FlowKind::EthTransfer { to } => ("$", format!("transfer ETH → {}", to)), - FlowKind::AssemblyBlock => ("⚙", "assembly { … }".to_string()), - FlowKind::DepthLimit => ("…", "depth limit".to_string()), - } -} - -fn color_for_kind(kind: &FlowKind, icon: &str) -> colored::ColoredString { - match kind { - FlowKind::Entry { .. } => c_accent(icon), - FlowKind::Require { .. } | FlowKind::Assert { .. } => c_muted(icon), - FlowKind::Write { .. } | FlowKind::StateWrite { .. } => c_danger(icon), - FlowKind::StateRead { .. } => c_muted(icon), - FlowKind::InternalCall { .. } => c_warn(icon), - FlowKind::ExternalCall { .. } | FlowKind::EthTransfer { .. } => c_danger(icon), - FlowKind::EmitEvent { .. } => c_accent(icon), - FlowKind::BranchTrue { .. } | FlowKind::BranchFalse { .. } => c_warn(icon), - FlowKind::LoopHeader { .. } => c_warn(icon), - FlowKind::Return => c_ok(icon), - FlowKind::Revert { .. } => c_danger(icon), - FlowKind::AssemblyBlock => c_warn(icon), - FlowKind::DepthLimit => c_muted(icon), - } -} - -fn color_for_kind_text(kind: &FlowKind, text: &str) -> colored::ColoredString { - match kind { - FlowKind::Entry { .. } => c_bright(text), - FlowKind::Write { .. } | FlowKind::StateWrite { .. } => c_danger(text), - FlowKind::ExternalCall { .. } | FlowKind::EthTransfer { .. } => c_danger(text), - FlowKind::InternalCall { .. } => c_warn(text), - FlowKind::BranchTrue { .. } | FlowKind::BranchFalse { .. } => c_warn(text), - FlowKind::Revert { .. } => c_danger(text), - FlowKind::Return => c_ok(text), - _ => c_muted(text), - } -} - -pub fn render_variable_timeline(tl: &VariableTimeline) -> String { - let mut out = String::new(); - out.push('\n'); - - let header = format!("{} {}", c_bright(&tl.variable), c_muted("— mutation timeline")); - out.push_str(&format!(" {}\n", header)); - out.push_str(&format!( - " {}\n", - "═".repeat(60).truecolor(60, 70, 90) - )); - - if tl.state_entries.is_empty() && tl.local_entries.is_empty() { - out.push_str(&format!(" {}\n", c_muted(&format!("no mutations of '{}' in current session — add steps with 'c <func>' first", tl.variable)))); - return out; - } - - if !tl.state_entries.is_empty() { - out.push_str(&format!(" {}\n", c_warn("[state]"))); - render_entries(&tl.state_entries, &mut out); - } - - if !tl.local_entries.is_empty() { - out.push_str(&format!(" {}\n", c_warn("[local]"))); - render_entries(&tl.local_entries, &mut out); - } - - let all_entries = tl.state_entries.iter().chain(tl.local_entries.iter()); - let mut seen = std::collections::HashSet::new(); - let hints: Vec<String> = all_entries - .filter(|e| seen.insert(e.function.clone())) - .map(|e| format!("sl {} {}", e.function, tl.variable)) - .collect(); - if !hints.is_empty() { - out.push_str(&format!(" {}\n", c_muted(&format!("→ {}", hints.join(", "))))); - } - - out.push('\n'); - out -} - -fn render_entries(entries: &[TimelineEntry], out: &mut String) { - let mut prev_step: Option<usize> = None; - for entry in entries { - if Some(entry.session_step_index) != prev_step { - out.push_str(&format!( - " {} {}\n", - c_accent(&format!("session step {}", entry.session_step_index)), - c_bright(&entry.function), - )); - prev_step = Some(entry.session_step_index); - } - - let op = entry.operator.as_str(); - let flow_ref = entry.flow_step_id - .map(|id| format!(" [trace step {}]", id)) - .unwrap_or_default(); - let via = entry.via.as_deref() - .map(|c| format!(" via {}", c)) - .unwrap_or_default(); - let scope_tag = match entry.scope { - MutationScope::State => "", - MutationScope::Local => " (local)", - }; - - out.push_str(&format!( - " {} {} {} {}{}{}{}\n", - c_danger("✏"), - c_muted(&entry.target), - c_muted(op), - c_muted(&entry.value_expr), - c_muted(&flow_ref), - c_muted(&via), - c_muted(scope_tag), - )); - - if !entry.reached_when.is_empty() { - out.push_str(&format!(" {}\n", c_muted("reached when:"))); - for cond in &entry.reached_when { - out.push_str(&format!(" {} {}\n", c_muted("•"), c_muted(cond))); - } - } - } -} - -pub fn render_slice_result(res: &SliceResult) -> String { - let mut out = String::new(); - out.push('\n'); - - let header = format!( - "{} {} {} {}", - c_bright(&res.function), - c_muted("·"), - c_bright(&res.variable), - c_muted("— dataflow slice"), - ); - out.push_str(&format!(" {}\n", header)); - out.push_str(&format!(" {}\n", "═".repeat(60).truecolor(60, 70, 90))); - - let show_backward = matches!( - res.direction, - SliceDirection::Backward | SliceDirection::Both - ); - let show_forward = matches!( - res.direction, - SliceDirection::Forward | SliceDirection::Both - ); - - if show_backward { - render_slice_side("backward", &res.backward, &res.variable, &mut out); - } - if show_forward { - render_slice_side("forward", &res.forward, &res.variable, &mut out); - } - - out.push_str(&format!( - " {}\n", - c_muted(&format!("→ tr {} | tl {}", res.function, res.variable)), - )); - - out.push('\n'); - out -} - -fn render_slice_side(label: &str, entries: &[SliceEntry], var: &str, out: &mut String) { - out.push_str(&format!(" {}\n", c_warn(&format!("[{}]", label)))); - if entries.is_empty() { - let reason = if label == "backward" { - format!("no definitions of '{}' found — may be a parameter or set only in constructor/modifier", var) - } else { - format!("no statements depend on '{}' after its definition in this function", var) - }; - out.push_str(&format!(" {}\n", c_muted(&reason))); - return; - } - for entry in entries { - let line_tag = entry.span - .map(|s| format!("L{:<4}", s.start_line)) - .unwrap_or_else(|| "L? ".into()); - let origin_tag = match &entry.origin { - StatementOrigin::FunctionBody => String::new(), - StatementOrigin::Modifier(name) => format!("{} ", c_accent(&format!("[mod {}]", name))), - }; - out.push_str(&format!( - " {} {}{}\n", - c_muted(&line_tag), - origin_tag, - entry.text, - )); - } -} - -pub fn render_scenario_list(items: &[ScenarioInfo]) -> String { - if items.is_empty() { - return format!(" {}\n", c_muted("(no scenarios)")); - } - - let name_width = items - .iter() - .map(|s| s.name.chars().count()) - .max() - .unwrap_or(0) - .max(4); - - let title = format!( - "scenarios — {} total, active: {}", - items.len(), - items - .iter() - .find(|s| s.active) - .map(|s| s.name.as_str()) - .unwrap_or("?"), - ); - let header = format!( - " {} {} {}", - pad_right("", 1), - pad_right("name", name_width), - "steps", - ); - let mut lines: Vec<String> = vec![title, header]; - for s in items { - let marker = if s.active { "→" } else { " " }; - lines.push(format!( - " {} {} {}", - marker, - pad_right(&s.name, name_width), - s.step_count, - )); - } - let refs: Vec<&str> = lines.iter().map(|s| s.as_str()).collect(); - let mut out = header_box(&refs); - out.push('\n'); - out -} - -pub fn render_scenario_forked(from: &str, to: &str, at_step: usize) -> String { - format!( - " {} Forked '{}' → '{}' at step {}", - c_ok("✓"), - c_accent(from), - c_accent(to), - at_step, - ) -} - -pub fn render_scenario_created(name: &str) -> String { - format!(" {} Created scenario '{}'", c_ok("✓"), c_accent(name)) -} - -pub fn render_scenario_switched(from: &str, to: &str) -> String { - if from == to { - format!(" {} Already on scenario '{}'", c_muted("·"), c_accent(to)) - } else { - format!( - " {} Switched: '{}' → '{}'", - c_ok("✓"), - c_accent(from), - c_accent(to), - ) - } -} - -pub fn render_scenario_deleted(name: &str) -> String { - format!(" {} Deleted scenario '{}'", c_ok("✓"), c_accent(name)) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn strip_ansi(s: &str) -> String { - let mut out = String::with_capacity(s.len()); - let mut in_esc = false; - for c in s.chars() { - if in_esc { - if c == 'm' { - in_esc = false; - } - continue; - } - if c == '\u{1b}' { - in_esc = true; - continue; - } - out.push(c); - } - out - } - - fn make_require_tree(message: Option<&str>) -> FlowTree { - FlowTree { - contract: "C".into(), - function: "f".into(), - signature: "f()".into(), - modifiers: vec![], - max_depth: 2, - root: FlowNode { - step_id: 0, - depth: 0, - kind: FlowKind::Entry { signature: "f()".into() }, - from_modifier: None, - children: vec![FlowNode { - step_id: 1, - depth: 1, - kind: FlowKind::Require { - condition: "x == 1".into(), - message: message.map(|s| s.into()), - }, - from_modifier: None, - children: vec![], - }], - }, - } - } - - #[test] - fn render_require_message_not_double_escaped() { - let tree = make_require_tree(Some("\"LOCKED\"")); - let rendered = strip_ansi(&render_flow_tree(&tree)); - - assert!( - rendered.contains("require(x == 1, \"LOCKED\")"), - "expected unescaped message in output.\nrendered:\n{}", - rendered, - ); - assert!( - !rendered.contains("\\\""), - "rendered output must not contain backslash-escaped quotes.\nrendered:\n{}", - rendered, - ); - } - - #[test] - fn render_require_without_message() { - let tree = make_require_tree(None); - let rendered = strip_ansi(&render_flow_tree(&tree)); - assert!( - rendered.contains("require(x == 1)"), - "rendered:\n{}", - rendered, - ); - } -} diff --git a/crates/ilold-cli/src/interactive.rs b/crates/ilold-cli/src/interactive.rs deleted file mode 100644 index 826f35b..0000000 --- a/crates/ilold-cli/src/interactive.rs +++ /dev/null @@ -1,508 +0,0 @@ -use std::collections::HashSet; -use std::io; -use std::time::Duration; - -use crossterm::event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind}; -use crossterm::execute; -use crossterm::terminal::{ - disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, -}; -use ratatui::backend::CrosstermBackend; -use ratatui::layout::{Constraint, Direction, Layout, Rect}; -use ratatui::style::{Color, Modifier, Style}; -use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph, Wrap}; -use ratatui::{Frame, Terminal}; - -use ilold_core::narrative::trace::{FlowKind, FlowNode, FlowTree}; - -struct TerminalGuard; - -impl TerminalGuard { - fn new() -> io::Result<Self> { - enable_raw_mode()?; - let mut stdout = io::stdout(); - execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; - Ok(TerminalGuard) - } -} - -impl Drop for TerminalGuard { - fn drop(&mut self) { - let _ = disable_raw_mode(); - let _ = execute!(io::stdout(), LeaveAlternateScreen, DisableMouseCapture); - } -} - -struct ViewerState { - tree: FlowTree, - collapsed: HashSet<usize>, - selected_step_id: usize, - show_help: bool, -} - -impl ViewerState { - fn new(tree: FlowTree) -> Self { - let selected_step_id = tree.root.step_id; - ViewerState { - tree, - collapsed: HashSet::new(), - selected_step_id, - show_help: false, - } - } - - fn flatten(&self) -> Vec<FlatRow<'_>> { - let mut out = Vec::new(); - flatten_node(&self.tree.root, "", true, true, &self.collapsed, &mut out); - out - } - - fn snapshot(&self) -> Vec<RowSnapshot> { - self.flatten() - .iter() - .map(|r| RowSnapshot { - step_id: r.node.step_id, - has_children: r.has_children, - is_collapsed: r.is_collapsed, - }) - .collect() - } - - fn cursor_in(&self, flat: &[FlatRow<'_>]) -> usize { - flat.iter() - .position(|r| r.node.step_id == self.selected_step_id) - .unwrap_or(0) - } - - fn cursor_in_snapshot(&self, snap: &[RowSnapshot]) -> usize { - snap.iter() - .position(|r| r.step_id == self.selected_step_id) - .unwrap_or(0) - } -} - -#[derive(Debug, Clone, Copy)] -struct RowSnapshot { - step_id: usize, - has_children: bool, - is_collapsed: bool, -} - -struct FlatRow<'a> { - node: &'a FlowNode, - prefix: String, - has_children: bool, - is_collapsed: bool, -} - -fn flatten_node<'a>( - node: &'a FlowNode, - parent_prefix: &str, - is_last: bool, - is_root: bool, - collapsed: &HashSet<usize>, - out: &mut Vec<FlatRow<'a>>, -) { - let connector = if is_root { - "" - } else if is_last { - "└─ " - } else { - "├─ " - }; - let prefix = format!("{}{}", parent_prefix, connector); - - let has_children = !node.children.is_empty(); - let is_collapsed = collapsed.contains(&node.step_id); - - out.push(FlatRow { - node, - prefix, - has_children, - is_collapsed, - }); - - if has_children && !is_collapsed { - let extension = if is_root { - "" - } else if is_last { - " " - } else { - "│ " - }; - let new_prefix = format!("{}{}", parent_prefix, extension); - let n = node.children.len(); - for (i, child) in node.children.iter().enumerate() { - flatten_node(child, &new_prefix, i == n - 1, false, collapsed, out); - } - } -} - -pub fn run_trace_viewer(tree: FlowTree) -> io::Result<()> { - let _guard = TerminalGuard::new()?; - - let backend = CrosstermBackend::new(io::stdout()); - let mut terminal = Terminal::new(backend)?; - - let mut state = ViewerState::new(tree); - run_loop(&mut terminal, &mut state)?; - - terminal.show_cursor()?; - Ok(()) -} - -fn draw_ui(frame: &mut Frame, state: &ViewerState, flat: &[FlatRow<'_>]) { - let area = frame.area(); - - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(3), - Constraint::Min(1), - Constraint::Length(1), - ]) - .split(area); - - draw_header(frame, chunks[0], state); - draw_list(frame, chunks[1], state, flat); - draw_footer(frame, chunks[2]); - - if state.show_help { - draw_help_overlay(frame, area); - } -} - -fn draw_help_overlay(frame: &mut Frame, area: Rect) { - let width = area.width.min(64); - let height = area.height.min(26); - let x = area.x + (area.width.saturating_sub(width)) / 2; - let y = area.y + (area.height.saturating_sub(height)) / 2; - let popup = Rect { x, y, width, height }; - - frame.render_widget(Clear, popup); - - let cyan = Style::default().fg(Color::Cyan); - let gray = Style::default().fg(Color::Gray); - let yellow = Style::default().fg(Color::Yellow); - let bold_white = Style::default().fg(Color::White).add_modifier(Modifier::BOLD); - - let lines: Vec<Line> = vec![ - Line::from(Span::styled("Navigation", bold_white)), - Line::from(vec![ - Span::styled(" ↑/↓ k/j ", cyan), - Span::styled("move cursor", gray), - ]), - Line::from(vec![ - Span::styled(" g Home ", cyan), - Span::styled("jump to top", gray), - ]), - Line::from(vec![ - Span::styled(" G End ", cyan), - Span::styled("jump to bottom", gray), - ]), - Line::from(""), - Line::from(Span::styled("Tree control", bold_white)), - Line::from(vec![ - Span::styled(" → Enter l ", cyan), - Span::styled("expand current", gray), - ]), - Line::from(vec![ - Span::styled(" ← h ", cyan), - Span::styled("collapse (leaf → parent)", gray), - ]), - Line::from(""), - Line::from(Span::styled("Icons", bold_white)), - Line::from(vec![ - Span::styled(" ▶ ", yellow), - Span::styled("collapsed ", gray), - Span::styled("▼ ", yellow), - Span::styled("expanded", gray), - ]), - Line::from(vec![ - Span::styled(" ◇ ", gray), - Span::styled("require ", gray), - Span::styled("✏ ", Style::default().fg(Color::Red)), - Span::styled("write", gray), - ]), - Line::from(vec![ - Span::styled(" → ", Style::default().fg(Color::Red)), - Span::styled("ext call ", gray), - Span::styled("○ ", yellow), - Span::styled("internal call", gray), - ]), - Line::from(vec![ - Span::styled(" ◆ ", Style::default().fg(Color::Cyan)), - Span::styled("emit event ", gray), - Span::styled("? ", yellow), - Span::styled("branch", gray), - ]), - Line::from(vec![ - Span::styled(" ✓ ", Style::default().fg(Color::Green)), - Span::styled("return ", gray), - Span::styled("✗ ", Style::default().fg(Color::Red)), - Span::styled("revert", gray), - ]), - Line::from(""), - Line::from(Span::styled("Tags", bold_white)), - Line::from(vec![ - Span::styled(" [from: name] ", yellow), - Span::styled("originated in modifier", gray), - ]), - Line::from(vec![ - Span::styled(" [+K ops, depth ", yellow), - Span::styled("call body hidden;", gray), - ]), - Line::from(vec![ - Span::styled(" limited] ", yellow), - Span::styled("use --depth N to see it", gray), - ]), - Line::from(""), - Line::from(vec![ - Span::styled(" ? ", cyan), - Span::styled("toggle this help", gray), - Span::styled(" q ", cyan), - Span::styled("quit", gray), - ]), - ]; - - let block = Block::default() - .borders(Borders::ALL) - .border_style(cyan) - .title(Span::styled(" Help ", bold_white)); - let help = Paragraph::new(lines) - .block(block) - .wrap(Wrap { trim: false }); - frame.render_widget(help, popup); -} - -fn draw_header(frame: &mut Frame, area: Rect, state: &ViewerState) { - let tree = &state.tree; - let mods = if tree.modifiers.is_empty() { - "(none)".to_string() - } else { - tree.modifiers.join(", ") - }; - let title = format!( - " {}::{} │ modifiers: {} │ depth: {} ", - tree.contract, tree.signature, mods, tree.max_depth, - ); - let header = Paragraph::new(title) - .style(Style::default().fg(Color::White)) - .block( - Block::default() - .borders(Borders::ALL) - .title(" ilold trace — interactive "), - ); - frame.render_widget(header, area); -} - -fn draw_list( - frame: &mut Frame, - area: Rect, - state: &ViewerState, - flat: &[FlatRow<'_>], -) { - let items: Vec<ListItem> = flat.iter().map(format_row).collect(); - - let list = List::new(items) - .highlight_style( - Style::default() - .bg(Color::Indexed(238)) - .add_modifier(Modifier::BOLD), - ) - .highlight_symbol("▌ "); - - let mut list_state = ListState::default(); - list_state.select(Some(state.cursor_in(flat))); - frame.render_stateful_widget(list, area, &mut list_state); -} - -fn draw_footer(frame: &mut Frame, area: Rect) { - let hint = Line::from(vec![ - Span::styled(" ↑↓/jk ", Style::default().fg(Color::Cyan)), - Span::raw("navigate "), - Span::styled("→/Enter ", Style::default().fg(Color::Cyan)), - Span::raw("expand "), - Span::styled("← ", Style::default().fg(Color::Cyan)), - Span::raw("collapse "), - Span::styled("g/G ", Style::default().fg(Color::Cyan)), - Span::raw("top/bot "), - Span::styled("? ", Style::default().fg(Color::Cyan)), - Span::raw("help "), - Span::styled("q ", Style::default().fg(Color::Cyan)), - Span::raw("quit"), - ]); - let footer = Paragraph::new(hint).style(Style::default().fg(Color::DarkGray)); - frame.render_widget(footer, area); -} - -fn format_row<'a>(row: &FlatRow<'a>) -> ListItem<'a> { - let collapse_icon = if row.has_children { - if row.is_collapsed { "▶ " } else { "▼ " } - } else { - " " - }; - - let (kind_icon, kind_text) = crate::fmt::format_flow_label(&row.node.kind); - let modifier_tag = row.node.from_modifier.as_deref() - .map(|m| format!(" [from: {}]", m)) - .unwrap_or_default(); - - let line = Line::from(vec![ - Span::styled(row.prefix.clone(), Style::default().fg(Color::DarkGray)), - Span::styled(collapse_icon, Style::default().fg(Color::Yellow)), - Span::styled( - kind_icon, - Style::default().fg(kind_color(&row.node.kind)), - ), - Span::raw(" "), - Span::styled(kind_text, Style::default().fg(kind_text_color(&row.node.kind))), - Span::styled(modifier_tag, Style::default().fg(Color::Yellow)), - ]); - ListItem::new(line) -} - -fn kind_color(kind: &FlowKind) -> Color { - match kind { - FlowKind::Entry { .. } | FlowKind::EmitEvent { .. } => Color::Cyan, - FlowKind::Write { .. } - | FlowKind::StateWrite { .. } - | FlowKind::ExternalCall { .. } - | FlowKind::EthTransfer { .. } - | FlowKind::Revert { .. } => Color::Red, - FlowKind::InternalCall { .. } - | FlowKind::BranchTrue { .. } - | FlowKind::BranchFalse { .. } - | FlowKind::LoopHeader { .. } - | FlowKind::AssemblyBlock => Color::Yellow, - FlowKind::Return => Color::Green, - _ => Color::Gray, - } -} - -fn kind_text_color(kind: &FlowKind) -> Color { - match kind { - FlowKind::Entry { .. } => Color::White, - FlowKind::Write { .. } - | FlowKind::StateWrite { .. } - | FlowKind::ExternalCall { .. } - | FlowKind::EthTransfer { .. } - | FlowKind::Revert { .. } => Color::Red, - FlowKind::InternalCall { .. } - | FlowKind::BranchTrue { .. } - | FlowKind::BranchFalse { .. } => Color::Yellow, - FlowKind::Return => Color::Green, - _ => Color::Gray, - } -} - -fn run_loop( - terminal: &mut Terminal<CrosstermBackend<io::Stdout>>, - state: &mut ViewerState, -) -> io::Result<()> { - loop { - let snap = state.snapshot(); - if !snap.iter().any(|r| r.step_id == state.selected_step_id) { - if let Some(first) = snap.first() { - state.selected_step_id = first.step_id; - } - } - - { - let flat = state.flatten(); - terminal.draw(|f| draw_ui(f, state, &flat))?; - } - - if !event::poll(Duration::from_millis(200))? { - continue; - } - let ev = event::read()?; - let Event::Key(key) = ev else { continue }; - if key.kind != KeyEventKind::Press && key.kind != KeyEventKind::Repeat { - continue; - } - - if !handle_key(state, &snap, key.code) { - return Ok(()); - } - } -} - -fn handle_key(state: &mut ViewerState, snap: &[RowSnapshot], code: KeyCode) -> bool { - if state.show_help { - match code { - KeyCode::Char('q') => return false, - KeyCode::Char('?') | KeyCode::Esc | KeyCode::F(1) => { - state.show_help = false; - } - _ => {} - } - return true; - } - - match code { - KeyCode::Char('q') | KeyCode::Esc => return false, - KeyCode::Char('?') | KeyCode::F(1) => { - state.show_help = true; - } - - KeyCode::Up | KeyCode::Char('k') => { - let idx = state.cursor_in_snapshot(snap); - if idx > 0 { - state.selected_step_id = snap[idx - 1].step_id; - } - } - KeyCode::Down | KeyCode::Char('j') => { - let idx = state.cursor_in_snapshot(snap); - if idx + 1 < snap.len() { - state.selected_step_id = snap[idx + 1].step_id; - } - } - - KeyCode::Right | KeyCode::Enter | KeyCode::Char('l') => { - let idx = state.cursor_in_snapshot(snap); - if let Some(row) = snap.get(idx) { - if row.has_children && row.is_collapsed { - state.collapsed.remove(&row.step_id); - } - } - } - KeyCode::Left | KeyCode::Char('h') => { - let idx = state.cursor_in_snapshot(snap); - if let Some(row) = snap.get(idx) { - if row.has_children && !row.is_collapsed { - state.collapsed.insert(row.step_id); - } else if let Some(parent_id) = find_parent_id(&state.tree.root, row.step_id) { - state.selected_step_id = parent_id; - } - } - } - - KeyCode::Char('g') | KeyCode::Home => { - if let Some(first) = snap.first() { - state.selected_step_id = first.step_id; - } - } - KeyCode::Char('G') | KeyCode::End => { - if let Some(last) = snap.last() { - state.selected_step_id = last.step_id; - } - } - - _ => {} - } - true -} - -fn find_parent_id(node: &FlowNode, child_id: usize) -> Option<usize> { - for child in &node.children { - if child.step_id == child_id { - return Some(node.step_id); - } - if let Some(found) = find_parent_id(child, child_id) { - return Some(found); - } - } - None -} diff --git a/crates/ilold-cli/src/main.rs b/crates/ilold-cli/src/main.rs index 987e361..54e88ab 100644 --- a/crates/ilold-cli/src/main.rs +++ b/crates/ilold-cli/src/main.rs @@ -4,16 +4,12 @@ use anyhow::Result; use clap::Parser; use ilold_solana_core::ingest::{detect, ProjectKind}; -mod analyze; mod colors; -mod context; mod explore; -mod fmt; mod help; -mod interactive; #[derive(Parser)] -#[command(name = "ilold", version, about = "Smart contract execution path analyzer")] +#[command(name = "ilold", version, about = "Solana program execution path analyzer")] struct Cli { #[command(subcommand)] command: Commands, @@ -21,43 +17,17 @@ struct Cli { #[derive(clap::Subcommand)] enum Commands { - /// Analyze Solidity contracts - Analyze { - path: PathBuf, - #[arg(long)] - contract: Option<String>, - #[arg(long, default_value = "3")] - max_seq_depth: usize, - #[arg(long)] - verbose: bool, - }, - /// Generate context for a function or sequence - Context { - path: PathBuf, - #[arg(long)] - contract: Option<String>, - #[arg(long)] - function: Option<String>, - #[arg(long)] - sequence: Option<String>, - #[arg(long)] - list: bool, - }, /// Start interactive web viewer Serve { path: PathBuf, #[arg(long, default_value = "8080")] port: u16, - #[arg(long, default_value = "3")] - max_seq_depth: usize, }, /// Interactive exploration REPL with web canvas Explore { path: PathBuf, #[arg(long, default_value = "0")] port: u16, - #[arg(long, default_value = "3")] - max_seq_depth: usize, /// Attach to a running server instead of starting one locally #[arg(long)] attach: Option<String>, @@ -76,83 +46,34 @@ enum Commands { }, } +fn ensure_solana(path: &PathBuf) -> Result<ilold_solana_core::ingest::DetectedProject> { + let detected = detect(path)?; + match detected.kind { + ProjectKind::Solana => Ok(detected), + ProjectKind::Solidity => anyhow::bail!( + "Only Solana (Anchor) projects are supported in ilold. Solidity support lives in the standalone ilold-evm repo." + ), + } +} + #[tokio::main] async fn main() -> Result<()> { let cli = Cli::parse(); match cli.command { - Commands::Analyze { path, contract, max_seq_depth, verbose } => { - analyze::run(&path, contract.as_deref(), max_seq_depth, verbose) - } - Commands::Context { path, contract, function, sequence, list } => { - context::run(&path, contract.as_deref(), function.as_deref(), sequence.as_deref(), list) - } - Commands::Serve { path, port, max_seq_depth } => { - let detected = detect(&path)?; - match detected.kind { - ProjectKind::Solidity => serve_solidity(&path, port, max_seq_depth).await, - ProjectKind::Solana => ilold_web::serve_solana(detected, port).await, - } + Commands::Serve { path, port } => { + let detected = ensure_solana(&path)?; + ilold_web::serve_solana(detected, port).await } - Commands::Explore { path, port, max_seq_depth, attach } => { + Commands::Explore { path, port, attach } => { if attach.is_some() { - return explore::run(Vec::new(), port, max_seq_depth, attach).await; - } - let detected = detect(&path)?; - match detected.kind { - ProjectKind::Solidity => explore_solidity(&path, port, max_seq_depth, attach).await, - ProjectKind::Solana => explore::run_solana(detected, port).await, + return explore::run(Vec::new(), port, 0, attach).await; } + let detected = ensure_solana(&path)?; + explore::run_solana(detected, port).await } Commands::Mcp { server_url, contract, narration } => { ilold_mcp::run(ilold_mcp::Config { server_url, contract, narration }).await } } } - -async fn serve_solidity(path: &PathBuf, port: u16, max_seq_depth: usize) -> Result<()> { - let paths = collect_sol_files(path)?; - if paths.is_empty() { - anyhow::bail!("No .sol files found at {}", path.display()); - } - ilold_web::serve(paths, port, max_seq_depth).await -} - -async fn explore_solidity(path: &PathBuf, port: u16, max_seq_depth: usize, attach: Option<String>) -> Result<()> { - let paths = collect_sol_files(path)?; - if paths.is_empty() { - anyhow::bail!("No .sol files found at {}", path.display()); - } - explore::run(paths, port, max_seq_depth, attach).await -} - -pub fn collect_sol_files(path: &PathBuf) -> Result<Vec<PathBuf>> { - if path.is_file() { - return Ok(vec![path.clone()]); - } - if path.is_dir() { - let mut files = Vec::new(); - walk_sol_files(path, &mut files)?; - files.sort(); - return Ok(files); - } - anyhow::bail!("Path does not exist: {}", path.display()); -} - -fn walk_sol_files(dir: &PathBuf, out: &mut Vec<PathBuf>) -> Result<()> { - const SKIP: &[&str] = &["out", "cache", "node_modules", "lib", "target", ".git", ".svelte-kit"]; - for entry in std::fs::read_dir(dir)? { - let entry = entry?; - let p = entry.path(); - if p.is_dir() { - let name = p.file_name().and_then(|n| n.to_str()).unwrap_or(""); - if name.starts_with('.') || SKIP.contains(&name) { - continue; - } - walk_sol_files(&p, out)?; - } else if p.extension().is_some_and(|ext| ext == "sol") { - out.push(p); - } - } - Ok(()) -} diff --git a/crates/ilold-core/Cargo.toml b/crates/ilold-core/Cargo.toml deleted file mode 100644 index 8a0df36..0000000 --- a/crates/ilold-core/Cargo.toml +++ /dev/null @@ -1,16 +0,0 @@ -[package] -name = "ilold-core" -version.workspace = true -edition.workspace = true -authors.workspace = true -license.workspace = true -repository.workspace = true -description = "Core library for Ilold: smart contract execution path analysis engine" - -[dependencies] -ilold-session-core = { path = "../ilold-session-core" } -solar = { workspace = true } -petgraph = { workspace = true } -serde = { workspace = true } -serde_json = { workspace = true } -thiserror = { workspace = true } diff --git a/crates/ilold-core/src/callgraph/builder.rs b/crates/ilold-core/src/callgraph/builder.rs deleted file mode 100644 index 5f3ee38..0000000 --- a/crates/ilold-core/src/callgraph/builder.rs +++ /dev/null @@ -1,167 +0,0 @@ -use std::collections::HashMap; - -use petgraph::stable_graph::NodeIndex; - -use crate::cfg::builder::CfgBuilder; -use crate::cfg::types::CfgStatement; -use crate::model::contract::ContractDef; -use crate::model::function::{Mutability, Visibility}; -use crate::model::project::Project; -use crate::util::is_type_cast; - -use super::types::*; - -pub fn build_call_graph(project: &Project, contract: &ContractDef) -> CallGraph { - let mut graph = CallGraph::new(); - let mut node_index: HashMap<String, NodeIndex> = HashMap::new(); - - // Add a node for each function in the contract - for func in &contract.functions { - let key = format!("{}::{}", contract.name, func.name); - let idx = graph.add_node(CallNode { - contract: contract.name.clone(), - function: func.name.clone(), - visibility: func.visibility, - mutability: func.mutability, - is_external: false, - }); - node_index.insert(key, idx); - } - - // Build CFG for each function and extract call edges - for func in &contract.functions { - let caller_key = format!("{}::{}", contract.name, func.name); - let caller_idx = match node_index.get(&caller_key) { - Some(idx) => *idx, - None => continue, - }; - - let cfg = match CfgBuilder::build(func, contract) { - Ok(cfg) => cfg, - Err(_) => continue, - }; - - // Scan all blocks for call statements - for block in cfg.node_weights() { - for stmt in &block.statements { - match stmt { - CfgStatement::InternalCall { function, .. } => { - if !is_type_cast(function) { - let callee_idx = resolve_internal( - function, - contract, - project, - &mut graph, - &mut node_index, - ); - add_or_increment_edge(&mut graph, caller_idx, callee_idx); - } - } - CfgStatement::ExternalCall { target, function, .. } => { - if !is_type_cast(function) && !is_type_cast(target) { - let callee_idx = resolve_external( - target, - function, - &mut graph, - &mut node_index, - ); - add_or_increment_edge(&mut graph, caller_idx, callee_idx); - } - } - _ => {} - } - } - } - } - - graph -} - -fn resolve_internal( - function_name: &str, - contract: &ContractDef, - project: &Project, - graph: &mut CallGraph, - index: &mut HashMap<String, NodeIndex>, -) -> NodeIndex { - // 1. Check current contract - let key = format!("{}::{}", contract.name, function_name); - if let Some(&idx) = index.get(&key) { - return idx; - } - - // 2. Check parent contracts (inheritance) - for parent_name in &contract.inherits { - if let Some(&parent_idx) = project.contract_index.get(parent_name) { - let parent = &project.contracts[parent_idx]; - if let Some(func) = parent.functions.iter().find(|f| f.name == function_name) { - let key = format!("{parent_name}::{function_name}"); - if let Some(&idx) = index.get(&key) { - return idx; - } - // Add inherited function as a node - let idx = graph.add_node(CallNode { - contract: parent_name.clone(), - function: function_name.to_string(), - visibility: func.visibility, - mutability: func.mutability, - is_external: false, - }); - index.insert(key, idx); - return idx; - } - } - } - - // 3. Unresolved — create external node - let key = format!("?::{function_name}"); - if let Some(&idx) = index.get(&key) { - return idx; - } - let idx = graph.add_node(CallNode { - contract: "?".to_string(), - function: function_name.to_string(), - visibility: Visibility::Public, - mutability: Mutability::NonPayable, - is_external: true, - }); - index.insert(key, idx); - idx -} - -fn resolve_external( - target: &str, - function_name: &str, - graph: &mut CallGraph, - index: &mut HashMap<String, NodeIndex>, -) -> NodeIndex { - let key = format!("{target}::{function_name}"); - if let Some(&idx) = index.get(&key) { - return idx; - } - let idx = graph.add_node(CallNode { - contract: target.to_string(), - function: function_name.to_string(), - visibility: Visibility::External, - mutability: Mutability::NonPayable, - is_external: true, - }); - index.insert(key, idx); - idx -} - -fn add_or_increment_edge(graph: &mut CallGraph, from: NodeIndex, to: NodeIndex) { - // Check if edge already exists - if let Some(edge) = graph.find_edge(from, to) { - graph[edge].call_count += 1; - } else { - let kind = if graph[to].is_external { - CallKind::External - } else if graph[from].contract != graph[to].contract { - CallKind::Inherited - } else { - CallKind::Internal - }; - graph.add_edge(from, to, CallEdge { kind, call_count: 1 }); - } -} diff --git a/crates/ilold-core/src/callgraph/mod.rs b/crates/ilold-core/src/callgraph/mod.rs deleted file mode 100644 index ab228e5..0000000 --- a/crates/ilold-core/src/callgraph/mod.rs +++ /dev/null @@ -1,2 +0,0 @@ -pub mod builder; -pub mod types; diff --git a/crates/ilold-core/src/callgraph/types.rs b/crates/ilold-core/src/callgraph/types.rs deleted file mode 100644 index 33f4546..0000000 --- a/crates/ilold-core/src/callgraph/types.rs +++ /dev/null @@ -1,28 +0,0 @@ -use petgraph::stable_graph::StableDiGraph; -use serde::{Deserialize, Serialize}; - -use crate::model::function::{Mutability, Visibility}; - -pub type CallGraph = StableDiGraph<CallNode, CallEdge>; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct CallNode { - pub contract: String, - pub function: String, - pub visibility: Visibility, - pub mutability: Mutability, - pub is_external: bool, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct CallEdge { - pub kind: CallKind, - pub call_count: usize, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum CallKind { - Internal, - External, - Inherited, -} diff --git a/crates/ilold-core/src/cfg/builder.rs b/crates/ilold-core/src/cfg/builder.rs deleted file mode 100644 index 2c64005..0000000 --- a/crates/ilold-core/src/cfg/builder.rs +++ /dev/null @@ -1,675 +0,0 @@ -use petgraph::stable_graph::NodeIndex; - -use crate::model::contract::ContractDef; -use crate::model::expression::{Expression, ExpressionKind}; -use crate::model::function::FunctionDef; -use crate::model::project::Project; -use crate::model::statement::{Statement, StatementKind}; - -use super::error::CfgError; -use super::modifier::inline_modifiers; -use super::types::*; - -pub struct CfgBuilder { - graph: CfgGraph, - next_block_id: usize, - current_block: NodeIndex, - /// Modifier name attached to statements being processed right now, or - /// `None` when processing the function's own body. - /// - /// Limitation: only top-level statements of a modifier are tagged. If a - /// modifier contains a compound statement (if/for/while) with the - /// placeholder inside, statements nested under that compound node lose - /// their modifier provenance because `process_statement` does not save/ - /// restore this field across recursive descent. This is acceptable for - /// common modifiers (onlyOwner, nonReentrant, lock, whenNotPaused). - current_modifier: Option<String>, -} - -impl CfgBuilder { - /// Build a CFG without cross-contract context. Modifier resolution is - /// limited to the current contract — inherited modifiers will be skipped. - pub fn build(function: &FunctionDef, contract: &ContractDef) -> Result<CfgGraph, CfgError> { - Self::build_with_project(function, contract, None) - } - - /// Build a CFG with an optional `Project` reference, enabling modifier - /// resolution through the inheritance chain. - pub fn build_with_project( - function: &FunctionDef, - contract: &ContractDef, - project: Option<&Project>, - ) -> Result<CfgGraph, CfgError> { - let mut builder = CfgBuilder { - graph: CfgGraph::new(), - next_block_id: 0, - current_block: NodeIndex::new(0), // will be replaced - current_modifier: None, - }; - - let entry = builder.add_block(BlockKind::Entry); - builder.current_block = entry; - - let body = match &function.body { - Some(body) => body.clone(), - None => return Ok(builder.graph), // interface/abstract — no body - }; - - // Inline modifiers if any - let tagged_body: Vec<super::modifier::TaggedStatement> = if function.modifiers.is_empty() { - body - .into_iter() - .map(|s| super::modifier::TaggedStatement { stmt: s, provenance: None }) - .collect() - } else { - let modifier_defs: Vec<&_> = function - .modifiers - .iter() - .filter_map(|mref| { - if let Some(proj) = project { - proj.resolve_modifier(contract, &mref.name) - } else { - contract.modifiers.iter().find(|m| m.name == mref.name) - } - }) - .collect(); - if modifier_defs.len() == function.modifiers.len() { - inline_modifiers(&body, &modifier_defs)? - } else { - body - .into_iter() - .map(|s| super::modifier::TaggedStatement { stmt: s, provenance: None }) - .collect() - } - }; - - for tagged in &tagged_body { - builder.current_modifier = tagged.provenance.clone(); - builder.process_statement(&tagged.stmt); - } - builder.current_modifier = None; - - // If current block has no outgoing edges and is NOT already terminal, add implicit return - if !builder.block_is_terminal(builder.current_block) - && builder - .graph - .edges(builder.current_block) - .next() - .is_none() - { - let ret = builder.add_block(BlockKind::Return); - builder.add_edge(builder.current_block, ret, BranchEdge::Unconditional); - } - - Ok(builder.graph) - } - - fn add_block(&mut self, kind: BlockKind) -> NodeIndex { - let id = self.next_block_id; - self.next_block_id += 1; - self.graph.add_node(BasicBlock { - id, - kind, - statements: Vec::new(), - span: None, - }) - } - - fn add_edge(&mut self, from: NodeIndex, to: NodeIndex, edge: BranchEdge) { - self.graph.add_edge(from, to, edge); - } - - fn add_stmt_to_current(&mut self, stmt: CfgStatement) { - if let Some(block) = self.graph.node_weight_mut(self.current_block) { - block.statements.push(stmt); - } - } - - fn process_statement(&mut self, stmt: &Statement) { - match &stmt.kind { - StatementKind::If { condition, then_body, else_body } => { - self.process_if(condition, then_body, else_body.as_deref()); - } - StatementKind::For { init, condition, increment, body } => { - self.process_for(init.as_deref(), condition.as_ref(), increment.as_ref(), body); - } - StatementKind::While { condition, body } => { - self.process_while(condition, body); - } - StatementKind::DoWhile { body, condition } => { - self.process_do_while(body, condition); - } - StatementKind::Return { value } => { - self.process_return(value.as_ref()); - } - StatementKind::Revert { .. } => { - let revert = self.add_block(BlockKind::Revert); - self.add_edge(self.current_block, revert, BranchEdge::Unconditional); - } - StatementKind::Emit { event_name, .. } => { - let from_modifier = self.current_modifier.clone(); - self.add_stmt_to_current(CfgStatement::EmitEvent { - event: event_name.clone(), - span: None, - from_modifier, - }); - } - StatementKind::Block { statements } => { - for s in statements { - self.process_statement(s); - } - } - StatementKind::UncheckedBlock { statements } => { - for s in statements { - self.process_statement(s); - } - } - StatementKind::ExpressionStmt { expression } => { - self.process_expression_stmt(expression); - } - StatementKind::VariableDeclaration { name, initial_value, .. } => { - if let Some(val) = initial_value { - let from_modifier = self.current_modifier.clone(); - self.add_stmt_to_current(CfgStatement::Assignment { - target: name.clone(), - value: expr_to_string(val), - operator: crate::model::expression::AssignOperator::Assign, - span: None, - from_modifier, - }); - } - } - StatementKind::TryCatch { expression, clauses } => { - self.process_try_catch(expression, clauses); - } - StatementKind::Assembly { .. } => { - let from_modifier = self.current_modifier.clone(); - self.add_stmt_to_current(CfgStatement::AssemblyBlock { span: None, from_modifier }); - } - StatementKind::Break | StatementKind::Continue | StatementKind::Placeholder => { - // Break/Continue handled by loop processors - // Placeholder should have been replaced by modifier inlining - } - } - } - - fn process_if( - &mut self, - condition: &Expression, - then_body: &[Statement], - else_body: Option<&[Statement]>, - ) { - let cond_str = expr_to_string(condition); - let before = self.current_block; - - // Then branch - let then_block = self.add_block(BlockKind::Normal); - self.add_edge( - before, - then_block, - BranchEdge::ConditionalTrue { condition: cond_str.clone() }, - ); - self.current_block = then_block; - for s in then_body { - self.process_statement(s); - } - let then_end = self.current_block; - - // Else branch - let else_end = if let Some(else_stmts) = else_body { - let else_block = self.add_block(BlockKind::Normal); - self.add_edge( - before, - else_block, - BranchEdge::ConditionalFalse { condition: cond_str }, - ); - self.current_block = else_block; - for s in else_stmts { - self.process_statement(s); - } - self.current_block - } else { - // No else: false branch goes directly to join - let join = self.add_block(BlockKind::Normal); - self.add_edge( - before, - join, - BranchEdge::ConditionalFalse { condition: cond_str }, - ); - join - }; - - // Join block: connect non-terminal branches - let then_needs_join = !self.block_is_terminal(then_end); - let else_needs_join = else_body.is_some() && !self.block_is_terminal(else_end); - - if then_needs_join || else_needs_join || else_body.is_none() { - let join = if else_body.is_none() { - else_end // already created as the join - } else { - self.add_block(BlockKind::Normal) - }; - - if then_needs_join { - self.add_edge(then_end, join, BranchEdge::Unconditional); - } - if else_needs_join { - self.add_edge(else_end, join, BranchEdge::Unconditional); - } - self.current_block = join; - } - } - - fn process_for( - &mut self, - init: Option<&Statement>, - condition: Option<&Expression>, - increment: Option<&Expression>, - body: &[Statement], - ) { - // Init - if let Some(init_stmt) = init { - self.process_statement(init_stmt); - } - - // Condition check - let cond_block = self.add_block(BlockKind::LoopCondition); - self.add_edge(self.current_block, cond_block, BranchEdge::Unconditional); - - let exit_block = self.add_block(BlockKind::Normal); - let body_block = self.add_block(BlockKind::Normal); - - if let Some(cond) = condition { - let cond_str = expr_to_string(cond); - self.add_edge( - cond_block, - body_block, - BranchEdge::ConditionalTrue { condition: cond_str.clone() }, - ); - self.add_edge( - cond_block, - exit_block, - BranchEdge::ConditionalFalse { condition: cond_str }, - ); - } else { - // for(;;) — always enters body - self.add_edge(cond_block, body_block, BranchEdge::Unconditional); - } - - // Body - self.current_block = body_block; - for s in body { - self.process_statement(s); - } - - // Increment + loop back - if let Some(_incr) = increment { - // increment is an expression, not a separate block for simplicity - } - if !self.block_is_terminal(self.current_block) { - self.add_edge(self.current_block, cond_block, BranchEdge::LoopBack); - } - - self.current_block = exit_block; - } - - fn process_while(&mut self, condition: &Expression, body: &[Statement]) { - let cond_str = expr_to_string(condition); - - let cond_block = self.add_block(BlockKind::LoopCondition); - self.add_edge(self.current_block, cond_block, BranchEdge::Unconditional); - - let body_block = self.add_block(BlockKind::Normal); - let exit_block = self.add_block(BlockKind::Normal); - - self.add_edge( - cond_block, - body_block, - BranchEdge::ConditionalTrue { condition: cond_str.clone() }, - ); - self.add_edge( - cond_block, - exit_block, - BranchEdge::ConditionalFalse { condition: cond_str }, - ); - - self.current_block = body_block; - for s in body { - self.process_statement(s); - } - if !self.block_is_terminal(self.current_block) { - self.add_edge(self.current_block, cond_block, BranchEdge::LoopBack); - } - - self.current_block = exit_block; - } - - fn process_do_while(&mut self, body: &[Statement], condition: &Expression) { - let cond_str = expr_to_string(condition); - - let body_block = self.add_block(BlockKind::Normal); - self.add_edge(self.current_block, body_block, BranchEdge::Unconditional); - - self.current_block = body_block; - for s in body { - self.process_statement(s); - } - - let cond_block = self.add_block(BlockKind::LoopCondition); - if !self.block_is_terminal(self.current_block) { - self.add_edge(self.current_block, cond_block, BranchEdge::Unconditional); - } - - let exit_block = self.add_block(BlockKind::Normal); - self.add_edge( - cond_block, - body_block, - BranchEdge::ConditionalTrue { condition: cond_str.clone() }, - ); - self.add_edge( - cond_block, - exit_block, - BranchEdge::ConditionalFalse { condition: cond_str }, - ); - - self.current_block = exit_block; - } - - fn process_return(&mut self, value: Option<&Expression>) { - if let Some(expr) = value { - for s in classify_expression(expr, &self.current_modifier) { - self.add_stmt_to_current(s); - } - } - let ret = self.add_block(BlockKind::Return); - self.add_edge(self.current_block, ret, BranchEdge::Unconditional); - self.current_block = ret; - } - - fn process_try_catch( - &mut self, - expression: &Expression, - clauses: &[crate::model::statement::CatchClause], - ) { - let before = self.current_block; - for s in classify_expression(expression, &self.current_modifier) { - self.add_stmt_to_current(s); - } - - let join = self.add_block(BlockKind::Normal); - - for (i, clause) in clauses.iter().enumerate() { - let clause_block = self.add_block(BlockKind::Normal); - let edge = if i == 0 { - // First clause is the success case (returns) - BranchEdge::ExternalCallSuccess - } else { - BranchEdge::CatchClause { - kind: clause.name.clone().unwrap_or("default".into()), - } - }; - self.add_edge(before, clause_block, edge); - - self.current_block = clause_block; - for s in &clause.body { - self.process_statement(s); - } - if !self.block_is_terminal(self.current_block) { - self.add_edge(self.current_block, join, BranchEdge::Unconditional); - } - } - - self.current_block = join; - } - - fn process_expression_stmt(&mut self, expr: &Expression) { - // Check if this is a require/assert call - if let ExpressionKind::FunctionCall { callee, arguments } = &expr.kind { - if let ExpressionKind::Identifier { name } = &callee.kind { - match name.as_str() { - "require" => { - self.process_require(arguments); - return; - } - "assert" => { - self.process_assert(arguments); - return; - } - _ => {} - } - } - } - - // Not require/assert — classify and add to current block - for s in classify_expression(expr, &self.current_modifier) { - self.add_stmt_to_current(s); - } - } - - fn process_require(&mut self, arguments: &[Expression]) { - let condition = arguments.first().map(expr_to_string).unwrap_or_default(); - let message = arguments.get(1).map(expr_to_string); - - let cond_str = condition.clone(); - let from_modifier = self.current_modifier.clone(); - self.add_stmt_to_current(CfgStatement::RequireCheck { - condition, - message, - span: None, - from_modifier, - }); - - let before = self.current_block; - - // True branch: continues - let next = self.add_block(BlockKind::Normal); - self.add_edge( - before, - next, - BranchEdge::ConditionalTrue { condition: cond_str.clone() }, - ); - - // False branch: reverts - let revert = self.add_block(BlockKind::Revert); - self.add_edge( - before, - revert, - BranchEdge::ConditionalFalse { condition: cond_str }, - ); - - self.current_block = next; - } - - fn process_assert(&mut self, arguments: &[Expression]) { - let condition = arguments.first().map(expr_to_string).unwrap_or_default(); - let cond_str = condition.clone(); - let from_modifier = self.current_modifier.clone(); - - self.add_stmt_to_current(CfgStatement::AssertCheck { - condition, - span: None, - from_modifier, - }); - - let before = self.current_block; - - let next = self.add_block(BlockKind::Normal); - self.add_edge( - before, - next, - BranchEdge::ConditionalTrue { condition: cond_str.clone() }, - ); - - let revert = self.add_block(BlockKind::Revert); - self.add_edge( - before, - revert, - BranchEdge::ConditionalFalse { condition: cond_str }, - ); - - self.current_block = next; - } - - fn block_is_terminal(&self, idx: NodeIndex) -> bool { - let kind = self.graph[idx].kind; - kind == BlockKind::Return || kind == BlockKind::Revert - } -} - -// ============================================================================ -// Expression classification -// ============================================================================ - -/// Classify an expression into CfgStatements for the detection engine. -/// Returns multiple statements when an expression contains embedded calls -/// (e.g., `x = foo()` produces both an Assignment and an InternalCall). -fn classify_expression(expr: &Expression, from_modifier: &Option<String>) -> Vec<CfgStatement> { - let mut stmts = Vec::new(); - collect_calls(expr, &mut stmts, from_modifier); - - if let ExpressionKind::Assignment { target, operator, value } = &expr.kind { - stmts.push(CfgStatement::Assignment { - target: expr_to_string(target), - value: expr_to_string(value), - operator: *operator, - span: None, - from_modifier: from_modifier.clone(), - }); - } - - stmts -} - -/// Recursively scan an expression for function calls and add them as CfgStatements. -fn collect_calls(expr: &Expression, stmts: &mut Vec<CfgStatement>, from_modifier: &Option<String>) { - match &expr.kind { - ExpressionKind::FunctionCall { callee, arguments } => { - // Classify this call - match &callee.kind { - // `uint32(x)` etc. — type conversion, not a call. - ExpressionKind::TypeMeta { .. } => {} - ExpressionKind::Identifier { name } => { - if !crate::util::is_type_cast(name) { - stmts.push(CfgStatement::InternalCall { - function: name.clone(), - span: None, - from_modifier: from_modifier.clone(), - }); - } - } - ExpressionKind::MemberAccess { object, member } => { - if let ExpressionKind::Identifier { name } = &object.kind { - if name == "this" || name == "super" { - stmts.push(CfgStatement::InternalCall { - function: member.clone(), - span: None, - from_modifier: from_modifier.clone(), - }); - } else { - stmts.push(CfgStatement::ExternalCall { - target: name.clone(), - function: member.clone(), - span: None, - from_modifier: from_modifier.clone(), - }); - } - } else { - stmts.push(CfgStatement::ExternalCall { - target: expr_to_string(object), - function: member.clone(), - span: None, - from_modifier: from_modifier.clone(), - }); - } - } - _ => { - stmts.push(CfgStatement::InternalCall { - function: expr_to_string(callee), - span: None, - from_modifier: from_modifier.clone(), - }); - } - } - // Also recurse into arguments (they might contain calls too) - for arg in arguments { - collect_calls(arg, stmts, from_modifier); - } - } - // Recurse into sub-expressions - ExpressionKind::Assignment { target, value, .. } => { - collect_calls(target, stmts, from_modifier); - collect_calls(value, stmts, from_modifier); - } - ExpressionKind::BinaryOp { left, right, .. } => { - collect_calls(left, stmts, from_modifier); - collect_calls(right, stmts, from_modifier); - } - ExpressionKind::UnaryOp { operand, .. } => { - collect_calls(operand, stmts, from_modifier); - } - ExpressionKind::MemberAccess { object, .. } => { - collect_calls(object, stmts, from_modifier); - } - ExpressionKind::IndexAccess { base, index } => { - collect_calls(base, stmts, from_modifier); - if let Some(idx) = index { - collect_calls(idx, stmts, from_modifier); - } - } - ExpressionKind::Ternary { condition, true_expr, false_expr } => { - collect_calls(condition, stmts, from_modifier); - collect_calls(true_expr, stmts, from_modifier); - collect_calls(false_expr, stmts, from_modifier); - } - _ => {} - } -} - -fn expr_to_string(expr: &Expression) -> String { - match &expr.kind { - ExpressionKind::Identifier { name } => name.clone(), - ExpressionKind::Literal { value, .. } => value.clone(), - ExpressionKind::MemberAccess { object, member } => { - format!("{}.{}", expr_to_string(object), member) - } - ExpressionKind::FunctionCall { callee, arguments } => { - // Render type conversion `uint32(x)` as `uint32(x)` instead of - // `type(uint32)(x)` when the callee is a TypeMeta expression. - if let ExpressionKind::TypeMeta { type_name } = &callee.kind { - let args: Vec<String> = arguments.iter().map(expr_to_string).collect(); - return format!("{type_name}({})", args.join(", ")); - } - let args: Vec<String> = arguments.iter().map(expr_to_string).collect(); - format!("{}({})", expr_to_string(callee), args.join(", ")) - } - ExpressionKind::BinaryOp { left, operator, right } => { - format!("{} {} {}", expr_to_string(left), operator.as_str(), expr_to_string(right)) - } - ExpressionKind::UnaryOp { operator, operand } => { - let (sym, postfix) = operator.format_parts(); - let s = expr_to_string(operand); - if postfix { format!("{}{}", s, sym) } else { format!("{}{}", sym, s) } - } - ExpressionKind::IndexAccess { base, index } => { - let idx = index.as_ref().map(|e| expr_to_string(e)).unwrap_or_default(); - format!("{}[{}]", expr_to_string(base), idx) - } - ExpressionKind::Assignment { target, operator, value } => { - format!("{} {} {}", expr_to_string(target), operator.as_str(), expr_to_string(value)) - } - ExpressionKind::Ternary { condition, true_expr, false_expr } => { - format!("{} ? {} : {}", expr_to_string(condition), expr_to_string(true_expr), expr_to_string(false_expr)) - } - ExpressionKind::TypeCast { type_name, expression } => { - format!("{type_name}({})", expr_to_string(expression)) - } - ExpressionKind::TypeMeta { type_name } => { - format!("type({type_name})") - } - ExpressionKind::New { type_name, arguments } => { - let args: Vec<String> = arguments.iter().map(expr_to_string).collect(); - format!("new {type_name}({})", args.join(", ")) - } - } -} - diff --git a/crates/ilold-core/src/cfg/error.rs b/crates/ilold-core/src/cfg/error.rs deleted file mode 100644 index 218d689..0000000 --- a/crates/ilold-core/src/cfg/error.rs +++ /dev/null @@ -1,16 +0,0 @@ -use crate::model::common::SourceSpan; - -#[derive(Debug, thiserror::Error)] -pub enum CfgError { - #[error("Modifier '{name}' not found in contract '{contract}'")] - ModifierNotFound { name: String, contract: String }, - - #[error("Modifier '{name}' has no _ placeholder")] - ModifierMissingPlaceholder { name: String }, - - #[error("Unsupported statement at {span:?}: {kind}")] - UnsupportedStatement { - kind: String, - span: Option<SourceSpan>, - }, -} diff --git a/crates/ilold-core/src/cfg/mod.rs b/crates/ilold-core/src/cfg/mod.rs deleted file mode 100644 index 025d9fd..0000000 --- a/crates/ilold-core/src/cfg/mod.rs +++ /dev/null @@ -1,4 +0,0 @@ -pub mod builder; -pub mod error; -pub mod modifier; -pub mod types; diff --git a/crates/ilold-core/src/cfg/modifier.rs b/crates/ilold-core/src/cfg/modifier.rs deleted file mode 100644 index d44307a..0000000 --- a/crates/ilold-core/src/cfg/modifier.rs +++ /dev/null @@ -1,302 +0,0 @@ -use crate::model::modifier::ModifierDef; -use crate::model::statement::{Statement, StatementKind}; - -use super::error::CfgError; - -/// A statement tagged with the name of the modifier it came from, or `None` -/// when the statement is part of the function's own body. -#[derive(Debug, Clone)] -pub struct TaggedStatement { - pub stmt: Statement, - pub provenance: Option<String>, -} - -/// Inline a chain of modifiers around a function body. -/// -/// Modifiers wrap last-to-first: `[onlyOwner, nonReentrant]` means the result -/// of inlining `nonReentrant` around the body is then inlined into `onlyOwner`. -pub fn inline_modifiers( - body: &[Statement], - modifier_defs: &[&ModifierDef], -) -> Result<Vec<TaggedStatement>, CfgError> { - let mut current: Vec<TaggedStatement> = body - .iter() - .cloned() - .map(|s| TaggedStatement { stmt: s, provenance: None }) - .collect(); - - for modifier in modifier_defs.iter().rev() { - if !has_placeholder(&modifier.body) { - return Err(CfgError::ModifierMissingPlaceholder { - name: modifier.name.clone(), - }); - } - current = replace_placeholder_tagged(&modifier.body, ¤t, &modifier.name); - } - - Ok(current) -} - -fn has_placeholder(stmts: &[Statement]) -> bool { - stmts.iter().any(|s| match &s.kind { - StatementKind::Placeholder => true, - StatementKind::Block { statements } => has_placeholder(statements), - StatementKind::UncheckedBlock { statements } => has_placeholder(statements), - StatementKind::If { then_body, else_body, .. } => { - has_placeholder(then_body) - || else_body.as_ref().is_some_and(|e| has_placeholder(e)) - } - StatementKind::For { body, .. } => has_placeholder(body), - StatementKind::While { body, .. } => has_placeholder(body), - StatementKind::DoWhile { body, .. } => has_placeholder(body), - StatementKind::TryCatch { clauses, .. } => { - clauses.iter().any(|c| has_placeholder(&c.body)) - } - _ => false, - }) -} - -/// Replace each `_` placeholder in `modifier_body` with `function_body`, -/// tagging modifier statements with `modifier_name`. Inner tags in the -/// function body (from prior nested inlining) are preserved. -fn replace_placeholder_tagged( - modifier_body: &[Statement], - function_body: &[TaggedStatement], - modifier_name: &str, -) -> Vec<TaggedStatement> { - let mut result: Vec<TaggedStatement> = Vec::new(); - - for stmt in modifier_body { - match &stmt.kind { - StatementKind::Placeholder => { - result.extend(function_body.iter().cloned()); - } - StatementKind::Block { statements } => { - let inner = replace_placeholder_tagged_block(statements, function_body, modifier_name); - result.push(TaggedStatement { - stmt: Statement { - kind: StatementKind::Block { statements: inner }, - span: stmt.span, - }, - provenance: Some(modifier_name.to_string()), - }); - } - StatementKind::UncheckedBlock { statements } => { - let inner = replace_placeholder_tagged_block(statements, function_body, modifier_name); - result.push(TaggedStatement { - stmt: Statement { - kind: StatementKind::UncheckedBlock { statements: inner }, - span: stmt.span, - }, - provenance: Some(modifier_name.to_string()), - }); - } - StatementKind::If { condition, then_body, else_body } => { - let then_body = replace_placeholder_tagged_block(then_body, function_body, modifier_name); - let else_body = else_body - .as_ref() - .map(|e| replace_placeholder_tagged_block(e, function_body, modifier_name)); - result.push(TaggedStatement { - stmt: Statement { - kind: StatementKind::If { - condition: condition.clone(), - then_body, - else_body, - }, - span: stmt.span, - }, - provenance: Some(modifier_name.to_string()), - }); - } - StatementKind::For { init, condition, increment, body } => { - let body = replace_placeholder_tagged_block(body, function_body, modifier_name); - result.push(TaggedStatement { - stmt: Statement { - kind: StatementKind::For { - init: init.clone(), - condition: condition.clone(), - increment: increment.clone(), - body, - }, - span: stmt.span, - }, - provenance: Some(modifier_name.to_string()), - }); - } - StatementKind::While { condition, body } => { - let body = replace_placeholder_tagged_block(body, function_body, modifier_name); - result.push(TaggedStatement { - stmt: Statement { - kind: StatementKind::While { - condition: condition.clone(), - body, - }, - span: stmt.span, - }, - provenance: Some(modifier_name.to_string()), - }); - } - StatementKind::DoWhile { body, condition } => { - let body = replace_placeholder_tagged_block(body, function_body, modifier_name); - result.push(TaggedStatement { - stmt: Statement { - kind: StatementKind::DoWhile { - body, - condition: condition.clone(), - }, - span: stmt.span, - }, - provenance: Some(modifier_name.to_string()), - }); - } - StatementKind::TryCatch { expression, clauses } => { - let new_clauses = clauses.iter().map(|c| crate::model::statement::CatchClause { - name: c.name.clone(), - params: c.params.clone(), - body: replace_placeholder_tagged_block(&c.body, function_body, modifier_name), - }).collect(); - result.push(TaggedStatement { - stmt: Statement { - kind: StatementKind::TryCatch { - expression: expression.clone(), - clauses: new_clauses, - }, - span: stmt.span, - }, - provenance: Some(modifier_name.to_string()), - }); - } - _ => result.push(TaggedStatement { - stmt: stmt.clone(), - provenance: Some(modifier_name.to_string()), - }), - } - } - - result -} - -/// Placeholder replacement inside compound statements. Drops the tag layer -/// because compound statements store plain `Statement`, not `TaggedStatement`. -fn replace_placeholder_tagged_block( - modifier_body: &[Statement], - function_body: &[TaggedStatement], - modifier_name: &str, -) -> Vec<Statement> { - replace_placeholder_tagged(modifier_body, function_body, modifier_name) - .into_iter() - .map(|t| t.stmt) - .collect() -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::model::common::SourceSpan; - use crate::model::expression::{Expression, ExpressionKind}; - - fn span() -> SourceSpan { - SourceSpan { file_index: 0, start_line: 0, start_col: 0, end_line: 0, end_col: 0 } - } - - fn make_require(name: &str) -> Statement { - Statement { - kind: StatementKind::ExpressionStmt { - expression: Expression { - kind: ExpressionKind::FunctionCall { - callee: Box::new(Expression { - kind: ExpressionKind::Identifier { name: "require".into() }, - span: span(), - }), - arguments: vec![Expression { - kind: ExpressionKind::Identifier { name: name.into() }, - span: span(), - }], - }, - span: span(), - }, - }, - span: span(), - } - } - - fn make_placeholder() -> Statement { - Statement { kind: StatementKind::Placeholder, span: span() } - } - - fn make_return() -> Statement { - Statement { kind: StatementKind::Return { value: None }, span: span() } - } - - #[test] - fn test_single_modifier_inlining() { - // modifier onlyOwner { require(isOwner); _; } - let modifier = ModifierDef { - name: "onlyOwner".into(), - params: vec![], - body: vec![make_require("isOwner"), make_placeholder()], - span: span(), - }; - - // function body: return; - let body = vec![make_return()]; - - let result = inline_modifiers(&body, &[&modifier]).unwrap(); - - // Expected: require(isOwner) tagged with "onlyOwner", return (no tag) - assert_eq!(result.len(), 2); - assert!(matches!(result[0].stmt.kind, StatementKind::ExpressionStmt { .. })); - assert_eq!(result[0].provenance.as_deref(), Some("onlyOwner")); - assert!(matches!(result[1].stmt.kind, StatementKind::Return { .. })); - assert_eq!(result[1].provenance, None); - } - - #[test] - fn test_chained_modifiers() { - // modifier A { require(a); _; } - let mod_a = ModifierDef { - name: "A".into(), - params: vec![], - body: vec![make_require("a"), make_placeholder()], - span: span(), - }; - - // modifier B { require(b); _; } - let mod_b = ModifierDef { - name: "B".into(), - params: vec![], - body: vec![make_require("b"), make_placeholder()], - span: span(), - }; - - let body = vec![make_return()]; - - // function foo() A B { return; } - // Expected: require(a) [tagged A], require(b) [tagged B], return [no tag] - let result = inline_modifiers(&body, &[&mod_a, &mod_b]).unwrap(); - - assert_eq!(result.len(), 3); - assert_eq!(result[0].provenance.as_deref(), Some("A")); - assert_eq!(result[1].provenance.as_deref(), Some("B")); - assert_eq!(result[2].provenance, None); - } - - #[test] - fn test_modifier_missing_placeholder() { - let modifier = ModifierDef { - name: "broken".into(), - params: vec![], - body: vec![make_require("x")], // no placeholder! - span: span(), - }; - - let body = vec![make_return()]; - let result = inline_modifiers(&body, &[&modifier]); - - assert!(result.is_err()); - assert!(matches!( - result.unwrap_err(), - CfgError::ModifierMissingPlaceholder { .. } - )); - } -} diff --git a/crates/ilold-core/src/cfg/types.rs b/crates/ilold-core/src/cfg/types.rs deleted file mode 100644 index 046c145..0000000 --- a/crates/ilold-core/src/cfg/types.rs +++ /dev/null @@ -1,121 +0,0 @@ -use petgraph::stable_graph::StableDiGraph; -use serde::{Deserialize, Serialize}; - -use crate::model::common::SourceSpan; -use crate::model::expression::AssignOperator; - -pub type CfgGraph = StableDiGraph<BasicBlock, BranchEdge>; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct BasicBlock { - pub id: usize, - pub kind: BlockKind, - pub statements: Vec<CfgStatement>, - pub span: Option<SourceSpan>, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum BlockKind { - Entry, - Normal, - LoopCondition, - Return, - Revert, - Assembly, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub enum BranchEdge { - Unconditional, - ConditionalTrue { condition: String }, - ConditionalFalse { condition: String }, - ExternalCallSuccess, - ExternalCallFailure, - LoopBack, - LoopExit, - CatchClause { kind: String }, -} - -impl BranchEdge { - /// Canonical ordering used to make CFG traversal deterministic. - /// Lower values are visited first; tiebreaker is the target node index. - pub fn variant_order(&self) -> u8 { - match self { - BranchEdge::Unconditional => 0, - BranchEdge::ConditionalTrue { .. } => 1, - BranchEdge::ConditionalFalse { .. } => 2, - BranchEdge::ExternalCallSuccess => 3, - BranchEdge::ExternalCallFailure => 4, - BranchEdge::LoopBack => 5, - BranchEdge::LoopExit => 6, - BranchEdge::CatchClause { .. } => 7, - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub enum CfgStatement { - Assignment { - target: String, - value: String, - operator: AssignOperator, - span: Option<SourceSpan>, - #[serde(default)] - from_modifier: Option<String>, - }, - ExternalCall { - target: String, - function: String, - span: Option<SourceSpan>, - #[serde(default)] - from_modifier: Option<String>, - }, - InternalCall { - function: String, - span: Option<SourceSpan>, - #[serde(default)] - from_modifier: Option<String>, - }, - EmitEvent { - event: String, - span: Option<SourceSpan>, - #[serde(default)] - from_modifier: Option<String>, - }, - StateRead { - variable: String, - span: Option<SourceSpan>, - #[serde(default)] - from_modifier: Option<String>, - }, - StateWrite { - variable: String, - span: Option<SourceSpan>, - #[serde(default)] - from_modifier: Option<String>, - }, - EthTransfer { - to: String, - span: Option<SourceSpan>, - #[serde(default)] - from_modifier: Option<String>, - }, - RequireCheck { - condition: String, - message: Option<String>, - span: Option<SourceSpan>, - #[serde(default)] - from_modifier: Option<String>, - }, - AssertCheck { - condition: String, - span: Option<SourceSpan>, - #[serde(default)] - from_modifier: Option<String>, - }, - AssemblyBlock { - span: Option<SourceSpan>, - #[serde(default)] - from_modifier: Option<String>, - }, -} diff --git a/crates/ilold-core/src/classify/entry_points.rs b/crates/ilold-core/src/classify/entry_points.rs deleted file mode 100644 index 80ce6a9..0000000 --- a/crates/ilold-core/src/classify/entry_points.rs +++ /dev/null @@ -1,351 +0,0 @@ -pub use ilold_session_core::exploration::access::AccessLevel; - -use crate::model::contract::ContractDef; -use crate::model::expression::{BinaryOperator, Expression, ExpressionKind}; -use crate::model::function::{FunctionDef, FunctionKind, Visibility}; -use crate::model::statement::{Statement, StatementKind}; - -// Modifiers that restrict WHO can call (access control) -const ACCESS_MODIFIERS: &[&str] = &[ - "onlyowner", "onlyadmin", "onlyrole", "onlygovernance", - "onlyminter", "onlypauser", "onlyoperator", "onlyguardian", - "onlyauthorized", "onlymanager", "onlycontroller", - "onlybridge", "onlyrelayer", "onlyvault", - "auth", "restricted", -]; - -// Modifiers that restrict WHEN you can call (state guards, not access control) -// These do NOT make a function "Restricted" — anyone can call when the condition is met -#[allow(dead_code)] -const STATE_GUARD_MODIFIERS: &[&str] = &[ - "whennotpaused", "whenpaused", - "nonreentrant", "noreentrant", - "initializer", "reinitializer", -]; - -pub fn classify_function(func: &FunctionDef, _contract: &ContractDef) -> AccessLevel { - // Constructor, fallback, receive are special — not normal entry points - match func.kind { - FunctionKind::Constructor => { - return AccessLevel::Special { kind: "constructor".into() }; - } - FunctionKind::Fallback => { - return AccessLevel::Special { kind: "fallback".into() }; - } - FunctionKind::Receive => { - return AccessLevel::Special { kind: "receive".into() }; - } - FunctionKind::Function => {} - } - - // Internal/Private → not externally callable - if matches!(func.visibility, Visibility::Internal | Visibility::Private) { - return AccessLevel::Internal; - } - - // Check modifiers for access control patterns (not state guards) - for modifier in &func.modifiers { - let lower = modifier.name.to_lowercase(); - for pattern in ACCESS_MODIFIERS { - if lower.contains(pattern) { - return AccessLevel::Restricted { - role: modifier.name.clone(), - }; - } - } - // State guards are noted but don't change classification - } - - // Check for require(msg.sender == owner) or similar comparison in function body - if let Some(body) = &func.body { - if let Some(role) = find_sender_access_check(body) { - return AccessLevel::Restricted { role }; - } - } - - AccessLevel::Public -} - -pub fn classify_all(contract: &ContractDef) -> Vec<(String, AccessLevel)> { - contract - .functions - .iter() - .map(|f| (f.name.clone(), classify_function(f, contract))) - .collect() -} - -/// Walks the AST looking for access control checks: require/assert/if where -/// msg.sender is COMPARED (==, !=) with another value. This is stricter than -/// just "mentions msg.sender" — balances[msg.sender] is NOT an access check. -fn find_sender_access_check(stmts: &[Statement]) -> Option<String> { - for stmt in stmts { - if let Some(role) = check_statement_for_access(stmt) { - return Some(role); - } - } - None -} - -fn check_statement_for_access(stmt: &Statement) -> Option<String> { - match &stmt.kind { - StatementKind::If { condition, then_body, else_body } => { - if expr_is_sender_comparison(condition) { - return Some("msg.sender check".into()); - } - if let Some(role) = find_sender_access_check(then_body) { - return Some(role); - } - if let Some(else_stmts) = else_body { - if let Some(role) = find_sender_access_check(else_stmts) { - return Some(role); - } - } - } - StatementKind::ExpressionStmt { expression } => { - if is_require_with_sender_comparison(expression) { - return Some("msg.sender check".into()); - } - } - StatementKind::Block { statements } | StatementKind::UncheckedBlock { statements } => { - if let Some(role) = find_sender_access_check(statements) { - return Some(role); - } - } - _ => {} - } - None -} - -/// Checks if an expression is require(...) or assert(...) where the condition -/// contains a msg.sender COMPARISON (==, !=), not just a mention. -fn is_require_with_sender_comparison(expr: &Expression) -> bool { - if let ExpressionKind::FunctionCall { callee, arguments } = &expr.kind { - let is_require = matches!(&callee.kind, - ExpressionKind::Identifier { name } if name == "require" || name == "assert" - ); - if is_require { - return arguments - .first() - .map_or(false, |arg| expr_is_sender_comparison(arg)); - } - } - false -} - -/// Returns true if the expression compares msg.sender with something using == or !=. -/// This catches: msg.sender == owner, owner == msg.sender, msg.sender != address(0) -/// But NOT: balances[msg.sender] (that's indexing, not a comparison) -fn expr_is_sender_comparison(expr: &Expression) -> bool { - match &expr.kind { - ExpressionKind::BinaryOp { left, operator, right } => { - match operator { - BinaryOperator::Eq | BinaryOperator::Neq => { - // One side must be msg.sender - expr_is_msg_sender(left) || expr_is_msg_sender(right) - } - BinaryOperator::And | BinaryOperator::Or => { - // Recurse into && and || chains - expr_is_sender_comparison(left) || expr_is_sender_comparison(right) - } - _ => false, - } - } - ExpressionKind::UnaryOp { operand, .. } => expr_is_sender_comparison(operand), - _ => false, - } -} - -/// Returns true only for the exact expression `msg.sender` -fn expr_is_msg_sender(expr: &Expression) -> bool { - if let ExpressionKind::MemberAccess { object, member } = &expr.kind { - if member == "sender" { - if let ExpressionKind::Identifier { name } = &object.kind { - return name == "msg"; - } - } - } - false -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::model::common::SourceSpan; - use crate::model::function::Mutability; - use crate::model::modifier::ModifierRef; - - fn span() -> SourceSpan { - SourceSpan { file_index: 0, start_line: 0, start_col: 0, end_line: 0, end_col: 0 } - } - - fn make_func(name: &str, vis: Visibility, kind: FunctionKind, modifiers: Vec<&str>, body: Option<Vec<Statement>>) -> FunctionDef { - FunctionDef { - name: name.into(), - kind, - visibility: vis, - mutability: Mutability::NonPayable, - modifiers: modifiers - .into_iter() - .map(|m| ModifierRef { name: m.into(), arguments: vec![] }) - .collect(), - params: vec![], - returns: vec![], - body, - is_virtual: false, - is_override: false, - span: span(), - } - } - - fn contract() -> ContractDef { - ContractDef { - name: "Test".into(), - kind: crate::model::contract::ContractKind::Contract, - functions: vec![], modifiers: vec![], state_vars: vec![], - structs: vec![], enums: vec![], events: vec![], errors: vec![], - inherits: vec![], span: span(), - } - } - - fn msg_sender_expr() -> Expression { - Expression { - kind: ExpressionKind::MemberAccess { - object: Box::new(Expression { - kind: ExpressionKind::Identifier { name: "msg".into() }, - span: span(), - }), - member: "sender".into(), - }, - span: span(), - } - } - - fn owner_expr() -> Expression { - Expression { - kind: ExpressionKind::Identifier { name: "owner".into() }, - span: span(), - } - } - - fn require_sender_eq_owner() -> Statement { - Statement { - kind: StatementKind::ExpressionStmt { - expression: Expression { - kind: ExpressionKind::FunctionCall { - callee: Box::new(Expression { - kind: ExpressionKind::Identifier { name: "require".into() }, - span: span(), - }), - arguments: vec![ - Expression { - kind: ExpressionKind::BinaryOp { - left: Box::new(msg_sender_expr()), - operator: BinaryOperator::Eq, - right: Box::new(owner_expr()), - }, - span: span(), - }, - ], - }, - span: span(), - }, - }, - span: span(), - } - } - - #[test] - fn internal_function() { - let f = make_func("_update", Visibility::Internal, FunctionKind::Function, vec![], Some(vec![])); - assert_eq!(classify_function(&f, &contract()), AccessLevel::Internal); - } - - #[test] - fn private_function() { - let f = make_func("_helper", Visibility::Private, FunctionKind::Function, vec![], Some(vec![])); - assert_eq!(classify_function(&f, &contract()), AccessLevel::Internal); - } - - #[test] - fn public_no_modifier() { - let f = make_func("deposit", Visibility::External, FunctionKind::Function, vec![], Some(vec![])); - assert_eq!(classify_function(&f, &contract()), AccessLevel::Public); - } - - #[test] - fn restricted_only_owner() { - let f = make_func("setRate", Visibility::External, FunctionKind::Function, vec!["onlyOwner"], Some(vec![])); - assert_eq!( - classify_function(&f, &contract()), - AccessLevel::Restricted { role: "onlyOwner".into() } - ); - } - - #[test] - fn when_not_paused_is_still_public() { - // whenNotPaused is a state guard, NOT access control - let f = make_func("deposit", Visibility::External, FunctionKind::Function, vec!["whenNotPaused"], Some(vec![])); - assert_eq!(classify_function(&f, &contract()), AccessLevel::Public); - } - - #[test] - fn nonreentrant_is_still_public() { - let f = make_func("withdraw", Visibility::External, FunctionKind::Function, vec!["nonReentrant"], Some(vec![])); - assert_eq!(classify_function(&f, &contract()), AccessLevel::Public); - } - - #[test] - fn constructor_is_special() { - let f = make_func("", Visibility::Public, FunctionKind::Constructor, vec![], Some(vec![])); - assert_eq!( - classify_function(&f, &contract()), - AccessLevel::Special { kind: "constructor".into() } - ); - } - - #[test] - fn receive_is_special() { - let f = make_func("", Visibility::External, FunctionKind::Receive, vec![], Some(vec![])); - assert_eq!( - classify_function(&f, &contract()), - AccessLevel::Special { kind: "receive".into() } - ); - } - - #[test] - fn require_msg_sender_eq_owner() { - let body = vec![require_sender_eq_owner()]; - let f = make_func("admin_func", Visibility::External, FunctionKind::Function, vec![], Some(body)); - assert_eq!( - classify_function(&f, &contract()), - AccessLevel::Restricted { role: "msg.sender check".into() } - ); - } - - #[test] - fn auth_modifier() { - let f = make_func("mint", Visibility::External, FunctionKind::Function, vec!["auth"], Some(vec![])); - assert_eq!( - classify_function(&f, &contract()), - AccessLevel::Restricted { role: "auth".into() } - ); - } - - #[test] - fn msg_sender_is_exact() { - assert!(expr_is_msg_sender(&msg_sender_expr())); - - // "sender" without "msg" is NOT msg.sender - let not_msg = Expression { - kind: ExpressionKind::MemberAccess { - object: Box::new(Expression { - kind: ExpressionKind::Identifier { name: "tx".into() }, - span: span(), - }), - member: "sender".into(), - }, - span: span(), - }; - assert!(!expr_is_msg_sender(¬_msg)); - } -} diff --git a/crates/ilold-core/src/classify/mod.rs b/crates/ilold-core/src/classify/mod.rs deleted file mode 100644 index 965bd9d..0000000 --- a/crates/ilold-core/src/classify/mod.rs +++ /dev/null @@ -1 +0,0 @@ -pub mod entry_points; diff --git a/crates/ilold-core/src/exploration/add_step_solidity.rs b/crates/ilold-core/src/exploration/add_step_solidity.rs deleted file mode 100644 index adc6cfc..0000000 --- a/crates/ilold-core/src/exploration/add_step_solidity.rs +++ /dev/null @@ -1,80 +0,0 @@ -use std::collections::HashMap; - -use ilold_session_core::exploration::session::{ - ExplorationSession, ExplorationStep, MutationScope, StateMutation, TraceConfig, -}; -use ilold_session_core::journal::types::JournalEntry; - -use crate::cfg::types::CfgGraph; -use crate::model::common::StateVar; -use crate::model::contract::ContractDef; -use crate::model::function::FunctionDef; -use crate::model::project::Project; -use crate::narrative::trace::{build_flow_tree_with_mutations, FlowConfig}; - -#[allow(clippy::too_many_arguments)] -pub fn add_solidity_step<'a>( - session: &'a mut ExplorationSession, - function: &FunctionDef, - cfg: &CfgGraph, - state_vars: &[StateVar], - project: &Project, - owning_contract: &ContractDef, - all_cfgs: &HashMap<(String, String), CfgGraph>, - timestamp: &str, - trace_config: TraceConfig, -) -> &'a ExplorationStep { - let step_index = session.steps.len(); - - let flow_config = FlowConfig { - max_depth: trace_config.depth, - include_reverts: trace_config.include_reverts, - expand_set: trace_config.expand_set.iter().copied().collect(), - }; - - let (flow_tree, raw_mutations) = build_flow_tree_with_mutations( - owning_contract, - function, - cfg, - project, - all_cfgs, - &flow_config, - ); - - let mutations: Vec<StateMutation> = raw_mutations - .into_iter() - .filter_map(|fm| { - let base = crate::util::target_base_name(&fm.target); - if !state_vars.iter().any(|sv| sv.name == base) { - return None; - } - Some(StateMutation { - variable: fm.target, - operator: fm.operator, - value_expr: fm.value, - step_index, - via: fm.via, - flow_step_id: Some(fm.flow_step_id), - scope: MutationScope::State, - }) - }) - .collect(); - - let flow_tree_value = serde_json::to_value(&flow_tree).ok(); - - session.steps.push(ExplorationStep { - function: function.name.clone(), - mutations, - flow_tree: flow_tree_value, - trace_config, - runtime_trace: None, - call_payload: None, - }); - - session.journal.record(JournalEntry::SequenceExplored { - steps: session.steps.iter().map(|s| s.function.clone()).collect(), - timestamp: timestamp.into(), - }); - - session.steps.last().unwrap() -} diff --git a/crates/ilold-core/src/exploration/commands.rs b/crates/ilold-core/src/exploration/commands.rs deleted file mode 100644 index 8a94bce..0000000 --- a/crates/ilold-core/src/exploration/commands.rs +++ /dev/null @@ -1,691 +0,0 @@ -use std::collections::HashMap; - -use serde::{Deserialize, Serialize}; - -use crate::cfg::types::CfgGraph; -use crate::classify::entry_points::{classify_all, AccessLevel}; -use crate::journal::types::{Finding, ReviewStatus, Severity}; -use crate::model::contract::ContractDef; -use crate::model::project::Project; -use crate::narrative::function::build_function_narrative; -use crate::narrative::sequence::{build_sequence_narrative, compute_flow_summary}; -use crate::narrative::trace::{build_flow_tree, FlowConfig, FlowTree}; -use crate::narrative::types::{FunctionNarrative, SequenceNarrative}; -use crate::pathtree::types::PathTree; -use crate::journal::export::export_markdown; -use crate::sequence::analysis::{FunctionBehavior, SequenceAnalysis, TransitionInfo}; - -use super::session::{ExplorationSession, TraceConfig, VariableSummary}; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct FunctionEntry { - pub name: String, - pub access: AccessLevel, - pub writes_state: bool, - pub has_external_calls: bool, - pub is_read_only: bool, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AccessibleFunctionEntry { - pub name: String, - pub access: AccessLevel, - pub origin: String, - pub is_inherited: bool, - pub writes_state: bool, - pub has_external_calls: bool, - pub is_read_only: bool, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AccessibleStateVarEntry { - pub name: String, - pub type_name: String, - pub is_constant: bool, - pub is_immutable: bool, - pub origin: String, - pub is_inherited: bool, -} - -pub struct AnalysisData<'a> { - pub project: &'a Project, - pub contract: &'a ContractDef, - pub cfgs: &'a HashMap<(String, String), CfgGraph>, - pub path_trees: &'a HashMap<(String, String), PathTree>, - pub behaviors: &'a [FunctionBehavior], - pub transitions: &'a [TransitionInfo], - pub classifications: &'a [(String, AccessLevel)], - pub all_sequence_analyses: &'a HashMap<String, SequenceAnalysis>, - pub all_classifications: &'a HashMap<String, Vec<(String, AccessLevel)>>, -} - -pub use ilold_session_core::exploration::canvas::CanvasPatch; -pub use ilold_session_core::exploration::scenario::{ - validate_scenario_name, ScenarioAction, ScenarioEvent, ScenarioInfo, -}; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub enum SessionCommand { - Call { - func: String, - #[serde(default)] - trace_config: Option<TraceConfig>, - }, - Back, - Clear, - State, - Functions, - Finding { severity: Severity, title: String, description: String }, - Note { text: String }, - Status { func: String, status: ReviewStatus }, - Session, - Who { variable: String }, - Export, - SaveSession, - LoadSession { json: String }, - FunctionsAll, - StateVarsAll, - Scenario { sub: ScenarioAction }, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub enum CommandResult { - StepAdded { - step_index: usize, - function: String, - access: AccessLevel, - state_changed: Vec<String>, - }, - StepRemoved { - remaining: usize, - }, - Cleared, - StateView { - summary: Vec<VariableSummary>, - }, - FunctionList { - functions: Vec<FunctionEntry>, - }, - FunctionListAll { - functions: Vec<AccessibleFunctionEntry>, - }, - StateVarListAll { - state_vars: Vec<AccessibleStateVarEntry>, - }, - FindingAdded { - id: String, - }, - NoteAdded, - StatusUpdated, - SessionView { - contract: String, - steps: Vec<String>, - findings_count: usize, - }, - VariableInfo { - variable: String, - writers: Vec<(String, AccessLevel)>, - readers: Vec<(String, AccessLevel)>, - }, - Exported { - markdown: String, - }, - SessionSaved { - json: String, - }, - SessionLoaded { - contract: String, - steps: Vec<String>, - }, - Error { - message: String, - }, - ScenarioList { items: Vec<ScenarioInfo> }, - ScenarioCreated { name: String }, - ScenarioSwitched { from: String, to: String }, - ScenarioForked { from: String, to: String, at_step: usize }, - ScenarioDeleted { name: String }, -} - -pub fn canvas_patch_from(result: &CommandResult, active_scenario: &str) -> Option<CanvasPatch> { - match result { - CommandResult::StepAdded { function, access, step_index, .. } => { - Some(CanvasPatch::AddNode { - scenario: active_scenario.to_string(), - function: function.clone(), - access: access.clone(), - step_index: *step_index, - runtime: None, - }) - } - CommandResult::StepRemoved { .. } => Some(CanvasPatch::RemoveLastNode { - scenario: active_scenario.to_string(), - }), - CommandResult::Cleared => Some(CanvasPatch::ClearAll { - scenario: active_scenario.to_string(), - }), - CommandResult::ScenarioCreated { name } => { - Some(CanvasPatch::ScenarioEvent(ScenarioEvent::Created { name: name.clone() })) - } - CommandResult::ScenarioSwitched { from, to } => { - if from == to { - // idempotent no-op: suppress WS broadcast - None - } else { - Some(CanvasPatch::ScenarioEvent(ScenarioEvent::Switched { - from: from.clone(), - to: to.clone(), - })) - } - } - CommandResult::ScenarioDeleted { name } => { - Some(CanvasPatch::ScenarioEvent(ScenarioEvent::Deleted { name: name.clone() })) - } - CommandResult::ScenarioForked { from, to, at_step } => { - Some(CanvasPatch::ScenarioEvent(ScenarioEvent::Forked { - from: from.clone(), - to: to.clone(), - at_step: *at_step, - })) - } - CommandResult::SessionLoaded { contract: _, steps: _ } => { - Some(CanvasPatch::ScenarioEvent(ScenarioEvent::Reloaded { - active: active_scenario.to_string(), - })) - } - _ => None, - } -} - -pub fn execute_command( - cmd: SessionCommand, - session: &mut ExplorationSession, - data: &AnalysisData, - timestamp: &str, -) -> CommandResult { - match cmd { - SessionCommand::Call { func, trace_config } => { - execute_call(session, data, &func, trace_config, timestamp) - } - SessionCommand::Back => execute_back(session), - SessionCommand::Clear => { session.clear(); CommandResult::Cleared } - SessionCommand::State => CommandResult::StateView { - summary: session.variable_summary(), - }, - SessionCommand::Functions => { - let classifications = classify_all(data.contract); - let functions: Vec<FunctionEntry> = classifications.into_iter().map(|(name, access)| { - let behavior = data.behaviors.iter().find(|b| b.name == name); - let writes_state = behavior - .map(|b| !b.effective_state_writes().is_empty()) - .unwrap_or(false); - let has_external_calls = behavior - .map(|b| !b.effective_external_calls().is_empty()) - .unwrap_or(false); - let is_read_only = !writes_state && !has_external_calls; - FunctionEntry { - name, - access, - writes_state, - has_external_calls, - is_read_only, - } - }).collect(); - CommandResult::FunctionList { functions } - } - SessionCommand::Finding { severity, title, description } => { - execute_finding(session, severity, title, description, timestamp) - } - SessionCommand::Note { text } => execute_note(session, &text, timestamp), - SessionCommand::Status { func, status } => { - execute_status(session, &func, status, timestamp, data) - } - SessionCommand::Session => CommandResult::SessionView { - contract: session.contract.clone(), - steps: session.current_sequence().into_iter().map(|s| s.to_string()).collect(), - findings_count: session.journal.findings.len(), - }, - SessionCommand::Who { variable } => execute_who(data, &variable), - SessionCommand::Export => { - let md = export_markdown(&session.journal, data.contract.functions.len()); - CommandResult::Exported { markdown: md } - } - SessionCommand::SaveSession | SessionCommand::LoadSession { .. } => CommandResult::Error { - message: "Save/Load commands must be dispatched at the store level, not through execute_command".into(), - }, - SessionCommand::FunctionsAll => execute_functions_all(data), - SessionCommand::StateVarsAll => execute_state_vars_all(data), - SessionCommand::Scenario { .. } => CommandResult::Error { - message: "Scenario commands must be dispatched at the store level, not through execute_command".into(), - }, - } -} - -fn execute_state_vars_all(data: &AnalysisData) -> CommandResult { - let state_vars: Vec<AccessibleStateVarEntry> = data.project - .accessible_state_vars(data.contract) - .into_iter() - .map(|(sv, origin, is_inherited)| AccessibleStateVarEntry { - name: sv.name, - type_name: sv.type_name, - is_constant: sv.is_constant, - is_immutable: sv.is_immutable, - origin, - is_inherited, - }) - .collect(); - - CommandResult::StateVarListAll { state_vars } -} - -fn execute_functions_all(data: &AnalysisData) -> CommandResult { - use crate::classify::entry_points::classify_function; - - let functions: Vec<AccessibleFunctionEntry> = data.project - .accessible_functions(data.contract) - .into_iter() - .map(|af| { - let access = if af.is_inherited { - if let Some(parent) = data.project.contracts.iter().find(|c| c.name == af.origin) { - classify_function(af.function, parent) - } else { - classify_function(af.function, data.contract) - } - } else { - classify_function(af.function, data.contract) - }; - - let behavior = if af.is_inherited { - data.all_sequence_analyses.get(&af.origin) - .and_then(|sa| sa.functions.iter().find(|b| b.name == af.function.name)) - } else { - data.behaviors.iter().find(|b| b.name == af.function.name) - }; - - let writes_state = behavior - .map(|b| !b.effective_state_writes().is_empty()) - .unwrap_or(false); - let has_external_calls = behavior - .map(|b| !b.effective_external_calls().is_empty()) - .unwrap_or(false); - let is_read_only = !writes_state && !has_external_calls; - - AccessibleFunctionEntry { - name: af.function.name.clone(), - access, - origin: af.origin, - is_inherited: af.is_inherited, - writes_state, - has_external_calls, - is_read_only, - } - }) - .collect(); - - CommandResult::FunctionListAll { functions } -} - -fn execute_call( - session: &mut ExplorationSession, - data: &AnalysisData, - func: &str, - trace_config: Option<TraceConfig>, - timestamp: &str, -) -> CommandResult { - let accessible = data.project.accessible_functions(data.contract); - let resolved = match accessible.iter().find(|a| a.function.name == func) { - Some(af) => af, - None => return CommandResult::Error { - message: format!("Function '{}' not found. Use 'functions' or 'funcs-all' to see available.", func), - }, - }; - - let owning_contract = if resolved.is_inherited { - match data.project.contracts.iter().find(|c| c.name == resolved.origin) { - Some(c) => c, - None => return CommandResult::Error { - message: format!("Parent contract '{}' not found", resolved.origin), - }, - } - } else { - data.contract - }; - - let key = (owning_contract.name.clone(), func.to_string()); - let cfg = match data.cfgs.get(&key) { - Some(c) => c, - None => return CommandResult::Error { - message: format!("No CFG available for {}::{}", owning_contract.name, func), - }, - }; - - let function_def = resolved.function; - - // Session steps model real external transactions. Internal/private - // functions cannot be invoked from outside the contract, so accepting - // them as entry points would produce an execution sequence that is - // impossible in practice — biasing every downstream analysis. - use crate::model::function::Visibility; - if matches!(function_def.visibility, Visibility::Internal | Visibility::Private) { - return CommandResult::Error { - message: format!( - "'{}' is {} and cannot be called from outside the contract — \ - not a valid session entry point. Use `tr {}` to view its flow, \ - or `c <public_caller>` to trace a real entry point.", - func, - match function_def.visibility { - Visibility::Internal => "internal", - Visibility::Private => "private", - _ => unreachable!(), - }, - func, - ), - }; - } - - let access = crate::classify::entry_points::classify_function(function_def, owning_contract); - - let combined_state_vars = data.project.inherited_state_vars(data.contract); - crate::exploration::add_step_solidity::add_solidity_step( - session, - function_def, - cfg, - &combined_state_vars, - data.project, - owning_contract, - data.cfgs, - timestamp, - trace_config.unwrap_or_default(), - ); - - let state_changed: Vec<String> = session.steps.last() - .map(|s| { - let mut vars: Vec<String> = s.mutations.iter().map(|m| m.variable.clone()).collect(); - vars.sort(); - vars.dedup(); - vars - }) - .unwrap_or_default(); - - CommandResult::StepAdded { - step_index: session.steps.len() - 1, - function: func.to_string(), - access, - state_changed, - } -} - -fn execute_back(session: &mut ExplorationSession) -> CommandResult { - if session.remove_last_step() { - CommandResult::StepRemoved { remaining: session.steps.len() } - } else { - CommandResult::Error { message: "No steps to undo".into() } - } -} - -fn execute_finding( - session: &mut ExplorationSession, - severity: Severity, - title: String, - description: String, - timestamp: &str, -) -> CommandResult { - let affected_sequence = if session.steps.is_empty() { - None - } else { - Some(session.current_sequence().into_iter().map(|s| s.to_string()).collect()) - }; - - let finding = Finding { - id: String::new(), - severity, - title, - affected_function: session.steps.last().map(|s| s.function.clone()).unwrap_or_default(), - affected_sequence, - description, - notes: vec![], - created_at: String::new(), - // Solidity flow does not yet capture step_index or recommendation; - // SDD-02 only wires the new fields on the Solana side. Setting None - // here preserves backward-compat with existing Solidity callers. - affected_step_index: None, - recommendation: None, - }; - - session.journal.add_finding(finding, timestamp); - let id = session.journal.findings.last().map(|f| f.id.clone()).unwrap_or_default(); - CommandResult::FindingAdded { id } -} - -fn execute_note(session: &mut ExplorationSession, text: &str, timestamp: &str) -> CommandResult { - let anchor = session.current_sequence().join(" → "); - session.journal.record(crate::journal::types::JournalEntry::NoteAdded { - anchor, - content: text.into(), - timestamp: timestamp.into(), - }); - CommandResult::NoteAdded -} - -fn execute_status( - session: &mut ExplorationSession, - func: &str, - status: ReviewStatus, - timestamp: &str, - data: &AnalysisData, -) -> CommandResult { - if data.project.resolve_function(data.contract, func).is_none() { - return CommandResult::Error { - message: format!("Function '{}' not found in {} or its ancestors", func, data.contract.name), - }; - } - session.journal.record(crate::journal::types::JournalEntry::StatusChanged { - function: func.into(), - status, - timestamp: timestamp.into(), - }); - CommandResult::StatusUpdated -} - -fn execute_who(data: &AnalysisData, variable: &str) -> CommandResult { - let var_lower = variable.to_lowercase(); - - let mut contract_set: std::collections::HashSet<String> = std::collections::HashSet::new(); - contract_set.insert(data.contract.name.clone()); - for af in data.project.accessible_functions(data.contract) { - if af.is_inherited { - contract_set.insert(af.origin); - } - } - let mut contract_names: Vec<String> = contract_set.into_iter().collect(); - contract_names.sort(); - - let access_for = |func_name: &str, contract_name: &str| -> AccessLevel { - data.all_classifications.get(contract_name) - .and_then(|classifs| classifs.iter().find(|(n, _)| n == func_name).map(|(_, a)| a.clone())) - .unwrap_or(AccessLevel::Internal) - }; - - // Match a normalized path against the requested var. - // The path equals the var (`var`), or ends with `.var`, or equals `var[]`, - // or starts with `var[]` followed by `.` or end. - let path_matches = |path: &str| -> bool { - let p = path.to_lowercase(); - if p == var_lower { - return true; - } - if p == format!("{}[]", var_lower) { - return true; - } - if p.ends_with(&format!(".{}", var_lower)) { - return true; - } - // base[].suffix or base.suffix where base == var - let base = p - .split(|c| c == '[' || c == '.') - .next() - .unwrap_or(&p); - base == var_lower - }; - - let mut writers: Vec<(String, AccessLevel)> = Vec::new(); - let mut readers: Vec<(String, AccessLevel)> = Vec::new(); - let mut seen_writers: std::collections::HashSet<(String, String)> = - std::collections::HashSet::new(); - let mut seen_readers: std::collections::HashSet<(String, String)> = - std::collections::HashSet::new(); - - for contract_name in &contract_names { - let behaviors = match data.all_sequence_analyses.get(contract_name) { - Some(sa) => &sa.functions, - None => continue, - }; - - for b in behaviors { - let eff_write_paths = b.effective_state_write_paths(); - let eff_read_paths = b.effective_state_read_paths(); - let eff_writes = b.effective_state_writes(); - let eff_reads = b.effective_state_reads(); - - let writes = eff_write_paths.iter().any(|p| path_matches(p)) - || eff_writes.iter().any(|w| { - let base = w - .split(|c| c == '[' || c == '.') - .next() - .unwrap_or(w) - .to_lowercase(); - base == var_lower - }); - let reads = eff_read_paths.iter().any(|p| path_matches(p)) - || eff_reads.iter().any(|r| { - let base = r - .split(|c| c == '[' || c == '.') - .next() - .unwrap_or(r) - .to_lowercase(); - base == var_lower - }); - - let key = (contract_name.clone(), b.name.clone()); - if writes && seen_writers.insert(key.clone()) { - writers.push((b.name.clone(), access_for(&b.name, contract_name))); - } - if reads && !writes && seen_readers.insert(key) { - readers.push((b.name.clone(), access_for(&b.name, contract_name))); - } - } - } - - if writers.is_empty() && readers.is_empty() { - return CommandResult::Error { - message: format!("Variable '{}' not found in any function", variable), - }; - } - - CommandResult::VariableInfo { variable: variable.to_string(), writers, readers } -} - -pub fn get_step_narrative( - session: &ExplorationSession, - step_index: usize, - data: &AnalysisData, -) -> Result<FunctionNarrative, String> { - let step = session.steps.get(step_index) - .ok_or_else(|| format!("Step {} not found", step_index))?; - - let (owning, func) = data.project - .resolve_function(data.contract, &step.function) - .ok_or_else(|| format!("Function '{}' not found", step.function))?; - - let key = (owning.name.clone(), step.function.clone()); - let cfg = data.cfgs.get(&key).ok_or("No CFG")?; - let pt = data.path_trees.get(&key).ok_or("No paths")?; - - let behaviors: &[FunctionBehavior] = if owning.name == data.contract.name { - data.behaviors - } else { - data.all_sequence_analyses.get(&owning.name) - .map(|sa| sa.functions.as_slice()) - .unwrap_or(&[]) - }; - - Ok(build_function_narrative(owning, func, pt, cfg, behaviors, data.project, data.all_sequence_analyses)) -} - -pub fn get_function_info( - func_name: &str, - data: &AnalysisData, -) -> Result<FunctionNarrative, String> { - let (owning, func) = data.project - .resolve_function(data.contract, func_name) - .ok_or_else(|| format!("Function '{}' not found", func_name))?; - - let key = (owning.name.clone(), func_name.to_string()); - let cfg = data.cfgs.get(&key).ok_or_else(|| format!("No CFG for {}::{}", owning.name, func_name))?; - let pt = data.path_trees.get(&key).ok_or("No paths")?; - - let behaviors: &[FunctionBehavior] = if owning.name == data.contract.name { - data.behaviors - } else { - data.all_sequence_analyses.get(&owning.name) - .map(|sa| sa.functions.as_slice()) - .unwrap_or(&[]) - }; - - Ok(build_function_narrative(owning, func, pt, cfg, behaviors, data.project, data.all_sequence_analyses)) -} - -pub fn get_flow_tree( - func_name: &str, - data: &AnalysisData, - max_depth: usize, - include_reverts: bool, - expand_set: std::collections::HashSet<usize>, -) -> Result<FlowTree, String> { - let (owning, func) = data.project - .resolve_function(data.contract, func_name) - .ok_or_else(|| format!("Function '{}' not found", func_name))?; - - let key = (owning.name.clone(), func_name.to_string()); - let cfg = data.cfgs.get(&key) - .ok_or_else(|| format!("No CFG for {}::{}", owning.name, func_name))?; - - let config = FlowConfig { - max_depth, - include_reverts, - expand_set, - }; - Ok(build_flow_tree(owning, func, cfg, data.project, data.cfgs, &config)) -} - -pub fn get_sequence_narrative( - session: &ExplorationSession, - data: &AnalysisData, -) -> Result<SequenceNarrative, String> { - if session.steps.len() < 2 { - return Err("Need at least 2 steps for a sequence narrative".into()); - } - - let names: Vec<&str> = session.current_sequence(); - let mut narrative = build_sequence_narrative( - &session.contract, &names, - data.behaviors, data.transitions, data.classifications, - ); - - // Enrich each narrative step with the FlowSummary derived from the - // session's persisted flow_tree. Legacy steps without a tree get - // None, which the renderer treats as "no summary available". - for (i, step) in narrative.steps.iter_mut().enumerate() { - if let Some(session_step) = session.steps.get(i) { - step.flow_summary = session_step.flow_tree.as_ref() - .and_then(|v| serde_json::from_value::<FlowTree>(v.clone()).ok()) - .map(|tree| compute_flow_summary(&tree, i)); - } - } - - Ok(narrative) -} - -pub fn get_session_state(session: &ExplorationSession) -> Vec<VariableSummary> { - session.variable_summary() -} diff --git a/crates/ilold-core/src/exploration/mod.rs b/crates/ilold-core/src/exploration/mod.rs deleted file mode 100644 index 46633d4..0000000 --- a/crates/ilold-core/src/exploration/mod.rs +++ /dev/null @@ -1,4 +0,0 @@ -pub use ilold_session_core::exploration::session; -pub mod commands; -pub mod timeline; -pub mod add_step_solidity; diff --git a/crates/ilold-core/src/exploration/timeline.rs b/crates/ilold-core/src/exploration/timeline.rs deleted file mode 100644 index 9b838cf..0000000 --- a/crates/ilold-core/src/exploration/timeline.rs +++ /dev/null @@ -1,196 +0,0 @@ -// Cross-step variable timeline. -// -// `build_variable_timeline` walks an `ExplorationSession` and collects -// every mutation of a target variable, annotating each one with the -// path conditions that had to be true to reach the write (extracted -// from the persisted FlowTree of the corresponding session step). - -use serde::{Deserialize, Serialize}; - -use crate::model::expression::AssignOperator; -use crate::narrative::trace::collect_path_conditions; -use crate::util::target_base_name; - -use super::session::{ExplorationSession, MutationScope}; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct VariableTimeline { - pub variable: String, - pub state_entries: Vec<TimelineEntry>, - pub local_entries: Vec<TimelineEntry>, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct TimelineEntry { - pub session_step_index: usize, - pub function: String, - pub flow_step_id: Option<usize>, - pub target: String, - pub operator: AssignOperator, - pub value_expr: String, - pub reached_when: Vec<String>, - pub via: Option<String>, - pub scope: MutationScope, -} - -/// Build a chronological timeline of every write to `variable` across the -/// session. Matches by base name (`balances[user]` matches `balances`). -/// Path conditions are extracted from each step's persisted FlowTree. -pub fn build_variable_timeline( - session: &ExplorationSession, - variable: &str, -) -> VariableTimeline { - let mut state_entries: Vec<TimelineEntry> = Vec::new(); - let mut local_entries: Vec<TimelineEntry> = Vec::new(); - - for (idx, step) in session.steps.iter().enumerate() { - for mutation in &step.mutations { - if !matches_variable(&mutation.variable, variable) { - continue; - } - - let reached_when = match (mutation.flow_step_id, &step.flow_tree) { - (Some(flow_id), Some(tree_value)) => { - serde_json::from_value::<crate::narrative::trace::FlowTree>(tree_value.clone()) - .ok() - .and_then(|tree| collect_path_conditions(&tree, flow_id)) - .unwrap_or_default() - } - _ => Vec::new(), - }; - - let entry = TimelineEntry { - session_step_index: idx, - function: step.function.clone(), - flow_step_id: mutation.flow_step_id, - target: mutation.variable.clone(), - operator: mutation.operator, - value_expr: mutation.value_expr.clone(), - reached_when, - via: mutation.via.clone(), - scope: mutation.scope, - }; - - match mutation.scope { - MutationScope::State => state_entries.push(entry), - MutationScope::Local => local_entries.push(entry), - } - } - } - - // Stable order: by session step index then by flow_step_id (None last - // so legacy mutations without ids appear after the resolved ones). - let sort_key = |e: &TimelineEntry| (e.session_step_index, e.flow_step_id.unwrap_or(usize::MAX)); - state_entries.sort_by_key(sort_key); - local_entries.sort_by_key(sort_key); - - VariableTimeline { - variable: variable.to_string(), - state_entries, - local_entries, - } -} - -/// True if `target` (a mutation target like `balances[msg.sender]` or -/// `config.fee`) refers to the variable `query`. -fn matches_variable(target: &str, query: &str) -> bool { - target_base_name(target) == query -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::exploration::session::{ExplorationStep, StateMutation, TraceConfig}; - - fn make_mutation(var: &str, op: AssignOperator, val: &str, flow_id: Option<usize>) -> StateMutation { - StateMutation { - variable: var.into(), - operator: op, - value_expr: val.into(), - step_index: 0, - via: None, - flow_step_id: flow_id, - scope: MutationScope::State, - } - } - - fn make_step(func: &str, mutations: Vec<StateMutation>) -> ExplorationStep { - ExplorationStep { - function: func.into(), - mutations, - flow_tree: None, - trace_config: TraceConfig::default(), - runtime_trace: None, - call_payload: None, - } - } - - #[test] - fn timeline_collects_state_writes_in_session_order() { - let mut session = ExplorationSession::new("C", "p"); - session.steps.push(make_step("deposit", vec![ - make_mutation("balances[user]", AssignOperator::AddAssign, "amount", Some(7)), - make_mutation("totalStaked", AssignOperator::AddAssign, "amount", Some(8)), - ])); - session.steps.push(make_step("withdraw", vec![ - make_mutation("balances[user]", AssignOperator::SubAssign, "amount", Some(14)), - ])); - - let tl = build_variable_timeline(&session, "balances"); - assert_eq!(tl.variable, "balances"); - assert_eq!(tl.state_entries.len(), 2); - assert!(tl.local_entries.is_empty()); - - // Order: session step 0 first, then 1. - assert_eq!(tl.state_entries[0].session_step_index, 0); - assert_eq!(tl.state_entries[0].function, "deposit"); - assert_eq!(tl.state_entries[0].target, "balances[user]"); - assert_eq!(tl.state_entries[1].session_step_index, 1); - assert_eq!(tl.state_entries[1].function, "withdraw"); - } - - #[test] - fn timeline_matches_by_base_name() { - let mut session = ExplorationSession::new("C", "p"); - session.steps.push(make_step("f", vec![ - make_mutation("balances[a]", AssignOperator::Assign, "1", None), - make_mutation("balancesOther", AssignOperator::Assign, "2", None), - make_mutation("config.fee", AssignOperator::Assign, "3", None), - ])); - - let tl = build_variable_timeline(&session, "balances"); - // Only `balances[a]` matches; `balancesOther` is a different name. - assert_eq!(tl.state_entries.len(), 1); - assert_eq!(tl.state_entries[0].target, "balances[a]"); - - let tl_config = build_variable_timeline(&session, "config"); - assert_eq!(tl_config.state_entries.len(), 1); - assert_eq!(tl_config.state_entries[0].target, "config.fee"); - } - - #[test] - fn timeline_returns_empty_when_no_matches() { - let mut session = ExplorationSession::new("C", "p"); - session.steps.push(make_step("f", vec![ - make_mutation("balances[a]", AssignOperator::Assign, "1", None), - ])); - - let tl = build_variable_timeline(&session, "totalSupply"); - assert!(tl.state_entries.is_empty()); - assert!(tl.local_entries.is_empty()); - } - - #[test] - fn timeline_legacy_mutation_has_empty_reached_when() { - let mut session = ExplorationSession::new("C", "p"); - // No flow_tree (legacy session) and no flow_step_id on the mutation. - session.steps.push(make_step("f", vec![ - make_mutation("balances", AssignOperator::Assign, "1", None), - ])); - - let tl = build_variable_timeline(&session, "balances"); - assert_eq!(tl.state_entries.len(), 1); - assert!(tl.state_entries[0].reached_when.is_empty()); - assert!(tl.state_entries[0].flow_step_id.is_none()); - } -} diff --git a/crates/ilold-core/src/lib.rs b/crates/ilold-core/src/lib.rs deleted file mode 100644 index 2276609..0000000 --- a/crates/ilold-core/src/lib.rs +++ /dev/null @@ -1,16 +0,0 @@ -//! Ilold Core — Smart contract execution path analysis engine. -//! -//! Pipeline: .sol files → Parser → Model Types → CFG Builder → CFG Graph - -pub mod model; -pub mod parse; -pub mod cfg; -pub mod callgraph; -pub mod pathtree; -pub mod sequence; -pub mod classify; -pub mod narrative; -pub mod slicing; -pub use ilold_session_core::journal; -pub mod exploration; -pub mod util; diff --git a/crates/ilold-core/src/model/common.rs b/crates/ilold-core/src/model/common.rs deleted file mode 100644 index e5b3dbc..0000000 --- a/crates/ilold-core/src/model/common.rs +++ /dev/null @@ -1,56 +0,0 @@ -use serde::{Deserialize, Serialize}; - -/// Points to a location in source code. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub struct SourceSpan { - pub file_index: usize, - pub start_line: u32, - pub start_col: u32, - pub end_line: u32, - pub end_col: u32, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Param { - pub name: String, - pub type_name: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct StateVar { - pub name: String, - pub type_name: String, - pub visibility: super::function::Visibility, - pub is_constant: bool, - pub is_immutable: bool, - pub initial_value: Option<super::expression::Expression>, - pub span: SourceSpan, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct StructDef { - pub name: String, - pub fields: Vec<Param>, - pub span: SourceSpan, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct EnumDef { - pub name: String, - pub variants: Vec<String>, - pub span: SourceSpan, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct EventDef { - pub name: String, - pub params: Vec<Param>, - pub span: SourceSpan, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ErrorDef { - pub name: String, - pub params: Vec<Param>, - pub span: SourceSpan, -} diff --git a/crates/ilold-core/src/model/contract.rs b/crates/ilold-core/src/model/contract.rs deleted file mode 100644 index 228a24f..0000000 --- a/crates/ilold-core/src/model/contract.rs +++ /dev/null @@ -1,28 +0,0 @@ -use serde::{Deserialize, Serialize}; - -use super::common::{EnumDef, ErrorDef, EventDef, SourceSpan, StateVar, StructDef}; -use super::function::FunctionDef; -use super::modifier::ModifierDef; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ContractDef { - pub name: String, - pub kind: ContractKind, - pub functions: Vec<FunctionDef>, - pub modifiers: Vec<ModifierDef>, - pub state_vars: Vec<StateVar>, - pub structs: Vec<StructDef>, - pub enums: Vec<EnumDef>, - pub events: Vec<EventDef>, - pub errors: Vec<ErrorDef>, - pub inherits: Vec<String>, - pub span: SourceSpan, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum ContractKind { - Contract, - Interface, - Library, - Abstract, -} diff --git a/crates/ilold-core/src/model/expression.rs b/crates/ilold-core/src/model/expression.rs deleted file mode 100644 index 17788b0..0000000 --- a/crates/ilold-core/src/model/expression.rs +++ /dev/null @@ -1,153 +0,0 @@ -use serde::{Deserialize, Serialize}; - -use super::common::SourceSpan; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Expression { - pub kind: ExpressionKind, - pub span: SourceSpan, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub enum ExpressionKind { - FunctionCall { - callee: Box<Expression>, - arguments: Vec<Expression>, - }, - MemberAccess { - object: Box<Expression>, - member: String, - }, - BinaryOp { - left: Box<Expression>, - operator: BinaryOperator, - right: Box<Expression>, - }, - UnaryOp { - operator: UnaryOperator, - operand: Box<Expression>, - }, - Identifier { - name: String, - }, - Literal { - value: String, - literal_type: LiteralType, - }, - IndexAccess { - base: Box<Expression>, - index: Option<Box<Expression>>, - }, - Assignment { - target: Box<Expression>, - operator: AssignOperator, - value: Box<Expression>, - }, - Ternary { - condition: Box<Expression>, - true_expr: Box<Expression>, - false_expr: Box<Expression>, - }, - /// uint256(x), address(y) — explicit type conversion - TypeCast { - type_name: String, - expression: Box<Expression>, - }, - /// type(uint256) — builtin that returns type metadata (min, max) - TypeMeta { - type_name: String, - }, - New { - type_name: String, - arguments: Vec<Expression>, - }, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum BinaryOperator { - Add, - Sub, - Mul, - Div, - Mod, - Pow, - Eq, - Neq, - Lt, - Gt, - Lte, - Gte, - And, - Or, - BitAnd, - BitOr, - BitXor, - Shl, - Shr, -} - -impl BinaryOperator { - /// Solidity source-form symbol for the operator (e.g. `Add` → `"+"`). - pub fn as_str(self) -> &'static str { - match self { - BinaryOperator::Add => "+", - BinaryOperator::Sub => "-", - BinaryOperator::Mul => "*", - BinaryOperator::Div => "/", - BinaryOperator::Mod => "%", - BinaryOperator::Pow => "**", - BinaryOperator::Eq => "==", - BinaryOperator::Neq => "!=", - BinaryOperator::Lt => "<", - BinaryOperator::Gt => ">", - BinaryOperator::Lte => "<=", - BinaryOperator::Gte => ">=", - BinaryOperator::And => "&&", - BinaryOperator::Or => "||", - BinaryOperator::BitAnd => "&", - BinaryOperator::BitOr => "|", - BinaryOperator::BitXor => "^", - BinaryOperator::Shl => "<<", - BinaryOperator::Shr => ">>", - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum UnaryOperator { - Not, - Neg, - BitNot, - PreIncrement, - PreDecrement, - PostIncrement, - PostDecrement, -} - -impl UnaryOperator { - /// Returns `(symbol, is_postfix)`. Prefix ops use `is_postfix = false` - /// so the caller can format `op + operand`; postfix increments use - /// `true` for `operand + op`. - pub fn format_parts(self) -> (&'static str, bool) { - match self { - UnaryOperator::Not => ("!", false), - UnaryOperator::Neg => ("-", false), - UnaryOperator::BitNot => ("~", false), - UnaryOperator::PreIncrement => ("++", false), - UnaryOperator::PreDecrement => ("--", false), - UnaryOperator::PostIncrement => ("++", true), - UnaryOperator::PostDecrement => ("--", true), - } - } -} - -pub use ilold_session_core::exploration::assign_operator::AssignOperator; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum LiteralType { - Number, - String, - Bool, - HexString, - Address, -} diff --git a/crates/ilold-core/src/model/function.rs b/crates/ilold-core/src/model/function.rs deleted file mode 100644 index 6a9d005..0000000 --- a/crates/ilold-core/src/model/function.rs +++ /dev/null @@ -1,44 +0,0 @@ -use serde::{Deserialize, Serialize}; - -use super::common::{Param, SourceSpan}; -use super::modifier::ModifierRef; -use super::statement::Statement; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct FunctionDef { - pub name: String, - pub kind: FunctionKind, - pub visibility: Visibility, - pub mutability: Mutability, - pub modifiers: Vec<ModifierRef>, - pub params: Vec<Param>, - pub returns: Vec<Param>, - pub body: Option<Vec<Statement>>, - pub is_virtual: bool, - pub is_override: bool, - pub span: SourceSpan, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum FunctionKind { - Function, - Constructor, - Fallback, - Receive, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum Visibility { - Public, - External, - Internal, - Private, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum Mutability { - Pure, - View, - Payable, - NonPayable, -} diff --git a/crates/ilold-core/src/model/mod.rs b/crates/ilold-core/src/model/mod.rs deleted file mode 100644 index 4206d92..0000000 --- a/crates/ilold-core/src/model/mod.rs +++ /dev/null @@ -1,7 +0,0 @@ -pub mod common; -pub mod contract; -pub mod function; -pub mod modifier; -pub mod statement; -pub mod expression; -pub mod project; diff --git a/crates/ilold-core/src/model/modifier.rs b/crates/ilold-core/src/model/modifier.rs deleted file mode 100644 index 45337a0..0000000 --- a/crates/ilold-core/src/model/modifier.rs +++ /dev/null @@ -1,21 +0,0 @@ -use serde::{Deserialize, Serialize}; - -use super::common::{Param, SourceSpan}; -use super::expression::Expression; -use super::statement::Statement; - -/// Modifier definition (the declaration with its body) -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ModifierDef { - pub name: String, - pub params: Vec<Param>, - pub body: Vec<Statement>, - pub span: SourceSpan, -} - -/// Modifier reference on a function (the usage, e.g. `onlyOwner` in function header) -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ModifierRef { - pub name: String, - pub arguments: Vec<Expression>, -} diff --git a/crates/ilold-core/src/model/project.rs b/crates/ilold-core/src/model/project.rs deleted file mode 100644 index c595476..0000000 --- a/crates/ilold-core/src/model/project.rs +++ /dev/null @@ -1,264 +0,0 @@ -use std::collections::HashMap; -use std::collections::HashSet; - -use serde::{Deserialize, Serialize}; - -use super::common::StateVar; -use super::contract::{ContractDef, ContractKind}; -use super::function::{FunctionDef, Visibility}; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Project { - pub source_files: Vec<SourceFile>, - pub contracts: Vec<ContractDef>, - #[serde(skip)] - pub contract_index: HashMap<String, usize>, -} - -impl Project { - /// Rebuild the contract_index from the contracts vec. - /// Call this after deserialization since serde(skip) means the index is empty. - pub fn rebuild_index(&mut self) { - self.contract_index = self - .contracts - .iter() - .enumerate() - .map(|(i, c)| (c.name.clone(), i)) - .collect(); - } - - /// Find a contract by exact name, or auto-pick the best candidate when - /// `filter` is None. Returns Err with a user-facing message on failure. - /// - /// Auto-pick rules (when filter is None): - /// 1. Skip interfaces. - /// 2. Prefer "top-level" contracts — those NOT inherited by any other. - /// 3. If there's still ambiguity, return Err listing the candidates. - pub fn find_contract(&self, filter: Option<&str>) -> Result<&ContractDef, String> { - if let Some(name) = filter { - return self - .contracts - .iter() - .find(|c| c.name == name) - .ok_or_else(|| format!("Contract '{}' not found", name)); - } - - let non_interface: Vec<&ContractDef> = self - .contracts - .iter() - .filter(|c| c.kind != ContractKind::Interface) - .collect(); - - match non_interface.len() { - 0 => Err("No contracts found".into()), - 1 => Ok(non_interface[0]), - _ => { - // Collect names that appear as a parent in `inherits` of any contract. - let inherited: HashSet<&str> = non_interface - .iter() - .flat_map(|c| c.inherits.iter().map(|s| s.as_str())) - .collect(); - - let top_level: Vec<&&ContractDef> = non_interface - .iter() - .filter(|c| !inherited.contains(c.name.as_str())) - .collect(); - - match top_level.len() { - 1 => Ok(*top_level[0]), - _ => { - let names: Vec<&str> = non_interface.iter().map(|c| c.name.as_str()).collect(); - Err(format!( - "Multiple contracts, specify one: {}", - names.join(", ") - )) - } - } - } - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SourceFile { - pub path: String, - pub content: String, -} - -pub struct AccessibleFunction<'a> { - pub function: &'a FunctionDef, - pub origin: String, - pub is_inherited: bool, -} - -impl Project { - pub fn accessible_functions<'a>(&'a self, contract: &'a ContractDef) -> Vec<AccessibleFunction<'a>> { - let mut seen: HashSet<String> = HashSet::new(); - let mut result: Vec<AccessibleFunction<'a>> = Vec::new(); - - for f in &contract.functions { - if f.name.is_empty() { continue; } - if seen.insert(f.name.clone()) { - result.push(AccessibleFunction { - function: f, - origin: contract.name.clone(), - is_inherited: false, - }); - } - } - - let mut visited: HashSet<String> = HashSet::new(); - let mut queue: Vec<&str> = contract.inherits.iter().map(|s| s.as_str()).collect(); - - while let Some(parent_name) = queue.pop() { - if !visited.insert(parent_name.to_string()) { continue; } - - let parent_idx = match self.contract_index.get(parent_name) { - Some(&i) => i, - None => continue, - }; - let parent = &self.contracts[parent_idx]; - - for f in &parent.functions { - if f.name.is_empty() { continue; } - if !matches!(f.visibility, Visibility::Public | Visibility::External) { continue; } - if seen.insert(f.name.clone()) { - result.push(AccessibleFunction { - function: f, - origin: parent.name.clone(), - is_inherited: true, - }); - } - } - - for grand in &parent.inherits { - queue.push(grand.as_str()); - } - } - - result - } - - pub fn resolve_function<'a>( - &'a self, - contract: &'a ContractDef, - func_name: &str, - ) -> Option<(&'a ContractDef, &'a FunctionDef)> { - let accessible = self.accessible_functions(contract); - for af in accessible { - if af.function.name == func_name { - if af.is_inherited { - return self.contracts.iter() - .find(|c| c.name == af.origin) - .map(|parent| (parent, af.function)); - } else { - return Some((contract, af.function)); - } - } - } - None - } - - pub fn accessible_state_vars(&self, contract: &ContractDef) -> Vec<(StateVar, String, bool)> { - let mut result: Vec<(StateVar, String, bool)> = contract - .state_vars - .iter() - .cloned() - .map(|sv| (sv, contract.name.clone(), false)) - .collect(); - - let mut seen_names: HashSet<String> = contract.state_vars.iter() - .map(|sv| sv.name.clone()) - .collect(); - - let mut visited: HashSet<String> = HashSet::new(); - let mut queue: Vec<&str> = contract.inherits.iter().map(|s| s.as_str()).collect(); - - while let Some(parent_name) = queue.pop() { - if !visited.insert(parent_name.to_string()) { continue; } - - let parent_idx = match self.contract_index.get(parent_name) { - Some(&i) => i, - None => continue, - }; - let parent = &self.contracts[parent_idx]; - - for sv in &parent.state_vars { - if seen_names.insert(sv.name.clone()) { - result.push((sv.clone(), parent.name.clone(), true)); - } - } - - for grand in &parent.inherits { - queue.push(grand.as_str()); - } - } - - result - } - - /// Resolve a modifier by name on `contract`, walking the inheritance chain. - /// Returns the first matching `ModifierDef` (current contract first, then - /// parents BFS). - pub fn resolve_modifier<'a>( - &'a self, - contract: &'a ContractDef, - mod_name: &str, - ) -> Option<&'a crate::model::modifier::ModifierDef> { - // Try current contract first - if let Some(m) = contract.modifiers.iter().find(|m| m.name == mod_name) { - return Some(m); - } - - // Walk inheritance chain BFS - let mut visited: HashSet<String> = HashSet::new(); - let mut queue: Vec<&str> = contract.inherits.iter().map(|s| s.as_str()).collect(); - - while let Some(parent_name) = queue.pop() { - if !visited.insert(parent_name.to_string()) { - continue; - } - - let parent_idx = match self.contract_index.get(parent_name) { - Some(&i) => i, - None => continue, - }; - let parent = &self.contracts[parent_idx]; - - if let Some(m) = parent.modifiers.iter().find(|m| m.name == mod_name) { - return Some(m); - } - - for grand in &parent.inherits { - queue.push(grand.as_str()); - } - } - - None - } - - pub fn inherited_state_vars(&self, contract: &ContractDef) -> Vec<StateVar> { - let mut result: Vec<StateVar> = contract.state_vars.clone(); - - let mut visited: HashSet<String> = HashSet::new(); - let mut queue: Vec<&str> = contract.inherits.iter().map(|s| s.as_str()).collect(); - - while let Some(parent_name) = queue.pop() { - if !visited.insert(parent_name.to_string()) { continue; } - - let parent_idx = match self.contract_index.get(parent_name) { - Some(&i) => i, - None => continue, - }; - let parent = &self.contracts[parent_idx]; - - result.extend(parent.state_vars.iter().cloned()); - - for grand in &parent.inherits { - queue.push(grand.as_str()); - } - } - - result - } -} diff --git a/crates/ilold-core/src/model/statement.rs b/crates/ilold-core/src/model/statement.rs deleted file mode 100644 index 460bcde..0000000 --- a/crates/ilold-core/src/model/statement.rs +++ /dev/null @@ -1,79 +0,0 @@ -use serde::{Deserialize, Serialize}; - -use super::common::{Param, SourceSpan}; -use super::expression::Expression; - -/// A single clause in a try/catch statement -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct CatchClause { - /// None for the success clause (returns), Some("Error"), Some("Panic"), or custom - pub name: Option<String>, - pub params: Vec<Param>, - pub body: Vec<Statement>, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Statement { - pub kind: StatementKind, - pub span: SourceSpan, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub enum StatementKind { - If { - condition: Expression, - then_body: Vec<Statement>, - else_body: Option<Vec<Statement>>, - }, - For { - init: Option<Box<Statement>>, - condition: Option<Expression>, - increment: Option<Expression>, - body: Vec<Statement>, - }, - While { - condition: Expression, - body: Vec<Statement>, - }, - DoWhile { - body: Vec<Statement>, - condition: Expression, - }, - Block { - statements: Vec<Statement>, - }, - UncheckedBlock { - statements: Vec<Statement>, - }, - Return { - value: Option<Expression>, - }, - Emit { - event_name: String, - arguments: Vec<Expression>, - }, - Revert { - error_name: Option<String>, - arguments: Vec<Expression>, - }, - ExpressionStmt { - expression: Expression, - }, - VariableDeclaration { - name: String, - type_name: String, - initial_value: Option<Expression>, - }, - /// try expr returns (...) { ok_body } catch Error(...) { err_body } catch (...) { ... } - TryCatch { - expression: Expression, - clauses: Vec<CatchClause>, - }, - Assembly { - span: SourceSpan, - }, - Break, - Continue, - /// The _ placeholder in modifiers - Placeholder, -} diff --git a/crates/ilold-core/src/narrative/formatter.rs b/crates/ilold-core/src/narrative/formatter.rs deleted file mode 100644 index afce27c..0000000 --- a/crates/ilold-core/src/narrative/formatter.rs +++ /dev/null @@ -1,153 +0,0 @@ -use std::fmt::Write; - -use crate::pathtree::types::TerminalKind; - -use super::types::*; - -pub fn function_to_markdown(n: &FunctionNarrative) -> String { - let mut md = String::new(); - - writeln!(md, "## {} ({})", n.name, n.access).unwrap(); - writeln!(md, "**Contract**: {} | **Paths**: {} ({} return, {} revert)", - n.contract, n.total_paths, n.happy_paths, n.revert_paths).unwrap(); - - if !n.modifiers.is_empty() { - writeln!(md, "**Modifiers**: {}", n.modifiers.join(", ")).unwrap(); - } - let has_writes = !n.state_writes.is_empty(); - let has_calls = !n.external_calls.is_empty(); - if has_writes && has_calls { - writeln!(md, "**Writes**: {} | **Calls**: {}", - n.state_writes.join(", "), n.external_calls.join(", ")).unwrap(); - } else if has_writes { - writeln!(md, "**Writes**: {}", n.state_writes.join(", ")).unwrap(); - } else if has_calls { - writeln!(md, "**Calls**: {}", n.external_calls.join(", ")).unwrap(); - } - - // Happy paths first, then revert paths - let mut happy: Vec<&PathNarrative> = n.paths.iter() - .filter(|p| p.terminal == TerminalKind::Return) - .collect(); - let mut revert: Vec<&PathNarrative> = n.paths.iter() - .filter(|p| p.terminal != TerminalKind::Return) - .collect(); - happy.sort_by_key(|p| p.id); - revert.sort_by_key(|p| p.id); - - for path in happy.iter().chain(revert.iter()) { - writeln!(md).unwrap(); - format_path(&mut md, path); - } - - if !n.observations.is_empty() { - writeln!(md).unwrap(); - writeln!(md, "### Observations").unwrap(); - for obs in &n.observations { - writeln!(md, "- **{}**: {}", obs.kind, obs.description).unwrap(); - } - } - - md -} - -pub fn sequence_to_markdown(n: &SequenceNarrative) -> String { - let mut md = String::new(); - - let names: Vec<&str> = n.steps.iter().map(|s| s.function.as_str()).collect(); - writeln!(md, "## Sequence: {}", names.join(" → ")).unwrap(); - writeln!(md, "**Contract**: {}", n.contract).unwrap(); - - for (i, step) in n.steps.iter().enumerate() { - writeln!(md).unwrap(); - writeln!(md, "### Step {} — {} ({})", i + 1, step.function, step.access).unwrap(); - - if !step.requires.is_empty() { - writeln!(md, "**Requires**: {}", step.requires.join(", ")).unwrap(); - } - if !step.effects.is_empty() { - writeln!(md, "**Effects**: {}", step.effects.join(", ")).unwrap(); - } - if !step.external_calls.is_empty() { - writeln!(md, "**Calls**: {}", step.external_calls.join(", ")).unwrap(); - } - if !step.events.is_empty() { - writeln!(md, "**Events**: {}", step.events.join(", ")).unwrap(); - } - - for dep in &step.dependencies { - writeln!(md, "- *Dependency*: {}", dep.relationship).unwrap(); - } - } - - if !n.observations.is_empty() { - writeln!(md).unwrap(); - writeln!(md, "### Observations").unwrap(); - for obs in &n.observations { - writeln!(md, "- **{}**: {}", obs.kind, obs.description).unwrap(); - } - } - - md -} - -pub fn overview_to_markdown( - contract_name: &str, - narratives: &[FunctionNarrative], -) -> String { - let mut md = String::new(); - - let total_paths: usize = narratives.iter().map(|n| n.total_paths).sum(); - let total_ext: usize = narratives.iter().map(|n| n.external_calls.len()).sum(); - writeln!(md, "## {} — {} functions, {} paths, {} external calls", - contract_name, narratives.len(), total_paths, total_ext).unwrap(); - - writeln!(md).unwrap(); - writeln!(md, "| Function | Access | Paths | Writes | Calls |").unwrap(); - writeln!(md, "|----------|--------|-------|--------|-------|").unwrap(); - - for n in narratives { - let writes = if n.state_writes.is_empty() { "—".into() } else { n.state_writes.join(", ") }; - let calls = if n.external_calls.is_empty() { "—".into() } else { n.external_calls.join(", ") }; - writeln!(md, "| {} | {} | {} ({}/{}R) | {} | {} |", - n.name, n.access, n.total_paths, n.happy_paths, n.revert_paths, - writes, calls).unwrap(); - } - - let all_obs: Vec<&Observation> = narratives.iter() - .flat_map(|n| n.observations.iter()) - .collect(); - - if !all_obs.is_empty() { - writeln!(md).unwrap(); - writeln!(md, "### Key Observations").unwrap(); - for obs in all_obs { - writeln!(md, "- **{}**: {}", obs.kind, obs.description).unwrap(); - } - } - - md -} - -fn format_path(md: &mut String, path: &PathNarrative) { - let terminal = match path.terminal { - TerminalKind::Return => "Return", - TerminalKind::Revert => "Revert", - TerminalKind::DepthCutoff => "DepthCutoff", - TerminalKind::LoopCutoff => "LoopCutoff", - }; - writeln!(md, "### Path #{} → {}", path.id, terminal).unwrap(); - - for (i, step) in path.steps.iter().enumerate() { - let branch_str = match step.branch { - Some(BranchDirection::True) => " [✓]", - Some(BranchDirection::False) => " [✗]", - None => "", - }; - write!(md, "{}. {} {}{}", i + 1, step.step_type.icon(), step.description, branch_str).unwrap(); - if let Some(detail) = &step.detail { - write!(md, " — {}", detail).unwrap(); - } - writeln!(md).unwrap(); - } -} diff --git a/crates/ilold-core/src/narrative/function.rs b/crates/ilold-core/src/narrative/function.rs deleted file mode 100644 index a5bf6f7..0000000 --- a/crates/ilold-core/src/narrative/function.rs +++ /dev/null @@ -1,356 +0,0 @@ -use std::collections::{HashMap, HashSet}; - -use crate::cfg::types::{BlockKind, BranchEdge, CfgGraph, CfgStatement}; -use crate::classify::entry_points::{classify_function, AccessLevel}; -use crate::model::contract::ContractDef; -use crate::model::function::FunctionDef; -use crate::model::project::Project; -use crate::narrative::types::TransitiveEffect; -use crate::pathtree::types::{PathTree, TerminalKind}; -use crate::sequence::analysis::{FunctionBehavior, SequenceAnalysis}; - -use super::types::*; - -pub fn build_function_narrative( - contract: &ContractDef, - function: &FunctionDef, - path_tree: &PathTree, - cfg: &CfgGraph, - all_behaviors: &[FunctionBehavior], - project: &Project, - all_sequence_analyses: &HashMap<String, SequenceAnalysis>, -) -> FunctionNarrative { - let access = classify_function(function, contract); - - let paths: Vec<PathNarrative> = path_tree - .paths - .iter() - .map(|path| { - let mut steps = Vec::new(); - - for node in &path.nodes { - let block = cfg - .node_indices() - .find(|&n| cfg[n].id == node.block_id) - .map(|n| &cfg[n]); - - let block = match block { - Some(b) => b, - None => continue, - }; - - let branch = branch_direction(&node.branch_taken); - - match block.kind { - BlockKind::Entry => { - steps.push(NarrativeStep { - step_type: StepType::Entry, - description: format!("{}()", function.name), - detail: None, - branch: None, - }); - } - BlockKind::Return => { - steps.push(NarrativeStep { - step_type: StepType::Return, - description: "return".into(), - detail: None, - branch, - }); - } - BlockKind::Revert => { - steps.push(NarrativeStep { - step_type: StepType::Revert, - description: "revert".into(), - detail: None, - branch, - }); - } - _ => { - for stmt in &block.statements { - if let Some(step) = statement_to_step(stmt, branch) { - steps.push(step); - } - } - } - } - } - - PathNarrative { - id: path.id, - terminal: path.terminal, - steps, - } - }) - .collect(); - - let observations = generate_observations( - &access, path_tree, cfg, all_behaviors, &function.name, - ); - - let mut writes = HashSet::new(); - let mut reads = HashSet::new(); - let mut calls = HashSet::new(); - let mut internal = HashSet::new(); - let mut events_set = HashSet::new(); - for p in &path_tree.paths { - for w in &p.annotations.state_writes { writes.insert(w.clone()); } - for r in &p.annotations.state_reads { reads.insert(r.clone()); } - for c in &p.annotations.external_calls { - calls.insert(format!("{}.{}", c.target, c.function)); - } - for ic in &p.annotations.internal_calls { - internal.insert(ic.clone()); - } - for ev in &p.annotations.events_emitted { - events_set.insert(ev.clone()); - } - } - - let mut writes: Vec<String> = writes.into_iter().collect(); - writes.sort(); - let mut reads: Vec<String> = reads.into_iter().collect(); - reads.sort(); - let mut calls: Vec<String> = calls.into_iter().collect(); - calls.sort(); - let mut internal: Vec<String> = internal.into_iter().collect(); - internal.sort(); - let mut events: Vec<String> = events_set.into_iter().collect(); - events.sort(); - - // Look up this function's behavior to get internal_calls - let root_behavior = all_behaviors.iter().find(|b| b.name == function.name); - - let (transitive_writes, transitive_reads, transitive_external, transitive_events) = - if let Some(root) = root_behavior { - collect_transitive_effects( - root, - contract, - project, - all_sequence_analyses, - &writes, - &reads, - &calls, - ) - } else { - (Vec::new(), Vec::new(), Vec::new(), Vec::new()) - }; - - FunctionNarrative { - contract: contract.name.clone(), - name: function.name.clone(), - access, - total_paths: path_tree.stats.total_paths, - happy_paths: path_tree.stats.happy_paths, - revert_paths: path_tree.stats.revert_paths, - paths, - observations, - state_writes: writes, - state_reads: reads, - external_calls: calls, - internal_calls: internal, - modifiers: function.modifiers.iter().map(|m| m.name.clone()).collect(), - events, - transitive_state_writes: transitive_writes, - transitive_state_reads: transitive_reads, - transitive_external_calls: transitive_external, - transitive_events, - } -} - -fn collect_transitive_effects( - root: &FunctionBehavior, - _root_contract: &ContractDef, - _project: &Project, - _all: &HashMap<String, SequenceAnalysis>, - _direct_writes: &[String], - _direct_reads: &[String], - _direct_external: &[String], -) -> (Vec<TransitiveEffect>, Vec<TransitiveEffect>, Vec<TransitiveEffect>, Vec<TransitiveEffect>) { - // Read pre-computed transitive sets from FunctionBehavior. - // Chain info is lost in this simpler form — will be restored in Phase 3. - fn map_to_effects(items: &[String]) -> Vec<TransitiveEffect> { - items - .iter() - .map(|item| TransitiveEffect { - via: vec!["(transitive)".to_string()], - item: item.clone(), - origin_contract: String::new(), - }) - .collect() - } - - ( - map_to_effects(&root.transitive_state_writes), - map_to_effects(&root.transitive_state_reads), - map_to_effects(&root.transitive_external_calls), - map_to_effects(&root.transitive_events), - ) -} - -fn statement_to_step(stmt: &CfgStatement, branch: Option<BranchDirection>) -> Option<NarrativeStep> { - match stmt { - CfgStatement::RequireCheck { condition, .. } => Some(NarrativeStep { - step_type: StepType::Condition, - description: format!("require({})", condition), - detail: None, - branch, - }), - CfgStatement::AssertCheck { condition, .. } => Some(NarrativeStep { - step_type: StepType::Condition, - description: format!("assert({})", condition), - detail: None, - branch, - }), - CfgStatement::ExternalCall { target, function, .. } => Some(NarrativeStep { - step_type: StepType::ExternalCall, - description: format!("{}.{}()", target, function), - detail: None, - branch: None, - }), - CfgStatement::InternalCall { function, .. } => Some(NarrativeStep { - step_type: StepType::InternalCall, - description: format!("{}()", function), - detail: None, - branch: None, - }), - CfgStatement::StateWrite { variable, .. } => Some(NarrativeStep { - step_type: StepType::StateWrite, - description: variable.clone(), - detail: None, - branch: None, - }), - CfgStatement::StateRead { variable, .. } => Some(NarrativeStep { - step_type: StepType::StateRead, - description: variable.clone(), - detail: None, - branch: None, - }), - CfgStatement::EthTransfer { to, .. } => Some(NarrativeStep { - step_type: StepType::EthTransfer, - description: format!("transfer ETH to {}", to), - detail: None, - branch: None, - }), - CfgStatement::EmitEvent { event, .. } => Some(NarrativeStep { - step_type: StepType::Event, - description: format!("emit {}", event), - detail: None, - branch: None, - }), - CfgStatement::AssemblyBlock { .. } => Some(NarrativeStep { - step_type: StepType::Assembly, - description: "assembly block".into(), - detail: None, - branch: None, - }), - CfgStatement::Assignment { .. } => None, - } -} - -fn branch_direction(edge: &Option<BranchEdge>) -> Option<BranchDirection> { - match edge { - Some(BranchEdge::ConditionalTrue { .. }) => Some(BranchDirection::True), - Some(BranchEdge::ConditionalFalse { .. }) => Some(BranchDirection::False), - _ => None, - } -} - -fn check_cei_violation( - path: &crate::pathtree::types::ExecutionPath, - cfg: &CfgGraph, -) -> Option<(String, String)> { - let mut seen_external_call: Option<String> = None; - - for node in &path.nodes { - let block = cfg - .node_indices() - .find(|&n| cfg[n].id == node.block_id) - .map(|n| &cfg[n]); - - let block = match block { - Some(b) => b, - None => continue, - }; - - for stmt in &block.statements { - match stmt { - CfgStatement::ExternalCall { target, function, .. } => { - if seen_external_call.is_none() { - seen_external_call = Some(format!("{}.{}", target, function)); - } - } - CfgStatement::StateWrite { variable, .. } => { - if let Some(call) = &seen_external_call { - return Some((call.clone(), variable.clone())); - } - } - _ => {} - } - } - } - None -} - -fn generate_observations( - access: &AccessLevel, - path_tree: &PathTree, - cfg: &CfgGraph, - all_behaviors: &[FunctionBehavior], - function_name: &str, -) -> Vec<Observation> { - let mut obs = Vec::new(); - - for path in &path_tree.paths { - if path.terminal == TerminalKind::Revert { continue; } - - if let Some((call, write)) = check_cei_violation(path, cfg) { - obs.push(Observation { - kind: ObservationKind::CeiViolation, - description: format!( - "Path #{}: {} called BEFORE writing {}", - path.id, call, write, - ), - }); - break; - } - } - - let my_writes: HashSet<&str> = path_tree - .paths - .iter() - .flat_map(|p| p.annotations.state_writes.iter().map(|s| s.as_str())) - .collect(); - - for behavior in all_behaviors { - if behavior.name == function_name { continue; } - let shared: Vec<&str> = behavior - .state_reads - .iter() - .filter(|r| my_writes.contains(r.as_str())) - .map(|s| s.as_str()) - .collect(); - if !shared.is_empty() { - obs.push(Observation { - kind: ObservationKind::SharedState, - description: format!( - "{} reads {} which this function writes", - behavior.name, - shared.join(", "), - ), - }); - } - } - - if access.is_unrestricted() && !my_writes.is_empty() { - obs.push(Observation { - kind: ObservationKind::NoAccessControl, - description: format!( - "Public function writes state ({}) without access control", - my_writes.into_iter().collect::<Vec<_>>().join(", "), - ), - }); - } - - obs -} diff --git a/crates/ilold-core/src/narrative/mod.rs b/crates/ilold-core/src/narrative/mod.rs deleted file mode 100644 index e0b68a1..0000000 --- a/crates/ilold-core/src/narrative/mod.rs +++ /dev/null @@ -1,5 +0,0 @@ -pub mod types; -pub mod function; -pub mod sequence; -pub mod formatter; -pub mod trace; diff --git a/crates/ilold-core/src/narrative/sequence.rs b/crates/ilold-core/src/narrative/sequence.rs deleted file mode 100644 index ccc38e4..0000000 --- a/crates/ilold-core/src/narrative/sequence.rs +++ /dev/null @@ -1,147 +0,0 @@ -use crate::classify::entry_points::AccessLevel; -use crate::sequence::analysis::{FunctionBehavior, TransitionInfo}; - -use super::trace::{FlowKind, FlowNode, FlowTree}; -use super::types::*; - -pub fn build_sequence_narrative( - contract_name: &str, - function_names: &[&str], - behaviors: &[FunctionBehavior], - transitions: &[TransitionInfo], - classifications: &[(String, AccessLevel)], -) -> SequenceNarrative { - let mut steps = Vec::new(); - let mut observations = Vec::new(); - - for (i, func_name) in function_names.iter().enumerate() { - let behavior = behaviors.iter().find(|b| b.name == *func_name); - let access = classifications - .iter() - .find(|(name, _)| name == func_name) - .map(|(_, a)| a.clone()) - .unwrap_or(AccessLevel::Public); - - let (requires, effects, ext_calls, events) = match behavior { - Some(b) => ( - b.preconditions.clone(), - b.state_writes.clone(), - b.external_calls.clone(), - b.events.clone(), - ), - None => (vec![], vec![], vec![], vec![]), - }; - - let mut deps = Vec::new(); - - for prev_idx in 0..i { - let prev_name = function_names[prev_idx]; - let transition = transitions - .iter() - .find(|t| t.from == prev_name && t.to == *func_name); - - if let Some(t) = transition { - for cond in &t.conditions_affected { - deps.push(Dependency { - from_step: prev_idx, - variable: extract_variable_from_condition(cond), - relationship: cond.clone(), - }); - } - } - } - - steps.push(SequenceStep { - function: func_name.to_string(), - access, - requires, - effects, - external_calls: ext_calls, - events, - dependencies: deps, - flow_summary: None, - }); - } - - for i in 0..steps.len() { - if steps[i].external_calls.is_empty() { continue; } - for j in (i + 1)..steps.len() { - if steps[j].dependencies.iter().any(|d| d.from_step == i) { - observations.push(Observation { - kind: ObservationKind::SharedState, - description: format!( - "Step {} ({}) makes external calls, step {} ({}) depends on its state — cross-step interaction", - i + 1, steps[i].function, - j + 1, steps[j].function, - ), - }); - } - } - } - - SequenceNarrative { - contract: contract_name.to_string(), - steps, - observations, - } -} - -fn extract_variable_from_condition(cond: &str) -> String { - // conditions_affected format: "deposit writes 'balances' → withdraw needs require(...)" - if let Some(start) = cond.find('\'') { - if let Some(end) = cond[start + 1..].find('\'') { - return cond[start + 1..start + 1 + end].to_string(); - } - } - String::new() -} - -/// Walk a persisted FlowTree and aggregate counts + mutation refs into -/// a compact `FlowSummary` for use in sequence narratives. -pub fn compute_flow_summary(tree: &FlowTree, session_step_index: usize) -> FlowSummary { - let mut summary = FlowSummary { - total_steps: 0, - mutation_count: 0, - external_call_count: 0, - internal_call_count: 0, - depth_limited_count: 0, - mutation_refs: Vec::new(), - }; - walk_for_summary(&tree.root, session_step_index, &mut summary); - summary -} - -fn walk_for_summary(node: &FlowNode, session_step_index: usize, summary: &mut FlowSummary) { - summary.total_steps += 1; - match &node.kind { - FlowKind::Write { target, .. } => { - summary.mutation_count += 1; - summary.mutation_refs.push(MutationRef { - variable: target.clone(), - flow_step_id: node.step_id, - session_step_index, - }); - } - FlowKind::StateWrite { variable } => { - summary.mutation_count += 1; - summary.mutation_refs.push(MutationRef { - variable: variable.clone(), - flow_step_id: node.step_id, - session_step_index, - }); - } - FlowKind::ExternalCall { .. } | FlowKind::EthTransfer { .. } => { - summary.external_call_count += 1; - } - FlowKind::InternalCall { depth_limited, .. } => { - summary.internal_call_count += 1; - if *depth_limited { - summary.depth_limited_count += 1; - } - } - _ => {} - } - for child in &node.children { - walk_for_summary(child, session_step_index, summary); - } -} diff --git a/crates/ilold-core/src/narrative/trace.rs b/crates/ilold-core/src/narrative/trace.rs deleted file mode 100644 index 70d9875..0000000 --- a/crates/ilold-core/src/narrative/trace.rs +++ /dev/null @@ -1,936 +0,0 @@ -// FlowTree builder: walks a function CFG and produces a temporal execution -// trace with inlined internal calls and per-arm branch walks. - -use std::collections::{HashMap, HashSet}; - -use petgraph::visit::EdgeRef; -use petgraph::Direction; -use serde::{Deserialize, Serialize}; - -use crate::cfg::types::{BlockKind, BranchEdge, CfgGraph, CfgStatement}; -use crate::model::contract::ContractDef; -use crate::model::expression::AssignOperator; -use crate::model::function::FunctionDef; -use crate::model::project::Project; - -/// Hard cap on the canonical walk's call-nesting depth. Cycle detection -/// via `visited_calls` is the primary safeguard; this is a second line of -/// defence against pathological cases and ensures termination. -const CANONICAL_WALK_SAFETY_CAP: usize = 32; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct FlowTree { - pub contract: String, - pub function: String, - pub signature: String, - pub modifiers: Vec<String>, - pub max_depth: usize, - pub root: FlowNode, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct FlowNode { - pub step_id: usize, - pub depth: usize, - pub kind: FlowKind, - #[serde(default)] - pub from_modifier: Option<String>, - pub children: Vec<FlowNode>, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub enum FlowKind { - Entry { - signature: String, - }, - Require { - condition: String, - message: Option<String>, - }, - Assert { - condition: String, - }, - Write { - target: String, - value: String, - op: AssignOperator, - }, - StateWrite { - variable: String, - }, - StateRead { - variable: String, - }, - InternalCall { - function: String, - origin: String, - depth_limited: bool, - ops_count: usize, - }, - ExternalCall { - target: String, - function: String, - }, - EmitEvent { - name: String, - }, - BranchTrue { - condition: String, - }, - BranchFalse { - condition: String, - }, - LoopHeader { - kind: String, - }, - Return, - Revert { - reason: Option<String>, - }, - EthTransfer { - to: String, - }, - AssemblyBlock, - DepthLimit, -} - -pub struct FlowConfig { - pub max_depth: usize, - pub include_reverts: bool, - pub expand_set: HashSet<usize>, -} - -impl Default for FlowConfig { - fn default() -> Self { - FlowConfig { - max_depth: 2, - include_reverts: false, - expand_set: HashSet::new(), - } - } -} - -/// A raw write event harvested from a FlowTree, decoupled from any -/// session-level mutation model. The caller filters and converts. -#[derive(Debug, Clone)] -pub struct FlowMutation { - pub flow_step_id: usize, - pub target: String, - pub value: String, - pub operator: AssignOperator, - pub via: Option<String>, -} - -pub fn build_flow_tree( - contract: &ContractDef, - function: &FunctionDef, - cfg: &CfgGraph, - project: &Project, - all_cfgs: &HashMap<(String, String), CfgGraph>, - config: &FlowConfig, -) -> FlowTree { - let canonical = build_canonical_tree(contract, function, cfg, project, all_cfgs, config); - filter_for_render(canonical, config) -} - -/// Build a FlowTree and harvest every write event in one canonical walk. -/// Mutations are harvested from the pre-filter tree so writes inside -/// depth-limited calls are still reported. -pub fn build_flow_tree_with_mutations( - contract: &ContractDef, - function: &FunctionDef, - cfg: &CfgGraph, - project: &Project, - all_cfgs: &HashMap<(String, String), CfgGraph>, - config: &FlowConfig, -) -> (FlowTree, Vec<FlowMutation>) { - let canonical = build_canonical_tree(contract, function, cfg, project, all_cfgs, config); - let mutations = harvest_mutations_from_tree(&canonical); - let rendered = filter_for_render(canonical, config); - (rendered, mutations) -} - -/// Internal: run the canonical walk with stable step_ids but no depth -/// filtering. Used by both `build_flow_tree` and `build_flow_tree_with_mutations`. -fn build_canonical_tree( - contract: &ContractDef, - function: &FunctionDef, - cfg: &CfgGraph, - project: &Project, - all_cfgs: &HashMap<(String, String), CfgGraph>, - config: &FlowConfig, -) -> FlowTree { - let signature = build_signature(function); - - let mut counter: usize = 0; - let mut root = FlowNode { - step_id: next_id(&mut counter), - depth: 0, - kind: FlowKind::Entry { signature: signature.clone() }, - from_modifier: None, - children: Vec::new(), - }; - - let mut visited_calls: HashSet<(String, String)> = HashSet::new(); - visited_calls.insert((contract.name.clone(), function.name.clone())); - - walk_cfg( - cfg, - contract, - project, - all_cfgs, - config, - 0, - &mut counter, - &mut visited_calls, - &mut root.children, - ); - - FlowTree { - contract: contract.name.clone(), - function: function.name.clone(), - signature, - modifiers: function.modifiers.iter().map(|m| m.name.clone()).collect(), - max_depth: config.max_depth, - root, - } -} - -/// Second pass over a canonical FlowTree: collapses internal calls whose -/// children exceed `config.max_depth`, unless their canonical `step_id` is -/// in `config.expand_set`. Step_ids on remaining nodes are preserved, so -/// references (e.g. `tr <func> +N`) stay valid across different configs. -fn filter_for_render(mut tree: FlowTree, config: &FlowConfig) -> FlowTree { - filter_node_children(&mut tree.root, config); - tree -} - -fn filter_node_children(node: &mut FlowNode, config: &FlowConfig) { - if let FlowKind::InternalCall { - ref mut depth_limited, - ref mut ops_count, - .. - } = node.kind - { - let children_depth = node.depth + 1; - let should_collapse = children_depth > config.max_depth - && !config.expand_set.contains(&node.step_id); - if should_collapse && !node.children.is_empty() { - *ops_count = count_subtree_nodes(&node.children); - *depth_limited = true; - node.children.clear(); - return; - } - } - - for child in &mut node.children { - filter_node_children(child, config); - } -} - -fn count_subtree_nodes(children: &[FlowNode]) -> usize { - let mut total = 0; - for child in children { - total += 1; - total += count_subtree_nodes(&child.children); - } - total -} - -/// Pre-order DFS over the tree collecting one FlowMutation per write. -/// Dedupes by (target, operator, value); first occurrence wins so the -/// flow_step_id is the earliest in pre-order. -fn harvest_mutations_from_tree(tree: &FlowTree) -> Vec<FlowMutation> { - let mut out = Vec::new(); - let mut seen: HashSet<(String, AssignOperator, String)> = HashSet::new(); - harvest_node(&tree.root, &[], &mut out, &mut seen); - out -} - -fn harvest_node( - node: &FlowNode, - call_chain: &[String], - out: &mut Vec<FlowMutation>, - seen: &mut HashSet<(String, AssignOperator, String)>, -) { - match &node.kind { - FlowKind::Write { target, value, op } => { - let key = (target.clone(), *op, value.clone()); - if seen.insert(key) { - out.push(FlowMutation { - flow_step_id: node.step_id, - target: target.clone(), - value: value.clone(), - operator: *op, - via: call_chain_via(call_chain), - }); - } - } - FlowKind::StateWrite { variable } => { - let key = (variable.clone(), AssignOperator::Assign, String::new()); - if seen.insert(key) { - out.push(FlowMutation { - flow_step_id: node.step_id, - target: variable.clone(), - value: String::new(), - operator: AssignOperator::Assign, - via: call_chain_via(call_chain), - }); - } - } - FlowKind::InternalCall { function, .. } => { - // Recurse with the extended chain, then early-return so the - // fallthrough loop below doesn't re-walk children. - let mut new_chain: Vec<String> = call_chain.to_vec(); - new_chain.push(function.clone()); - for child in &node.children { - harvest_node(child, &new_chain, out, seen); - } - return; - } - _ => {} - } - for child in &node.children { - harvest_node(child, call_chain, out, seen); - } -} - -fn call_chain_via(chain: &[String]) -> Option<String> { - if chain.is_empty() { - None - } else { - Some(chain.join(" -> ")) - } -} - -/// Find the chain of branch conditions that had to be true to reach the -/// node with `target_step_id` in the tree. Returns `None` if the target -/// is not present. -/// -/// `BranchTrue { cond }` contributes `cond`; `BranchFalse { cond }` -/// contributes `!(cond)`. The branch node's own conditions are NOT -/// included in its own path — they describe the path TO its children. -pub fn collect_path_conditions( - tree: &FlowTree, - target_step_id: usize, -) -> Option<Vec<String>> { - let mut path: Vec<String> = Vec::new(); - if collect_path_rec(&tree.root, target_step_id, &mut path) { - Some(path) - } else { - None - } -} - -fn collect_path_rec(node: &FlowNode, target: usize, path: &mut Vec<String>) -> bool { - if node.step_id == target { - return true; - } - - let pushed = match &node.kind { - FlowKind::BranchTrue { condition } => { - path.push(condition.clone()); - true - } - FlowKind::BranchFalse { condition } => { - path.push(format!("!({})", condition)); - true - } - _ => false, - }; - - for child in &node.children { - if collect_path_rec(child, target, path) { - return true; - } - } - - if pushed { - path.pop(); - } - false -} - -fn next_id(counter: &mut usize) -> usize { - let id = *counter; - *counter += 1; - id -} - -fn build_signature(function: &FunctionDef) -> String { - let params: Vec<String> = function - .params - .iter() - .map(|p| format!("{} {}", p.type_name, p.name)) - .collect(); - format!("{}({})", function.name, params.join(", ")) -} - -#[allow(clippy::too_many_arguments)] -fn walk_cfg( - cfg: &CfgGraph, - contract: &ContractDef, - project: &Project, - all_cfgs: &HashMap<(String, String), CfgGraph>, - config: &FlowConfig, - depth: usize, - counter: &mut usize, - visited_calls: &mut HashSet<(String, String)>, - out: &mut Vec<FlowNode>, -) { - let entry = match cfg - .node_indices() - .find(|&n| cfg[n].kind == BlockKind::Entry) - { - Some(n) => n, - None => return, - }; - - let mut visited_blocks: HashSet<petgraph::stable_graph::NodeIndex> = HashSet::new(); - walk_block( - cfg, - entry, - contract, - project, - all_cfgs, - config, - depth, - counter, - visited_calls, - &mut visited_blocks, - out, - ); -} - -#[allow(clippy::too_many_arguments)] -fn walk_block( - cfg: &CfgGraph, - node: petgraph::stable_graph::NodeIndex, - contract: &ContractDef, - project: &Project, - all_cfgs: &HashMap<(String, String), CfgGraph>, - config: &FlowConfig, - depth: usize, - counter: &mut usize, - visited_calls: &mut HashSet<(String, String)>, - visited_blocks: &mut HashSet<petgraph::stable_graph::NodeIndex>, - out: &mut Vec<FlowNode>, -) { - if !visited_blocks.insert(node) { - return; - } - - let block = &cfg[node]; - - match block.kind { - BlockKind::Return => { - out.push(FlowNode { - step_id: next_id(counter), - depth, - kind: FlowKind::Return, - from_modifier: None, - children: Vec::new(), - }); - return; - } - BlockKind::Revert => { - if config.include_reverts { - out.push(FlowNode { - step_id: next_id(counter), - depth, - kind: FlowKind::Revert { reason: None }, - from_modifier: None, - children: Vec::new(), - }); - } - return; - } - _ => {} - } - - for stmt in &block.statements { - if let Some(node) = stmt_to_flow_node( - stmt, - depth, - counter, - contract, - project, - all_cfgs, - config, - visited_calls, - ) { - out.push(node); - } - } - - let mut edges: Vec<(petgraph::stable_graph::NodeIndex, BranchEdge)> = cfg - .edges_directed(node, Direction::Outgoing) - .map(|e| (e.target(), e.weight().clone())) - .collect(); - - // Deterministic edge ordering so step_ids are stable across runs, - // independent of petgraph's iteration order. - edges.sort_by_key(|(target, edge)| (edge.variant_order(), target.index())); - - if edges.is_empty() { - return; - } - - if edges.len() == 1 { - walk_block( - cfg, - edges[0].0, - contract, - project, - all_cfgs, - config, - depth, - counter, - visited_calls, - visited_blocks, - out, - ); - return; - } - - // Drop arms going to Revert blocks (require/assert/if-then-revert). - let mut useful: Vec<(petgraph::stable_graph::NodeIndex, BranchEdge)> = Vec::new(); - for (target, edge) in edges { - if cfg[target].kind == BlockKind::Revert && !config.include_reverts { - continue; - } - useful.push((target, edge)); - } - - if useful.is_empty() { - return; - } - - // One useful arm: collapse require/assert into linear walk. - if useful.len() == 1 { - let (target, _edge) = useful.into_iter().next().unwrap(); - walk_block( - cfg, - target, - contract, - project, - all_cfgs, - config, - depth, - counter, - visited_calls, - visited_blocks, - out, - ); - return; - } - - // Real if/else: clone visited_blocks per arm so each branch is complete. - // Post-merge code is duplicated under each arm. - for (target, edge) in useful { - let mut arm_visited = visited_blocks.clone(); - let mut children = Vec::new(); - walk_block( - cfg, - target, - contract, - project, - all_cfgs, - config, - depth + 1, - counter, - visited_calls, - &mut arm_visited, - &mut children, - ); - - if children.is_empty() { - continue; - } - - let kind = match edge { - BranchEdge::ConditionalTrue { condition } => FlowKind::BranchTrue { condition }, - BranchEdge::ConditionalFalse { condition } => FlowKind::BranchFalse { condition }, - BranchEdge::ExternalCallSuccess => FlowKind::BranchTrue { - condition: "<call success>".to_string(), - }, - BranchEdge::ExternalCallFailure => FlowKind::BranchFalse { - condition: "<call failure>".to_string(), - }, - BranchEdge::CatchClause { kind: catch_kind } => FlowKind::BranchTrue { - condition: format!("catch {}", catch_kind), - }, - BranchEdge::LoopBack => FlowKind::LoopHeader { kind: "back".to_string() }, - BranchEdge::LoopExit => FlowKind::LoopHeader { kind: "exit".to_string() }, - BranchEdge::Unconditional => { - out.extend(children); - continue; - } - }; - out.push(FlowNode { - step_id: next_id(counter), - depth, - kind, - from_modifier: None, - children, - }); - } -} - -#[allow(clippy::too_many_arguments)] -fn stmt_to_flow_node( - stmt: &CfgStatement, - depth: usize, - counter: &mut usize, - contract: &ContractDef, - project: &Project, - all_cfgs: &HashMap<(String, String), CfgGraph>, - config: &FlowConfig, - visited_calls: &mut HashSet<(String, String)>, -) -> Option<FlowNode> { - match stmt { - CfgStatement::Assignment { target, value, operator, from_modifier, .. } => { - Some(FlowNode { - step_id: next_id(counter), - depth, - kind: FlowKind::Write { - target: target.clone(), - value: value.clone(), - op: *operator, - }, - from_modifier: from_modifier.clone(), - children: Vec::new(), - }) - } - CfgStatement::RequireCheck { condition, message, from_modifier, .. } => { - Some(FlowNode { - step_id: next_id(counter), - depth, - kind: FlowKind::Require { - condition: condition.clone(), - message: message.clone(), - }, - from_modifier: from_modifier.clone(), - children: Vec::new(), - }) - } - CfgStatement::AssertCheck { condition, from_modifier, .. } => { - Some(FlowNode { - step_id: next_id(counter), - depth, - kind: FlowKind::Assert { condition: condition.clone() }, - from_modifier: from_modifier.clone(), - children: Vec::new(), - }) - } - CfgStatement::InternalCall { function, from_modifier, .. } => { - Some(build_internal_call_node( - function, - from_modifier.clone(), - depth, - counter, - contract, - project, - all_cfgs, - config, - visited_calls, - )) - } - CfgStatement::ExternalCall { target, function, from_modifier, .. } => { - Some(FlowNode { - step_id: next_id(counter), - depth, - kind: FlowKind::ExternalCall { - target: target.clone(), - function: function.clone(), - }, - from_modifier: from_modifier.clone(), - children: Vec::new(), - }) - } - CfgStatement::EmitEvent { event, from_modifier, .. } => { - Some(FlowNode { - step_id: next_id(counter), - depth, - kind: FlowKind::EmitEvent { name: event.clone() }, - from_modifier: from_modifier.clone(), - children: Vec::new(), - }) - } - CfgStatement::StateWrite { variable, from_modifier, .. } => { - Some(FlowNode { - step_id: next_id(counter), - depth, - kind: FlowKind::StateWrite { variable: variable.clone() }, - from_modifier: from_modifier.clone(), - children: Vec::new(), - }) - } - CfgStatement::StateRead { variable, from_modifier, .. } => { - Some(FlowNode { - step_id: next_id(counter), - depth, - kind: FlowKind::StateRead { variable: variable.clone() }, - from_modifier: from_modifier.clone(), - children: Vec::new(), - }) - } - CfgStatement::EthTransfer { to, from_modifier, .. } => { - Some(FlowNode { - step_id: next_id(counter), - depth, - kind: FlowKind::EthTransfer { to: to.clone() }, - from_modifier: from_modifier.clone(), - children: Vec::new(), - }) - } - CfgStatement::AssemblyBlock { from_modifier, .. } => { - Some(FlowNode { - step_id: next_id(counter), - depth, - kind: FlowKind::AssemblyBlock, - from_modifier: from_modifier.clone(), - children: Vec::new(), - }) - } - } -} - -#[allow(clippy::too_many_arguments)] -fn build_internal_call_node( - callee_name: &str, - from_modifier: Option<String>, - depth: usize, - counter: &mut usize, - contract: &ContractDef, - project: &Project, - all_cfgs: &HashMap<(String, String), CfgGraph>, - config: &FlowConfig, - visited_calls: &mut HashSet<(String, String)>, -) -> FlowNode { - let resolved = resolve_callee_cfg(callee_name, contract, project, all_cfgs); - - let (owning_name, callee_cfg) = match resolved { - Some(x) => x, - None => { - return FlowNode { - step_id: next_id(counter), - depth, - kind: FlowKind::InternalCall { - function: callee_name.to_string(), - origin: String::new(), - depth_limited: false, - ops_count: 0, - }, - from_modifier, - children: Vec::new(), - }; - } - }; - - let call_key = (owning_name.clone(), callee_name.to_string()); - let already_visited = visited_calls.contains(&call_key); - // Canonical walk always inlines up to the safety cap. Depth-based - // pruning is applied by `filter_for_render` as a second pass so that - // step_ids remain stable across different max_depth / expand_set values. - let depth_exhausted = depth + 1 > CANONICAL_WALK_SAFETY_CAP; - - if already_visited || depth_exhausted { - let ops_count = count_statements(callee_cfg); - return FlowNode { - step_id: next_id(counter), - depth, - kind: FlowKind::InternalCall { - function: callee_name.to_string(), - origin: owning_name, - depth_limited: true, - ops_count, - }, - from_modifier, - children: Vec::new(), - }; - } - - visited_calls.insert(call_key.clone()); - - let owning_contract = project - .contracts - .iter() - .find(|c| c.name == owning_name) - .unwrap_or(contract); - - let mut children = Vec::new(); - walk_cfg( - callee_cfg, - owning_contract, - project, - all_cfgs, - config, - depth + 1, - counter, - visited_calls, - &mut children, - ); - - // Pop the call stack so sibling calls to the same function still inline. - visited_calls.remove(&call_key); - - let ops_count = children.len(); - - FlowNode { - step_id: next_id(counter), - depth, - kind: FlowKind::InternalCall { - function: callee_name.to_string(), - origin: owning_name, - depth_limited: false, - ops_count, - }, - from_modifier, - children, - } -} - -fn resolve_callee_cfg<'a>( - callee: &str, - starting_contract: &ContractDef, - project: &Project, - all_cfgs: &'a HashMap<(String, String), CfgGraph>, -) -> Option<(String, &'a CfgGraph)> { - let key = (starting_contract.name.clone(), callee.to_string()); - if let Some(cfg) = all_cfgs.get(&key) { - return Some((starting_contract.name.clone(), cfg)); - } - - let mut visited: HashSet<String> = HashSet::new(); - let mut queue: Vec<String> = starting_contract.inherits.clone(); - - while let Some(parent_name) = queue.pop() { - if !visited.insert(parent_name.clone()) { - continue; - } - - let key = (parent_name.clone(), callee.to_string()); - if let Some(cfg) = all_cfgs.get(&key) { - return Some((parent_name, cfg)); - } - - if let Some(parent_idx) = project.contract_index.get(&parent_name) { - let parent = &project.contracts[*parent_idx]; - for grand in &parent.inherits { - queue.push(grand.clone()); - } - } - } - - None -} - -fn count_statements(cfg: &CfgGraph) -> usize { - cfg.node_indices() - .map(|n| cfg[n].statements.len()) - .sum() -} - -#[cfg(test)] -mod tests { - use super::*; - - fn leaf(step_id: usize, kind: FlowKind) -> FlowNode { - FlowNode { - step_id, - depth: 0, - kind, - from_modifier: None, - children: Vec::new(), - } - } - - fn parent(step_id: usize, kind: FlowKind, children: Vec<FlowNode>) -> FlowNode { - FlowNode { - step_id, - depth: 0, - kind, - from_modifier: None, - children, - } - } - - /// Build a synthetic tree: - /// Entry (0) - /// ├── BranchTrue("a > 0") (1) - /// │ └── BranchFalse("b > 0") (2) - /// │ └── Write reserve0 (3) - /// └── BranchFalse("a > 0") (4) - /// └── Write balance (5) - fn fixture_tree() -> FlowTree { - let inner_write = leaf(3, FlowKind::Write { - target: "reserve0".into(), - value: "x".into(), - op: AssignOperator::Assign, - }); - let bf_inner = parent(2, FlowKind::BranchFalse { condition: "b > 0".into() }, vec![inner_write]); - let bt_outer = parent(1, FlowKind::BranchTrue { condition: "a > 0".into() }, vec![bf_inner]); - - let other_write = leaf(5, FlowKind::Write { - target: "balance".into(), - value: "y".into(), - op: AssignOperator::Assign, - }); - let bf_outer = parent(4, FlowKind::BranchFalse { condition: "a > 0".into() }, vec![other_write]); - - let root = parent( - 0, - FlowKind::Entry { signature: "f()".into() }, - vec![bt_outer, bf_outer], - ); - - FlowTree { - contract: "C".into(), - function: "f".into(), - signature: "f()".into(), - modifiers: Vec::new(), - max_depth: 4, - root, - } - } - - #[test] - fn collect_path_conditions_inside_nested_branches() { - let tree = fixture_tree(); - // Step 3 lives inside BranchTrue(a>0) → BranchFalse(b>0) → Write - let path = collect_path_conditions(&tree, 3).expect("step 3 should be reachable"); - assert_eq!(path, vec!["a > 0".to_string(), "!(b > 0)".to_string()]); - } - - #[test] - fn collect_path_conditions_in_other_branch() { - let tree = fixture_tree(); - // Step 5 lives inside BranchFalse(a>0) → Write - let path = collect_path_conditions(&tree, 5).expect("step 5 should be reachable"); - assert_eq!(path, vec!["!(a > 0)".to_string()]); - } - - #[test] - fn collect_path_conditions_root_returns_empty_path() { - let tree = fixture_tree(); - // Root itself has step_id 0 — no conditions on the path TO the root. - let path = collect_path_conditions(&tree, 0).expect("root should be reachable"); - assert!(path.is_empty()); - } - - #[test] - fn collect_path_conditions_branch_node_itself() { - let tree = fixture_tree(); - // Step 2 IS the BranchFalse(b>0) node. Its own condition is NOT - // included; only the parent BranchTrue(a>0) is. - let path = collect_path_conditions(&tree, 2).expect("step 2 should be reachable"); - assert_eq!(path, vec!["a > 0".to_string()]); - } - - #[test] - fn collect_path_conditions_returns_none_for_unknown_step() { - let tree = fixture_tree(); - assert!(collect_path_conditions(&tree, 9999).is_none()); - } -} diff --git a/crates/ilold-core/src/narrative/types.rs b/crates/ilold-core/src/narrative/types.rs deleted file mode 100644 index 4c9fd3c..0000000 --- a/crates/ilold-core/src/narrative/types.rs +++ /dev/null @@ -1,163 +0,0 @@ -use serde::{Deserialize, Serialize}; -use std::fmt; - -use crate::classify::entry_points::AccessLevel; -use crate::pathtree::types::TerminalKind; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct FunctionNarrative { - pub contract: String, - pub name: String, - pub access: AccessLevel, - pub total_paths: usize, - pub happy_paths: usize, - pub revert_paths: usize, - pub paths: Vec<PathNarrative>, - pub observations: Vec<Observation>, - pub state_writes: Vec<String>, - pub state_reads: Vec<String>, - pub external_calls: Vec<String>, - #[serde(default)] - pub internal_calls: Vec<String>, - pub modifiers: Vec<String>, - #[serde(default)] - pub events: Vec<String>, - #[serde(default)] - pub transitive_state_writes: Vec<TransitiveEffect>, - #[serde(default)] - pub transitive_state_reads: Vec<TransitiveEffect>, - #[serde(default)] - pub transitive_external_calls: Vec<TransitiveEffect>, - #[serde(default)] - pub transitive_events: Vec<TransitiveEffect>, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct TransitiveEffect { - pub via: Vec<String>, - pub item: String, - pub origin_contract: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct PathNarrative { - pub id: usize, - pub terminal: TerminalKind, - pub steps: Vec<NarrativeStep>, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct NarrativeStep { - pub step_type: StepType, - pub description: String, - pub detail: Option<String>, - pub branch: Option<BranchDirection>, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum StepType { - Entry, - Condition, - StateWrite, - StateRead, - ExternalCall, - InternalCall, - EthTransfer, - Event, - Return, - Revert, - Assembly, -} - -impl StepType { - pub fn icon(&self) -> &str { - match self { - StepType::Entry => "▶", - StepType::Condition => "▸", - StepType::StateWrite => "✏", - StepType::StateRead => "◇", - StepType::ExternalCall => "→", - StepType::InternalCall => "○", - StepType::EthTransfer => "Ξ", - StepType::Event => "◆", - StepType::Return => "✓", - StepType::Revert => "✗", - StepType::Assembly => "⚙", - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum BranchDirection { - True, - False, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Observation { - pub kind: ObservationKind, - pub description: String, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub enum ObservationKind { - CeiViolation, - SharedState, - NoAccessControl, - ExternalCallRisk, -} - -impl fmt::Display for ObservationKind { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - match self { - ObservationKind::CeiViolation => write!(f, "CEI violation"), - ObservationKind::SharedState => write!(f, "Shared state"), - ObservationKind::NoAccessControl => write!(f, "No access control"), - ObservationKind::ExternalCallRisk => write!(f, "External call"), - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SequenceNarrative { - pub contract: String, - pub steps: Vec<SequenceStep>, - pub observations: Vec<Observation>, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SequenceStep { - pub function: String, - pub access: AccessLevel, - pub requires: Vec<String>, - pub effects: Vec<String>, - pub external_calls: Vec<String>, - pub events: Vec<String>, - pub dependencies: Vec<Dependency>, - #[serde(default)] - pub flow_summary: Option<FlowSummary>, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Dependency { - pub from_step: usize, - pub variable: String, - pub relationship: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct FlowSummary { - pub total_steps: usize, - pub mutation_count: usize, - pub external_call_count: usize, - pub internal_call_count: usize, - pub depth_limited_count: usize, - pub mutation_refs: Vec<MutationRef>, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct MutationRef { - pub variable: String, - pub flow_step_id: usize, - pub session_step_index: usize, -} diff --git a/crates/ilold-core/src/parse/error.rs b/crates/ilold-core/src/parse/error.rs deleted file mode 100644 index 4831b5a..0000000 --- a/crates/ilold-core/src/parse/error.rs +++ /dev/null @@ -1,26 +0,0 @@ -use crate::model::common::SourceSpan; - -#[derive(Debug, thiserror::Error)] -pub enum ParseError { - #[error("Failed to parse {path}: {message}")] - SyntaxError { - path: String, - message: String, - span: Option<SourceSpan>, - }, - - #[error("Unsupported Solidity version in {path}: requires 0.8.x or higher")] - UnsupportedVersion { path: String }, - - #[error("File not found: {path}")] - FileNotFound { path: String }, - - #[error("Import resolution failed: {import_path} (from {source_path})")] - ImportResolution { - import_path: String, - source_path: String, - }, - - #[error("Internal parser error: {message}")] - Internal { message: String }, -} diff --git a/crates/ilold-core/src/parse/mod.rs b/crates/ilold-core/src/parse/mod.rs deleted file mode 100644 index 38b049e..0000000 --- a/crates/ilold-core/src/parse/mod.rs +++ /dev/null @@ -1,14 +0,0 @@ -pub mod error; -pub mod solar_frontend; - -use std::path::PathBuf; - -use crate::model::project::Project; -use error::ParseError; - -/// Abstraction over the concrete parser implementation. -/// Enables swapping solar-parse for another backend (e.g. solang-parser) -/// without changing any downstream code. -pub trait ProjectParser { - fn parse(&self, paths: &[PathBuf]) -> Result<Project, ParseError>; -} diff --git a/crates/ilold-core/src/parse/solar_frontend.rs b/crates/ilold-core/src/parse/solar_frontend.rs deleted file mode 100644 index 2b1c583..0000000 --- a/crates/ilold-core/src/parse/solar_frontend.rs +++ /dev/null @@ -1,826 +0,0 @@ -use std::path::{Path, PathBuf}; - -use solar::ast::{self, ExprKind, ItemKind, LitKind, StmtKind}; -use solar::interface::{ColorChoice, Session}; -use solar::parse::Parser; - -use crate::model::common::*; -use crate::model::contract::*; -use crate::model::expression::*; -use crate::model::function::*; -use crate::model::modifier::*; -use crate::model::project::*; -use crate::model::statement::{CatchClause, Statement, StatementKind}; - -use super::error::ParseError; -use super::ProjectParser; - -pub struct SolarParser; - -impl ProjectParser for SolarParser { - fn parse(&self, paths: &[PathBuf]) -> Result<Project, ParseError> { - let mut all_contracts = Vec::new(); - let mut source_files = Vec::new(); - - for path in paths { - if !path.exists() { - return Err(ParseError::FileNotFound { - path: path.display().to_string(), - }); - } - - let src = std::fs::read_to_string(path).map_err(|e| ParseError::Internal { - message: format!("Failed to read {}: {e}", path.display()), - })?; - - let file_index = source_files.len(); - // Canonicalize to absolute so downstream consumers (the canvas - // source viewer → `vscode://` deep link) can open the file - // regardless of the user's cwd. Falls back to the original - // display path on error (e.g. network filesystems) so the - // parse never fails over a cosmetic concern. - let stored_path = std::fs::canonicalize(path) - .map(|p| p.display().to_string()) - .unwrap_or_else(|_| path.display().to_string()); - source_files.push(SourceFile { - path: stored_path, - content: src.clone(), - }); - - let contracts = parse_single_file(path, &src, file_index)?; - all_contracts.extend(contracts); - } - - let mut project = Project { - source_files, - contracts: all_contracts, - contract_index: Default::default(), - }; - project.rebuild_index(); - Ok(project) - } -} - -fn parse_single_file( - path: &Path, - src: &str, - file_index: usize, -) -> Result<Vec<ContractDef>, ParseError> { - let sess = Session::builder() - .with_buffer_emitter(ColorChoice::Never) - .build(); - - let mut contracts = Vec::new(); - - let result = sess.enter(|| -> Result<(), ParseError> { - let arena = ast::Arena::new(); - let mut parser = Parser::from_source_code( - &sess, - &arena, - solar::interface::source_map::FileName::Real(path.to_path_buf()), - src.to_string(), - ) - .map_err(|_| ParseError::SyntaxError { - path: path.display().to_string(), - message: "Failed to initialize parser".into(), - span: None, - })?; - - let source_unit = parser.parse_file().map_err(|e| { - e.emit(); - ParseError::SyntaxError { - path: path.display().to_string(), - message: "Syntax error".into(), - span: None, - } - })?; - - // Solar's BytePos starts at 1 (not 0). For multi-file parsing, each file - // has an offset. We detect it from the source_map. - let base_offset = sess.source_map() - .files() - .first() - .map(|f| f.start_pos.to_usize()) - .unwrap_or(1); - - let mapper = SpanMapper::new(file_index, src, base_offset); - - for item in source_unit.items.iter() { - if let ItemKind::Contract(ref contract) = item.kind { - contracts.push(convert_contract(contract, &mapper)); - } - } - - Ok(()) - }); - - result?; - Ok(contracts) -} - -// ============================================================================ -// Contract -// ============================================================================ - -fn convert_contract(contract: &ast::ItemContract<'_>, mapper: &SpanMapper) -> ContractDef { - let mut functions = Vec::new(); - let mut modifiers = Vec::new(); - let mut state_vars = Vec::new(); - let mut structs = Vec::new(); - let mut enums = Vec::new(); - let mut events = Vec::new(); - let mut errors = Vec::new(); - - for item in contract.body.iter() { - match &item.kind { - ItemKind::Function(f) => { - if f.kind == ast::FunctionKind::Modifier { - modifiers.push(convert_modifier_def(f, mapper)); - } else { - functions.push(convert_function(f, mapper)); - } - } - ItemKind::Variable(v) => { - state_vars.push(convert_state_var(v, mapper)); - } - ItemKind::Struct(s) => { - structs.push(convert_struct(s, mapper)); - } - ItemKind::Enum(e) => { - enums.push(convert_enum(e, mapper)); - } - ItemKind::Event(ev) => { - events.push(convert_event(ev, mapper)); - } - ItemKind::Error(er) => { - errors.push(convert_error(er, mapper)); - } - _ => {} - } - } - - let inherits: Vec<String> = contract - .bases - .iter() - .map(|base| path_to_string(&base.name)) - .collect(); - - ContractDef { - name: contract.name.as_str().to_string(), - kind: convert_contract_kind(contract.kind), - functions, - modifiers, - state_vars, - structs, - enums, - events, - errors, - inherits, - span: mapper.convert(contract.name.span), - } -} - -fn convert_contract_kind(kind: ast::ContractKind) -> ContractKind { - match kind { - ast::ContractKind::Contract => ContractKind::Contract, - ast::ContractKind::Interface => ContractKind::Interface, - ast::ContractKind::Library => ContractKind::Library, - ast::ContractKind::AbstractContract => ContractKind::Abstract, - } -} - -// ============================================================================ -// Functions and modifiers -// ============================================================================ - -fn convert_function(f: &ast::ItemFunction<'_>, mapper: &SpanMapper) -> FunctionDef { - let name = f - .header - .name - .as_ref() - .map(|n| n.as_str().to_string()) - .unwrap_or_default(); - - let kind = match f.kind { - ast::FunctionKind::Constructor => FunctionKind::Constructor, - ast::FunctionKind::Fallback => FunctionKind::Fallback, - ast::FunctionKind::Receive => FunctionKind::Receive, - _ => FunctionKind::Function, - }; - - // FunctionHeader has helper methods that extract from Spanned - let visibility = f - .header - .visibility() - .map(convert_visibility) - .unwrap_or(Visibility::Internal); - - let mutability = convert_mutability(f.header.state_mutability()); - - let modifiers: Vec<ModifierRef> = f - .header - .modifiers - .iter() - .map(|m| convert_modifier_ref(m, mapper)) - .collect(); - - let params = convert_param_list(&f.header.parameters); - let returns = convert_returns(&f.header); - let body = f - .body - .as_ref() - .map(|block| convert_block_stmts(block, mapper)); - - // Span covers the full function (signature + body) so consumers like - // the canvas source viewer can slice the complete declaration. When - // there's no body (interfaces, abstract) the header span is all we - // have and stays correct. - let header_span = mapper.convert(f.header.span); - let span = match f.body.as_ref().and_then(|b| b.last()) { - Some(last_stmt) => { - let end = mapper.convert(last_stmt.span); - crate::model::common::SourceSpan { - file_index: header_span.file_index, - start_line: header_span.start_line, - start_col: header_span.start_col, - end_line: end.end_line, - end_col: end.end_col, - } - } - None => header_span, - }; - - FunctionDef { - name, - kind, - visibility, - mutability, - modifiers, - params, - returns, - body, - is_virtual: f.header.virtual_(), - is_override: f.header.override_.is_some(), - span, - } -} - -fn convert_modifier_def(f: &ast::ItemFunction<'_>, mapper: &SpanMapper) -> ModifierDef { - let name = f - .header - .name - .as_ref() - .map(|n| n.as_str().to_string()) - .unwrap_or_default(); - - let params = convert_param_list(&f.header.parameters); - let body = f - .body - .as_ref() - .map(|block| convert_block_stmts(block, mapper)) - .unwrap_or_default(); - - ModifierDef { - name, - params, - body, - span: mapper.convert(f.header.span), - } -} - -fn convert_modifier_ref(m: &ast::Modifier<'_>, mapper: &SpanMapper) -> ModifierRef { - let name = path_to_string(&m.name); - let arguments: Vec<Expression> = m.arguments.exprs().map(|e| convert_expression(e, mapper)).collect(); - ModifierRef { name, arguments } -} - -fn convert_visibility(v: ast::Visibility) -> Visibility { - match v { - ast::Visibility::Public => Visibility::Public, - ast::Visibility::External => Visibility::External, - ast::Visibility::Internal => Visibility::Internal, - ast::Visibility::Private => Visibility::Private, - } -} - -fn convert_mutability(m: ast::StateMutability) -> Mutability { - match m { - ast::StateMutability::Pure => Mutability::Pure, - ast::StateMutability::View => Mutability::View, - ast::StateMutability::Payable => Mutability::Payable, - ast::StateMutability::NonPayable => Mutability::NonPayable, - } -} - -fn convert_param_list(params: &ast::ParameterList<'_>) -> Vec<Param> { - params - .iter() - .map(|p| Param { - name: p - .name - .as_ref() - .map(|n| n.as_str().to_string()) - .unwrap_or_default(), - type_name: type_to_string(&p.ty), - }) - .collect() -} - -fn convert_returns(header: &ast::FunctionHeader<'_>) -> Vec<Param> { - header - .returns() - .iter() - .map(|p| Param { - name: p - .name - .as_ref() - .map(|n| n.as_str().to_string()) - .unwrap_or_default(), - type_name: type_to_string(&p.ty), - }) - .collect() -} - -// ============================================================================ -// State vars, structs, enums, events, errors -// ============================================================================ - -fn convert_state_var(v: &ast::VariableDefinition<'_>, mapper: &SpanMapper) -> StateVar { - let visibility = v - .visibility - .map(convert_visibility) - .unwrap_or(Visibility::Internal); - - let is_constant = v - .mutability - .map(|m| m == ast::VarMut::Constant) - .unwrap_or(false); - - let is_immutable = v - .mutability - .map(|m| m == ast::VarMut::Immutable) - .unwrap_or(false); - - let initial_value = v - .initializer - .as_ref() - .map(|expr| convert_expression(expr, mapper)); - - StateVar { - name: v - .name - .as_ref() - .map(|n| n.as_str().to_string()) - .unwrap_or_default(), - type_name: type_to_string(&v.ty), - visibility, - is_constant, - is_immutable, - initial_value, - span: mapper.convert(v.span), - } -} - -fn convert_struct(s: &ast::ItemStruct<'_>, mapper: &SpanMapper) -> StructDef { - let fields = s - .fields - .iter() - .map(|f| Param { - name: f - .name - .as_ref() - .map(|n| n.as_str().to_string()) - .unwrap_or_default(), - type_name: type_to_string(&f.ty), - }) - .collect(); - - StructDef { - name: s.name.as_str().to_string(), - fields, - span: mapper.convert(s.name.span), - } -} - -fn convert_enum(e: &ast::ItemEnum<'_>, mapper: &SpanMapper) -> EnumDef { - EnumDef { - name: e.name.as_str().to_string(), - variants: e.variants.iter().map(|v| v.as_str().to_string()).collect(), - span: mapper.convert(e.name.span), - } -} - -fn convert_event(ev: &ast::ItemEvent<'_>, mapper: &SpanMapper) -> EventDef { - let params = ev - .parameters - .iter() - .map(|p| Param { - name: p - .name - .as_ref() - .map(|n| n.as_str().to_string()) - .unwrap_or_default(), - type_name: type_to_string(&p.ty), - }) - .collect(); - - EventDef { - name: ev.name.as_str().to_string(), - params, - span: mapper.convert(ev.name.span), - } -} - -fn convert_error(er: &ast::ItemError<'_>, mapper: &SpanMapper) -> ErrorDef { - let params = er - .parameters - .iter() - .map(|p| Param { - name: p - .name - .as_ref() - .map(|n| n.as_str().to_string()) - .unwrap_or_default(), - type_name: type_to_string(&p.ty), - }) - .collect(); - - ErrorDef { - name: er.name.as_str().to_string(), - params, - span: mapper.convert(er.name.span), - } -} - -// ============================================================================ -// Statements -// ============================================================================ - -fn convert_block_stmts(block: &ast::Block<'_>, mapper: &SpanMapper) -> Vec<Statement> { - block - .stmts - .iter() - .map(|s| convert_statement(s, mapper)) - .collect() -} - -fn convert_statement(stmt: &ast::Stmt<'_>, mapper: &SpanMapper) -> Statement { - let kind = match &stmt.kind { - StmtKind::If(condition, then_stmt, else_stmt) => StatementKind::If { - condition: convert_expression(condition, mapper), - then_body: wrap_stmt_as_body(then_stmt, mapper), - else_body: else_stmt - .as_ref() - .map(|s| wrap_stmt_as_body(s, mapper)), - }, - - StmtKind::For { init, cond, next, body } => StatementKind::For { - init: init - .as_ref() - .map(|s| Box::new(convert_statement(s, mapper))), - condition: cond - .as_ref() - .map(|e| convert_expression(e, mapper)), - increment: next - .as_ref() - .map(|e| convert_expression(e, mapper)), - body: wrap_stmt_as_body(body, mapper), - }, - - StmtKind::While(condition, body) => StatementKind::While { - condition: convert_expression(condition, mapper), - body: wrap_stmt_as_body(body, mapper), - }, - - StmtKind::DoWhile(body, condition) => StatementKind::DoWhile { - body: wrap_stmt_as_body(body, mapper), - condition: convert_expression(condition, mapper), - }, - - StmtKind::Block(block) => StatementKind::Block { - statements: convert_block_stmts(block, mapper), - }, - - StmtKind::UncheckedBlock(block) => StatementKind::UncheckedBlock { - statements: convert_block_stmts(block, mapper), - }, - - StmtKind::Return(value) => StatementKind::Return { - value: value.as_ref().map(|e| convert_expression(e, mapper)), - }, - - StmtKind::Emit(path, args) => StatementKind::Emit { - event_name: path_to_string(path), - arguments: args.exprs().map(|e| convert_expression(e, mapper)).collect(), - }, - - StmtKind::Revert(path, args) => StatementKind::Revert { - error_name: Some(path_to_string(path)), - arguments: args.exprs().map(|e| convert_expression(e, mapper)).collect(), - }, - - StmtKind::Expr(expr) => StatementKind::ExpressionStmt { - expression: convert_expression(expr, mapper), - }, - - StmtKind::DeclSingle(var) => StatementKind::VariableDeclaration { - name: var - .name - .as_ref() - .map(|n| n.as_str().to_string()) - .unwrap_or_default(), - type_name: type_to_string(&var.ty), - initial_value: var - .initializer - .as_ref() - .map(|e| convert_expression(e, mapper)), - }, - - StmtKind::Try(try_stmt) => { - let expression = convert_expression(&try_stmt.expr, mapper); - let clauses = try_stmt - .clauses - .iter() - .map(|c| CatchClause { - name: c.name.as_ref().map(|n| n.as_str().to_string()), - params: convert_param_list(&c.args), - body: convert_block_stmts(&c.block, mapper), - }) - .collect(); - StatementKind::TryCatch { expression, clauses } - } - - StmtKind::DeclMulti(vars, expr) => { - // (uint a, uint b) = foo() — we store the whole thing as a single - // VariableDeclaration with a combined name for simplicity in v1. - let names: Vec<String> = vars - .iter() - .map(|v| match v { - solar::interface::SpannedOption::Some(var) => var - .name - .as_ref() - .map(|n: &ast::Ident| n.as_str().to_string()) - .unwrap_or("_".into()), - solar::interface::SpannedOption::None(_) => "_".into(), - }) - .collect(); - StatementKind::VariableDeclaration { - name: format!("({})", names.join(", ")), - type_name: "tuple".into(), - initial_value: Some(convert_expression(expr, mapper)), - } - } - - StmtKind::Assembly(..) => StatementKind::Assembly { - span: mapper.convert(stmt.span), - }, - - StmtKind::Break => StatementKind::Break, - StmtKind::Continue => StatementKind::Continue, - StmtKind::Placeholder => StatementKind::Placeholder, - }; - - Statement { - kind, - span: mapper.convert(stmt.span), - } -} - -fn wrap_stmt_as_body(stmt: &ast::Stmt<'_>, mapper: &SpanMapper) -> Vec<Statement> { - match &stmt.kind { - StmtKind::Block(block) => convert_block_stmts(block, mapper), - _ => vec![convert_statement(stmt, mapper)], - } -} - -// ============================================================================ -// Expressions -// ============================================================================ - -fn convert_expression(expr: &ast::Expr<'_>, mapper: &SpanMapper) -> Expression { - let kind = match &expr.kind { - ExprKind::Call(callee, args) => ExpressionKind::FunctionCall { - callee: Box::new(convert_expression(callee, mapper)), - arguments: args.exprs().map(|e| convert_expression(e, mapper)).collect(), - }, - - ExprKind::CallOptions(callee, _options) => ExpressionKind::FunctionCall { - callee: Box::new(convert_expression(callee, mapper)), - arguments: Vec::new(), - }, - - ExprKind::Member(object, member) => ExpressionKind::MemberAccess { - object: Box::new(convert_expression(object, mapper)), - member: member.as_str().to_string(), - }, - - ExprKind::Index(base, index_kind) => ExpressionKind::IndexAccess { - base: Box::new(convert_expression(base, mapper)), - index: match index_kind { - ast::IndexKind::Index(Some(expr)) => { - Some(Box::new(convert_expression(expr, mapper))) - } - _ => None, - }, - }, - - ExprKind::Binary(left, op, right) => ExpressionKind::BinaryOp { - left: Box::new(convert_expression(left, mapper)), - operator: convert_binop_kind(op.kind), - right: Box::new(convert_expression(right, mapper)), - }, - - ExprKind::Unary(op, operand) => ExpressionKind::UnaryOp { - operator: convert_unop_kind(op.kind), - operand: Box::new(convert_expression(operand, mapper)), - }, - - ExprKind::Assign(target, op, value) => ExpressionKind::Assignment { - target: Box::new(convert_expression(target, mapper)), - operator: op - .as_ref() - .map(|o| convert_assign_op_kind(o.kind)) - .unwrap_or(AssignOperator::Assign), - value: Box::new(convert_expression(value, mapper)), - }, - - ExprKind::Ternary(cond, true_expr, false_expr) => ExpressionKind::Ternary { - condition: Box::new(convert_expression(cond, mapper)), - true_expr: Box::new(convert_expression(true_expr, mapper)), - false_expr: Box::new(convert_expression(false_expr, mapper)), - }, - - ExprKind::Ident(ident) => ExpressionKind::Identifier { - name: ident.as_str().to_string(), - }, - - ExprKind::Lit(lit, _sub) => ExpressionKind::Literal { - value: format!("{lit}"), - literal_type: convert_literal_type(&lit.kind), - }, - - ExprKind::New(ty) => ExpressionKind::New { - type_name: type_to_string(ty), - arguments: Vec::new(), - }, - - ExprKind::Type(ty) => ExpressionKind::TypeMeta { - type_name: type_to_string(ty), - }, - - _ => ExpressionKind::Identifier { - name: "/* unsupported expr */".into(), - }, - }; - - Expression { - kind, - span: mapper.convert(expr.span), - } -} - -// ============================================================================ -// Operator conversion -// ============================================================================ - -fn convert_binop_kind(kind: ast::BinOpKind) -> BinaryOperator { - use ast::BinOpKind::*; - match kind { - Add => BinaryOperator::Add, - Sub => BinaryOperator::Sub, - Mul => BinaryOperator::Mul, - Div => BinaryOperator::Div, - Rem => BinaryOperator::Mod, - Pow => BinaryOperator::Pow, - Eq => BinaryOperator::Eq, - Ne => BinaryOperator::Neq, - Lt => BinaryOperator::Lt, - Gt => BinaryOperator::Gt, - Le => BinaryOperator::Lte, - Ge => BinaryOperator::Gte, - And => BinaryOperator::And, - Or => BinaryOperator::Or, - BitAnd => BinaryOperator::BitAnd, - BitOr => BinaryOperator::BitOr, - BitXor => BinaryOperator::BitXor, - Shl => BinaryOperator::Shl, - Shr | Sar => BinaryOperator::Shr, - } -} - -fn convert_unop_kind(kind: ast::UnOpKind) -> UnaryOperator { - use ast::UnOpKind::*; - match kind { - Not => UnaryOperator::Not, - Neg => UnaryOperator::Neg, - BitNot => UnaryOperator::BitNot, - PreInc => UnaryOperator::PreIncrement, - PreDec => UnaryOperator::PreDecrement, - PostInc => UnaryOperator::PostIncrement, - PostDec => UnaryOperator::PostDecrement, - } -} - -fn convert_assign_op_kind(kind: ast::BinOpKind) -> AssignOperator { - use ast::BinOpKind::*; - match kind { - Add => AssignOperator::AddAssign, - Sub => AssignOperator::SubAssign, - Mul => AssignOperator::MulAssign, - Div => AssignOperator::DivAssign, - Rem => AssignOperator::ModAssign, - BitAnd => AssignOperator::BitAndAssign, - BitOr => AssignOperator::BitOrAssign, - BitXor => AssignOperator::BitXorAssign, - Shl => AssignOperator::ShlAssign, - Shr | Sar => AssignOperator::ShrAssign, - _ => AssignOperator::Assign, - } -} - -fn convert_literal_type(kind: &LitKind<'_>) -> LiteralType { - match kind { - LitKind::Number(_) => LiteralType::Number, - LitKind::Str(..) => LiteralType::String, - LitKind::Bool(_) => LiteralType::Bool, - LitKind::Address(_) => LiteralType::Address, - _ => LiteralType::String, - } -} - -// ============================================================================ -// Helpers -// ============================================================================ - -/// Converts a solar Type to a readable string like "uint256", "address", -/// "mapping(address => uint256)", "uint256[]", or the custom type name. -/// Solar's Type doesn't implement Display, so we need our own conversion. -fn type_to_string(ty: &ast::Type<'_>) -> String { - match &ty.kind { - ast::TypeKind::Elementary(elem) => format!("{elem}"), - ast::TypeKind::Custom(path) => path_to_string(path), - ast::TypeKind::Array(arr) => { - let elem = type_to_string(&arr.element); - match &arr.size { - Some(size) => format!("{elem}[{size:?}]"), - None => format!("{elem}[]"), - } - } - ast::TypeKind::Mapping(m) => { - let key = type_to_string(&m.key); - let value = type_to_string(&m.value); - format!("mapping({key} => {value})") - } - ast::TypeKind::Function(_) => "function".into(), - } -} - -fn path_to_string(path: &ast::AstPath<'_>) -> String { - path.segments() - .last() - .map(|s| s.as_str().to_string()) - .unwrap_or_default() -} - -/// Pre-computed line offset table for converting byte positions to line/column. -struct SpanMapper { - file_index: usize, - /// Byte offset where each line starts. line_offsets[0] = 0 (first line). - line_offsets: Vec<usize>, - /// Offset added to solar's BytePos to get the position relative to this file. - /// Solar uses absolute positions across all files in the SourceMap. - base_offset: usize, -} - -impl SpanMapper { - fn new(file_index: usize, src: &str, base_offset: usize) -> Self { - let mut line_offsets = vec![0usize]; - for (i, ch) in src.char_indices() { - if ch == '\n' { - line_offsets.push(i + 1); - } - } - Self { file_index, line_offsets, base_offset } - } - - fn convert(&self, span: solar::interface::Span) -> SourceSpan { - let lo = span.lo().to_usize().saturating_sub(self.base_offset); - let hi = span.hi().to_usize().saturating_sub(self.base_offset); - let (start_line, start_col) = self.offset_to_line_col(lo); - let (end_line, end_col) = self.offset_to_line_col(hi); - SourceSpan { - file_index: self.file_index, - start_line: start_line as u32, - start_col: start_col as u32, - end_line: end_line as u32, - end_col: end_col as u32, - } - } - - fn offset_to_line_col(&self, offset: usize) -> (usize, usize) { - let line = self.line_offsets.partition_point(|&o| o <= offset).saturating_sub(1); - let col = offset.saturating_sub(self.line_offsets.get(line).copied().unwrap_or(0)); - (line + 1, col + 1) // 1-based - } - -} diff --git a/crates/ilold-core/src/pathtree/config.rs b/crates/ilold-core/src/pathtree/config.rs deleted file mode 100644 index 5cd1198..0000000 --- a/crates/ilold-core/src/pathtree/config.rs +++ /dev/null @@ -1,21 +0,0 @@ -/// Controls how aggressively the walker prunes paths. -/// Without limits, complex functions could generate millions of paths. -#[derive(Debug, Clone)] -pub struct PruningConfig { - /// Stop exploring a path after this many blocks. Default: 32. - pub max_depth: usize, - /// Stop unrolling a loop after this many iterations per path. Default: 3. - pub max_loop_unroll: usize, - /// Stop enumerating paths for a function after this many. Default: 10_000. - pub max_paths: usize, -} - -impl Default for PruningConfig { - fn default() -> Self { - Self { - max_depth: 32, - max_loop_unroll: 3, - max_paths: 10_000, - } - } -} diff --git a/crates/ilold-core/src/pathtree/mod.rs b/crates/ilold-core/src/pathtree/mod.rs deleted file mode 100644 index 0ae8170..0000000 --- a/crates/ilold-core/src/pathtree/mod.rs +++ /dev/null @@ -1,3 +0,0 @@ -pub mod config; -pub mod types; -pub mod walker; diff --git a/crates/ilold-core/src/pathtree/types.rs b/crates/ilold-core/src/pathtree/types.rs deleted file mode 100644 index a479fdf..0000000 --- a/crates/ilold-core/src/pathtree/types.rs +++ /dev/null @@ -1,83 +0,0 @@ -use serde::{Deserialize, Serialize}; - -use crate::cfg::types::{BlockKind, BranchEdge, CfgGraph}; - -/// All paths through a single function, plus statistics. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct PathTree { - pub contract: String, - pub function: String, - pub paths: Vec<ExecutionPath>, - pub stats: PathTreeStats, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct PathTreeStats { - pub total_paths: usize, - pub paths_pruned: usize, - pub max_depth_reached: usize, - pub revert_paths: usize, - pub happy_paths: usize, -} - -/// One complete route from Entry to a terminal block. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ExecutionPath { - pub id: usize, - pub nodes: Vec<PathNode>, - pub terminal: TerminalKind, - pub annotations: PathAnnotations, - pub depth: usize, -} - -/// A single step in a path — which block we visited and how we got there. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct PathNode { - pub block_id: usize, - pub block_kind: BlockKind, - /// The edge taken to reach this block. None for the Entry node. - pub branch_taken: Option<BranchEdge>, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum TerminalKind { - Return, - Revert, - DepthCutoff, - LoopCutoff, -} - -/// What happens along a path — accumulated during DFS traversal. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct PathAnnotations { - pub state_writes: Vec<String>, - pub state_reads: Vec<String>, - /// Fully qualified write paths with normalized indices, e.g. - /// `proposals[].executed`, `balances[]`. Preserved for accurate - /// who-style queries that distinguish nested fields. - #[serde(default)] - pub state_write_paths: Vec<String>, - #[serde(default)] - pub state_read_paths: Vec<String>, - pub external_calls: Vec<ExternalCallInfo>, - pub internal_calls: Vec<String>, - pub events_emitted: Vec<String>, - pub require_checks: Vec<String>, - pub eth_transfers: Vec<String>, - pub has_assembly: bool, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ExternalCallInfo { - pub target: String, - pub function: String, -} - -/// Groups a function's CFG with its path tree for easy access. -#[derive(Debug, Clone)] -pub struct FunctionAnalysis { - pub contract: String, - pub function: String, - pub cfg: CfgGraph, - pub path_tree: PathTree, -} diff --git a/crates/ilold-core/src/pathtree/walker.rs b/crates/ilold-core/src/pathtree/walker.rs deleted file mode 100644 index 75e727c..0000000 --- a/crates/ilold-core/src/pathtree/walker.rs +++ /dev/null @@ -1,425 +0,0 @@ -use std::collections::HashMap; - -use petgraph::stable_graph::NodeIndex; -use petgraph::visit::EdgeRef; -use petgraph::Direction; - -use crate::cfg::types::*; -use crate::model::common::StateVar; - -use super::config::PruningConfig; -use super::types::*; - -/// One item on the DFS stack. Represents a path being explored. -struct WalkState { - node: NodeIndex, - path: Vec<PathNode>, - annotations: PathAnnotations, - depth: usize, - /// How many times each LoopCondition block has been visited in THIS path. - /// Each path gets its own copy so loops don't interfere between paths. - loop_counts: HashMap<usize, usize>, - /// The edge that led to this node (for recording in PathNode). - edge_taken: Option<BranchEdge>, -} - -pub fn build_path_tree( - cfg: &CfgGraph, - contract_name: &str, - function_name: &str, - state_vars: &[StateVar], - config: &PruningConfig, -) -> PathTree { - let mut paths: Vec<ExecutionPath> = Vec::new(); - let mut paths_pruned: usize = 0; - let mut max_depth_reached: usize = 0; - let mut next_path_id: usize = 0; - - // Find the Entry node - let entry = match cfg.node_indices().find(|&n| cfg[n].kind == BlockKind::Entry) { - Some(n) => n, - None => { - // No entry = interface function with no body - return PathTree { - contract: contract_name.to_string(), - function: function_name.to_string(), - paths: Vec::new(), - stats: PathTreeStats { - total_paths: 0, - paths_pruned: 0, - max_depth_reached: 0, - revert_paths: 0, - happy_paths: 0, - }, - }; - } - }; - - // DFS stack — each item is a path being explored - let mut stack: Vec<WalkState> = vec![WalkState { - node: entry, - path: Vec::new(), - annotations: PathAnnotations::default(), - depth: 0, - loop_counts: HashMap::new(), - edge_taken: None, - }]; - - while let Some(state) = stack.pop() { - // Budget check: stop if we've found enough paths - if paths.len() >= config.max_paths { - paths_pruned += 1; - continue; - } - - let block = &cfg[state.node]; - let mut path = state.path; - let mut annotations = state.annotations; - let mut loop_counts = state.loop_counts; - let depth = state.depth; - - // Track max depth - if depth > max_depth_reached { - max_depth_reached = depth; - } - - // Pruning: max depth - if depth > config.max_depth { - paths_pruned += 1; - path.push(PathNode { - block_id: block.id, - block_kind: block.kind, - branch_taken: state.edge_taken, - }); - let id = next_path_id; - next_path_id += 1; - paths.push(ExecutionPath { - id, - nodes: path, - terminal: TerminalKind::DepthCutoff, - annotations, - depth, - }); - continue; - } - - // Pruning: loop detection - if block.kind == BlockKind::LoopCondition { - let count = loop_counts.entry(block.id).or_insert(0); - *count += 1; - if *count > config.max_loop_unroll { - paths_pruned += 1; - path.push(PathNode { - block_id: block.id, - block_kind: block.kind, - branch_taken: state.edge_taken, - }); - let id = next_path_id; - next_path_id += 1; - paths.push(ExecutionPath { - id, - nodes: path, - terminal: TerminalKind::LoopCutoff, - annotations, - depth, - }); - continue; - } - } - - // Add this block to the path - path.push(PathNode { - block_id: block.id, - block_kind: block.kind, - branch_taken: state.edge_taken, - }); - - // Collect annotations from this block's statements - collect_annotations(&block.statements, state_vars, &mut annotations); - - // Terminal check - match block.kind { - BlockKind::Return => { - let id = next_path_id; - next_path_id += 1; - paths.push(ExecutionPath { - id, - nodes: path, - terminal: TerminalKind::Return, - annotations, - depth, - }); - continue; - } - BlockKind::Revert => { - let id = next_path_id; - next_path_id += 1; - paths.push(ExecutionPath { - id, - nodes: path, - terminal: TerminalKind::Revert, - annotations, - depth, - }); - continue; - } - _ => {} - } - - // Get outgoing edges and push successors onto stack - let edges: Vec<_> = cfg - .edges_directed(state.node, Direction::Outgoing) - .map(|e| (e.target(), e.weight().clone())) - .collect(); - - if edges.is_empty() { - // Entry with no edges = interface/abstract function (no body) → skip - if block.kind == BlockKind::Entry && block.statements.is_empty() { - continue; - } - // Dead end in a normal block → treat as implicit return - let id = next_path_id; - next_path_id += 1; - paths.push(ExecutionPath { - id, - nodes: path, - terminal: TerminalKind::Return, - annotations, - depth, - }); - continue; - } - - // For each outgoing edge, clone the state and push - for (i, (target, edge)) in edges.into_iter().enumerate() { - let (p, a, lc) = if i == 0 { - // First edge reuses the original (avoids one clone) - (path.clone(), annotations.clone(), loop_counts.clone()) - } else { - (path.clone(), annotations.clone(), loop_counts.clone()) - }; - stack.push(WalkState { - node: target, - path: p, - annotations: a, - depth: depth + 1, - loop_counts: lc, - edge_taken: Some(edge), - }); - } - } - - let revert_paths = paths.iter().filter(|p| p.terminal == TerminalKind::Revert).count(); - let happy_paths = paths.iter().filter(|p| p.terminal == TerminalKind::Return).count(); - - PathTree { - contract: contract_name.to_string(), - function: function_name.to_string(), - paths, - stats: PathTreeStats { - total_paths: next_path_id, - paths_pruned, - max_depth_reached, - revert_paths, - happy_paths, - }, - } -} - -/// Normalize a state-var access path by collapsing array indices to `[]` -/// while preserving struct/field accesses. E.g. -/// `proposals[id].executed` -> `proposals[].executed`, -/// `balances[msg.sender]` -> `balances[]`. -fn normalize_path(target: &str) -> String { - let mut out = String::with_capacity(target.len()); - let mut depth = 0i32; - for ch in target.chars() { - match ch { - '[' => { - if depth == 0 { - out.push('['); - } - depth += 1; - } - ']' => { - depth -= 1; - if depth == 0 { - out.push(']'); - } - } - _ if depth > 0 => {} - _ => out.push(ch), - } - } - out -} - -/// Check whether an expression mentions a variable name as an identifier -/// (not as a substring of a larger identifier). -fn expression_mentions_var(expr: &str, name: &str) -> bool { - if name.is_empty() { - return false; - } - let bytes = expr.as_bytes(); - let nb = name.as_bytes(); - let mut i = 0; - while i + nb.len() <= bytes.len() { - if &bytes[i..i + nb.len()] == nb { - let before_ok = i == 0 || !is_ident_char(bytes[i - 1]); - let after_idx = i + nb.len(); - let after_ok = after_idx >= bytes.len() || !is_ident_char(bytes[after_idx]); - if before_ok && after_ok { - return true; - } - } - i += 1; - } - false -} - -fn is_ident_char(b: u8) -> bool { - b.is_ascii_alphanumeric() || b == b'_' -} - -/// Extract qualified state-var paths from an expression for a known var name. -/// Returns normalized forms like `proposals[].executed` for matches. -fn extract_qualified_paths(expr: &str, name: &str) -> Vec<String> { - let mut out = Vec::new(); - if name.is_empty() { - return out; - } - let bytes = expr.as_bytes(); - let nb = name.as_bytes(); - let mut i = 0; - while i + nb.len() <= bytes.len() { - if &bytes[i..i + nb.len()] == nb { - let before_ok = i == 0 || !is_ident_char(bytes[i - 1]); - let after_idx = i + nb.len(); - let after_ok = after_idx >= bytes.len() || !is_ident_char(bytes[after_idx]); - if before_ok && after_ok { - // Walk forward consuming `[...]` and `.field` chains. - let mut end = after_idx; - loop { - if end < bytes.len() && bytes[end] == b'[' { - let mut depth = 1i32; - end += 1; - while end < bytes.len() && depth > 0 { - match bytes[end] { - b'[' => depth += 1, - b']' => depth -= 1, - _ => {} - } - end += 1; - } - continue; - } - if end < bytes.len() && bytes[end] == b'.' { - let mut j = end + 1; - while j < bytes.len() && is_ident_char(bytes[j]) { - j += 1; - } - if j > end + 1 { - end = j; - continue; - } - } - break; - } - let raw = &expr[i..end]; - let norm = normalize_path(raw); - if !out.contains(&norm) { - out.push(norm); - } - i = end; - continue; - } - } - i += 1; - } - out -} - -/// Scan a block's statements and accumulate annotations. -fn collect_annotations( - statements: &[CfgStatement], - state_vars: &[StateVar], - annotations: &mut PathAnnotations, -) { - for stmt in statements { - match stmt { - CfgStatement::ExternalCall { target, function, .. } => { - annotations.external_calls.push(ExternalCallInfo { - target: target.clone(), - function: function.clone(), - }); - } - CfgStatement::InternalCall { function, .. } => { - annotations.internal_calls.push(function.clone()); - } - CfgStatement::EmitEvent { event, .. } => { - annotations.events_emitted.push(event.clone()); - } - CfgStatement::EthTransfer { to, .. } => { - annotations.eth_transfers.push(to.clone()); - } - CfgStatement::RequireCheck { condition, .. } => { - annotations.require_checks.push(condition.clone()); - for sv in state_vars { - if expression_mentions_var(condition, &sv.name) { - if !annotations.state_reads.contains(&sv.name) { - annotations.state_reads.push(sv.name.clone()); - } - for path in extract_qualified_paths(condition, &sv.name) { - if !annotations.state_read_paths.contains(&path) { - annotations.state_read_paths.push(path); - } - } - } - } - } - CfgStatement::AssertCheck { condition, .. } => { - annotations.require_checks.push(format!("assert: {condition}")); - } - CfgStatement::AssemblyBlock { .. } => { - annotations.has_assembly = true; - } - CfgStatement::Assignment { target, value, .. } => { - let base_name = crate::util::target_base_name(target); - if state_vars.iter().any(|sv| sv.name == base_name) { - annotations.state_writes.push(target.clone()); - let normalized = normalize_path(target); - if !annotations.state_write_paths.contains(&normalized) { - annotations.state_write_paths.push(normalized); - } - } - // Detect state reads in the value expression and target - for sv in state_vars { - if expression_mentions_var(value, &sv.name) { - if !annotations.state_reads.contains(&sv.name) { - annotations.state_reads.push(sv.name.clone()); - } - for path in extract_qualified_paths(value, &sv.name) { - if !annotations.state_read_paths.contains(&path) { - annotations.state_read_paths.push(path); - } - } - } - } - } - CfgStatement::StateWrite { variable, .. } => { - annotations.state_writes.push(variable.clone()); - let normalized = normalize_path(variable); - if !annotations.state_write_paths.contains(&normalized) { - annotations.state_write_paths.push(normalized); - } - } - CfgStatement::StateRead { variable, .. } => { - annotations.state_reads.push(variable.clone()); - let normalized = normalize_path(variable); - if !annotations.state_read_paths.contains(&normalized) { - annotations.state_read_paths.push(normalized); - } - } - } - } -} diff --git a/crates/ilold-core/src/sequence/analysis.rs b/crates/ilold-core/src/sequence/analysis.rs deleted file mode 100644 index 2527262..0000000 --- a/crates/ilold-core/src/sequence/analysis.rs +++ /dev/null @@ -1,457 +0,0 @@ -use std::collections::{HashMap, HashSet}; - -use serde::{Deserialize, Serialize}; - -use crate::model::contract::ContractDef; -use crate::model::project::Project; -use crate::pathtree::types::{PathTree, TerminalKind}; -use crate::util::is_type_cast; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct FunctionBehavior { - pub name: String, - pub preconditions: Vec<String>, - pub state_writes: Vec<String>, - pub state_reads: Vec<String>, - /// Fully qualified, normalized write paths (e.g. `proposals[].executed`). - #[serde(default)] - pub state_write_paths: Vec<String>, - #[serde(default)] - pub state_read_paths: Vec<String>, - pub external_calls: Vec<String>, - #[serde(default)] - pub internal_calls: Vec<String>, - pub events: Vec<String>, - pub can_revert: bool, - pub always_reverts: bool, - pub read_only: bool, - - #[serde(default)] - pub transitive_state_writes: Vec<String>, - #[serde(default)] - pub transitive_state_reads: Vec<String>, - #[serde(default)] - pub transitive_state_write_paths: Vec<String>, - #[serde(default)] - pub transitive_state_read_paths: Vec<String>, - #[serde(default)] - pub transitive_external_calls: Vec<String>, - #[serde(default)] - pub transitive_events: Vec<String>, - #[serde(default)] - pub transitive_internal_calls: Vec<String>, -} - -impl FunctionBehavior { - /// Returns direct + transitive state writes (base names) - pub fn effective_state_writes(&self) -> Vec<String> { - let mut out: Vec<String> = self.state_writes.clone(); - for w in &self.transitive_state_writes { - if !out.contains(w) { out.push(w.clone()); } - } - out.sort(); - out - } - - pub fn effective_state_reads(&self) -> Vec<String> { - let mut out: Vec<String> = self.state_reads.clone(); - for r in &self.transitive_state_reads { - if !out.contains(r) { out.push(r.clone()); } - } - out.sort(); - out - } - - pub fn effective_state_write_paths(&self) -> Vec<String> { - let mut out: Vec<String> = self.state_write_paths.clone(); - for p in &self.transitive_state_write_paths { - if !out.contains(p) { out.push(p.clone()); } - } - out.sort(); - out - } - - pub fn effective_state_read_paths(&self) -> Vec<String> { - let mut out: Vec<String> = self.state_read_paths.clone(); - for p in &self.transitive_state_read_paths { - if !out.contains(p) { out.push(p.clone()); } - } - out.sort(); - out - } - - pub fn effective_external_calls(&self) -> Vec<String> { - let mut out: Vec<String> = self.external_calls.clone(); - for c in &self.transitive_external_calls { - if !out.contains(c) { out.push(c.clone()); } - } - out.sort(); - out - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct TransitionInfo { - pub from: String, - pub to: String, - pub shared_state: Vec<String>, - pub conditions_affected: Vec<String>, - pub has_external_in_from: bool, - pub has_external_in_to: bool, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SequenceAnalysis { - pub functions: Vec<FunctionBehavior>, - pub transitions: Vec<TransitionInfo>, -} - -pub fn analyze_sequences( - path_trees: &HashMap<(String, String), PathTree>, - contract_name: &str, -) -> SequenceAnalysis { - let mut behaviors: Vec<FunctionBehavior> = Vec::new(); - - for ((c, f), pt) in path_trees { - if c != contract_name || f.is_empty() { continue; } - - let mut preconditions = HashSet::new(); - let mut state_writes = HashSet::new(); - let mut state_reads = HashSet::new(); - let mut state_write_paths = HashSet::new(); - let mut state_read_paths = HashSet::new(); - let mut external_calls = HashSet::new(); - let mut internal_calls = HashSet::new(); - let mut events = HashSet::new(); - let mut has_return = false; - let mut has_revert = false; - - for path in &pt.paths { - for check in &path.annotations.require_checks { - preconditions.insert(check.clone()); - } - for write in &path.annotations.state_writes { - let base = write - .split(|c| c == '[' || c == '.') - .next() - .unwrap_or(write) - .to_string(); - state_writes.insert(base); - } - for write in &path.annotations.state_write_paths { - state_write_paths.insert(write.clone()); - } - for read in &path.annotations.state_reads { - let base = read - .split(|c| c == '[' || c == '.') - .next() - .unwrap_or(read) - .to_string(); - state_reads.insert(base); - } - for read in &path.annotations.state_read_paths { - state_read_paths.insert(read.clone()); - } - for call in &path.annotations.external_calls { - external_calls.insert(format!("{}.{}", call.target, call.function)); - } - for ic in &path.annotations.internal_calls { - internal_calls.insert(ic.clone()); - } - for event in &path.annotations.events_emitted { - events.insert(event.clone()); - } - match path.terminal { - TerminalKind::Return => has_return = true, - TerminalKind::Revert => has_revert = true, - _ => {} - } - } - - let read_only = state_writes.is_empty() && external_calls.is_empty(); - - let mut state_writes: Vec<String> = state_writes.into_iter().collect(); - state_writes.sort(); - let mut state_reads: Vec<String> = state_reads.into_iter().collect(); - state_reads.sort(); - let mut state_write_paths: Vec<String> = state_write_paths.into_iter().collect(); - state_write_paths.sort(); - let mut state_read_paths: Vec<String> = state_read_paths.into_iter().collect(); - state_read_paths.sort(); - let mut external_calls: Vec<String> = external_calls.into_iter().collect(); - external_calls.sort(); - let mut internal_calls: Vec<String> = internal_calls.into_iter().collect(); - internal_calls.sort(); - let mut preconditions: Vec<String> = preconditions.into_iter().collect(); - preconditions.sort(); - let mut events: Vec<String> = events.into_iter().collect(); - events.sort(); - - behaviors.push(FunctionBehavior { - name: f.clone(), - preconditions, - state_writes, - state_reads, - state_write_paths, - state_read_paths, - external_calls, - internal_calls, - events, - can_revert: has_revert, - always_reverts: !has_return && has_revert, - read_only, - transitive_state_writes: Vec::new(), - transitive_state_reads: Vec::new(), - transitive_state_write_paths: Vec::new(), - transitive_state_read_paths: Vec::new(), - transitive_external_calls: Vec::new(), - transitive_events: Vec::new(), - transitive_internal_calls: Vec::new(), - }); - } - - behaviors.sort_by(|a, b| a.name.cmp(&b.name)); - - // For each pair A→B, describe what conditions connect them - let mut transitions = Vec::new(); - - for a in &behaviors { - for b in &behaviors { - let shared: Vec<String> = a.state_writes.iter() - .filter(|w| b.state_reads.contains(*w) || b.state_writes.contains(*w)) - .cloned() - .collect(); - - let mut conditions_affected: Vec<String> = Vec::new(); - for w in &a.state_writes { - for p in &b.preconditions { - if p.contains(w.as_str()) { - conditions_affected.push(format!("{} writes '{}' → {} needs require({})", a.name, w, b.name, p)); - } - } - } - - // Only include transitions that have something interesting - if !shared.is_empty() || !conditions_affected.is_empty() || !a.external_calls.is_empty() || !b.external_calls.is_empty() { - transitions.push(TransitionInfo { - from: a.name.clone(), - to: b.name.clone(), - shared_state: shared, - conditions_affected, - has_external_in_from: !a.external_calls.is_empty(), - has_external_in_to: !b.external_calls.is_empty(), - }); - } - } - } - - SequenceAnalysis { - functions: behaviors, - transitions, - } -} - -// ============================================================================ -// Project-level transitive effect computation -// ============================================================================ - -pub fn analyze_project( - project: &Project, - analyses: &mut HashMap<String, SequenceAnalysis>, -) { - let all_funcs: Vec<(String, String)> = analyses - .iter() - .flat_map(|(c, sa)| sa.functions.iter().map(move |b| (c.clone(), b.name.clone()))) - .collect(); - - // Read-only snapshot so we can mutate `analyses` in place. - let snapshot: HashMap<String, SequenceAnalysis> = analyses.clone(); - - for (contract_name, func_name) in all_funcs { - let root_contract = match project.contracts.iter().find(|c| c.name == contract_name) { - Some(c) => c, - None => continue, - }; - - let root_behavior = match snapshot - .get(&contract_name) - .and_then(|sa| sa.functions.iter().find(|b| b.name == func_name)) - { - Some(b) => b, - None => continue, - }; - - let effects = collect_transitive_for_function( - root_behavior, - root_contract, - project, - &snapshot, - ); - - if let Some(sa) = analyses.get_mut(&contract_name) { - if let Some(b) = sa.functions.iter_mut().find(|b| b.name == func_name) { - b.transitive_state_writes = effects.state_writes; - b.transitive_state_reads = effects.state_reads; - b.transitive_state_write_paths = effects.state_write_paths; - b.transitive_state_read_paths = effects.state_read_paths; - b.transitive_external_calls = effects.external_calls; - b.transitive_events = effects.events; - b.transitive_internal_calls = effects.internal_calls; - } - } - } -} - -struct TransitiveSets { - state_writes: Vec<String>, - state_reads: Vec<String>, - state_write_paths: Vec<String>, - state_read_paths: Vec<String>, - external_calls: Vec<String>, - events: Vec<String>, - internal_calls: Vec<String>, -} - -fn collect_transitive_for_function( - root: &FunctionBehavior, - root_contract: &ContractDef, - project: &Project, - all: &HashMap<String, SequenceAnalysis>, -) -> TransitiveSets { - let mut writes: HashSet<String> = HashSet::new(); - let mut reads: HashSet<String> = HashSet::new(); - let mut write_paths: HashSet<String> = HashSet::new(); - let mut read_paths: HashSet<String> = HashSet::new(); - let mut external: HashSet<String> = HashSet::new(); - let mut events: HashSet<String> = HashSet::new(); - let mut internal: HashSet<String> = HashSet::new(); - - let mut visited: HashSet<(String, String)> = HashSet::new(); - let mut stack: Vec<(String, String)> = root - .internal_calls - .iter() - .filter(|c| !is_type_cast(c)) - .map(|c| (root_contract.name.clone(), c.clone())) - .collect(); - - while let Some((ctx_name, callee_name)) = stack.pop() { - let ctx = match project.contract_index.get(&ctx_name) { - Some(&i) => &project.contracts[i], - None => continue, - }; - - let (callee_behavior, owning) = match resolve_internal_callee(&callee_name, ctx, project, all) { - Some(x) => x, - None => continue, - }; - - let key = (owning.clone(), callee_name.clone()); - if !visited.insert(key) { - continue; - } - - for w in &callee_behavior.state_writes { - if !root.state_writes.contains(w) { - writes.insert(w.clone()); - } - } - for r in &callee_behavior.state_reads { - if !root.state_reads.contains(r) { - reads.insert(r.clone()); - } - } - for p in &callee_behavior.state_write_paths { - if !root.state_write_paths.contains(p) { - write_paths.insert(p.clone()); - } - } - for p in &callee_behavior.state_read_paths { - if !root.state_read_paths.contains(p) { - read_paths.insert(p.clone()); - } - } - for c in &callee_behavior.external_calls { - if !root.external_calls.contains(c) { - external.insert(c.clone()); - } - } - for e in &callee_behavior.events { - if !root.events.contains(e) { - events.insert(e.clone()); - } - } - internal.insert(callee_name.clone()); - - for ic in &callee_behavior.internal_calls { - if is_type_cast(ic) { - continue; - } - stack.push((owning.clone(), ic.clone())); - } - } - - fn to_sorted(s: HashSet<String>) -> Vec<String> { - let mut v: Vec<String> = s.into_iter().collect(); - v.sort(); - v - } - - TransitiveSets { - state_writes: to_sorted(writes), - state_reads: to_sorted(reads), - state_write_paths: to_sorted(write_paths), - state_read_paths: to_sorted(read_paths), - external_calls: to_sorted(external), - events: to_sorted(events), - internal_calls: to_sorted(internal), - } -} - -/// Resolve an internal callee name to its `FunctionBehavior`, walking the -/// inheritance chain of `starting_contract`. Returns the behavior plus the -/// name of the contract that owns the resolved function. -pub fn resolve_internal_callee<'a>( - callee: &str, - starting_contract: &'a ContractDef, - project: &'a Project, - all: &'a HashMap<String, SequenceAnalysis>, -) -> Option<(&'a FunctionBehavior, String)> { - // 1. Try starting_contract first - if let Some(sa) = all.get(&starting_contract.name) { - if let Some(b) = sa.functions.iter().find(|b| b.name == callee) { - return Some((b, starting_contract.name.clone())); - } - } - - // 2. Walk the inheritance chain (BFS) - let mut visited: HashSet<String> = HashSet::new(); - let mut queue: Vec<&str> = starting_contract - .inherits - .iter() - .map(|s| s.as_str()) - .collect(); - - while let Some(parent_name) = queue.pop() { - if !visited.insert(parent_name.to_string()) { - continue; - } - - let parent_idx = match project.contract_index.get(parent_name) { - Some(&i) => i, - None => continue, - }; - let parent = &project.contracts[parent_idx]; - - if let Some(sa) = all.get(&parent.name) { - if let Some(b) = sa.functions.iter().find(|b| b.name == callee) { - return Some((b, parent.name.clone())); - } - } - - for grand in &parent.inherits { - queue.push(grand.as_str()); - } - } - - None -} diff --git a/crates/ilold-core/src/sequence/builder.rs b/crates/ilold-core/src/sequence/builder.rs deleted file mode 100644 index 5322f21..0000000 --- a/crates/ilold-core/src/sequence/builder.rs +++ /dev/null @@ -1,75 +0,0 @@ -use crate::model::contract::ContractDef; -use crate::model::function::{FunctionKind, Mutability}; -use crate::pathtree::types::PathTree; - -use super::types::*; - -pub fn build_sequence_tree( - contract: &ContractDef, - path_trees: &[PathTree], - max_depth: usize, -) -> SequenceTree { - // Collect public/external functions, excluding constructors (REQ-SEQ-6) - let functions: Vec<SequenceFunction> = contract - .functions - .iter() - .filter(|f| f.kind != FunctionKind::Constructor) - .filter(|f| matches!(f.visibility, crate::model::function::Visibility::Public | crate::model::function::Visibility::External)) - .map(|f| { - let path_count = path_trees - .iter() - .find(|pt| pt.function == f.name) - .map(|pt| pt.stats.total_paths) - .unwrap_or(0); - - SequenceFunction { - name: f.name.clone(), - visibility: f.visibility, - read_only: matches!(f.mutability, Mutability::Pure | Mutability::View), - path_count, - } - }) - .collect(); - - let n = functions.len(); - let mut sequences: Vec<TransactionSequence> = Vec::new(); - - // Depth 1: each function alone - for i in 0..n { - sequences.push(TransactionSequence { - steps: vec![i], - depth: 1, - path_count: functions[i].path_count as u64, - has_state_change: !functions[i].read_only, - }); - } - - // Depth 2..D: extend previous depth by appending each function - for d in 2..=max_depth { - let prev: Vec<TransactionSequence> = sequences - .iter() - .filter(|s| s.depth == d - 1) - .cloned() - .collect(); - - for seq in &prev { - for i in 0..n { - let mut steps = seq.steps.clone(); - steps.push(i); - sequences.push(TransactionSequence { - steps, - depth: d, - path_count: seq.path_count * functions[i].path_count as u64, - has_state_change: seq.has_state_change || !functions[i].read_only, - }); - } - } - } - - SequenceTree { - contract: contract.name.clone(), - functions, - sequences, - max_depth, - } -} diff --git a/crates/ilold-core/src/sequence/mod.rs b/crates/ilold-core/src/sequence/mod.rs deleted file mode 100644 index 9d7948f..0000000 --- a/crates/ilold-core/src/sequence/mod.rs +++ /dev/null @@ -1,3 +0,0 @@ -pub mod analysis; -pub mod builder; -pub mod types; diff --git a/crates/ilold-core/src/sequence/types.rs b/crates/ilold-core/src/sequence/types.rs deleted file mode 100644 index fa649da..0000000 --- a/crates/ilold-core/src/sequence/types.rs +++ /dev/null @@ -1,33 +0,0 @@ -use serde::{Deserialize, Serialize}; - -use crate::model::function::Visibility; - -/// All possible function call sequences for a contract. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SequenceTree { - pub contract: String, - pub functions: Vec<SequenceFunction>, - pub sequences: Vec<TransactionSequence>, - pub max_depth: usize, -} - -/// A callable function in the sequence tree. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SequenceFunction { - pub name: String, - pub visibility: Visibility, - pub read_only: bool, - pub path_count: usize, -} - -/// One specific ordering of function calls (e.g., deposit → withdraw → claim). -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct TransactionSequence { - /// Indices into SequenceTree.functions - pub steps: Vec<usize>, - pub depth: usize, - /// Product of each step's path_count - pub path_count: u64, - /// false only if ALL steps are read_only - pub has_state_change: bool, -} diff --git a/crates/ilold-core/src/slicing/backward.rs b/crates/ilold-core/src/slicing/backward.rs deleted file mode 100644 index 8f832d2..0000000 --- a/crates/ilold-core/src/slicing/backward.rs +++ /dev/null @@ -1,85 +0,0 @@ -use std::collections::{BTreeSet, HashSet}; - -use super::extract::extract_statement_uses_defs; -use super::flatten::FlatStatement; -use super::types::StatementPath; - -/// Intraprocedural backward dataflow slice for `variable`. -/// -/// Iterates to a fixed point over the flattened body: -/// 1. Any statement whose def set intersects the `relevant` set is added -/// to the slice and its uses are merged into `relevant`. -/// 2. Every included statement drags in its lexical ancestors -/// (If / For / While / Block / TryCatch wrappers) and *their* uses -/// are merged into `relevant` — that is how `If (c)` conditions pull -/// the data deps of `c` into the backward slice. -/// The loop re-runs until neither set grows, so transitive and -/// control-dependency-driven propagation are both handled. -/// -/// Returns the selected paths in program order. -pub fn backward_slice(flat: &[FlatStatement<'_>], variable: &str) -> Vec<StatementPath> { - let mut relevant: HashSet<String> = HashSet::new(); - relevant.insert(variable.to_string()); - - let mut included: BTreeSet<usize> = BTreeSet::new(); - - loop { - let mut changed = false; - - for i in (0..flat.len()).rev() { - if included.contains(&i) { - continue; - } - let (uses, defs) = extract_statement_uses_defs(flat[i].statement); - if defs.is_disjoint(&relevant) { - continue; - } - included.insert(i); - changed = true; - for u in uses { - if relevant.insert(u) { - changed = true; - } - } - } - - if add_ancestors_with_uses(flat, &mut included, &mut relevant) { - changed = true; - } - - if !changed { - break; - } - } - - included.into_iter().map(|i| flat[i].path.clone()).collect() -} - -/// Walk up the parent chain of every currently-included statement and -/// add every enclosing control-flow statement to the set, plus fold that -/// ancestor's own uses into `relevant`. Returns `true` if either set -/// grew — callers use that as a "re-run fixed-point pass" signal. -pub(super) fn add_ancestors_with_uses( - flat: &[FlatStatement<'_>], - included: &mut BTreeSet<usize>, - relevant: &mut HashSet<String>, -) -> bool { - let mut changed = false; - let seeds: Vec<usize> = included.iter().copied().collect(); - for idx in seeds { - let mut cur = flat[idx].parent; - while let Some(p) = cur { - if included.insert(p) { - changed = true; - let (uses, _defs) = extract_statement_uses_defs(flat[p].statement); - for u in uses { - if relevant.insert(u) { - changed = true; - } - } - } - cur = flat[p].parent; - } - } - changed -} diff --git a/crates/ilold-core/src/slicing/extract.rs b/crates/ilold-core/src/slicing/extract.rs deleted file mode 100644 index fb84eea..0000000 --- a/crates/ilold-core/src/slicing/extract.rs +++ /dev/null @@ -1,267 +0,0 @@ -use std::collections::HashSet; - -use crate::model::expression::{Expression, ExpressionKind}; -use crate::model::statement::{Statement, StatementKind}; - -/// Walk an expression tree and collect every `Identifier` leaf name. -/// Used to build the "uses" set of a statement. -pub fn walk_expr_identifiers(expr: &Expression, out: &mut HashSet<String>) { - match &expr.kind { - ExpressionKind::Identifier { name } => { - out.insert(name.clone()); - } - ExpressionKind::MemberAccess { object, .. } => { - walk_expr_identifiers(object, out); - } - ExpressionKind::IndexAccess { base, index } => { - walk_expr_identifiers(base, out); - if let Some(idx) = index { - walk_expr_identifiers(idx, out); - } - } - ExpressionKind::BinaryOp { left, right, .. } => { - walk_expr_identifiers(left, out); - walk_expr_identifiers(right, out); - } - ExpressionKind::UnaryOp { operand, .. } => { - walk_expr_identifiers(operand, out); - } - ExpressionKind::FunctionCall { callee, arguments } => { - // The callee may itself mention identifiers (e.g. `someFn`, or - // `obj.method` — we care about `obj`). Skip the callee if it's - // just a plain Identifier that names the function being called, - // since that's not a data read. - match &callee.kind { - ExpressionKind::Identifier { .. } => { - // Pure function name — not a data use. - } - _ => walk_expr_identifiers(callee, out), - } - for arg in arguments { - walk_expr_identifiers(arg, out); - } - } - ExpressionKind::Assignment { target, value, .. } => { - // Used when an assignment is part of a bigger expression. The - // target's side effects (e.g. `a[i] = x` reads `i`) need to be - // captured; the value is a normal use. - walk_assignment_target_uses(target, out); - walk_expr_identifiers(value, out); - } - ExpressionKind::Ternary { condition, true_expr, false_expr } => { - walk_expr_identifiers(condition, out); - walk_expr_identifiers(true_expr, out); - walk_expr_identifiers(false_expr, out); - } - ExpressionKind::TypeCast { expression, .. } => { - walk_expr_identifiers(expression, out); - } - ExpressionKind::New { arguments, .. } => { - for arg in arguments { - walk_expr_identifiers(arg, out); - } - } - ExpressionKind::Literal { .. } | ExpressionKind::TypeMeta { .. } => {} - } -} - -/// Collect the USE identifiers inside an assignment target. `x = …` has -/// no uses on the target, but `arr[i] = …` uses `i`, and `obj.field = …` -/// uses `obj`. The base identifier itself (`x`, `arr`, `obj`) is the DEF -/// and is NOT added here. -fn walk_assignment_target_uses(target: &Expression, out: &mut HashSet<String>) { - match &target.kind { - ExpressionKind::Identifier { .. } => { - // The target identifier is the def, not a use. - } - ExpressionKind::IndexAccess { base, index } => { - walk_assignment_target_uses(base, out); - if let Some(idx) = index { - walk_expr_identifiers(idx, out); - } - } - ExpressionKind::MemberAccess { object, .. } => { - walk_assignment_target_uses(object, out); - } - _ => { - // Anything else on the LHS is unusual (tuple? deref?) — treat - // sub-expressions as uses to stay safe. - walk_expr_identifiers(target, out); - } - } -} - -/// Collect the DEF identifier of an assignment target. Returns the base -/// variable name being written (e.g. `balances` for `balances[to] = x`). -pub fn extract_assignment_def(target: &Expression) -> Option<String> { - match &target.kind { - ExpressionKind::Identifier { name } => Some(name.clone()), - ExpressionKind::IndexAccess { base, .. } => extract_assignment_def(base), - ExpressionKind::MemberAccess { object, .. } => extract_assignment_def(object), - _ => None, - } -} - -/// Use/def sets for a single `Statement`. Does NOT recurse into nested -/// statements — callers handle block walking separately so they can -/// associate sub-statements with their own paths. -/// -/// For statements that only introduce control flow (If/For/While), the -/// returned uses are the condition's uses; defs is empty. -pub fn extract_statement_uses_defs(stmt: &Statement) -> (HashSet<String>, HashSet<String>) { - let mut uses: HashSet<String> = HashSet::new(); - let mut defs: HashSet<String> = HashSet::new(); - - match &stmt.kind { - StatementKind::ExpressionStmt { expression } => { - match &expression.kind { - ExpressionKind::Assignment { target, value, .. } => { - if let Some(def) = extract_assignment_def(target) { - defs.insert(def); - } - walk_assignment_target_uses(target, &mut uses); - walk_expr_identifiers(value, &mut uses); - } - _ => walk_expr_identifiers(expression, &mut uses), - } - } - StatementKind::VariableDeclaration { name, initial_value, .. } => { - defs.insert(name.clone()); - if let Some(val) = initial_value { - walk_expr_identifiers(val, &mut uses); - } - } - StatementKind::If { condition, .. } => { - walk_expr_identifiers(condition, &mut uses); - } - StatementKind::For { init: _, condition, increment, .. } => { - // `init` is a Statement of its own and will be handled as a - // separate flat entry; same for the body. Increment is an - // Expression evaluated each iteration — its uses count for - // the loop header. - if let Some(c) = condition { - walk_expr_identifiers(c, &mut uses); - } - if let Some(inc) = increment { - walk_expr_identifiers(inc, &mut uses); - } - } - StatementKind::While { condition, .. } | StatementKind::DoWhile { condition, .. } => { - walk_expr_identifiers(condition, &mut uses); - } - StatementKind::Return { value } => { - if let Some(v) = value { - walk_expr_identifiers(v, &mut uses); - } - } - StatementKind::Emit { arguments, .. } => { - for arg in arguments { - walk_expr_identifiers(arg, &mut uses); - } - } - StatementKind::Revert { arguments, .. } => { - for arg in arguments { - walk_expr_identifiers(arg, &mut uses); - } - } - StatementKind::TryCatch { expression, .. } => { - walk_expr_identifiers(expression, &mut uses); - } - StatementKind::Block { .. } - | StatementKind::UncheckedBlock { .. } - | StatementKind::Assembly { .. } - | StatementKind::Placeholder - | StatementKind::Continue - | StatementKind::Break => {} - } - - (uses, defs) -} - -/// Render a statement as a short human-readable string for slice output. -/// This is NOT a full Solidity pretty-printer — just enough to identify -/// the statement in a slice result. -pub fn statement_text(stmt: &Statement) -> String { - match &stmt.kind { - StatementKind::ExpressionStmt { expression } => expr_to_text(expression), - StatementKind::VariableDeclaration { name, type_name, initial_value } => { - match initial_value { - Some(v) => format!("{} {} = {}", type_name, name, expr_to_text(v)), - None => format!("{} {}", type_name, name), - } - } - StatementKind::If { condition, .. } => format!("if ({}) {{ … }}", expr_to_text(condition)), - StatementKind::For { .. } => "for (…) { … }".to_string(), - StatementKind::While { condition, .. } => format!("while ({}) {{ … }}", expr_to_text(condition)), - StatementKind::DoWhile { condition, .. } => format!("do {{ … }} while ({})", expr_to_text(condition)), - StatementKind::Return { value } => match value { - Some(v) => format!("return {}", expr_to_text(v)), - None => "return".to_string(), - }, - StatementKind::Emit { event_name, arguments } => { - let args = arguments.iter().map(expr_to_text).collect::<Vec<_>>().join(", "); - format!("emit {}({})", event_name, args) - } - StatementKind::Revert { error_name, arguments } => { - let args = arguments.iter().map(expr_to_text).collect::<Vec<_>>().join(", "); - match error_name { - Some(n) => format!("revert {}({})", n, args), - None => format!("revert({})", args), - } - } - StatementKind::TryCatch { expression, .. } => { - format!("try {} {{ … }} catch {{ … }}", expr_to_text(expression)) - } - StatementKind::Block { .. } => "{ … }".to_string(), - StatementKind::UncheckedBlock { .. } => "unchecked { … }".to_string(), - StatementKind::Assembly { .. } => "assembly { … }".to_string(), - StatementKind::Placeholder => "_".to_string(), - StatementKind::Continue => "continue".to_string(), - StatementKind::Break => "break".to_string(), - } -} - -/// Minimal expression-to-text for slice entries. Keeps the output -/// compact — full fidelity is not the goal. -fn expr_to_text(expr: &Expression) -> String { - match &expr.kind { - ExpressionKind::Identifier { name } => name.clone(), - ExpressionKind::Literal { value, .. } => value.clone(), - ExpressionKind::MemberAccess { object, member } => { - format!("{}.{}", expr_to_text(object), member) - } - ExpressionKind::IndexAccess { base, index } => { - let idx = index.as_ref().map(|e| expr_to_text(e)).unwrap_or_default(); - format!("{}[{}]", expr_to_text(base), idx) - } - ExpressionKind::BinaryOp { left, operator, right } => { - format!("{} {} {}", expr_to_text(left), operator.as_str(), expr_to_text(right)) - } - ExpressionKind::UnaryOp { operator, operand } => { - let (sym, postfix) = operator.format_parts(); - if postfix { - format!("{}{}", expr_to_text(operand), sym) - } else { - format!("{}{}", sym, expr_to_text(operand)) - } - } - ExpressionKind::FunctionCall { callee, arguments } => { - let args = arguments.iter().map(expr_to_text).collect::<Vec<_>>().join(", "); - format!("{}({})", expr_to_text(callee), args) - } - ExpressionKind::Assignment { target, operator, value } => { - format!("{} {} {}", expr_to_text(target), operator.as_str(), expr_to_text(value)) - } - ExpressionKind::Ternary { condition, true_expr, false_expr } => { - format!("{} ? {} : {}", expr_to_text(condition), expr_to_text(true_expr), expr_to_text(false_expr)) - } - ExpressionKind::TypeCast { type_name, expression } => { - format!("{}({})", type_name, expr_to_text(expression)) - } - ExpressionKind::TypeMeta { type_name } => format!("type({})", type_name), - ExpressionKind::New { type_name, arguments } => { - let args = arguments.iter().map(expr_to_text).collect::<Vec<_>>().join(", "); - format!("new {}({})", type_name, args) - } - } -} diff --git a/crates/ilold-core/src/slicing/flatten.rs b/crates/ilold-core/src/slicing/flatten.rs deleted file mode 100644 index cbb395c..0000000 --- a/crates/ilold-core/src/slicing/flatten.rs +++ /dev/null @@ -1,164 +0,0 @@ -use crate::model::contract::ContractDef; -use crate::model::function::FunctionDef; -use crate::model::project::Project; -use crate::model::statement::{Statement, StatementKind}; - -use super::types::{StatementOrigin, StatementPath}; - -/// A single statement in the flattened walk of a function body, carrying -/// its position in program order, nesting depth, a back-link to its -/// lexical parent (another `FlatStatement` index) when nested inside -/// If/For/While/Block/TryCatch, and an `origin` tag distinguishing -/// statements lifted from the function body vs from a wrapping modifier. -pub struct FlatStatement<'a> { - pub path: StatementPath, - pub statement: &'a Statement, - pub depth: usize, - pub parent: Option<usize>, - pub origin: StatementOrigin, -} - -/// Walk a function and every applied modifier in execution order, emitting -/// one `FlatStatement` per encountered statement. -/// -/// Order: for `function f() mod1 mod2 { body }` we walk -/// `mod1.before` → `mod2.before` → `body` → `mod2.after` → `mod1.after` -/// where each modifier body is split at the first top-level `_;` -/// placeholder. Modifiers without an explicit placeholder are treated -/// entirely as `before` (over-inclusive but lossless). -/// -/// Modifier resolution walks the inheritance chain via -/// `Project::resolve_modifier`; unresolved modifiers are silently -/// skipped (the slicer should not crash on a partially-parsed project). -/// -/// Paths are single-element `[global_index]` values that match the -/// position of the entry in the returned vector — the same scheme as -/// before, just over a larger flat list. -pub fn flatten_function<'a>( - project: &'a Project, - contract: &'a ContractDef, - function: &'a FunctionDef, -) -> Vec<FlatStatement<'a>> { - let mut out: Vec<FlatStatement<'a>> = Vec::new(); - - let body: &'a [Statement] = function.body.as_deref().unwrap_or(&[]); - - // Resolve every applied modifier and split its body at the first - // top-level Placeholder. We collect the references up-front so we - // can iterate the "after" parts in reverse order at the end. - struct ModifierParts<'a> { - name: &'a str, - before: &'a [Statement], - after: &'a [Statement], - } - let mut modifier_parts: Vec<ModifierParts<'a>> = Vec::new(); - for m_ref in &function.modifiers { - let Some(m_def) = project.resolve_modifier(contract, &m_ref.name) else { - continue; - }; - let split = m_def - .body - .iter() - .position(|s| matches!(s.kind, StatementKind::Placeholder)); - let (before, after) = match split { - Some(idx) => (&m_def.body[..idx], &m_def.body[idx + 1..]), - None => (m_def.body.as_slice(), &[][..]), - }; - modifier_parts.push(ModifierParts { - name: m_def.name.as_str(), - before, - after, - }); - } - - // Walk every modifier's "before" half, in declaration order. - for parts in &modifier_parts { - let origin = StatementOrigin::Modifier(parts.name.to_string()); - for stmt in parts.before { - walk(stmt, 0, None, &origin, &mut out); - } - } - - // Walk the function body itself. - for stmt in body { - walk(stmt, 0, None, &StatementOrigin::FunctionBody, &mut out); - } - - // Walk every modifier's "after" half in reverse — innermost modifier - // unwraps first, matching Solidity's call stack semantics. - for parts in modifier_parts.iter().rev() { - let origin = StatementOrigin::Modifier(parts.name.to_string()); - for stmt in parts.after { - walk(stmt, 0, None, &origin, &mut out); - } - } - - out -} - -fn walk<'a>( - stmt: &'a Statement, - depth: usize, - parent: Option<usize>, - origin: &StatementOrigin, - out: &mut Vec<FlatStatement<'a>>, -) { - let idx = out.len(); - out.push(FlatStatement { - path: StatementPath(vec![idx]), - statement: stmt, - depth, - parent, - origin: origin.clone(), - }); - - let child_parent = Some(idx); - let child_depth = depth + 1; - - match &stmt.kind { - StatementKind::If { then_body, else_body, .. } => { - for s in then_body { - walk(s, child_depth, child_parent, origin, out); - } - if let Some(eb) = else_body { - for s in eb { - walk(s, child_depth, child_parent, origin, out); - } - } - } - StatementKind::For { init, body, .. } => { - if let Some(init_stmt) = init { - walk(init_stmt, child_depth, child_parent, origin, out); - } - for s in body { - walk(s, child_depth, child_parent, origin, out); - } - } - StatementKind::While { body, .. } | StatementKind::DoWhile { body, .. } => { - for s in body { - walk(s, child_depth, child_parent, origin, out); - } - } - StatementKind::Block { statements } | StatementKind::UncheckedBlock { statements } => { - for s in statements { - walk(s, child_depth, child_parent, origin, out); - } - } - StatementKind::TryCatch { clauses, .. } => { - for clause in clauses { - for s in &clause.body { - walk(s, child_depth, child_parent, origin, out); - } - } - } - StatementKind::ExpressionStmt { .. } - | StatementKind::VariableDeclaration { .. } - | StatementKind::Return { .. } - | StatementKind::Emit { .. } - | StatementKind::Revert { .. } - | StatementKind::Assembly { .. } - | StatementKind::Placeholder - | StatementKind::Continue - | StatementKind::Break => {} - } -} diff --git a/crates/ilold-core/src/slicing/forward.rs b/crates/ilold-core/src/slicing/forward.rs deleted file mode 100644 index b82526f..0000000 --- a/crates/ilold-core/src/slicing/forward.rs +++ /dev/null @@ -1,61 +0,0 @@ -use std::collections::{BTreeSet, HashSet}; - -use super::backward::add_ancestors_with_uses; -use super::extract::extract_statement_uses_defs; -use super::flatten::FlatStatement; -use super::types::StatementPath; - -/// Intraprocedural forward dataflow slice for `variable`. -/// -/// Iterates to a fixed point over the flattened body: -/// 1. Any statement whose use set intersects the `tainted` set is added -/// to the slice and its defs are merged into `tainted` — taint -/// spreads along data dependencies. -/// 2. Every included statement drags in its lexical ancestors so that -/// enclosing control-flow headers appear in the rendered slice. The -/// ancestor's own uses are merged into `tainted` too, which keeps -/// parity with `backward_slice` and handles nested propagation. -/// The loop re-runs until neither set grows. -/// -/// Returns the selected paths in program order. -pub fn forward_slice(flat: &[FlatStatement<'_>], variable: &str) -> Vec<StatementPath> { - let mut tainted: HashSet<String> = HashSet::new(); - tainted.insert(variable.to_string()); - - let mut included: BTreeSet<usize> = BTreeSet::new(); - - loop { - let mut changed = false; - - for i in 0..flat.len() { - if included.contains(&i) { - continue; - } - let (uses, defs) = extract_statement_uses_defs(flat[i].statement); - if uses.is_disjoint(&tainted) { - continue; - } - included.insert(i); - changed = true; - for d in defs { - if tainted.insert(d) { - changed = true; - } - } - } - - // Known: ancestor merge may over-taint — an enclosing `if (c)` - // adds `c` to the tainted set even if the sliced variable has no - // data relationship with `c`. Acceptable for v1; a precise - // control-dep analysis would require a separate CDG pass. - if add_ancestors_with_uses(flat, &mut included, &mut tainted) { - changed = true; - } - - if !changed { - break; - } - } - - included.into_iter().map(|i| flat[i].path.clone()).collect() -} diff --git a/crates/ilold-core/src/slicing/mod.rs b/crates/ilold-core/src/slicing/mod.rs deleted file mode 100644 index 627ff55..0000000 --- a/crates/ilold-core/src/slicing/mod.rs +++ /dev/null @@ -1,104 +0,0 @@ -// Intraprocedural backward + forward dataflow slicing over the model AST. -// -// Operates on `FunctionDef.body: Vec<Statement>` directly, not on the CFG. -// The CFG stringifies expressions and discards structure; we need the -// structured AST to extract variable use/def sets. -// -// Known extraction limitations (v1, documented so callers can reason -// about false negatives in the slice): -// -// * Only `Assignment` expressions produce DEFs. Solidity mutations that -// are not modelled as Assignment — `x++`, `--x`, `delete x`, -// `arr.push(v)`, `arr.pop()` — are captured as USEs of `x`/`arr` but -// NOT as DEFs. A slice on a variable mutated only through `.push()` -// will miss the mutating call as a def. -// * Tuple destructuring assignments `(a, b) = foo()` may not hit the -// top-level `Assignment` arm depending on how the frontend lowers -// them; in that case sub-expressions are treated as USEs only. -// * Function-call side effects on state are not tracked — this is an -// intraprocedural slice, not a whole-program one. -// -// These are acceptable for the audit use case (the 80% that relies on -// plain `x = …` assignments works correctly). Lifting them requires an -// effects database, which is out of scope for Phase 2b. - -pub mod backward; -pub mod extract; -pub mod flatten; -pub mod forward; -pub mod types; - -use crate::model::contract::ContractDef; -use crate::model::function::FunctionDef; -use crate::model::project::Project; - -pub use types::{SliceDirection, SliceEntry, SliceResult, StatementOrigin, StatementPath}; - -/// Compute a dataflow slice for `variable` in `function`. -/// -/// The slicer walks both the function body AND the bodies of every -/// applied modifier (resolved through the inheritance chain), so writes -/// hidden inside `nonReentrant` / `updateReward` / etc. show up in the -/// slice. Each entry carries an `origin` tag distinguishing -/// function-body statements from modifier statements. -/// -/// `Backward`: statements whose values feed into reads of `variable`. -/// `Forward`: statements whose values derive from writes of `variable`. -/// `Both`: union of backward and forward, annotated per entry. -pub fn build_slice_result( - project: &Project, - contract: &ContractDef, - function: &FunctionDef, - variable: &str, - direction: SliceDirection, -) -> SliceResult { - let flat = flatten::flatten_function(project, contract, function); - - if flat.is_empty() { - return SliceResult { - function: function.name.clone(), - variable: variable.to_string(), - direction, - backward: Vec::new(), - forward: Vec::new(), - }; - } - - let backward = if matches!(direction, SliceDirection::Backward | SliceDirection::Both) { - backward::backward_slice(&flat, variable) - } else { - Vec::new() - }; - - let forward = if matches!(direction, SliceDirection::Forward | SliceDirection::Both) { - forward::forward_slice(&flat, variable) - } else { - Vec::new() - }; - - SliceResult { - function: function.name.clone(), - variable: variable.to_string(), - direction, - backward: backward.into_iter().map(|p| entry_for(&flat, p)).collect(), - forward: forward.into_iter().map(|p| entry_for(&flat, p)).collect(), - } -} - -fn entry_for(flat: &[flatten::FlatStatement<'_>], path: StatementPath) -> SliceEntry { - let entry = flat.iter().find(|f| f.path == path); - match entry { - Some(f) => SliceEntry { - path, - span: Some(f.statement.span), - text: extract::statement_text(f.statement), - origin: f.origin.clone(), - }, - None => SliceEntry { - path, - span: None, - text: String::new(), - origin: StatementOrigin::FunctionBody, - }, - } -} diff --git a/crates/ilold-core/src/slicing/types.rs b/crates/ilold-core/src/slicing/types.rs deleted file mode 100644 index efa52a4..0000000 --- a/crates/ilold-core/src/slicing/types.rs +++ /dev/null @@ -1,75 +0,0 @@ -use serde::{Deserialize, Serialize}; - -use crate::model::common::SourceSpan; - -/// Direction of a dataflow slice. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum SliceDirection { - /// Statements whose values feed the target variable (sources → var). - Backward, - /// Statements whose values derive from the target variable (var → sinks). - Forward, - /// Both directions, reported separately in the result. - Both, -} - -/// Where a sliced statement was lifted from. The slicer walks both the -/// function body and the bodies of every applied modifier; entries carry -/// this tag so renderers can distinguish "real" function code from -/// modifier code that wraps it. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(tag = "kind", content = "name")] -pub enum StatementOrigin { - FunctionBody, - /// `name` is the modifier identifier (e.g. `updateReward`). - Modifier(String), -} - -impl Default for StatementOrigin { - fn default() -> Self { - StatementOrigin::FunctionBody - } -} - -/// Unique identifier for a statement inside a flattened function body. -/// -/// The slicing pipeline pre-walks the function body in program order and -/// assigns every statement — including nested ones inside If / For / -/// While / Block / TryCatch — a single global index. A `StatementPath` -/// wraps that index as `vec![global_index]`. We keep it as a `Vec<usize>` -/// (rather than a bare `usize`) so the serialized shape stays open for a -/// future hierarchical scheme without breaking on-disk session formats. -#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub struct StatementPath(pub Vec<usize>); - -impl StatementPath { - pub fn new(indices: Vec<usize>) -> Self { - StatementPath(indices) - } -} - -/// One statement in a slice result. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SliceEntry { - pub path: StatementPath, - #[serde(default)] - pub span: Option<SourceSpan>, - /// Rendered source-like text of the statement (e.g. `reserve0 = uint112(balance0)`). - pub text: String, - /// Function-body statement vs modifier-body statement. Defaults to - /// `FunctionBody` so older serialized payloads keep deserializing. - #[serde(default)] - pub origin: StatementOrigin, -} - -/// Full slice result for a (function, variable) query. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SliceResult { - pub function: String, - pub variable: String, - pub direction: SliceDirection, - /// Backward slice entries in program order. Empty if direction is Forward. - pub backward: Vec<SliceEntry>, - /// Forward slice entries in program order. Empty if direction is Backward. - pub forward: Vec<SliceEntry>, -} diff --git a/crates/ilold-core/src/util.rs b/crates/ilold-core/src/util.rs deleted file mode 100644 index 2185c5e..0000000 --- a/crates/ilold-core/src/util.rs +++ /dev/null @@ -1,39 +0,0 @@ -//! Shared utility helpers used across multiple analysis passes. - -/// Filter out type casts that look like function calls -/// (e.g. `IERC20(addr)`, `address(0)`, `uint256(x)`). -/// -/// Used by the call graph builder and by transitive-effect analysis -/// to avoid treating type casts as real internal calls. -pub fn is_type_cast(name: &str) -> bool { - let name = name.trim(); - // Solidity elementary types - if name.starts_with("type(") - || name.starts_with("address") - || name.starts_with("uint") - || name.starts_with("int") - || name.starts_with("bytes") - || name.starts_with("bool") - || name.starts_with("string") - { - return true; - } - // Interface type casts: starts with I + uppercase (IERC20, IUniswapV2Pair) - if name.starts_with('I') - && name.len() > 1 - && name.chars().nth(1).is_some_and(|c| c.is_uppercase()) - { - return true; - } - false -} - -/// Extract the base variable name from an assignment target expression. -/// -/// Strips any indexing (`balances[user]` → `balances`) and field access -/// (`config.fee` → `config`). Used by mutation harvesters and path -/// analyzers to match target expressions against state variable names. -pub fn target_base_name(target: &str) -> &str { - let base = target.split('[').next().unwrap_or(target); - base.split('.').next().unwrap_or(base) -} diff --git a/crates/ilold-core/tests/callgraph_test.rs b/crates/ilold-core/tests/callgraph_test.rs deleted file mode 100644 index a29de6a..0000000 --- a/crates/ilold-core/tests/callgraph_test.rs +++ /dev/null @@ -1,86 +0,0 @@ -use std::path::PathBuf; - -use ilold_core::callgraph::builder::build_call_graph; -use ilold_core::callgraph::types::CallKind; -use ilold_core::parse::solar_frontend::SolarParser; -use ilold_core::parse::ProjectParser; - -fn fixture_path(name: &str) -> PathBuf { - PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .parent().unwrap() - .parent().unwrap() - .join("tests/fixtures") - .join(name) -} - -#[test] -fn test_staking_call_graph() { - let parser = SolarParser; - let mut project = parser.parse(&[fixture_path("staking.sol")]).unwrap(); - project.rebuild_index(); - - let staking = project.contracts.iter().find(|c| c.name == "Staking").unwrap(); - let cg = build_call_graph(&project, staking); - - println!("Call graph nodes:"); - for idx in cg.node_indices() { - let node = &cg[idx]; - println!(" {}::{} (external={})", node.contract, node.function, node.is_external); - } - - println!("\nCall graph edges:"); - for edge_idx in cg.edge_indices() { - let (src, dst) = cg.edge_endpoints(edge_idx).unwrap(); - let edge = &cg[edge_idx]; - println!(" {}::{} → {}::{} ({:?}, count={})", - cg[src].contract, cg[src].function, - cg[dst].contract, cg[dst].function, - edge.kind, edge.call_count); - } - - // deposit calls stakingToken.transferFrom (external) - let has_deposit_to_transfer = cg.edge_indices().any(|e| { - let (src, dst) = cg.edge_endpoints(e).unwrap(); - cg[src].function == "deposit" - && cg[dst].function == "transferFrom" - && cg[dst].is_external - }); - assert!(has_deposit_to_transfer, "deposit should call stakingToken.transferFrom"); - - // claimRewards calls rewardToken.transfer (external) - let has_claim_to_transfer = cg.edge_indices().any(|e| { - let (src, dst) = cg.edge_endpoints(e).unwrap(); - cg[src].function == "claimRewards" - && cg[dst].function == "transfer" - && cg[dst].is_external - }); - assert!(has_claim_to_transfer, "claimRewards should call rewardToken.transfer"); - - // deposit calls rewardPerToken (internal, from inlined modifier) - let has_deposit_to_reward = cg.edge_indices().any(|e| { - let (src, dst) = cg.edge_endpoints(e).unwrap(); - cg[src].function == "deposit" - && cg[dst].function == "rewardPerToken" - }); - assert!(has_deposit_to_reward, "deposit should call rewardPerToken (from updateReward modifier)"); - - // setRewardRate has NO external calls - let set_rate_external = cg.edge_indices().any(|e| { - let (src, _) = cg.edge_endpoints(e).unwrap(); - cg[src].function == "setRewardRate" && cg[e].kind == CallKind::External - }); - assert!(!set_rate_external, "setRewardRate should have no external calls"); -} - -#[test] -fn test_simple_storage_no_calls() { - let parser = SolarParser; - let mut project = parser.parse(&[fixture_path("simple_storage.sol")]).unwrap(); - project.rebuild_index(); - - let contract = &project.contracts[0]; - let cg = build_call_graph(&project, contract); - - // SimpleStorage has no function calls — graph should have 0 edges - assert_eq!(cg.edge_count(), 0, "SimpleStorage should have no call edges"); -} diff --git a/crates/ilold-core/tests/integration_test.rs b/crates/ilold-core/tests/integration_test.rs deleted file mode 100644 index 8152af8..0000000 --- a/crates/ilold-core/tests/integration_test.rs +++ /dev/null @@ -1,108 +0,0 @@ -use std::path::PathBuf; - -use ilold_core::cfg::builder::CfgBuilder; -use ilold_core::cfg::types::BlockKind; -use ilold_core::model::function::{FunctionKind, Visibility, Mutability}; -use ilold_core::parse::solar_frontend::SolarParser; -use ilold_core::parse::ProjectParser; - -fn fixture_path(name: &str) -> PathBuf { - PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .parent() - .unwrap() - .parent() - .unwrap() - .join("tests/fixtures") - .join(name) -} - -#[test] -fn test_parse_simple_storage() { - let parser = SolarParser; - let project = parser.parse(&[fixture_path("simple_storage.sol")]).unwrap(); - - // Should have 1 contract - assert_eq!(project.contracts.len(), 1); - let contract = &project.contracts[0]; - assert_eq!(contract.name, "SimpleStorage"); - - // Should have 2 functions: get and set - assert_eq!(contract.functions.len(), 2); - - let get_fn = contract.functions.iter().find(|f| f.name == "get").unwrap(); - assert_eq!(get_fn.visibility, Visibility::Public); - assert_eq!(get_fn.mutability, Mutability::View); - assert_eq!(get_fn.kind, FunctionKind::Function); - assert_eq!(get_fn.returns.len(), 1); - assert_eq!(get_fn.returns[0].type_name, "uint256"); - - let set_fn = contract.functions.iter().find(|f| f.name == "set").unwrap(); - assert_eq!(set_fn.visibility, Visibility::Public); - assert_eq!(set_fn.mutability, Mutability::NonPayable); - assert_eq!(set_fn.params.len(), 1); - assert_eq!(set_fn.params[0].name, "newValue"); - assert_eq!(set_fn.params[0].type_name, "uint256"); - - // Should have 1 state variable - assert_eq!(contract.state_vars.len(), 1); - assert_eq!(contract.state_vars[0].name, "value"); - - // Should have 1 event - assert_eq!(contract.events.len(), 1); - assert_eq!(contract.events[0].name, "ValueChanged"); -} - -#[test] -fn test_cfg_simple_get() { - let parser = SolarParser; - let project = parser.parse(&[fixture_path("simple_storage.sol")]).unwrap(); - let contract = &project.contracts[0]; - let get_fn = contract.functions.iter().find(|f| f.name == "get").unwrap(); - - let cfg = CfgBuilder::build(get_fn, contract).unwrap(); - - // get() just returns — should have Entry + Return (2 nodes minimum) - let node_count = cfg.node_count(); - assert!(node_count >= 2, "Expected at least 2 nodes, got {node_count}"); - - // Should have exactly one Entry and one Return - let entries: Vec<_> = cfg.node_weights().filter(|b| b.kind == BlockKind::Entry).collect(); - let returns: Vec<_> = cfg.node_weights().filter(|b| b.kind == BlockKind::Return).collect(); - assert_eq!(entries.len(), 1); - assert_eq!(returns.len(), 1); -} - -#[test] -fn test_cfg_set_with_require() { - let parser = SolarParser; - let project = parser.parse(&[fixture_path("simple_storage.sol")]).unwrap(); - let contract = &project.contracts[0]; - let set_fn = contract.functions.iter().find(|f| f.name == "set").unwrap(); - - let cfg = CfgBuilder::build(set_fn, contract).unwrap(); - - // set() has require(newValue > 0) which creates a branch: - // Entry → [require check] → True path (assignment + emit + return) - // → False path (revert) - let node_count = cfg.node_count(); - assert!(node_count >= 4, "Expected at least 4 nodes, got {node_count}"); - - // Must have at least one Revert node (from require failing) - let reverts: Vec<_> = cfg.node_weights().filter(|b| b.kind == BlockKind::Revert).collect(); - assert!(!reverts.is_empty(), "Expected at least one Revert node"); - - // Must have at least one Return node - let returns: Vec<_> = cfg.node_weights().filter(|b| b.kind == BlockKind::Return).collect(); - assert!(!returns.is_empty(), "Expected at least one Return node"); - - // Must have branch edges (from require) - let edge_count = cfg.edge_count(); - assert!(edge_count >= 3, "Expected at least 3 edges, got {edge_count}"); -} - -#[test] -fn test_parse_file_not_found() { - let parser = SolarParser; - let result = parser.parse(&[PathBuf::from("nonexistent.sol")]); - assert!(result.is_err()); -} diff --git a/crates/ilold-core/tests/pathtree_test.rs b/crates/ilold-core/tests/pathtree_test.rs deleted file mode 100644 index 05eb4f2..0000000 --- a/crates/ilold-core/tests/pathtree_test.rs +++ /dev/null @@ -1,123 +0,0 @@ -use std::path::PathBuf; - -use ilold_core::cfg::builder::CfgBuilder; -use ilold_core::parse::solar_frontend::SolarParser; -use ilold_core::parse::ProjectParser; -use ilold_core::pathtree::config::PruningConfig; -use ilold_core::pathtree::types::TerminalKind; -use ilold_core::pathtree::walker::build_path_tree; - -fn fixture_path(name: &str) -> PathBuf { - PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .parent().unwrap() - .parent().unwrap() - .join("tests/fixtures") - .join(name) -} - -// REQ-PT-5: set() must produce exactly 2 paths (1 happy + 1 revert) -#[test] -fn test_set_produces_2_paths() { - let parser = SolarParser; - let project = parser.parse(&[fixture_path("simple_storage.sol")]).unwrap(); - let contract = &project.contracts[0]; - let set_fn = contract.functions.iter().find(|f| f.name == "set").unwrap(); - let cfg = CfgBuilder::build(set_fn, contract).unwrap(); - - let pt = build_path_tree(&cfg, &contract.name, "set", &contract.state_vars, &PruningConfig::default()); - - println!("set() paths:"); - for p in &pt.paths { - let nodes: Vec<String> = p.nodes.iter().map(|n| format!("{:?}", n.block_kind)).collect(); - println!(" [{}] {:?} → {}", p.id, p.terminal, nodes.join(" → ")); - } - - assert_eq!(pt.stats.total_paths, 2, "set() should have exactly 2 paths"); - assert_eq!(pt.stats.happy_paths, 1); - assert_eq!(pt.stats.revert_paths, 1); -} - -// REQ-PT-6: transferFrom() must produce exactly 5 paths (1 happy + 4 revert) -#[test] -fn test_transfer_from_produces_5_paths() { - let parser = SolarParser; - let project = parser.parse(&[fixture_path("erc20.sol")]).unwrap(); - let contract = &project.contracts[0]; - let func = contract.functions.iter().find(|f| f.name == "transferFrom").unwrap(); - let cfg = CfgBuilder::build(func, contract).unwrap(); - - let pt = build_path_tree(&cfg, &contract.name, "transferFrom", &contract.state_vars, &PruningConfig::default()); - - println!("transferFrom() paths:"); - for p in &pt.paths { - let nodes: Vec<String> = p.nodes.iter().map(|n| format!("{:?}", n.block_kind)).collect(); - println!(" [{}] {:?} → {}", p.id, p.terminal, nodes.join(" → ")); - } - - assert_eq!(pt.stats.total_paths, 5, "transferFrom() should have exactly 5 paths"); - assert_eq!(pt.stats.happy_paths, 1); - assert_eq!(pt.stats.revert_paths, 4); -} - -// REQ-PT-4: paths should have annotations (external calls, state writes, events) -#[test] -fn test_path_annotations() { - let parser = SolarParser; - let project = parser.parse(&[fixture_path("simple_storage.sol")]).unwrap(); - let contract = &project.contracts[0]; - let set_fn = contract.functions.iter().find(|f| f.name == "set").unwrap(); - let cfg = CfgBuilder::build(set_fn, contract).unwrap(); - - let pt = build_path_tree(&cfg, &contract.name, "set", &contract.state_vars, &PruningConfig::default()); - - // The happy path should have: state write (value) + event (ValueChanged) - let happy = pt.paths.iter().find(|p| p.terminal == TerminalKind::Return).unwrap(); - println!("Happy path annotations: {:?}", happy.annotations); - - assert!(!happy.annotations.state_writes.is_empty(), "Happy path should write state"); - assert!(!happy.annotations.events_emitted.is_empty(), "Happy path should emit event"); - assert!(!happy.annotations.require_checks.is_empty(), "Happy path should have require check"); - - // The revert path should have: require check but no state write - let revert = pt.paths.iter().find(|p| p.terminal == TerminalKind::Revert).unwrap(); - assert!(revert.annotations.state_writes.is_empty(), "Revert path should NOT write state"); -} - -// REQ-PT-8: loops should be unrolled max 3 times -#[test] -fn test_loop_unrolling() { - let parser = SolarParser; - let project = parser.parse(&[fixture_path("uniswap_v2_pair.sol")]).unwrap(); - let contract = project.contracts.iter().find(|c| c.name == "UniswapV2Pair").unwrap(); - let sqrt_fn = contract.functions.iter().find(|f| f.name == "_sqrt").unwrap(); - let cfg = CfgBuilder::build(sqrt_fn, contract).unwrap(); - - let config = PruningConfig { max_loop_unroll: 3, ..PruningConfig::default() }; - let pt = build_path_tree(&cfg, &contract.name, "_sqrt", &contract.state_vars, &config); - - println!("_sqrt() paths: {} total, {} pruned", pt.stats.total_paths, pt.stats.paths_pruned); - for p in &pt.paths { - println!(" [{}] {:?} depth={}", p.id, p.terminal, p.depth); - } - - // Should have some LoopCutoff paths (loop was stopped) - let loop_cutoffs = pt.paths.iter().filter(|p| p.terminal == TerminalKind::LoopCutoff).count(); - println!("Loop cutoff paths: {loop_cutoffs}"); - - // _sqrt has a while loop — with max_unroll=3, we should get cutoffs - assert!(loop_cutoffs > 0, "_sqrt should have loop cutoff paths"); -} - -// Interface functions should produce 0 paths -#[test] -fn test_interface_function_no_paths() { - let parser = SolarParser; - let project = parser.parse(&[fixture_path("staking.sol")]).unwrap(); - let ierc20 = project.contracts.iter().find(|c| c.name == "IERC20").unwrap(); - let transfer = ierc20.functions.iter().find(|f| f.name == "transfer").unwrap(); - let cfg = CfgBuilder::build(transfer, ierc20).unwrap(); - - let pt = build_path_tree(&cfg, "IERC20", "transfer", &ierc20.state_vars, &PruningConfig::default()); - - assert_eq!(pt.stats.total_paths, 0, "Interface function should have 0 paths"); -} diff --git a/crates/ilold-core/tests/sequence_test.rs b/crates/ilold-core/tests/sequence_test.rs deleted file mode 100644 index 84f5756..0000000 --- a/crates/ilold-core/tests/sequence_test.rs +++ /dev/null @@ -1,119 +0,0 @@ -use std::path::PathBuf; - -use ilold_core::cfg::builder::CfgBuilder; -use ilold_core::parse::solar_frontend::SolarParser; -use ilold_core::parse::ProjectParser; -use ilold_core::pathtree::config::PruningConfig; -use ilold_core::pathtree::walker::build_path_tree; -use ilold_core::sequence::builder::build_sequence_tree; - -fn fixture_path(name: &str) -> PathBuf { - PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .parent().unwrap() - .parent().unwrap() - .join("tests/fixtures") - .join(name) -} - -fn build_staking_path_trees() -> (ilold_core::model::project::Project, Vec<ilold_core::pathtree::types::PathTree>) { - let parser = SolarParser; - let mut project = parser.parse(&[fixture_path("staking.sol")]).unwrap(); - project.rebuild_index(); - - let staking = project.contracts.iter().find(|c| c.name == "Staking").unwrap(); - let config = PruningConfig::default(); - - let path_trees: Vec<_> = staking - .functions - .iter() - .map(|f| { - let cfg = CfgBuilder::build(f, staking).unwrap(); - build_path_tree(&cfg, &staking.name, &f.name, &staking.state_vars, &config) - }) - .collect(); - - (project, path_trees) -} - -// REQ-SEQ-4: 8 functions, depth 2 → 72 sequences (8 + 64) -#[test] -fn test_staking_sequence_count_depth_2() { - let (project, path_trees) = build_staking_path_trees(); - let staking = project.contracts.iter().find(|c| c.name == "Staking").unwrap(); - - let st = build_sequence_tree(staking, &path_trees, 2); - - println!("Functions in sequence tree ({}):", st.functions.len()); - for (i, f) in st.functions.iter().enumerate() { - println!(" [{}] {} (read_only={}, paths={})", i, f.name, f.read_only, f.path_count); - } - - let depth_1 = st.sequences.iter().filter(|s| s.depth == 1).count(); - let depth_2 = st.sequences.iter().filter(|s| s.depth == 2).count(); - println!("\nSequences: {} total (depth 1: {}, depth 2: {})", st.sequences.len(), depth_1, depth_2); - - assert_eq!(st.functions.len(), 8, "Should have 8 public/external functions (excluding constructor)"); - assert_eq!(depth_1, 8); - assert_eq!(depth_2, 64); - assert_eq!(st.sequences.len(), 72, "Total should be 8 + 64 = 72"); -} - -// REQ-SEQ-1: read_only flag correct -#[test] -fn test_read_only_flags() { - let (project, path_trees) = build_staking_path_trees(); - let staking = project.contracts.iter().find(|c| c.name == "Staking").unwrap(); - - let st = build_sequence_tree(staking, &path_trees, 1); - - let read_only: Vec<_> = st.functions.iter().filter(|f| f.read_only).collect(); - let state_changing: Vec<_> = st.functions.iter().filter(|f| !f.read_only).collect(); - - println!("Read-only: {:?}", read_only.iter().map(|f| &f.name).collect::<Vec<_>>()); - println!("State-changing: {:?}", state_changing.iter().map(|f| &f.name).collect::<Vec<_>>()); - - // rewardPerToken (view) and earned (view) should be read_only - assert_eq!(read_only.len(), 2, "Should have 2 read-only functions"); - assert_eq!(state_changing.len(), 6, "Should have 6 state-changing functions"); -} - -// REQ-SEQ-6: constructors excluded -#[test] -fn test_constructor_excluded() { - let (project, path_trees) = build_staking_path_trees(); - let staking = project.contracts.iter().find(|c| c.name == "Staking").unwrap(); - - let st = build_sequence_tree(staking, &path_trees, 1); - - let has_constructor = st.functions.iter().any(|f| f.name.is_empty() || f.name == "constructor"); - assert!(!has_constructor, "Constructor should not appear in sequences"); -} - -// REQ-SEQ-3: has_state_change correct -#[test] -fn test_has_state_change_flag() { - let (project, path_trees) = build_staking_path_trees(); - let staking = project.contracts.iter().find(|c| c.name == "Staking").unwrap(); - - let st = build_sequence_tree(staking, &path_trees, 2); - - // A sequence of only read-only functions should have has_state_change = false - let earned_idx = st.functions.iter().position(|f| f.name == "earned").unwrap(); - let reward_idx = st.functions.iter().position(|f| f.name == "rewardPerToken").unwrap(); - - let read_only_seq = st.sequences.iter().find(|s| { - s.depth == 2 && s.steps == vec![earned_idx, reward_idx] - }); - - if let Some(seq) = read_only_seq { - assert!(!seq.has_state_change, "earned → rewardPerToken should have no state change"); - } - - // A sequence with deposit should have has_state_change = true - let deposit_idx = st.functions.iter().position(|f| f.name == "deposit").unwrap(); - let deposit_seq = st.sequences.iter().find(|s| { - s.depth == 1 && s.steps == vec![deposit_idx] - }).unwrap(); - - assert!(deposit_seq.has_state_change, "deposit should have state change"); -} diff --git a/crates/ilold-core/tests/slicing_test.rs b/crates/ilold-core/tests/slicing_test.rs deleted file mode 100644 index f29df4e..0000000 --- a/crates/ilold-core/tests/slicing_test.rs +++ /dev/null @@ -1,191 +0,0 @@ -use std::path::PathBuf; - -use ilold_core::parse::solar_frontend::SolarParser; -use ilold_core::parse::ProjectParser; -use ilold_core::slicing::{ - build_slice_result, SliceDirection, SliceEntry, SliceResult, StatementOrigin, -}; - -fn fixture_path(name: &str) -> PathBuf { - PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .parent().unwrap() - .parent().unwrap() - .join("tests/fixtures") - .join(name) -} - -fn slice( - contract: &str, - function: &str, - variable: &str, - direction: SliceDirection, -) -> SliceResult { - let parser = SolarParser; - let mut project = parser.parse(&[fixture_path("staking.sol")]).unwrap(); - project.rebuild_index(); - - let c = project - .contracts - .iter() - .find(|c| c.name == contract) - .unwrap_or_else(|| panic!("contract {contract} not found")); - let f = c - .functions - .iter() - .find(|f| f.name == function) - .unwrap_or_else(|| panic!("function {function} not found")); - - build_slice_result(&project, c, f, variable, direction) -} - -fn texts(entries: &[SliceEntry]) -> Vec<String> { - entries.iter().map(|e| e.text.clone()).collect() -} - -fn modifier_origin(entries: &[SliceEntry], substr: &str) -> Option<String> { - entries.iter().find(|e| e.text.contains(substr)).and_then(|e| match &e.origin { - StatementOrigin::Modifier(name) => Some(name.clone()), - StatementOrigin::FunctionBody => None, - }) -} - -#[test] -fn backward_slice_picks_up_transitive_assignment() { - // deposit(): totalStaked += amount. The backward slice on totalStaked - // should include that line and the `require(amount > 0)` can be - // skipped (require is an ExpressionStmt/FunctionCall, not an - // assignment, so it's not a def of totalStaked — but it does USE - // `amount`, so it won't appear in backward slice). - let res = slice("Staking", "deposit", "totalStaked", SliceDirection::Backward); - let lines = texts(&res.backward); - assert!( - lines.iter().any(|l| l.contains("totalStaked")), - "expected a totalStaked assignment, got {lines:?}" - ); - // Forward is not requested — must stay empty. - assert!(res.forward.is_empty()); -} - -#[test] -fn forward_slice_propagates_taint_across_assignments() { - // withdraw(): amount flows into balances[..] -= amount, totalStaked -= - // amount, stakingToken.transfer(..., amount), and emit Withdrawn(...). - let res = slice("Staking", "withdraw", "amount", SliceDirection::Forward); - let lines = texts(&res.forward); - assert!( - lines.iter().any(|l| l.contains("balances")), - "expected balances update in forward slice, got {lines:?}" - ); - assert!( - lines.iter().any(|l| l.contains("totalStaked")), - "expected totalStaked update in forward slice, got {lines:?}" - ); - assert!(res.backward.is_empty()); -} - -#[test] -fn backward_slice_pulls_in_control_dependency() { - // claimRewards(): - // uint256 reward = rewards[msg.sender]; - // if (reward > 0) { - // rewards[msg.sender] = 0; // ← def of `rewards` inside If - // ... - // } - // Backward slice on `rewards` should: - // 1. include `rewards[msg.sender] = 0` (direct def hit) - // 2. drag the enclosing `if (reward > 0)` via ancestor merge - // 3. propagate `reward` into the relevant set - // 4. pick up `uint256 reward = rewards[msg.sender]` (def of reward) - let res = slice("Staking", "claimRewards", "rewards", SliceDirection::Backward); - let lines = texts(&res.backward); - assert!( - lines.iter().any(|l| l.starts_with("if (")), - "expected enclosing if(...) in backward slice, got {lines:?}" - ); - assert!( - lines.iter().any(|l| l.contains("uint256 reward")), - "expected `uint256 reward = rewards[..]` pulled in via control dep, got {lines:?}" - ); -} - -#[test] -fn slice_on_unknown_variable_is_empty() { - let res = slice("Staking", "deposit", "nonexistent_var", SliceDirection::Both); - assert!(res.backward.is_empty(), "backward should be empty"); - assert!(res.forward.is_empty(), "forward should be empty"); -} - -#[test] -fn both_direction_populates_both_sides() { - let res = slice("Staking", "withdraw", "amount", SliceDirection::Both); - assert!(!res.forward.is_empty(), "forward should have entries"); - // `amount` is a parameter, never reassigned inside withdraw → backward - // data-dep pass finds nothing, backward stays empty. This is the - // expected behavior for parameter slicing. - assert!(res.backward.is_empty(), "backward on parameter should be empty"); -} - -#[test] -fn backward_slice_pulls_writes_from_modifier() { - // deposit() applies updateReward(msg.sender), which writes - // lastUpdateTime, rewardPerTokenStored, rewards[account], and - // userRewardPerTokenPaid[account] before the function body runs. - // None of these are touched by deposit's body itself, so without - // modifier walking the slice would be empty. - let res = slice("Staking", "deposit", "lastUpdateTime", SliceDirection::Backward); - let lines = texts(&res.backward); - assert!( - lines.iter().any(|l| l.contains("lastUpdateTime") && l.contains("block.timestamp")), - "expected `lastUpdateTime = block.timestamp` from updateReward, got {lines:?}" - ); - assert_eq!( - modifier_origin(&res.backward, "lastUpdateTime"), - Some("updateReward".to_string()), - "the lastUpdateTime entry should be tagged as coming from updateReward" - ); -} - -#[test] -fn modifier_writes_appear_before_function_body_in_program_order() { - // updateReward writes happen BEFORE deposit's own writes in the - // execution timeline. Forward slice on the modifier-defined - // `rewardPerTokenStored` should put its def first, and any later - // function-body statements that read it (none in deposit, but the - // ordering invariant must hold for the entries that do exist). - let res = slice("Staking", "deposit", "rewardPerTokenStored", SliceDirection::Forward); - let lines = texts(&res.forward); - if let Some(idx) = lines.iter().position(|l| l.contains("rewardPerTokenStored")) { - // Anything function-body that follows must come AFTER the - // modifier write in the flat order. - let modifier_entry = &res.forward[idx]; - assert!( - matches!(modifier_entry.origin, StatementOrigin::Modifier(ref n) if n == "updateReward"), - "rewardPerTokenStored entry should be tagged as updateReward, got {:?}", - modifier_entry.origin - ); - } else { - panic!("expected rewardPerTokenStored in forward slice, got {lines:?}"); - } -} - -#[test] -fn slice_respects_program_order() { - // deposit() body order: require → transferFrom → balances += → - // totalStaked += → emit. Forward slice on `amount` should keep them - // in that program order inside the returned vec. - let res = slice("Staking", "deposit", "amount", SliceDirection::Forward); - let lines = texts(&res.forward); - let find = |needle: &str| lines.iter().position(|l| l.contains(needle)); - if let (Some(i_balances), Some(i_total), Some(i_emit)) = ( - find("balances"), - find("totalStaked"), - find("emit"), - ) { - assert!( - i_balances < i_total && i_total < i_emit, - "program order broken: balances@{i_balances}, total@{i_total}, emit@{i_emit} — {lines:?}" - ); - } else { - panic!("expected balances / totalStaked / emit in forward slice, got {lines:?}"); - } -} diff --git a/crates/ilold-render/Cargo.toml b/crates/ilold-render/Cargo.toml index 32b16af..6067cc7 100644 --- a/crates/ilold-render/Cargo.toml +++ b/crates/ilold-render/Cargo.toml @@ -8,7 +8,6 @@ repository.workspace = true description = "Shared text renderers for Ilold (REPL + MCP). Output is byte-identical to the legacy CLI prints." [dependencies] -ilold-core = { path = "../ilold-core" } ilold-solana-core = { path = "../ilold-solana-core" } ilold-session-core = { path = "../ilold-session-core" } serde_json = { workspace = true } diff --git a/crates/ilold-render/src/colors.rs b/crates/ilold-render/src/colors.rs index 8a156de..22e1c4f 100644 --- a/crates/ilold-render/src/colors.rs +++ b/crates/ilold-render/src/colors.rs @@ -1,5 +1,5 @@ use colored::{ColoredString, Colorize}; -use ilold_core::classify::entry_points::AccessLevel; +use ilold_session_core::exploration::access::AccessLevel; pub fn c_accent(s: &str) -> ColoredString { s.truecolor(140, 170, 210) } pub fn c_warn(s: &str) -> ColoredString { s.truecolor(180, 150, 80) } diff --git a/crates/ilold-web/Cargo.toml b/crates/ilold-web/Cargo.toml index 7d1ffe6..e59d648 100644 --- a/crates/ilold-web/Cargo.toml +++ b/crates/ilold-web/Cargo.toml @@ -8,7 +8,6 @@ repository.workspace = true description = "Web server and interactive viewer for Ilold" [dependencies] -ilold-core = { path = "../ilold-core" } ilold-session-core = { path = "../ilold-session-core" } ilold-solana-core = { path = "../ilold-solana-core" } solana-address = { version = "2.0", features = ["serde", "decode"] } diff --git a/crates/ilold-web/frontend/src/lib/api/rest.ts b/crates/ilold-web/frontend/src/lib/api/rest.ts index 02739d5..3174d3c 100644 --- a/crates/ilold-web/frontend/src/lib/api/rest.ts +++ b/crates/ilold-web/frontend/src/lib/api/rest.ts @@ -77,9 +77,7 @@ export interface SearchSuggestions { export interface ProjectMap { kind: string; - contracts: MapContract[]; programs: MapProgram[]; - relationships: MapRelationship[]; } export interface MapProgram { diff --git a/crates/ilold-web/frontend/src/lib/components/contract/FunctionSidebar.svelte b/crates/ilold-web/frontend/src/lib/components/contract/FunctionSidebar.svelte index 1a4e532..5ec8f1a 100644 --- a/crates/ilold-web/frontend/src/lib/components/contract/FunctionSidebar.svelte +++ b/crates/ilold-web/frontend/src/lib/components/contract/FunctionSidebar.svelte @@ -17,7 +17,7 @@ program = null, canvasFuncs, mode, - kind = 'solidity', + kind = 'solana', onadd, onremove, }: { @@ -50,7 +50,7 @@ hasPdas?: boolean; signers?: string[]; }; - const allRows = $derived<Row[]>(() => { + const allRows = $derived.by<Row[]>(() => { if (kind === 'solana' && program) { return (program.instructions ?? []).map((ix): Row => ({ name: ix.name, @@ -85,8 +85,7 @@ const filtered = $derived.by(() => { const q = query.trim().toLowerCase(); - const rows = allRows(); - return rows.filter((r) => { + return allRows.filter((r: Row) => { if (q && !r.name.toLowerCase().includes(q)) return false; if (kind === 'solana') { if (onlyPdas && !r.hasPdas) return false; @@ -99,9 +98,9 @@ }); }); - const ownFiltered = $derived(filtered.filter((r) => r.source === 'own')); - const inheritedFiltered = $derived(filtered.filter((r) => r.source === 'inherited')); - const totalCount = $derived(allRows().length); + const ownFiltered = $derived(filtered.filter((r: Row) => r.source === 'own')); + const inheritedFiltered = $derived(filtered.filter((r: Row) => r.source === 'inherited')); + const totalCount = $derived(allRows.length); const visibleCount = $derived(filtered.length); const canvasCount = $derived(canvasFuncs.size); const canClear = $derived(mode !== 'session' && canvasCount > 0); diff --git a/crates/ilold-web/frontend/src/lib/components/contract/FunctionSourcePanel.svelte b/crates/ilold-web/frontend/src/lib/components/contract/FunctionSourcePanel.svelte index dbcf7eb..e96c8cd 100644 --- a/crates/ilold-web/frontend/src/lib/components/contract/FunctionSourcePanel.svelte +++ b/crates/ilold-web/frontend/src/lib/components/contract/FunctionSourcePanel.svelte @@ -10,7 +10,7 @@ // Uses the CodeMirror `oneDark` theme and the JS grammar as a Solidity/Rust // fallback so it stays close enough for highlighting without pulling extra // language bundles. - let { contract, func, kind = 'solidity', onclose }: { + let { contract, func, kind = 'solana', onclose }: { contract: string; func: string; kind?: 'solidity' | 'solana'; diff --git a/crates/ilold-web/frontend/src/lib/components/contract/Legend.svelte b/crates/ilold-web/frontend/src/lib/components/contract/Legend.svelte index 2a161c0..dbc8cc7 100644 --- a/crates/ilold-web/frontend/src/lib/components/contract/Legend.svelte +++ b/crates/ilold-web/frontend/src/lib/components/contract/Legend.svelte @@ -1,7 +1,7 @@ <script lang="ts"> let { mode, - kind = 'solidity', + kind = 'solana', }: { mode: 'cfg' | 'sequences' | 'session'; kind?: 'solidity' | 'solana'; diff --git a/crates/ilold-web/frontend/src/lib/components/contract/TopBar.svelte b/crates/ilold-web/frontend/src/lib/components/contract/TopBar.svelte index 28472c6..3b7e008 100644 --- a/crates/ilold-web/frontend/src/lib/components/contract/TopBar.svelte +++ b/crates/ilold-web/frontend/src/lib/components/contract/TopBar.svelte @@ -9,7 +9,7 @@ contractName, mode, seqDirection, - kind = 'solidity', + kind = 'solana', hideSystem = false, onmodechange, onsearch, diff --git a/crates/ilold-web/frontend/src/lib/components/session/SessionSidebar.svelte b/crates/ilold-web/frontend/src/lib/components/session/SessionSidebar.svelte index 55e26b3..0c646a7 100644 --- a/crates/ilold-web/frontend/src/lib/components/session/SessionSidebar.svelte +++ b/crates/ilold-web/frontend/src/lib/components/session/SessionSidebar.svelte @@ -24,7 +24,7 @@ lookupBlock = () => null, onpathselect = () => {}, onexpandcfg = () => {}, - kind = 'solidity', + kind = 'solana', program = null, solanaUsers = [], onsolanarun = () => {}, diff --git a/crates/ilold-web/frontend/src/routes/+page.svelte b/crates/ilold-web/frontend/src/routes/+page.svelte index 82a7828..ac4c1e0 100644 --- a/crates/ilold-web/frontend/src/routes/+page.svelte +++ b/crates/ilold-web/frontend/src/routes/+page.svelte @@ -18,12 +18,6 @@ } }); - // Publish project-level commands: navigate into any contract. The - // contract page replaces this list with its richer set on mount. - // ProjectMap may report the same name twice (e.g. an interface that - // appears as its own entry and as an inherited reference elsewhere), - // so we dedupe by name before mapping to Commands — Svelte's keyed - // each block would otherwise error on the duplicate id. $effect(() => { if (!projectMap) { setPaletteCommands([]); @@ -31,76 +25,33 @@ } const seen = new Set<string>(); const cmds: Command[] = []; - if (projectMap.kind === 'solana') { - for (const p of projectMap.programs ?? []) { - if (seen.has(p.name)) continue; - seen.add(p.name); - cmds.push({ - id: `program:${p.name}`, - label: p.name, - category: 'Contract' as const, - icon: '◊', - detail: `${p.instructions.length} ix · ${p.account_types.length} account types`, - keywords: ['program', 'open', 'navigate', 'solana'], - run: () => goto(`/contract/${encodeURIComponent(p.name)}`), - }); - } - } else { - for (const c of projectMap.contracts ?? []) { - if (seen.has(c.name)) continue; - seen.add(c.name); - cmds.push({ - id: `contract:${c.name}`, - label: c.name, - category: 'Contract' as const, - icon: '◈', - detail: c.kind, - keywords: ['contract', 'open', 'navigate'], - run: () => goto(`/contract/${encodeURIComponent(c.name)}`), - }); - } + for (const p of projectMap.programs ?? []) { + if (seen.has(p.name)) continue; + seen.add(p.name); + cmds.push({ + id: `program:${p.name}`, + label: p.name, + category: 'Contract' as const, + icon: '◊', + detail: `${p.instructions.length} ix · ${p.account_types.length} account types`, + keywords: ['program', 'open', 'navigate', 'solana'], + run: () => goto(`/contract/${encodeURIComponent(p.name)}`), + }); } setPaletteCommands(cmds); }); onDestroy(() => clearPaletteCommands()); - let contracts: any[] = $state([]); - let interfaces: any[] = $state([]); - let programs: any[] = $state([]); - let kind = $state<'solidity' | 'solana'>('solidity'); - - $effect(() => { - if (projectMap) { - kind = projectMap.kind === 'solana' ? 'solana' : 'solidity'; - if (kind === 'solana') { - programs = projectMap.programs ?? []; - contracts = []; - interfaces = []; - } else { - contracts = (projectMap.contracts ?? []).filter(c => c.kind !== 'Interface'); - interfaces = (projectMap.contracts ?? []).filter(c => c.kind === 'Interface'); - programs = []; - } - } - }); - - function mutColorClass(m: string): string { - if (m === 'View' || m === 'Pure') return 'bg-accent'; - return 'bg-accent-hover'; - } + let programs = $derived(projectMap?.programs ?? []); </script> <div class="fixed inset-0 flex flex-col bg-dark"> <div class="flex items-center gap-2.5 px-4 py-2 bg-hover border-b border-border-subtle z-10 shrink-0"> <span class="text-lg font-bold text-text">ilold</span> - <span class="text-xs text-text-dim">execution path analyzer</span> + <span class="text-xs text-text-dim">Solana execution path analyzer</span> {#if projectMap} - {#if kind === 'solana'} - <span class="text-xs text-text-muted">{programs.length} programs · solana</span> - {:else} - <span class="text-xs text-text-muted">{contracts.length + interfaces.length} contracts · {projectMap.relationships.length} cross-contract calls</span> - {/if} + <span class="text-xs text-text-muted">{programs.length} programs</span> {/if} <div class="ml-auto flex gap-1"> <button class="bg-hover border border-border-subtle text-accent-hover px-3 py-1 rounded-sm cursor-pointer text-xs hover:border-accent" onclick={togglePalette}>⌘K Search</button> @@ -111,7 +62,7 @@ <div class="p-6 text-danger">{error}</div> {:else if !projectMap} <div class="p-6 text-text-muted">Analyzing...</div> - {:else if kind === 'solana'} + {:else} <div class="flex-1 overflow-y-auto p-6"> <div class="grid grid-cols-[repeat(auto-fill,minmax(340px,1fr))] gap-4"> {#each programs as program} @@ -148,80 +99,6 @@ {/each} </div> </div> - {:else} - <div class="flex-1 overflow-y-auto p-6"> - <div class="grid grid-cols-[repeat(auto-fill,minmax(340px,1fr))] gap-4"> - {#each contracts as contract} - <div class="bg-hover border border-border-subtle rounded-[10px] overflow-hidden"> - <div class="px-3.5 pt-3 pb-2 border-b border-border-subtle"> - <span class="text-[10px] text-text-muted uppercase tracking-wide">{contract.kind.toLowerCase()}</span> - <h2 class="text-lg mt-0.5 mb-0"><a class="text-text no-underline hover:text-accent-hover" href="/contract/{contract.name}">{contract.name}</a></h2> - {#if contract.inherits.length > 0} - <div class="text-[11px] text-text-dim italic mt-0.5">inherits {contract.inherits.join(', ')}</div> - {/if} - </div> - - <div class="card-section"> - <div class="text-[9px] text-text-muted uppercase tracking-wide mb-1 font-semibold">Functions</div> - {#each contract.functions as func} - <a href="/contract/{contract.name}/{func.name}" class="flex items-center gap-1.5 px-1 py-1 rounded-sm text-xs text-inherit no-underline hover:bg-border"> - <span class="size-1.5 rounded-full shrink-0 {mutColorClass(func.mutability)}"></span> - <span class="text-text font-semibold font-mono flex-1">{func.name}</span> - <span class="text-[10px] text-text-dim">{func.visibility.toLowerCase()}</span> - {#if func.has_external_calls} - <span class="text-[9px] px-1 py-px rounded-md bg-warning/10 text-warning">ext</span> - {/if} - <span class="text-[10px] text-text-muted flex gap-0.5"> - {func.path_count}p - {#if func.happy_paths > 0}<span class="text-success">{func.happy_paths}✓</span>{/if} - {#if func.revert_paths > 0}<span class="text-danger">{func.revert_paths}✗</span>{/if} - </span> - </a> - {/each} - </div> - - {#if contract.state_vars.length > 0} - <div class="card-section"> - <div class="text-[9px] text-text-muted uppercase tracking-wide mb-1 font-semibold">Variables</div> - {#each contract.state_vars as sv} - <div class="flex justify-between px-1 py-0.5 text-[11px] font-mono"> - <span class="text-text">{sv.name}</span> - <span class="text-text-dim text-[10px] max-w-[150px] overflow-hidden text-ellipsis whitespace-nowrap">{sv.type_name}</span> - </div> - {/each} - </div> - {/if} - - {#if projectMap.relationships.filter(r => r.from_contract === contract.name).length > 0} - <div class="card-section"> - <div class="text-[9px] text-text-muted uppercase tracking-wide mb-1 font-semibold">Calls to</div> - {#each projectMap.relationships.filter(r => r.from_contract === contract.name) as rel} - <div class="flex items-center gap-1 px-1 py-0.5 text-[11px] font-mono"> - <span class="text-text">{rel.from_function}</span> - <span class="text-accent">→</span> - <span class="text-accent-hover">{rel.to_contract}.{rel.to_function}</span> - </div> - {/each} - </div> - {/if} - </div> - {/each} - </div> - - {#if interfaces.length > 0} - <div class="mt-6"> - <h3 class="text-sm text-text-muted mb-2">Interfaces</h3> - <div class="flex gap-2 flex-wrap"> - {#each interfaces as iface} - <div class="bg-hover border border-dashed border-border-subtle rounded-md px-3 py-2 flex gap-2 items-center"> - <span class="text-text-muted font-semibold text-[13px]">{iface.name}</span> - <span class="text-text-dim text-[11px]">{iface.functions.length} functions</span> - </div> - {/each} - </div> - </div> - {/if} - </div> {/if} </div> diff --git a/crates/ilold-web/frontend/src/routes/contract/[name]/+page.svelte b/crates/ilold-web/frontend/src/routes/contract/[name]/+page.svelte index 75d8f89..6e8d853 100644 --- a/crates/ilold-web/frontend/src/routes/contract/[name]/+page.svelte +++ b/crates/ilold-web/frontend/src/routes/contract/[name]/+page.svelte @@ -48,7 +48,7 @@ let contract: ContractDetail | null = $state(null); let solanaProgram: ProgramView | null = $state(null); - let kind: 'solidity' | 'solana' = $state('solidity'); + let kind: 'solidity' | 'solana' = $state('solana'); let solanaCanvasIxs: Set<string> = $state(new Set()); let solanaExpandedIxs: Set<string> = $state(new Set()); let solanaUsers: { name: string; pubkey: string; lamports: number }[] = $state([]); @@ -984,47 +984,25 @@ clearUserLabels(); setSearchContext(contractName); try { - const pm = await getProjectMap(); + await getProjectMap(); if (!stillFresh()) return; - kind = pm.kind === 'solana' ? 'solana' : 'solidity'; - if (kind === 'solana') { - try { - const prog = await getProgramView(contractName); - if (!stillFresh()) return; - solanaProgram = prog; - } catch { - if (stillFresh()) error = `Program "${contractName}" not found`; - return; - } - projectMap = []; - await refreshSolanaUsers(); - const scenario = getActiveScenario(); - await loadRuntimeOverlay(contractName, scenario); - if (scenario) await loadUserLabels(scenario); - paintCpiEdges(); - return; - } - projectMap = pm.contracts ?? []; - const ctr = await getContract(contractName); - if (!stillFresh()) return; - contract = ctr; - const callgraphData = await getCallGraph(contractName); - if (!stillFresh()) return; - callgraphRaw = callgraphData; + kind = 'solana'; try { - const tree = await getSequences(contractName); - if (stillFresh()) seqTree = tree; - } catch (e) { - if (stillFresh() && kind !== 'solana') console.warn('getSequences failed:', e); - } - try { - const analysis = await getSequenceAnalysis(contractName); - if (stillFresh()) seqAnalysis = analysis; - } catch (e) { - if (stillFresh() && kind !== 'solana') console.warn('getSequenceAnalysis failed:', e); + const prog = await getProgramView(contractName); + if (!stillFresh()) return; + solanaProgram = prog; + } catch { + if (stillFresh()) error = `Program "${contractName}" not found`; + return; } + projectMap = []; + await refreshSolanaUsers(); + const scenario = getActiveScenario(); + await loadRuntimeOverlay(contractName, scenario); + if (scenario) await loadUserLabels(scenario); + paintCpiEdges(); } catch (e) { - if (stillFresh()) error = `Contract "${contractName}" not found`; + if (stillFresh()) error = `Program "${contractName}" not found`; } }); diff --git a/crates/ilold-web/src/api/contract.rs b/crates/ilold-web/src/api/contract.rs deleted file mode 100644 index 0aa2f2d..0000000 --- a/crates/ilold-web/src/api/contract.rs +++ /dev/null @@ -1,544 +0,0 @@ -use std::sync::Arc; - -use axum::extract::{Path, Query, State}; -use axum::http::StatusCode; -use axum::Json; -use serde::{Deserialize, Serialize}; - -use ilold_core::model::common::SourceSpan; -use ilold_core::pathtree::types::PathTree; -use ilold_core::sequence::types::SequenceTree; - -use crate::state::{require_solidity, AppState}; - -#[derive(Serialize)] -pub struct ContractDetail { - pub name: String, - pub kind: String, - pub inherits: Vec<String>, - pub functions: Vec<FunctionSummary>, - pub state_vars: Vec<StateVarSummary>, - pub inherited_functions: Vec<FunctionSummary>, - pub inherited_state_vars: Vec<StateVarSummary>, -} - -#[derive(Serialize)] -pub struct FunctionSummary { - pub name: String, - pub kind: String, - pub visibility: String, - pub mutability: String, - pub params: Vec<ParamSummary>, - pub path_count: usize, - pub happy_paths: usize, - pub revert_paths: usize, - pub modifiers: Vec<String>, -} - -#[derive(Serialize)] -pub struct ParamSummary { - pub name: String, - pub type_name: String, -} - -#[derive(Serialize)] -pub struct StateVarSummary { - pub name: String, - pub type_name: String, - pub visibility: String, - pub is_constant: bool, - pub is_immutable: bool, -} - -pub async fn get_contract( - State(state): State<Arc<AppState>>, - Path(name): Path<String>, -) -> Result<Json<ContractDetail>, StatusCode> { - let s = require_solidity(&state)?; - let contract = s - .project - .contracts - .iter() - .find(|c| c.name == name) - .ok_or(StatusCode::NOT_FOUND)?; - - let functions = contract - .functions - .iter() - .map(|f| { - let key = (contract.name.clone(), f.name.clone()); - let pt = s.path_trees.get(&key); - FunctionSummary { - name: f.name.clone(), - kind: format!("{:?}", f.kind), - visibility: format!("{:?}", f.visibility), - mutability: format!("{:?}", f.mutability), - params: f.params.iter().map(|p| ParamSummary { - name: p.name.clone(), - type_name: p.type_name.clone(), - }).collect(), - path_count: pt.map(|p| p.stats.total_paths).unwrap_or(0), - happy_paths: pt.map(|p| p.stats.happy_paths).unwrap_or(0), - revert_paths: pt.map(|p| p.stats.revert_paths).unwrap_or(0), - modifiers: f.modifiers.iter().map(|m| m.name.clone()).collect(), - } - }) - .collect(); - - let state_vars = contract - .state_vars - .iter() - .map(|sv| StateVarSummary { - name: sv.name.clone(), - type_name: sv.type_name.clone(), - visibility: format!("{:?}", sv.visibility), - is_constant: sv.is_constant, - is_immutable: sv.is_immutable, - }) - .collect(); - - let inherited_functions: Vec<FunctionSummary> = s.project - .accessible_functions(contract) - .into_iter() - .filter(|af| af.is_inherited) - .map(|af| { - let f = af.function; - let key = (af.origin.clone(), f.name.clone()); - let pt = s.path_trees.get(&key); - FunctionSummary { - name: f.name.clone(), - kind: format!("{:?}", f.kind), - visibility: format!("{:?}", f.visibility), - mutability: format!("{:?}", f.mutability), - params: f.params.iter().map(|p| ParamSummary { - name: p.name.clone(), - type_name: p.type_name.clone(), - }).collect(), - path_count: pt.map(|p| p.stats.total_paths).unwrap_or(0), - happy_paths: pt.map(|p| p.stats.happy_paths).unwrap_or(0), - revert_paths: pt.map(|p| p.stats.revert_paths).unwrap_or(0), - modifiers: f.modifiers.iter().map(|m| m.name.clone()).collect(), - } - }) - .collect(); - - let own_var_names: std::collections::HashSet<String> = contract.state_vars.iter() - .map(|sv| sv.name.clone()) - .collect(); - let inherited_state_vars: Vec<StateVarSummary> = s.project - .inherited_state_vars(contract) - .into_iter() - .filter(|sv| !own_var_names.contains(&sv.name)) - .map(|sv| StateVarSummary { - name: sv.name.clone(), - type_name: sv.type_name.clone(), - visibility: format!("{:?}", sv.visibility), - is_constant: sv.is_constant, - is_immutable: sv.is_immutable, - }) - .collect(); - - Ok(Json(ContractDetail { - name: contract.name.clone(), - kind: format!("{:?}", contract.kind), - inherits: contract.inherits.clone(), - functions, - state_vars, - inherited_functions, - inherited_state_vars, - })) -} - -#[derive(Serialize)] -pub struct CytoscapeGraph { - pub nodes: Vec<CytoscapeNode>, - pub edges: Vec<CytoscapeEdge>, -} - -#[derive(Serialize)] -pub struct CytoscapeNode { - pub data: CytoscapeNodeData, -} - -#[derive(Serialize)] -pub struct CytoscapeNodeData { - pub id: String, - pub label: String, - pub node_type: String, - pub contract: String, - pub is_external: bool, - #[serde(skip_serializing_if = "Vec::is_empty")] - pub statements: Vec<String>, -} - -#[derive(Serialize)] -pub struct CytoscapeEdge { - pub data: CytoscapeEdgeData, -} - -#[derive(Serialize)] -pub struct CytoscapeEdgeData { - pub id: String, - pub source: String, - pub target: String, - pub kind: String, - pub call_count: usize, -} - -pub async fn get_callgraph( - State(state): State<Arc<AppState>>, - Path(name): Path<String>, -) -> Result<Json<CytoscapeGraph>, StatusCode> { - let s = require_solidity(&state)?; - let cg = s - .call_graphs - .get(&name) - .ok_or(StatusCode::NOT_FOUND)?; - - let nodes: Vec<CytoscapeNode> = cg - .node_indices() - .map(|idx| { - let node = &cg[idx]; - CytoscapeNode { - data: CytoscapeNodeData { - id: format!("{}::{}", node.contract, node.function), - label: node.function.clone(), - node_type: if node.is_external { "external" } else { "internal" }.into(), - contract: node.contract.clone(), - is_external: node.is_external, - statements: Vec::new(), - }, - } - }) - .collect(); - - let edges: Vec<CytoscapeEdge> = cg - .edge_indices() - .enumerate() - .map(|(i, edge_idx)| { - let (src, dst) = cg.edge_endpoints(edge_idx).unwrap(); - let edge = &cg[edge_idx]; - CytoscapeEdge { - data: CytoscapeEdgeData { - id: format!("e{i}"), - source: format!("{}::{}", cg[src].contract, cg[src].function), - target: format!("{}::{}", cg[dst].contract, cg[dst].function), - kind: format!("{:?}", edge.kind), - call_count: edge.call_count, - }, - } - }) - .collect(); - - Ok(Json(CytoscapeGraph { nodes, edges })) -} - -pub async fn get_cfg( - State(state): State<Arc<AppState>>, - Path((contract_name, func_name)): Path<(String, String)>, -) -> Result<Json<CytoscapeGraph>, StatusCode> { - let s = require_solidity(&state)?; - let contract = s.project.contracts.iter() - .find(|c| c.name == contract_name) - .ok_or(StatusCode::NOT_FOUND)?; - - let (owning, _func) = s.project.resolve_function(contract, &func_name) - .ok_or(StatusCode::NOT_FOUND)?; - - let key = (owning.name.clone(), func_name); - let cfg = s.cfgs.get(&key).ok_or(StatusCode::NOT_FOUND)?; - - let nodes: Vec<CytoscapeNode> = cfg - .node_indices() - .map(|idx| { - let block = &cfg[idx]; - let stmts: Vec<String> = block.statements.iter().map(|s| summarize_stmt(s)).collect(); - let label = make_block_label(block, &stmts); - CytoscapeNode { - data: CytoscapeNodeData { - id: format!("b{}", block.id), - label, - node_type: format!("{:?}", block.kind), - contract: key.0.clone(), - is_external: false, - statements: stmts, - }, - } - }) - .collect(); - - let edges: Vec<CytoscapeEdge> = cfg - .edge_indices() - .enumerate() - .map(|(i, edge_idx)| { - let (src, dst) = cfg.edge_endpoints(edge_idx).unwrap(); - let edge = &cfg[edge_idx]; - CytoscapeEdge { - data: CytoscapeEdgeData { - id: format!("e{i}"), - source: format!("b{}", cfg[src].id), - target: format!("b{}", cfg[dst].id), - kind: format!("{:?}", edge), - call_count: 0, - }, - } - }) - .collect(); - - Ok(Json(CytoscapeGraph { nodes, edges })) -} - -#[derive(Serialize)] -pub struct FunctionSourceResponse { - pub file_path: String, - pub source: String, - pub span: SourceSpan, -} - -pub async fn get_function_source( - State(state): State<Arc<AppState>>, - Path((contract_name, func_name)): Path<(String, String)>, -) -> Result<Json<FunctionSourceResponse>, StatusCode> { - let s = require_solidity(&state)?; - let contract = s.project.contracts.iter() - .find(|c| c.name == contract_name) - .ok_or(StatusCode::NOT_FOUND)?; - - let (_owning, func) = s.project.resolve_function(contract, &func_name) - .ok_or(StatusCode::NOT_FOUND)?; - - let file = s.project.source_files.get(func.span.file_index) - .ok_or(StatusCode::NOT_FOUND)?; - - let source = slice_lines( - &file.content, - func.span.start_line as usize, - func.span.end_line as usize, - ); - - Ok(Json(FunctionSourceResponse { - file_path: file.path.clone(), - source, - span: func.span, - })) -} - -fn slice_lines(src: &str, start_1based: usize, end_1based: usize) -> String { - if start_1based == 0 || end_1based < start_1based { - return String::new(); - } - src.lines() - .skip(start_1based - 1) - .take(end_1based - start_1based + 1) - .collect::<Vec<_>>() - .join("\n") -} - -pub async fn get_paths( - State(state): State<Arc<AppState>>, - Path((contract_name, func_name)): Path<(String, String)>, -) -> Result<Json<PathTree>, StatusCode> { - let s = require_solidity(&state)?; - let contract = s - .project - .contracts - .iter() - .find(|c| c.name == contract_name) - .ok_or(StatusCode::NOT_FOUND)?; - - let (owning, _func) = s.project.resolve_function(contract, &func_name) - .ok_or(StatusCode::NOT_FOUND)?; - - let key = (owning.name.clone(), func_name); - s.path_trees - .get(&key) - .cloned() - .map(Json) - .ok_or(StatusCode::NOT_FOUND) -} - -#[derive(Deserialize)] -pub struct SequenceQuery { - pub depth: Option<usize>, -} - -pub async fn get_sequences( - State(state): State<Arc<AppState>>, - Path(name): Path<String>, - Query(_query): Query<SequenceQuery>, -) -> Result<Json<SequenceTree>, StatusCode> { - let s = require_solidity(&state)?; - s.sequence_trees - .get(&name) - .cloned() - .map(Json) - .ok_or(StatusCode::NOT_FOUND) -} - -use ilold_core::sequence::analysis::{analyze_sequences, SequenceAnalysis}; - -pub async fn get_sequence_analysis( - State(state): State<Arc<AppState>>, - Path(name): Path<String>, -) -> Result<Json<SequenceAnalysis>, StatusCode> { - let s = require_solidity(&state)?; - let analysis = analyze_sequences(&s.path_trees, &name); - Ok(Json(analysis)) -} - -#[derive(Serialize)] -pub struct SearchSuggestions { - pub functions: Vec<String>, - pub state_vars: Vec<String>, - pub events: Vec<String>, - pub external_calls: Vec<String>, - pub categories: Vec<SuggestionCategory>, -} - -#[derive(Serialize)] -pub struct SuggestionCategory { - pub label: String, - pub items: Vec<String>, -} - -pub async fn get_search_suggestions( - State(state): State<Arc<AppState>>, - Path(name): Path<String>, -) -> Result<Json<SearchSuggestions>, StatusCode> { - let s = require_solidity(&state)?; - let contract = s - .project - .contracts - .iter() - .find(|c| c.name == name) - .ok_or(StatusCode::NOT_FOUND)?; - - let accessible = s.project.accessible_functions(contract); - let functions: Vec<String> = accessible - .iter() - .filter(|af| !af.function.name.is_empty()) - .map(|af| af.function.name.clone()) - .collect(); - - let state_vars: Vec<String> = s.project - .inherited_state_vars(contract) - .into_iter() - .map(|sv| sv.name) - .collect(); - - let events: Vec<String> = contract - .events - .iter() - .map(|e| e.name.clone()) - .collect(); - - let mut origins: std::collections::HashSet<String> = std::collections::HashSet::new(); - origins.insert(name.clone()); - for af in &accessible { - if af.is_inherited { - origins.insert(af.origin.clone()); - } - } - - let mut ext_calls = std::collections::HashSet::new(); - for ((c, _), pt) in &s.path_trees { - if !origins.contains(c) { continue; } - for path in &pt.paths { - for call in &path.annotations.external_calls { - ext_calls.insert(format!("{}.{}", call.target, call.function)); - } - } - } - let external_calls: Vec<String> = ext_calls.into_iter().collect(); - - let categories = vec![ - SuggestionCategory { - label: "Functions".into(), - items: functions.clone(), - }, - SuggestionCategory { - label: "State Variables".into(), - items: state_vars.clone(), - }, - SuggestionCategory { - label: "Events".into(), - items: events.clone(), - }, - SuggestionCategory { - label: "External Calls".into(), - items: external_calls.clone(), - }, - SuggestionCategory { - label: "Path Types".into(), - items: vec!["revert".into(), "return".into(), "assembly".into()], - }, - ]; - - Ok(Json(SearchSuggestions { - functions, - state_vars, - events, - external_calls, - categories, - })) -} - -use ilold_core::cfg::types::{BasicBlock, BlockKind, CfgStatement}; - -fn summarize_stmt(stmt: &CfgStatement) -> String { - match stmt { - CfgStatement::RequireCheck { condition, .. } => format!("require({})", condition), - CfgStatement::AssertCheck { condition, .. } => format!("assert({})", condition), - CfgStatement::ExternalCall { target, function, .. } => format!("{}.{}()", target, function), - CfgStatement::InternalCall { function, .. } => format!("{}()", function), - CfgStatement::EmitEvent { event, .. } => format!("emit {}", event), - CfgStatement::StateWrite { variable, .. } => format!("{} = ...", variable), - CfgStatement::StateRead { variable, .. } => format!("read {}", variable), - CfgStatement::EthTransfer { to, .. } => format!("transfer → {}", to), - CfgStatement::Assignment { target, .. } => { - if target.is_empty() { - "...".into() - } else { - format!("{} = ...", target) - } - } - CfgStatement::AssemblyBlock { .. } => "assembly { ... }".into(), - } -} - -fn make_block_label(block: &BasicBlock, stmts: &[String]) -> String { - match block.kind { - BlockKind::Entry => { - if stmts.is_empty() { - "▶ Entry".into() - } else { - let s = truncate_label(&stmts[0], 24); - format!("▶ {s}") - } - } - BlockKind::Return => "✓ Return".into(), - BlockKind::Revert => "✗ Revert".into(), - BlockKind::Assembly => "⚙ Assembly".into(), - BlockKind::LoopCondition => "⟳ Loop".into(), - BlockKind::Normal => { - if stmts.is_empty() { - "·".into() - } else { - let first = truncate_label(&stmts[0], 24); - if stmts.len() == 1 { - first - } else { - format!("{first} (+{})", stmts.len() - 1) - } - } - } - } -} - -fn truncate_label(s: &str, max: usize) -> String { - if s.len() <= max { - s.to_string() - } else { - format!("{}…", &s[..max]) - } -} diff --git a/crates/ilold-web/src/api/mod.rs b/crates/ilold-web/src/api/mod.rs index 32e2345..dd3fb20 100644 --- a/crates/ilold-web/src/api/mod.rs +++ b/crates/ilold-web/src/api/mod.rs @@ -1,4 +1,3 @@ pub mod project; -pub mod contract; pub mod annotations; pub mod session; diff --git a/crates/ilold-web/src/api/project.rs b/crates/ilold-web/src/api/project.rs index 3673da0..2879f59 100644 --- a/crates/ilold-web/src/api/project.rs +++ b/crates/ilold-web/src/api/project.rs @@ -4,7 +4,6 @@ use std::sync::Arc; use axum::extract::{Path, Query, State}; use axum::http::StatusCode; use axum::Json; -use ilold_core::model::common::SourceSpan; use ilold_solana_core::model::ProgramDef; use ilold_solana_core::overlay::RuntimeOverlay; use ilold_solana_core::view::ProgramView; @@ -12,7 +11,16 @@ use serde::{Deserialize, Serialize}; use solana_keypair::Signer; use syn::spanned::Spanned; -use crate::state::{require_solidity_msg, AppState, Backend}; +use crate::state::AppState; + +#[derive(Debug, Serialize)] +pub struct SourceSpan { + pub file_index: u32, + pub start_line: u32, + pub start_col: u32, + pub end_line: u32, + pub end_col: u32, +} fn find_solana_program( state: &Arc<AppState>, @@ -30,48 +38,45 @@ fn find_solana_program( #[derive(Serialize)] pub struct ProjectSummary { - pub files: usize, - pub contracts: Vec<ContractSummary>, + pub kind: &'static str, + pub programs: Vec<ProgramSummary>, } #[derive(Serialize)] -pub struct ContractSummary { +pub struct ProgramSummary { pub name: String, - pub kind: String, - pub functions: usize, - pub state_vars: usize, - pub inherits: Vec<String>, + pub program_id: String, + pub instructions: usize, + pub account_types: usize, } pub async fn get_project( State(state): State<Arc<AppState>>, ) -> Result<Json<ProjectSummary>, (StatusCode, String)> { - let s = require_solidity_msg(&state)?; - let contracts = s + let solana = state + .solana() + .ok_or((StatusCode::BAD_REQUEST, "endpoint is Solana-only".into()))?; + let programs = solana .project - .contracts + .programs .iter() - .map(|c| ContractSummary { - name: c.name.clone(), - kind: format!("{:?}", c.kind), - functions: c.functions.len(), - state_vars: c.state_vars.len(), - inherits: c.inherits.clone(), + .map(|p| ProgramSummary { + name: p.name.clone(), + program_id: p.program_id.to_string(), + instructions: p.instructions.len(), + account_types: p.account_types.len(), }) .collect(); - Ok(Json(ProjectSummary { - files: s.project.source_files.len(), - contracts, + kind: "solana", + programs, })) } #[derive(Serialize)] pub struct ProjectMap { pub kind: &'static str, - pub contracts: Vec<MapContract>, pub programs: Vec<MapProgram>, - pub relationships: Vec<MapRelationship>, } #[derive(Serialize)] @@ -95,42 +100,6 @@ pub struct MapAccountType { pub name: String, } -#[derive(Serialize)] -pub struct MapContract { - pub name: String, - pub kind: String, - pub inherits: Vec<String>, - pub functions: Vec<MapFunction>, - pub state_vars: Vec<MapStateVar>, -} - -#[derive(Serialize)] -pub struct MapFunction { - pub name: String, - pub visibility: String, - pub mutability: String, - pub path_count: usize, - pub happy_paths: usize, - pub revert_paths: usize, - pub has_external_calls: bool, -} - -#[derive(Serialize)] -pub struct MapStateVar { - pub name: String, - pub type_name: String, - pub is_constant: bool, -} - -#[derive(Serialize)] -pub struct MapRelationship { - pub from_contract: String, - pub from_function: String, - pub to_contract: String, - pub to_function: String, - pub kind: String, -} - pub async fn get_program_view( State(state): State<Arc<AppState>>, Path(name): Path<String>, @@ -189,10 +158,16 @@ pub async fn get_program_overlay( pub async fn get_project_map( State(state): State<Arc<AppState>>, ) -> Json<ProjectMap> { - match &state.backend { - Backend::Solidity(_) => Json(build_solidity_map(&state)), - Backend::Solana(s) => Json(build_solana_map(s)), - } + let solana = match state.solana() { + Some(s) => s, + None => { + return Json(ProjectMap { + kind: "solana", + programs: Vec::new(), + }); + } + }; + Json(build_solana_map(solana)) } fn build_solana_map(s: &crate::state::SolanaState) -> ProjectMap { @@ -223,83 +198,7 @@ fn build_solana_map(s: &crate::state::SolanaState) -> ProjectMap { ProjectMap { kind: "solana", - contracts: Vec::new(), programs, - relationships: Vec::new(), - } -} - -fn build_solidity_map(state: &Arc<AppState>) -> ProjectMap { - let s = state.unwrap_solidity(); - let mut contracts = Vec::new(); - let mut relationships = Vec::new(); - - for contract in &s.project.contracts { - let functions: Vec<MapFunction> = contract - .functions - .iter() - .filter(|f| !f.name.is_empty()) - .map(|f| { - let key = (contract.name.clone(), f.name.clone()); - let pt = s.path_trees.get(&key); - let has_ext = pt - .map(|p| p.paths.iter().any(|path| !path.annotations.external_calls.is_empty())) - .unwrap_or(false); - MapFunction { - name: f.name.clone(), - visibility: format!("{:?}", f.visibility), - mutability: format!("{:?}", f.mutability), - path_count: pt.map(|p| p.stats.total_paths).unwrap_or(0), - happy_paths: pt.map(|p| p.stats.happy_paths).unwrap_or(0), - revert_paths: pt.map(|p| p.stats.revert_paths).unwrap_or(0), - has_external_calls: has_ext, - } - }) - .collect(); - - let state_vars: Vec<MapStateVar> = contract - .state_vars - .iter() - .map(|sv| MapStateVar { - name: sv.name.clone(), - type_name: sv.type_name.clone(), - is_constant: sv.is_constant, - }) - .collect(); - - contracts.push(MapContract { - name: contract.name.clone(), - kind: format!("{:?}", contract.kind), - inherits: contract.inherits.clone(), - functions, - state_vars, - }); - - if let Some(cg) = s.call_graphs.get(&contract.name) { - for edge_idx in cg.edge_indices() { - let (src, dst) = cg.edge_endpoints(edge_idx).unwrap(); - let src_node = &cg[src]; - let dst_node = &cg[dst]; - let edge = &cg[edge_idx]; - - if dst_node.is_external || src_node.contract != dst_node.contract { - relationships.push(MapRelationship { - from_contract: src_node.contract.clone(), - from_function: src_node.function.clone(), - to_contract: dst_node.contract.clone(), - to_function: dst_node.function.clone(), - kind: format!("{:?}", edge.kind), - }); - } - } - } - } - - ProjectMap { - kind: "solidity", - contracts, - programs: Vec::new(), - relationships, } } diff --git a/crates/ilold-web/src/api/session.rs b/crates/ilold-web/src/api/session.rs index 289bdd0..293b8e7 100644 --- a/crates/ilold-web/src/api/session.rs +++ b/crates/ilold-web/src/api/session.rs @@ -1,35 +1,27 @@ use std::sync::Arc; -use axum::extract::{Path, Query, State}; +use axum::extract::{Path, State}; use axum::http::StatusCode; use axum::Json; use serde::{Deserialize, Serialize}; use serde_json::Value; -use ilold_core::classify::entry_points::AccessLevel; -use ilold_core::exploration::commands::{ - AnalysisData, CanvasPatch, CommandResult, ScenarioAction, ScenarioInfo, SessionCommand, - canvas_patch_from, execute_command, get_flow_tree, get_function_info, get_sequence_narrative, - get_session_state, get_step_narrative, validate_scenario_name, +use ilold_session_core::exploration::scenario::{ + ScenarioAction as SharedScenarioAction, ScenarioInfo, }; -use ilold_core::exploration::session::{ExplorationSession, ForkOrigin, VariableSummary}; -use ilold_core::journal::types::JournalEntry; -use ilold_core::exploration::timeline::{build_variable_timeline, VariableTimeline}; -use ilold_core::narrative::trace::FlowTree; -use ilold_core::narrative::types::{FunctionNarrative, SequenceNarrative}; -use ilold_core::slicing::{build_slice_result, SliceDirection, SliceResult}; -use ilold_session_core::exploration::scenario::ScenarioAction as SharedScenarioAction; +use ilold_session_core::exploration::session::{ExplorationSession, ForkOrigin}; +use ilold_session_core::journal::types::JournalEntry; use ilold_solana_core::exploration::{ canvas_patches_from_solana, execute_airdrop, execute_back, execute_call, execute_clear, - execute_coupling, execute_coverage, execute_export, execute_findings_list, execute_info, - execute_step, execute_timeline, execute_vars, execute_who, execute_finding, - execute_funcs, execute_inspect, execute_note, execute_pda, execute_session, - execute_state, execute_status, execute_time_warp, execute_users, execute_users_new, - SolanaCommand, SolanaCommandResult, + execute_coupling, execute_coverage, execute_export, execute_findings_list, execute_finding, + execute_funcs, execute_info, execute_inspect, execute_note, execute_pda, execute_session, + execute_state, execute_status, execute_step, execute_time_warp, execute_timeline, + execute_users, execute_users_new, execute_vars, execute_who, SolanaCommand, + SolanaCommandResult, }; use solana_keypair::Keypair; -use crate::state::{require_solidity_msg, AppState, Backend, ScenarioStore}; +use crate::state::{AppState, ScenarioStore}; #[derive(Deserialize)] pub struct CommandRequest { @@ -37,52 +29,6 @@ pub struct CommandRequest { pub command: Value, } -fn build_analysis_data<'a>( - state: &'a AppState, - contract_name: &str, -) -> Result<AnalysisData<'a>, (StatusCode, String)> { - let s = require_solidity_msg(state)?; - let contract = s.project.contracts.iter() - .find(|c| c.name == contract_name) - .ok_or((StatusCode::NOT_FOUND, format!("Contract '{}' not found", contract_name)))?; - - let seq_analysis = s.sequence_analyses.get(contract_name) - .ok_or((StatusCode::NOT_FOUND, "No analysis for contract".into()))?; - - let classifs = s.classifications.get(contract_name) - .ok_or((StatusCode::NOT_FOUND, "No classifications for contract".into()))?; - - Ok(AnalysisData { - project: &s.project, - contract, - cfgs: &s.cfgs, - path_trees: &s.path_trees, - behaviors: &seq_analysis.functions, - transitions: &seq_analysis.transitions, - classifications: classifs, - all_sequence_analyses: &s.sequence_analyses, - all_classifications: &s.classifications, - }) -} - -fn resolve_contract(state: &AppState, explicit: Option<&str>) -> Result<String, (StatusCode, String)> { - if let Some(name) = explicit { - return Ok(name.to_string()); - } - - let scenarios_guard = state.scenarios.read().unwrap(); - let session = scenarios_guard.active_session(); - if !session.contract.is_empty() { - return Ok(session.contract.clone()); - } - drop(scenarios_guard); - - let s = require_solidity_msg(state)?; - s.project.find_contract(None) - .map(|c| c.name.clone()) - .map_err(|e| (StatusCode::BAD_REQUEST, e)) -} - fn timestamp_now() -> String { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -90,212 +36,11 @@ fn timestamp_now() -> String { .unwrap_or_default() } -fn reserve_name(store: &ScenarioStore, name: &str) -> Result<(), CommandResult> { - if let Err(e) = validate_scenario_name(name) { - return Err(CommandResult::Error { message: e }); - } - if store.contains(name) { - return Err(CommandResult::Error { - message: format!("Scenario '{name}' already exists"), - }); - } - Ok(()) -} - -fn execute_scenario( - store: &mut ScenarioStore, - action: ScenarioAction, - timestamp: &str, - contract: &str, -) -> CommandResult { - match action { - ScenarioAction::New { name } => { - if let Err(err) = reserve_name(store, &name) { - return err; - } - let session = ExplorationSession::new(contract, "ilold"); - store.insert(name.clone(), session); - CommandResult::ScenarioCreated { name } - } - ScenarioAction::List => { - let active = store.active().to_string(); - let items: Vec<ScenarioInfo> = store - .names() - .iter() - .map(|n| ScenarioInfo { - name: n.clone(), - active: n == &active, - step_count: store.get(n).map(|s| s.steps.len()).unwrap_or(0), - }) - .collect(); - CommandResult::ScenarioList { items } - } - ScenarioAction::Switch { name } => { - let from = store.active().to_string(); - if name == from { - return CommandResult::ScenarioSwitched { from, to: name }; - } - match store.set_active(name.clone()) { - Ok(()) => CommandResult::ScenarioSwitched { from, to: name }, - Err(e) => CommandResult::Error { message: e }, - } - } - ScenarioAction::Fork { name, at_step } => fork_scenario(store, name, at_step, timestamp), - ScenarioAction::Delete { name } => { - if name == store.active() { - return CommandResult::Error { - message: "Cannot delete active scenario — switch first.".into(), - }; - } - if store.len() == 1 { - return CommandResult::Error { - message: "Cannot delete the only remaining scenario.".into(), - }; - } - if !store.contains(&name) { - return CommandResult::Error { - message: format!("Scenario '{name}' does not exist"), - }; - } - store.remove(&name); - CommandResult::ScenarioDeleted { name } - } - } -} - -fn fork_scenario( - store: &mut ScenarioStore, - new_name: String, - at_step: Option<usize>, - timestamp: &str, -) -> CommandResult { - if let Err(err) = reserve_name(store, &new_name) { - return err; - } - let from = store.active().to_string(); - let mut cloned = store.active_session().clone(); - cloned.reset_scenario_local_observations(); - - let len = cloned.steps.len(); - let effective = match at_step { - None => len, - Some(n) if n > len => { - let noun = if len == 1 { "step" } else { "steps" }; - return CommandResult::Error { - message: format!( - "Cannot fork at step {n}: only {len} {noun} in active scenario" - ), - }; - } - Some(n) => { - cloned.steps.truncate(n); - n - } - }; - - cloned.forked_from = Some(ForkOrigin { - scenario: from.clone(), - at_step: effective, - }); - cloned.journal.record(JournalEntry::BranchCreated { - from_function: from.clone(), - branch_function: new_name.clone(), - timestamp: timestamp.to_string(), - }); - store.insert(new_name.clone(), cloned); - CommandResult::ScenarioForked { - from, - to: new_name, - at_step: effective, - } -} - pub async fn handle_command( State(state): State<Arc<AppState>>, Json(req): Json<CommandRequest>, ) -> Result<Json<Value>, (StatusCode, String)> { - if matches!(state.backend, Backend::Solana(_)) { - return handle_solana_command(state, req).await; - } - let contract_name = resolve_contract(&state, req.contract.as_deref())?; - let data = build_analysis_data(&state, &contract_name)?; - let timestamp = timestamp_now(); - let solidity_command: SessionCommand = serde_json::from_value(req.command).map_err(|e| { - ( - StatusCode::BAD_REQUEST, - format!("invalid Solidity command: {e}"), - ) - })?; - - let mut scenarios_guard = state.scenarios.write().unwrap(); - let is_persistence = matches!( - solidity_command, - SessionCommand::SaveSession | SessionCommand::LoadSession { .. } - ); - let needs_reset = !is_persistence - && scenarios_guard.active_session().contract != contract_name; - if needs_reset { - let had_steps = !scenarios_guard.active_session().steps.is_empty(); - let active_before_reset = scenarios_guard.active().to_string(); - *scenarios_guard = ScenarioStore::new_for_contract(&contract_name); - if had_steps { - state.session_tx.send(CanvasPatch::ClearAll { - scenario: active_before_reset, - }).ok(); - } - } - let result: CommandResult = match solidity_command { - SessionCommand::Scenario { sub } => { - let active_before = scenarios_guard.active().to_string(); - let result = execute_scenario(&mut scenarios_guard, sub, ×tamp, &contract_name); - if let Some(patch) = canvas_patch_from(&result, &active_before) { - state.session_tx.send(patch).ok(); - } - result - } - SessionCommand::SaveSession => { - let active_before = scenarios_guard.active().to_string(); - let result = match scenarios_guard.save_to_json(crate::state::SaveOpts::none()) { - Ok(json) => CommandResult::SessionSaved { json }, - Err(message) => CommandResult::Error { message }, - }; - if let Some(patch) = canvas_patch_from(&result, &active_before) { - state.session_tx.send(patch).ok(); - } - result - } - SessionCommand::LoadSession { json } => { - let result = match ScenarioStore::load_from_json(&json) { - Ok((loaded, _kp_bundle)) => { - let contract = loaded.contract.clone(); - let step_names: Vec<String> = loaded - .active_session() - .steps - .iter() - .map(|s| s.function.clone()) - .collect(); - *scenarios_guard = loaded; - CommandResult::SessionLoaded { contract, steps: step_names } - } - Err(message) => CommandResult::Error { message }, - }; - let active_after = scenarios_guard.active().to_string(); - if let Some(patch) = canvas_patch_from(&result, &active_after) { - state.session_tx.send(patch).ok(); - } - result - } - other => { - let active_name = scenarios_guard.active().to_string(); - let session = scenarios_guard.active_session_mut(); - let result = execute_command(other, session, &data, ×tamp); - if let Some(patch) = canvas_patch_from(&result, &active_name) { - state.session_tx.send(patch).ok(); - } - result - } - }; - Ok(Json(serde_json::to_value(result).unwrap_or(Value::Null))) + handle_solana_command(state, req).await } async fn handle_solana_command( @@ -681,7 +426,7 @@ fn solana_scenario_action( let items = scenarios .names() .iter() - .map(|n| ilold_session_core::exploration::scenario::ScenarioInfo { + .map(|n| ScenarioInfo { name: n.clone(), active: n == &active, step_count: scenarios.get(n).map(|s| s.steps.len()).unwrap_or(0), @@ -813,21 +558,6 @@ fn solana_scenario_action( } } -pub async fn get_step_detail( - State(state): State<Arc<AppState>>, - Path(step_index): Path<usize>, -) -> Result<Json<FunctionNarrative>, (StatusCode, String)> { - let scenarios_guard = state.scenarios.read().unwrap(); - let session = scenarios_guard.active_session(); - - let data = build_analysis_data(&state, &session.contract)?; - - let narrative = get_step_narrative(session, step_index, &data) - .map_err(|e| (StatusCode::NOT_FOUND, e))?; - - Ok(Json(narrative)) -} - pub async fn get_session_step_trace( State(state): State<Arc<AppState>>, Path(step_index): Path<usize>, @@ -838,165 +568,28 @@ pub async fn get_session_step_trace( let step = session.steps.get(step_index) .ok_or((StatusCode::NOT_FOUND, format!("step {} not found", step_index)))?; - let tree = step.flow_tree.clone() + let trace = step.runtime_trace.clone() .ok_or(( StatusCode::NOT_FOUND, - format!( - "step {} has no persisted trace (loaded from a pre-Phase-2a session); \ - use 'tr <func>' to rebuild from source", - step_index - ), + format!("step {} has no persisted runtime trace", step_index), ))?; - Ok(Json(tree)) -} - -pub async fn get_variable_timeline_handler( - State(state): State<Arc<AppState>>, - Path(variable): Path<String>, -) -> Result<Json<VariableTimeline>, (StatusCode, String)> { - let scenarios_guard = state.scenarios.read().unwrap(); - let session = scenarios_guard.active_session(); - - let timeline = build_variable_timeline(session, &variable); - Ok(Json(timeline)) -} - -pub async fn get_state_detail( - State(state): State<Arc<AppState>>, -) -> Result<Json<Vec<VariableSummary>>, (StatusCode, String)> { - let scenarios_guard = state.scenarios.read().unwrap(); - let session = scenarios_guard.active_session(); - - Ok(Json(get_session_state(session))) + Ok(Json(trace)) } -pub async fn get_sequence_detail( - State(state): State<Arc<AppState>>, -) -> Result<Json<SequenceNarrative>, (StatusCode, String)> { - let scenarios_guard = state.scenarios.read().unwrap(); - let session = scenarios_guard.active_session(); - - let data = build_analysis_data(&state, &session.contract)?; - - let narrative = get_sequence_narrative(session, &data) - .map_err(|e| (StatusCode::BAD_REQUEST, e))?; - - Ok(Json(narrative)) -} - -pub async fn get_function_detail( - State(state): State<Arc<AppState>>, - Path((contract_name, func_name)): Path<(String, String)>, -) -> Result<Json<FunctionNarrative>, (StatusCode, String)> { - let data = build_analysis_data(&state, &contract_name)?; - - let narrative = get_function_info(&func_name, &data) - .map_err(|e| (StatusCode::NOT_FOUND, e))?; - - Ok(Json(narrative)) -} - -#[derive(Deserialize)] -pub struct TraceQuery { - #[serde(default)] - pub depth: Option<usize>, - #[serde(default)] - pub reverts: Option<bool>, - #[serde(default)] - pub expand: Option<String>, -} - -pub async fn get_flow_trace( - State(state): State<Arc<AppState>>, - Path((contract_name, func_name)): Path<(String, String)>, - Query(params): Query<TraceQuery>, -) -> Result<Json<FlowTree>, (StatusCode, String)> { - let data = build_analysis_data(&state, &contract_name)?; - - let max_depth = params.depth.unwrap_or(2); - let include_reverts = params.reverts.unwrap_or(false); - let expand_set = parse_expand_set(params.expand.as_deref()) - .map_err(|e| (StatusCode::BAD_REQUEST, e))?; - - let tree = get_flow_tree(&func_name, &data, max_depth, include_reverts, expand_set) - .map_err(|e| (StatusCode::NOT_FOUND, e))?; - - Ok(Json(tree)) -} - -#[derive(Deserialize)] -pub struct SliceQuery { - #[serde(default)] - pub direction: Option<String>, -} - -pub async fn get_function_slice( - State(state): State<Arc<AppState>>, - Path((func_name, variable)): Path<(String, String)>, - Query(params): Query<SliceQuery>, -) -> Result<Json<SliceResult>, (StatusCode, String)> { - let contract_name = { - let guard = state.scenarios.read().unwrap(); - guard.active_session().contract.clone() - }; - - let s = require_solidity_msg(&state)?; - let contract = s.project.contracts.iter() - .find(|c| c.name == contract_name) - .ok_or(( - StatusCode::NOT_FOUND, - format!("Contract '{}' not found", contract_name), - ))?; - - let function = contract.functions.iter() - .find(|f| f.name == func_name) - .ok_or(( - StatusCode::NOT_FOUND, - format!("Function '{}' not found in {}", func_name, contract_name), - ))?; - - let direction = parse_slice_direction(params.direction.as_deref()) - .map_err(|e| (StatusCode::BAD_REQUEST, e))?; - - Ok(Json(build_slice_result( - &s.project, - contract, - function, - &variable, - direction, - ))) -} - -fn parse_slice_direction(raw: Option<&str>) -> Result<SliceDirection, String> { - let Some(value) = raw.map(str::trim).filter(|s| !s.is_empty()) else { - return Ok(SliceDirection::Both); - }; - match value.to_ascii_lowercase().as_str() { - "backward" | "back" | "b" => Ok(SliceDirection::Backward), - "forward" | "fwd" | "f" => Ok(SliceDirection::Forward), - "both" | "all" => Ok(SliceDirection::Both), - other => Err(format!( - "invalid direction {:?}, expected backward|forward|both", - other - )), - } +#[derive(Serialize)] +pub struct ScenarioSnapshot { + pub name: String, + pub steps: Vec<SessionStepView>, + pub forked_from: Option<ForkOrigin>, } #[derive(Serialize)] pub struct SessionStepView { pub function: String, - pub access: AccessLevel, pub step_index: usize, } -#[derive(Serialize)] -pub struct ScenarioSnapshot { - pub name: String, - pub steps: Vec<SessionStepView>, - pub forked_from: Option<ForkOrigin>, -} - #[derive(Serialize)] pub struct AllScenariosResponse { pub active: String, @@ -1023,27 +616,18 @@ pub async fn get_scenarios( pub async fn get_all_scenarios( State(state): State<Arc<AppState>>, ) -> Result<Json<AllScenariosResponse>, (StatusCode, String)> { - let solidity = state.solidity(); let guard = state.scenarios.read().unwrap(); let active = guard.active().to_string(); let mut scenarios: Vec<ScenarioSnapshot> = Vec::with_capacity(guard.len()); for name in guard.names() { let Some(session) = guard.get(name) else { continue }; - let classifs = solidity.and_then(|s| s.classifications.get(&session.contract)); let steps = session .steps .iter() .enumerate() - .map(|(idx, step)| { - let access = classifs - .and_then(|c| c.iter().find(|(n, _)| n == &step.function)) - .map(|(_, a)| a.clone()) - .unwrap_or(AccessLevel::Public); - SessionStepView { - function: step.function.clone(), - access, - step_index: idx, - } + .map(|(idx, step)| SessionStepView { + function: step.function.clone(), + step_index: idx, }) .collect(); scenarios.push(ScenarioSnapshot { @@ -1054,21 +638,3 @@ pub async fn get_all_scenarios( } Ok(Json(AllScenariosResponse { active, scenarios })) } - -fn parse_expand_set(raw: Option<&str>) -> Result<std::collections::HashSet<usize>, String> { - let mut set = std::collections::HashSet::new(); - let raw = match raw { - Some(s) if !s.trim().is_empty() => s, - _ => return Ok(set), - }; - for part in raw.split(',') { - let trimmed = part.trim(); - if trimmed.is_empty() { - continue; - } - let id: usize = trimmed.parse() - .map_err(|_| format!("invalid step_id in expand: {:?}", trimmed))?; - set.insert(id); - } - Ok(set) -} diff --git a/crates/ilold-web/src/lib.rs b/crates/ilold-web/src/lib.rs index ed23e9b..f3479fd 100644 --- a/crates/ilold-web/src/lib.rs +++ b/crates/ilold-web/src/lib.rs @@ -2,7 +2,6 @@ pub mod api; pub mod state; pub mod ws; -use std::path::PathBuf; use std::sync::Arc; use axum::routing::{delete, get, post, put}; @@ -21,27 +20,12 @@ fn build_router(state: Arc<AppState>) -> Router { .route("/api/program/{name}/overlay", get(api::project::get_program_overlay)) .route("/api/program/{name}/{ix}/source", get(api::project::get_instruction_source)) .route("/api/users/{scenario}/labels", get(api::project::get_user_labels)) - .route("/api/contract/{name}", get(api::contract::get_contract)) - .route("/api/contract/{name}/callgraph", get(api::contract::get_callgraph)) - .route("/api/contract/{name}/{func}/cfg", get(api::contract::get_cfg)) - .route("/api/contract/{name}/{func}/paths", get(api::contract::get_paths)) - .route("/api/contract/{name}/{func}/source", get(api::contract::get_function_source)) - .route("/api/contract/{name}/sequences", get(api::contract::get_sequences)) - .route("/api/contract/{name}/analysis", get(api::contract::get_sequence_analysis)) - .route("/api/contract/{name}/suggestions", get(api::contract::get_search_suggestions)) .route("/api/annotations", get(api::annotations::list_annotations)) .route("/api/annotations", post(api::annotations::create_annotation)) .route("/api/annotations/{id}", put(api::annotations::update_annotation)) .route("/api/annotations/{id}", delete(api::annotations::delete_annotation)) .route("/api/cmd", post(api::session::handle_command)) - .route("/api/session/step/{index}/narrative", get(api::session::get_step_detail)) .route("/api/session/step/{index}/trace", get(api::session::get_session_step_trace)) - .route("/api/session/timeline/{variable}", get(api::session::get_variable_timeline_handler)) - .route("/api/session/state", get(api::session::get_state_detail)) - .route("/api/session/sequence", get(api::session::get_sequence_detail)) - .route("/api/session/function/{contract}/{func}", get(api::session::get_function_detail)) - .route("/api/session/trace/{contract}/{func}", get(api::session::get_flow_trace)) - .route("/api/session/slice/{func}/{variable}", get(api::session::get_function_slice)) .route("/api/scenarios", get(api::session::get_scenarios)) .route("/api/scenarios/all", get(api::session::get_all_scenarios)) .route("/ws", get(ws::handler::ws_handler)) @@ -50,44 +34,6 @@ fn build_router(state: Arc<AppState>) -> Router { .with_state(state) } -pub async fn serve(paths: Vec<PathBuf>, port: u16, max_seq_depth: usize) -> anyhow::Result<()> { - println!("Analyzing {} file(s)...", paths.len()); - let project_root = paths.first().map(|p| p.parent().unwrap_or(p).to_path_buf()).unwrap_or_default(); - let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{port}")).await?; - let actual_port = listener.local_addr()?.port(); - let state = Arc::new(AppState::from_paths(&paths, max_seq_depth, actual_port, project_root)?); - if let Some(s) = state.solidity() { - println!( - "Ready: {} contracts, {} functions analyzed\n", - s.project.contracts.len(), - s.cfgs.len(), - ); - } - - let app = build_router(state); - println!("Server running at http://localhost:{actual_port}"); - axum::serve(listener, app).await?; - Ok(()) -} - -pub async fn start_server( - paths: Vec<PathBuf>, - port: u16, - max_seq_depth: usize, -) -> anyhow::Result<(Arc<AppState>, u16)> { - let project_root = paths.first().map(|p| p.parent().unwrap_or(p).to_path_buf()).unwrap_or_default(); - let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{port}")).await?; - let actual_port = listener.local_addr()?.port(); - let state = Arc::new(AppState::from_paths(&paths, max_seq_depth, actual_port, project_root)?); - let app = build_router(state.clone()); - - tokio::spawn(async move { - axum::serve(listener, app).await.ok(); - }); - - Ok((state, actual_port)) -} - pub async fn serve_solana(detected: DetectedProject, port: u16) -> anyhow::Result<()> { println!("Analyzing {} IDL(s)...", detected.idl_paths.len()); let project = build_solana_project(&detected)?; @@ -100,13 +46,12 @@ pub async fn serve_solana(detected: DetectedProject, port: u16) -> anyhow::Resul actual_port, detected.root.clone(), )?); - if let Some(s) = state.solana() { - println!( - "Ready: {} program(s) analyzed, {} .so loaded\n", - s.project.programs.len(), - s.program_artifacts.len() - ); - } + let s = state.solana().expect("solana backend required"); + println!( + "Ready: {} program(s) analyzed, {} .so loaded\n", + s.project.programs.len(), + s.program_artifacts.len() + ); let app = build_router(state); println!("Server running at http://localhost:{actual_port}"); axum::serve(listener, app).await?; diff --git a/crates/ilold-web/src/state.rs b/crates/ilold-web/src/state.rs index 4739699..2f879f5 100644 --- a/crates/ilold-web/src/state.rs +++ b/crates/ilold-web/src/state.rs @@ -2,25 +2,10 @@ use std::collections::HashMap; use std::path::PathBuf; use std::sync::RwLock; -use axum::http::StatusCode; use tokio::sync::broadcast; -use ilold_core::callgraph::builder::build_call_graph; -use ilold_core::exploration::commands::CanvasPatch; -use ilold_core::callgraph::types::CallGraph; -use ilold_core::cfg::builder::CfgBuilder; -use ilold_core::cfg::types::CfgGraph; -use ilold_core::classify::entry_points::{classify_all, AccessLevel}; -use ilold_core::exploration::session::ExplorationSession; -use ilold_core::model::project::Project; -use ilold_core::parse::solar_frontend::SolarParser; -use ilold_core::parse::ProjectParser; -use ilold_core::pathtree::config::PruningConfig; -use ilold_core::pathtree::types::PathTree; -use ilold_core::pathtree::walker::build_path_tree; -use ilold_core::sequence::analysis::{analyze_project, analyze_sequences, SequenceAnalysis}; -use ilold_core::sequence::builder::build_sequence_tree; -use ilold_core::sequence::types::SequenceTree; +use ilold_session_core::exploration::canvas::CanvasPatch; +use ilold_session_core::exploration::session::ExplorationSession; use ilold_solana_core::execute::VmHost; use ilold_solana_core::model::SolanaProject; use solana_address::Address; @@ -252,16 +237,6 @@ fn decode_keypair_bundle( Ok(out) } -pub struct SolidityState { - pub project: Project, - pub cfgs: HashMap<(String, String), CfgGraph>, - pub path_trees: HashMap<(String, String), PathTree>, - pub call_graphs: HashMap<String, CallGraph>, - pub sequence_trees: HashMap<String, SequenceTree>, - pub sequence_analyses: HashMap<String, SequenceAnalysis>, - pub classifications: HashMap<String, Vec<(String, AccessLevel)>>, -} - pub struct SolanaState { pub project: SolanaProject, pub program_artifacts: Vec<(Address, Vec<u8>)>, @@ -271,7 +246,6 @@ pub struct SolanaState { } pub enum Backend { - Solidity(SolidityState), Solana(SolanaState), } @@ -285,37 +259,12 @@ pub struct AppState { } impl AppState { - pub fn solidity(&self) -> Option<&SolidityState> { - match &self.backend { - Backend::Solidity(s) => Some(s), - Backend::Solana(_) => None, - } - } - pub fn solana(&self) -> Option<&SolanaState> { - match &self.backend { - Backend::Solana(s) => Some(s), - Backend::Solidity(_) => None, - } - } -} - -impl AppState { - pub fn unwrap_solidity(&self) -> &SolidityState { - self.solidity().expect("Solidity backend required") + let Backend::Solana(s) = &self.backend; + Some(s) } } -pub fn require_solidity(state: &AppState) -> Result<&SolidityState, StatusCode> { - state.solidity().ok_or(StatusCode::BAD_REQUEST) -} - -pub fn require_solidity_msg(state: &AppState) -> Result<&SolidityState, (StatusCode, String)> { - state - .solidity() - .ok_or_else(|| (StatusCode::BAD_REQUEST, "endpoint is Solidity-only".to_string())) -} - #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Annotation { pub id: String, @@ -387,81 +336,4 @@ impl AppState { project_root, }) } - - pub fn from_paths(paths: &[PathBuf], max_seq_depth: usize, port: u16, project_root: PathBuf) -> anyhow::Result<Self> { - let parser = SolarParser; - let mut project = parser.parse(paths)?; - project.rebuild_index(); - - let config = PruningConfig::default(); - let mut cfgs = HashMap::new(); - let mut path_trees = HashMap::new(); - let mut call_graphs = HashMap::new(); - let mut sequence_trees = HashMap::new(); - let mut sequence_analyses = HashMap::new(); - let mut classifications = HashMap::new(); - - for contract in &project.contracts { - let cg = build_call_graph(&project, contract); - call_graphs.insert(contract.name.clone(), cg); - - let mut contract_path_trees = Vec::new(); - let combined_state_vars = project.inherited_state_vars(contract); - - for func in &contract.functions { - let key = (contract.name.clone(), func.name.clone()); - - if let Ok(cfg) = CfgBuilder::build_with_project(func, contract, Some(&project)) { - let pt = build_path_tree( - &cfg, - &contract.name, - &func.name, - &combined_state_vars, - &config, - ); - contract_path_trees.push(pt.clone()); - path_trees.insert(key.clone(), pt); - cfgs.insert(key, cfg); - } - } - - let st = build_sequence_tree(contract, &contract_path_trees, max_seq_depth); - sequence_trees.insert(contract.name.clone(), st); - - let pt_map: HashMap<(String, String), PathTree> = contract_path_trees - .iter() - .map(|pt| ((pt.contract.clone(), pt.function.clone()), pt.clone())) - .collect(); - let analysis = analyze_sequences(&pt_map, &contract.name); - sequence_analyses.insert(contract.name.clone(), analysis); - - classifications.insert(contract.name.clone(), classify_all(contract)); - } - - analyze_project(&project, &mut sequence_analyses); - - let (session_tx, _) = broadcast::channel(64); - - let default_contract = project.contracts.iter() - .find(|c| !c.name.is_empty()) - .map(|c| c.name.clone()) - .unwrap_or_else(|| "unknown".to_string()); - - Ok(Self { - backend: Backend::Solidity(SolidityState { - project, - cfgs, - path_trees, - call_graphs, - sequence_trees, - sequence_analyses, - classifications, - }), - annotations: RwLock::new(Vec::new()), - scenarios: RwLock::new(ScenarioStore::new_for_contract(default_contract)), - session_tx, - port, - project_root, - }) - } } diff --git a/crates/ilold-web/src/ws/handler.rs b/crates/ilold-web/src/ws/handler.rs index 8615340..ca60378 100644 --- a/crates/ilold-web/src/ws/handler.rs +++ b/crates/ilold-web/src/ws/handler.rs @@ -3,31 +3,18 @@ use std::sync::Arc; use axum::extract::ws::{Message, WebSocket}; use axum::extract::{State, WebSocketUpgrade}; use axum::response::IntoResponse; -use serde::{Deserialize, Serialize}; +use serde::Serialize; use tokio::sync::broadcast; -use ilold_core::classify::entry_points::AccessLevel; -use ilold_core::exploration::commands::{CanvasPatch, ScenarioEvent}; +use ilold_session_core::exploration::access::AccessLevel; +use ilold_session_core::exploration::canvas::CanvasPatch; +use ilold_session_core::exploration::scenario::ScenarioEvent; use crate::state::AppState; -use super::search::{self, SearchQuery}; - -#[derive(Deserialize)] -#[serde(tag = "type")] -enum ClientMessage { - #[serde(rename = "search")] - Search(SearchQuery), -} #[derive(Serialize)] #[serde(tag = "type")] enum ServerMessage { - #[serde(rename = "search_result")] - SearchResult(search::SearchResult), - #[serde(rename = "search_complete")] - SearchComplete { total: usize }, - #[serde(rename = "error")] - Error { message: String }, #[serde(rename = "session_add_node")] SessionAddNode { scenario: String, @@ -127,37 +114,10 @@ async fn handle_socket(mut socket: WebSocket, state: Arc<AppState>) { loop { tokio::select! { client_msg = socket.recv() => { - let msg = match client_msg { - Some(Ok(Message::Text(text))) => text, + match client_msg { + Some(Ok(Message::Text(_))) => continue, Some(Ok(Message::Close(_))) | None => break, _ => continue, - }; - - let parsed: ClientMessage = match serde_json::from_str(&msg) { - Ok(m) => m, - Err(e) => { - let err = ServerMessage::Error { message: format!("Invalid message: {e}") }; - if !send_json(&mut socket, &err).await { break; } - continue; - } - }; - - match parsed { - ClientMessage::Search(query) => { - let results = match state.solidity() { - Some(s) => search::search_paths(s, &query), - None => Vec::new(), - }; - let total = results.len(); - - for result in results { - let msg = ServerMessage::SearchResult(result); - if !send_json(&mut socket, &msg).await { break; } - } - - let complete = ServerMessage::SearchComplete { total }; - if !send_json(&mut socket, &complete).await { break; } - } } } diff --git a/crates/ilold-web/src/ws/mod.rs b/crates/ilold-web/src/ws/mod.rs index cfda953..c86cb83 100644 --- a/crates/ilold-web/src/ws/mod.rs +++ b/crates/ilold-web/src/ws/mod.rs @@ -1,3 +1,2 @@ pub mod handler; pub mod pty; -pub mod search; diff --git a/crates/ilold-web/src/ws/search.rs b/crates/ilold-web/src/ws/search.rs deleted file mode 100644 index a3dca7c..0000000 --- a/crates/ilold-web/src/ws/search.rs +++ /dev/null @@ -1,136 +0,0 @@ -use serde::{Deserialize, Serialize}; - -use crate::state::SolidityState; - -#[derive(Debug, Deserialize)] -pub struct SearchQuery { - pub query: String, - pub contract: Option<String>, - pub function: Option<String>, -} - -#[derive(Debug, Serialize)] -pub struct SearchResult { - pub contract: String, - pub function: String, - pub path_id: usize, - pub terminal: String, - pub matches: Vec<SearchMatch>, - pub depth: usize, -} - -#[derive(Debug, Serialize)] -pub struct SearchMatch { - pub field: String, - pub value: String, -} - -#[derive(Debug, Serialize)] -pub struct SearchComplete { - pub total: usize, -} - -pub fn search_paths(state: &SolidityState, query: &SearchQuery) -> Vec<SearchResult> { - let q = query.query.to_lowercase(); - let mut results = Vec::new(); - - for ((contract, function), path_tree) in &state.path_trees { - if let Some(filter_contract) = query.contract.as_ref() { - if contract != filter_contract { continue; } - } - if let Some(filter_function) = query.function.as_ref() { - if function != filter_function { continue; } - } - - for path in &path_tree.paths { - let mut matches = Vec::new(); - - for check in &path.annotations.require_checks { - if check.to_lowercase().contains(&q) { - matches.push(SearchMatch { - field: "require".into(), - value: check.clone(), - }); - } - } - - for call in &path.annotations.external_calls { - let call_str = format!("{}.{}", call.target, call.function); - if call_str.to_lowercase().contains(&q) - || "external".contains(&q) - || "ext call".contains(&q) - { - matches.push(SearchMatch { - field: "external_call".into(), - value: call_str, - }); - } - } - - for call in &path.annotations.internal_calls { - if call.to_lowercase().contains(&q) { - matches.push(SearchMatch { - field: "internal_call".into(), - value: call.clone(), - }); - } - } - - for write in &path.annotations.state_writes { - if write.to_lowercase().contains(&q) - || "state write".contains(&q) - || "write".contains(&q) - { - matches.push(SearchMatch { - field: "state_write".into(), - value: write.clone(), - }); - } - } - - for event in &path.annotations.events_emitted { - if event.to_lowercase().contains(&q) { - matches.push(SearchMatch { - field: "event".into(), - value: event.clone(), - }); - } - } - - if path.annotations.has_assembly && "assembly".contains(&q) { - matches.push(SearchMatch { - field: "assembly".into(), - value: "contains assembly block".into(), - }); - } - - let terminal_str = format!("{:?}", path.terminal); - if terminal_str.to_lowercase().contains(&q) { - matches.push(SearchMatch { - field: "terminal".into(), - value: terminal_str.clone(), - }); - } - - if function.to_lowercase().contains(&q) { - matches.push(SearchMatch { - field: "function".into(), - value: function.clone(), - }); - } - - if !matches.is_empty() { - results.push(SearchResult { - contract: contract.clone(), - function: function.clone(), - path_id: path.id, - terminal: format!("{:?}", path.terminal), - matches, - depth: path.depth, - }); - } - } - } - - results -} diff --git a/crates/ilold-web/tests/contract_source_test.rs b/crates/ilold-web/tests/contract_source_test.rs deleted file mode 100644 index 6275404..0000000 --- a/crates/ilold-web/tests/contract_source_test.rs +++ /dev/null @@ -1,103 +0,0 @@ -//! Integration tests for `GET /api/contract/:contract/:func/source`. -//! -//! Scaffolding mirrors `scenario_commands_test.rs` — starts a server on -//! port 0 (parallel-safe) against a fixture, then issues GET requests. - -use std::path::PathBuf; - -fn fixture(name: &str) -> PathBuf { - PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .parent() - .unwrap() - .parent() - .unwrap() - .join("tests/fixtures") - .join(name) -} - -async fn start_with(fixture_name: &str) -> (reqwest::Client, u16) { - let paths = vec![fixture(fixture_name)]; - let (_state, port) = ilold_web::start_server(paths, 0, 2).await.unwrap(); - (reqwest::Client::new(), port) -} - -#[tokio::test] -async fn get_function_source_returns_source_for_deposit() { - let (client, port) = start_with("staking.sol").await; - - let res = client - .get(format!( - "http://127.0.0.1:{port}/api/contract/Staking/deposit/source" - )) - .send() - .await - .unwrap(); - assert!(res.status().is_success(), "status: {}", res.status()); - - let body: serde_json::Value = res.json().await.unwrap(); - let source = body.get("source").and_then(|v| v.as_str()).unwrap(); - assert!( - source.contains("function deposit"), - "expected signature line, got:\n{source}" - ); - // Full-body guarantee: deposit writes to balances + transfers + emits. - // If the span only captured the header we would miss these. - assert!( - source.contains("balances[msg.sender]"), - "expected deposit body, got:\n{source}" - ); - assert!( - source.contains("emit Staked"), - "expected deposit emit statement, got:\n{source}" - ); - // Span must cover multiple lines. - let start_line = body.pointer("/span/start_line").and_then(|v| v.as_u64()).unwrap(); - let end_line = body.pointer("/span/end_line").and_then(|v| v.as_u64()).unwrap(); - assert!(end_line > start_line, "full-body span must span >1 lines, got: {start_line}..{end_line}"); - // File path must be absolute (CLI passes absolute paths) and end in .sol. - let file_path = body.get("file_path").and_then(|v| v.as_str()).unwrap(); - assert!(file_path.ends_with("staking.sol"), "unexpected file_path: {file_path}"); -} - -#[tokio::test] -async fn get_function_source_returns_404_for_missing_function() { - let (client, port) = start_with("staking.sol").await; - - let res = client - .get(format!( - "http://127.0.0.1:{port}/api/contract/Staking/doesNotExist/source" - )) - .send() - .await - .unwrap(); - assert_eq!( - res.status(), - reqwest::StatusCode::NOT_FOUND, - "unknown function must 404, got: {}", - res.status(), - ); -} - -#[tokio::test] -async fn get_function_source_resolves_inherited_function() { - // `Governor is TimelockController`; `schedule` is declared in the parent. - // `resolve_function` must walk the inheritance chain so that asking for - // Governor/schedule returns the parent's FunctionDef (with its span). - let (client, port) = start_with("governor.sol").await; - - let res = client - .get(format!( - "http://127.0.0.1:{port}/api/contract/Governor/schedule/source" - )) - .send() - .await - .unwrap(); - assert!(res.status().is_success(), "status: {}", res.status()); - - let body: serde_json::Value = res.json().await.unwrap(); - let source = body.get("source").and_then(|v| v.as_str()).unwrap(); - assert!( - source.contains("function schedule"), - "inherited function source missing, got:\n{source}" - ); -} diff --git a/crates/ilold-web/tests/explore_commands_test.rs b/crates/ilold-web/tests/explore_commands_test.rs deleted file mode 100644 index 9bf2172..0000000 --- a/crates/ilold-web/tests/explore_commands_test.rs +++ /dev/null @@ -1,941 +0,0 @@ -use std::path::PathBuf; - -fn fixture(name: &str) -> PathBuf { - PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .parent().unwrap() - .parent().unwrap() - .join("tests/fixtures") - .join(name) -} - -#[derive(Debug, Clone)] -struct FlowEvent { - variant: String, - payload: serde_json::Value, -} - -fn flatten_flow_tree(node: &serde_json::Value, out: &mut Vec<FlowEvent>) { - if let Some(kind) = node.get("kind") { - match kind { - serde_json::Value::String(s) => { - out.push(FlowEvent { variant: s.clone(), payload: serde_json::Value::Null }); - } - serde_json::Value::Object(map) => { - if let Some((variant, payload)) = map.iter().next() { - out.push(FlowEvent { variant: variant.clone(), payload: payload.clone() }); - } - } - _ => {} - } - } - if let Some(children) = node.get("children").and_then(|c| c.as_array()) { - for child in children { - flatten_flow_tree(child, out); - } - } -} - -#[tokio::test] -async fn vars_endpoint_returns_state_variables() { - let paths = vec![fixture("staking.sol")]; - let (_, port) = ilold_web::start_server(paths, 0, 2).await.unwrap(); - - let client = reqwest::Client::new(); - let res = client - .get(format!("http://127.0.0.1:{port}/api/contract/Staking")) - .send().await.unwrap(); - - assert!(res.status().is_success()); - let body: serde_json::Value = res.json().await.unwrap(); - - let vars = body["state_vars"].as_array().unwrap(); - assert!(!vars.is_empty(), "Should have state variables"); - - let names: Vec<&str> = vars.iter() - .filter_map(|v| v["name"].as_str()) - .collect(); - assert!(names.contains(&"balances"), "Should contain balances: {:?}", names); - assert!(names.contains(&"totalStaked"), "Should contain totalStaked: {:?}", names); - assert!(names.contains(&"rewardRate"), "Should contain rewardRate: {:?}", names); - - let balances = vars.iter().find(|v| v["name"] == "balances").unwrap(); - assert_eq!(balances["is_constant"], false); - assert_eq!(balances["is_immutable"], false); - assert!(balances["type_name"].as_str().is_some()); -} - -#[tokio::test] -async fn info_endpoint_returns_narrative_with_tree_data() { - let paths = vec![fixture("staking.sol")]; - let (_, port) = ilold_web::start_server(paths, 0, 2).await.unwrap(); - - let client = reqwest::Client::new(); - - // Need an active session first - client.post(format!("http://127.0.0.1:{port}/api/cmd")) - .json(&serde_json::json!({"contract": "Staking", "command": {"Call": {"func": "deposit"}}})) - .send().await.unwrap(); - - let res = client - .get(format!("http://127.0.0.1:{port}/api/session/function/Staking/deposit")) - .send().await.unwrap(); - - assert!(res.status().is_success()); - let body: serde_json::Value = res.json().await.unwrap(); - - assert_eq!(body["name"], "deposit"); - assert!(body["total_paths"].as_u64().unwrap() > 0); - assert!(body["state_writes"].as_array().unwrap().len() > 0); - assert!(body["modifiers"].as_array().unwrap().len() > 0); - assert!(body["external_calls"].as_array().unwrap().len() > 0); -} - -#[tokio::test] -async fn sequence_endpoint_with_two_steps() { - let paths = vec![fixture("staking.sol")]; - let (_, port) = ilold_web::start_server(paths, 0, 2).await.unwrap(); - - let client = reqwest::Client::new(); - - // Build 2-step sequence - client.post(format!("http://127.0.0.1:{port}/api/cmd")) - .json(&serde_json::json!({"contract": "Staking", "command": {"Call": {"func": "deposit"}}})) - .send().await.unwrap(); - client.post(format!("http://127.0.0.1:{port}/api/cmd")) - .json(&serde_json::json!({"contract": "Staking", "command": {"Call": {"func": "withdraw"}}})) - .send().await.unwrap(); - - let res = client - .get(format!("http://127.0.0.1:{port}/api/session/sequence")) - .send().await.unwrap(); - - assert!(res.status().is_success()); - let body: serde_json::Value = res.json().await.unwrap(); - - assert!(body["steps"].as_array().unwrap().len() >= 2); - assert!(body["observations"].as_array().is_some()); -} - -#[tokio::test] -async fn sequence_with_one_step_returns_error() { - let paths = vec![fixture("staking.sol")]; - let (_, port) = ilold_web::start_server(paths, 0, 2).await.unwrap(); - - let client = reqwest::Client::new(); - - client.post(format!("http://127.0.0.1:{port}/api/cmd")) - .json(&serde_json::json!({"contract": "Staking", "command": {"Call": {"func": "deposit"}}})) - .send().await.unwrap(); - - let res = client - .get(format!("http://127.0.0.1:{port}/api/session/sequence")) - .send().await.unwrap(); - - assert_eq!(res.status(), 400); - let body = res.text().await.unwrap(); - assert!(body.contains("at least 2 steps")); -} - -#[tokio::test] -async fn full_explore_workflow() { - let paths = vec![fixture("staking.sol")]; - let (_, port) = ilold_web::start_server(paths, 0, 2).await.unwrap(); - - let client = reqwest::Client::new(); - let cmd = |body: serde_json::Value| { - let c = client.clone(); - let p = port; - async move { - c.post(format!("http://127.0.0.1:{p}/api/cmd")) - .json(&body) - .send().await.unwrap() - .json::<serde_json::Value>().await.unwrap() - } - }; - - // 1. Functions - let res = cmd(serde_json::json!({"contract": "Staking", "command": "Functions"})).await; - let funcs = res["FunctionList"]["functions"].as_array().unwrap(); - assert!(funcs.len() >= 8); - - // 2. Call deposit - let res = cmd(serde_json::json!({"contract": "Staking", "command": {"Call": {"func": "deposit"}}})).await; - assert_eq!(res["StepAdded"]["function"], "deposit"); - assert_eq!(res["StepAdded"]["step_index"], 0); - - // 3. State after 1 step - let res = cmd(serde_json::json!({"contract": "Staking", "command": "State"})).await; - let summary = res["StateView"]["summary"].as_array().unwrap(); - assert!(!summary.is_empty()); - - // 4. Who balances - let res = cmd(serde_json::json!({"contract": "Staking", "command": {"Who": {"variable": "balances"}}})).await; - let writers = res["VariableInfo"]["writers"].as_array().unwrap(); - assert!(writers.len() >= 2, "balances should have at least 2 writers"); - - // 5. Call withdraw - let res = cmd(serde_json::json!({"contract": "Staking", "command": {"Call": {"func": "withdraw"}}})).await; - assert_eq!(res["StepAdded"]["function"], "withdraw"); - assert_eq!(res["StepAdded"]["step_index"], 1); - - // 6. Back - let res = cmd(serde_json::json!({"contract": "Staking", "command": "Back"})).await; - assert_eq!(res["StepRemoved"]["remaining"], 1); - - // 7. Session - let res = cmd(serde_json::json!({"contract": "Staking", "command": "Session"})).await; - assert_eq!(res["SessionView"]["contract"], "Staking"); - let steps = res["SessionView"]["steps"].as_array().unwrap(); - assert_eq!(steps.len(), 1); - - // 8. Clear - let res = cmd(serde_json::json!({"contract": "Staking", "command": "Clear"})).await; - assert_eq!(res, serde_json::json!("Cleared")); - - // 9. Session after clear - let res = cmd(serde_json::json!({"contract": "Staking", "command": "Session"})).await; - assert_eq!(res["SessionView"]["steps"].as_array().unwrap().len(), 0); -} - -#[tokio::test] -async fn trace_swap_shows_external_call_before_update_writes() { - let paths = vec![fixture("uniswap_v2_pair.sol")]; - let (_, port) = ilold_web::start_server(paths, 0, 2).await.unwrap(); - - let client = reqwest::Client::new(); - let res = client - .get(format!("http://127.0.0.1:{port}/api/session/trace/UniswapV2Pair/swap?depth=4")) - .send().await.unwrap(); - - assert!(res.status().is_success(), "trace endpoint failed: {}", res.status()); - let tree: serde_json::Value = res.json().await.unwrap(); - - assert_eq!(tree["contract"], "UniswapV2Pair"); - assert_eq!(tree["function"], "swap"); - assert_eq!(tree["max_depth"], 4); - let modifiers = tree["modifiers"].as_array().unwrap(); - assert!(modifiers.iter().any(|m| m == "lock")); - - let mut events: Vec<FlowEvent> = Vec::new(); - flatten_flow_tree(&tree["root"], &mut events); - - let first_external = events.iter().position(|e| { - e.variant == "ExternalCall" - && e.payload.get("function").and_then(|v| v.as_str()) == Some("transfer") - }); - assert!(first_external.is_some(), "Expected IERC20.transfer ExternalCall"); - - assert!( - events.iter().any(|e| { - e.variant == "InternalCall" - && e.payload.get("function").and_then(|v| v.as_str()) == Some("_update") - }), - "Expected _update as InternalCall", - ); - - assert!( - events.iter().any(|e| { - e.variant == "Write" - && e.payload.get("target").and_then(|v| v.as_str()) == Some("reserve0") - }), - "Expected inlined write to reserve0", - ); - - // CEI order: a reserve0 write must exist after the first external transfer. - let ext_idx = first_external.unwrap(); - assert!( - events[ext_idx..].iter().any(|e| { - e.variant == "Write" - && e.payload.get("target").and_then(|v| v.as_str()) == Some("reserve0") - }), - "Expected reserve0 write AFTER first transfer (CEI). first_external={}", - ext_idx, - ); -} - -#[tokio::test] -async fn trace_getreserves_has_no_empty_target_writes() { - // Regression for classify_expression catchall emitting fake Assignments - // for tuple returns. getReserves must have zero Write events. - let paths = vec![fixture("uniswap_v2_pair.sol")]; - let (_, port) = ilold_web::start_server(paths, 0, 2).await.unwrap(); - - let client = reqwest::Client::new(); - let res = client - .get(format!("http://127.0.0.1:{port}/api/session/trace/UniswapV2Pair/getReserves")) - .send().await.unwrap(); - - assert!(res.status().is_success()); - let tree: serde_json::Value = res.json().await.unwrap(); - - let mut events: Vec<FlowEvent> = Vec::new(); - flatten_flow_tree(&tree["root"], &mut events); - - let write_count = events.iter().filter(|e| e.variant == "Write").count(); - assert_eq!(write_count, 0, "getReserves should emit no Write events"); -} - - -#[tokio::test] -async fn trace_update_has_no_internal_calls_and_shows_state_writes() { - let paths = vec![fixture("uniswap_v2_pair.sol")]; - let (_, port) = ilold_web::start_server(paths, 0, 2).await.unwrap(); - - let client = reqwest::Client::new(); - let res = client - .get(format!("http://127.0.0.1:{port}/api/session/trace/UniswapV2Pair/_update")) - .send().await.unwrap(); - - assert!(res.status().is_success()); - let tree: serde_json::Value = res.json().await.unwrap(); - - let mut events: Vec<FlowEvent> = Vec::new(); - flatten_flow_tree(&tree["root"], &mut events); - - let has_write_to = |name: &str| { - events.iter().any(|e| { - e.variant == "Write" - && e.payload.get("target").and_then(|v| v.as_str()) == Some(name) - }) - }; - - // _update has 3 writes + 1 emit, no internal calls - assert!(has_write_to("reserve0"), "expected write to reserve0"); - assert!(has_write_to("reserve1"), "expected write to reserve1"); - assert!(events.iter().any(|e| e.variant == "EmitEvent")); - assert!(!events.iter().any(|e| e.variant == "InternalCall")); -} - -/// Collect (step_id, variant) pairs from a FlowTree in pre-order. -fn collect_step_ids(node: &serde_json::Value, out: &mut Vec<(u64, String)>) { - let step_id = node.get("step_id").and_then(|v| v.as_u64()).unwrap_or(0); - let variant = match node.get("kind") { - Some(serde_json::Value::String(s)) => s.clone(), - Some(serde_json::Value::Object(map)) => { - map.keys().next().cloned().unwrap_or_default() - } - _ => String::new(), - }; - out.push((step_id, variant)); - if let Some(children) = node.get("children").and_then(|c| c.as_array()) { - for child in children { - collect_step_ids(child, out); - } - } -} - -/// Canonical step_ids must be stable across configs that only differ -/// in max_depth and expand_set. For every step_id present in BOTH trees, -/// the FlowKind variant must match. -#[tokio::test] -async fn step_ids_are_stable_across_max_depth_configs() { - let paths = vec![fixture("uniswap_v2_pair.sol")]; - let (_, port) = ilold_web::start_server(paths, 0, 2).await.unwrap(); - - let client = reqwest::Client::new(); - let get_tree = |depth: usize| { - let client = client.clone(); - async move { - let res = client - .get(format!( - "http://127.0.0.1:{port}/api/session/trace/UniswapV2Pair/swap?depth={depth}" - )) - .send().await.unwrap(); - assert!(res.status().is_success()); - res.json::<serde_json::Value>().await.unwrap() - } - }; - - let tree_shallow = get_tree(2).await; - let tree_deep = get_tree(4).await; - - let mut shallow: Vec<(u64, String)> = Vec::new(); - collect_step_ids(&tree_shallow["root"], &mut shallow); - let mut deep: Vec<(u64, String)> = Vec::new(); - collect_step_ids(&tree_deep["root"], &mut deep); - - // Both walks must visit a non-trivial tree. - assert!(shallow.len() > 5, "shallow tree too small: {}", shallow.len()); - assert!(deep.len() > shallow.len(), - "deep tree should have at least as many nodes as shallow (deep={}, shallow={})", - deep.len(), shallow.len()); - - // Build maps keyed by step_id. - let shallow_map: std::collections::HashMap<u64, &str> = - shallow.iter().map(|(id, v)| (*id, v.as_str())).collect(); - let deep_map: std::collections::HashMap<u64, &str> = - deep.iter().map(|(id, v)| (*id, v.as_str())).collect(); - - // Every step_id that exists in both trees must map to the same variant. - let mut common_ids = 0usize; - for (id, shallow_variant) in &shallow_map { - if let Some(deep_variant) = deep_map.get(id) { - assert_eq!( - shallow_variant, deep_variant, - "step_id {} has different variants: shallow={:?}, deep={:?}", - id, shallow_variant, deep_variant, - ); - common_ids += 1; - } - } - assert!(common_ids > 5, "expected meaningful overlap, got {}", common_ids); -} - -/// After `c <func>`, the persisted session must contain a non-null -/// flow_tree on the new step AND every harvested mutation must carry a -/// flow_step_id resolving to a Write/StateWrite node in that tree. -#[tokio::test] -async fn session_step_persists_flow_tree_with_populated_flow_step_ids() { - let paths = vec![fixture("staking.sol")]; - let (_, port) = ilold_web::start_server(paths, 0, 2).await.unwrap(); - - let client = reqwest::Client::new(); - - // 1. Add deposit to the session. - let res = client - .post(format!("http://127.0.0.1:{port}/api/cmd")) - .json(&serde_json::json!({"contract": "Staking", "command": {"Call": {"func": "deposit"}}})) - .send().await.unwrap(); - assert!(res.status().is_success()); - - // 2. Save the session via the SaveSession command to get its JSON form. - let res = client - .post(format!("http://127.0.0.1:{port}/api/cmd")) - .json(&serde_json::json!({"contract": "Staking", "command": "SaveSession"})) - .send().await.unwrap(); - assert!(res.status().is_success()); - let body: serde_json::Value = res.json().await.unwrap(); - let json_str = body["SessionSaved"]["json"].as_str() - .expect("SaveSession should return a JSON string"); - let session: serde_json::Value = serde_json::from_str(json_str).unwrap(); - - // 3. Inspect the persisted step. Save format is v2: scenarios are - // keyed under `scenarios.<name>` and the active scenario name - // sits at the top level — drill in to reach the bare session. - assert_eq!(session["version"], 2); - let main = &session["scenarios"]["main"]; - assert!(!main.is_null(), "scenarios.main must be present in v2 save"); - let steps = main["steps"].as_array().unwrap(); - assert_eq!(steps.len(), 1); - let step = &steps[0]; - - // 3a. flow_tree must be non-null. - assert!( - !step["flow_tree"].is_null(), - "step.flow_tree must be persisted (not null) after c <func>" - ); - let flow_tree = &step["flow_tree"]; - assert_eq!(flow_tree["function"], "deposit"); - let root = &flow_tree["root"]; - assert!(root["children"].as_array().unwrap().len() > 0, - "persisted flow_tree must have non-empty root.children"); - - // 3b. trace_config must be persisted with default depth. - assert_eq!(step["trace_config"]["depth"], 2); - - // 3c. Every mutation must have flow_step_id = Some(_). - let mutations = step["mutations"].as_array().unwrap(); - assert!(!mutations.is_empty(), "deposit should produce at least one mutation"); - for m in mutations { - assert!( - !m["flow_step_id"].is_null(), - "mutation {:?} must have flow_step_id populated", - m - ); - } - - // 3d. Each flow_step_id must resolve to a Write or StateWrite node in the - // persisted flow_tree. - let mut tree_step_ids: std::collections::HashMap<u64, String> = std::collections::HashMap::new(); - collect_step_id_kinds(root, &mut tree_step_ids); - for m in mutations { - let id = m["flow_step_id"].as_u64().unwrap(); - let variant = tree_step_ids.get(&id) - .unwrap_or_else(|| panic!("flow_step_id {} not found in persisted tree", id)); - assert!( - variant == "Write" || variant == "StateWrite", - "flow_step_id {} resolves to {}, expected Write or StateWrite", - id, variant, - ); - } -} - -/// Session steps must model real external transactions — an internal -/// function like `_update` should be rejected with a clear error because -/// it cannot be called from outside the contract. Letting it in would -/// build an execution sequence that is impossible on-chain. -#[tokio::test] -async fn call_rejects_internal_function_as_session_entry() { - let paths = vec![fixture("uniswap_v2_pair.sol")]; - let (_, port) = ilold_web::start_server(paths, 0, 2).await.unwrap(); - - let client = reqwest::Client::new(); - let res = client - .post(format!("http://127.0.0.1:{port}/api/cmd")) - .json(&serde_json::json!({ - "contract": "UniswapV2Pair", - "command": {"Call": {"func": "_update"}} - })) - .send().await.unwrap(); - assert!(res.status().is_success(), "request should succeed — the error lives in the payload"); - - let body: serde_json::Value = res.json().await.unwrap(); - let msg = body["Error"]["message"].as_str() - .expect("expected CommandResult::Error for internal function"); - assert!(msg.contains("_update"), "error should name the function: {}", msg); - assert!(msg.contains("internal") || msg.contains("private"), - "error should explain why: {}", msg); - assert!(msg.contains("tr") || msg.contains("view"), - "error should suggest an alternative: {}", msg); - - // Public entry points must still work — sanity check the happy path. - let res = client - .post(format!("http://127.0.0.1:{port}/api/cmd")) - .json(&serde_json::json!({ - "contract": "UniswapV2Pair", - "command": {"Call": {"func": "swap"}} - })) - .send().await.unwrap(); - assert!(res.status().is_success()); - let body: serde_json::Value = res.json().await.unwrap(); - assert_eq!(body["StepAdded"]["function"], "swap"); -} - -/// `?expand=N` forces a specific InternalCall to be inlined regardless -/// of `depth`. Verifies the new query parameter wires through to the -/// walker's expand_set. -#[tokio::test] -async fn tr_swap_expand_inlines_update() { - let paths = vec![fixture("uniswap_v2_pair.sol")]; - let (_, port) = ilold_web::start_server(paths, 0, 2).await.unwrap(); - - let client = reqwest::Client::new(); - - // 1. Build the trace at depth 2 — _update should appear depth_limited. - let res = client - .get(format!("http://127.0.0.1:{port}/api/session/trace/UniswapV2Pair/swap?depth=2")) - .send().await.unwrap(); - assert!(res.status().is_success()); - let tree: serde_json::Value = res.json().await.unwrap(); - - // 2. Find the first InternalCall to _update with depth_limited=true. - let mut update_step_id: Option<u64> = None; - fn find_depth_limited_update(node: &serde_json::Value, out: &mut Option<u64>) { - if out.is_some() { return; } - if let Some(obj) = node.get("kind").and_then(|v| v.as_object()) { - if let Some(ic) = obj.get("InternalCall") { - let is_update = ic.get("function").and_then(|v| v.as_str()) == Some("_update"); - let is_limited = ic.get("depth_limited").and_then(|v| v.as_bool()) == Some(true); - if is_update && is_limited { - if let Some(id) = node.get("step_id").and_then(|v| v.as_u64()) { - *out = Some(id); - return; - } - } - } - } - if let Some(children) = node.get("children").and_then(|c| c.as_array()) { - for child in children { - find_depth_limited_update(child, out); - if out.is_some() { return; } - } - } - } - find_depth_limited_update(&tree["root"], &mut update_step_id); - let target_id = update_step_id - .expect("expected at least one depth_limited _update at depth=2"); - - // 3. Re-fetch with ?expand=<id>. Now _update at that step_id should be - // inlined (depth_limited=false) AND have non-empty children. - let res = client - .get(format!( - "http://127.0.0.1:{port}/api/session/trace/UniswapV2Pair/swap?depth=2&expand={target_id}" - )) - .send().await.unwrap(); - assert!(res.status().is_success()); - let tree2: serde_json::Value = res.json().await.unwrap(); - - // 4. Walk the new tree, find the node with target_id, and assert it's - // no longer depth_limited and has children. - fn find_node_by_id<'a>( - node: &'a serde_json::Value, - target: u64, - ) -> Option<&'a serde_json::Value> { - if node.get("step_id").and_then(|v| v.as_u64()) == Some(target) { - return Some(node); - } - if let Some(children) = node.get("children").and_then(|c| c.as_array()) { - for child in children { - if let Some(found) = find_node_by_id(child, target) { - return Some(found); - } - } - } - None - } - let expanded_node = find_node_by_id(&tree2["root"], target_id) - .expect("step_id should still exist after expand (canonical step_ids are stable)"); - - let kind = expanded_node["kind"].as_object().unwrap(); - let ic = kind.get("InternalCall") - .expect("expanded node should still be an InternalCall"); - assert_eq!( - ic.get("depth_limited").and_then(|v| v.as_bool()), - Some(false), - "step {} should be expanded (not depth_limited) after ?expand={}", - target_id, target_id - ); - - let children = expanded_node["children"].as_array().unwrap(); - assert!(!children.is_empty(), - "expanded _update must have inlined children"); - - // 5. Bonus: those children should include writes to reserve0 / reserve1. - let has_reserve_write = children.iter().any(|c| { - c.get("kind") - .and_then(|k| k.as_object()) - .and_then(|m| m.get("Write")) - .and_then(|w| w.get("target")) - .and_then(|t| t.as_str()) - .map(|s| s.starts_with("reserve")) - .unwrap_or(false) - }); - assert!(has_reserve_write, - "expanded _update children should include reserve* writes"); -} - -/// `GET /api/session/timeline/{var}` returns every mutation of `var` -/// across the session, ordered by session step then flow_step_id, with -/// path conditions populated for each entry. -#[tokio::test] -async fn timeline_balances_across_multiple_steps() { - let paths = vec![fixture("staking.sol")]; - let (_, port) = ilold_web::start_server(paths, 0, 2).await.unwrap(); - - let client = reqwest::Client::new(); - let post_call = |func: &'static str| { - let client = client.clone(); - async move { - client - .post(format!("http://127.0.0.1:{port}/api/cmd")) - .json(&serde_json::json!({"contract": "Staking", "command": {"Call": {"func": func}}})) - .send().await.unwrap() - } - }; - - assert!(post_call("deposit").await.status().is_success()); - assert!(post_call("withdraw").await.status().is_success()); - - let res = client - .get(format!("http://127.0.0.1:{port}/api/session/timeline/balances")) - .send().await.unwrap(); - assert!(res.status().is_success(), "endpoint failed: {}", res.status()); - - let tl: serde_json::Value = res.json().await.unwrap(); - assert_eq!(tl["variable"], "balances"); - - let state = tl["state_entries"].as_array().unwrap(); - assert!(state.len() >= 2, - "expected at least 2 entries (deposit + withdraw), got {}", state.len()); - - // Locals always empty in Phase 2a-2 (walker doesn't emit locals yet). - let locals = tl["local_entries"].as_array().unwrap(); - assert!(locals.is_empty()); - - // Entries are ordered by session_step_index then flow_step_id. - let mut prev_key: Option<(u64, u64)> = None; - for entry in state { - let session_idx = entry["session_step_index"].as_u64().unwrap(); - let flow_id = entry["flow_step_id"].as_u64().unwrap_or(u64::MAX); - let key = (session_idx, flow_id); - if let Some(p) = prev_key { - assert!(key >= p, "timeline entries not sorted: {:?} after {:?}", key, p); - } - prev_key = Some(key); - - // Each entry must point to its source function and carry a flow_step_id. - assert!(!entry["function"].as_str().unwrap().is_empty()); - assert!(entry["flow_step_id"].as_u64().is_some(), - "non-legacy mutation should have flow_step_id"); - // Target should start with 'balances' (our base-name match). - let target = entry["target"].as_str().unwrap(); - assert!(target.starts_with("balances"), - "target should be a balances variant, got {:?}", target); - } -} - -/// A pre-Phase-2a session JSON (no flow_tree, no flow_step_id, no scope, -/// no trace_config) must load cleanly and the existing commands must -/// still work in a degraded mode: -/// - `s` (state) renders mutations using the legacy `step N` format -/// - `seq` works without flow_summary entries -/// - `tr step <N>` returns the documented 404 for legacy sessions -#[tokio::test] -async fn legacy_session_loads_and_degrades_gracefully() { - let paths = vec![fixture("staking.sol")]; - let (_, port) = ilold_web::start_server(paths, 0, 2).await.unwrap(); - - let client = reqwest::Client::new(); - - // 1. Build a real session with two steps so we get a valid AuditJournal - // serialized form, then save it. - for func in ["deposit", "withdraw"] { - let res = client - .post(format!("http://127.0.0.1:{port}/api/cmd")) - .json(&serde_json::json!({"contract": "Staking", "command": {"Call": {"func": func}}})) - .send().await.unwrap(); - assert!(res.status().is_success()); - } - let res = client - .post(format!("http://127.0.0.1:{port}/api/cmd")) - .json(&serde_json::json!({"contract": "Staking", "command": "SaveSession"})) - .send().await.unwrap(); - let body: serde_json::Value = res.json().await.unwrap(); - let json_str = body["SessionSaved"]["json"].as_str().unwrap().to_string(); - - // 2. Extract the bare `main` ExplorationSession out of the v2 wrapper - // and strip Phase-2a fields from its steps. Sending it back as a - // bare session (no `version`/`scenarios` wrapper) exercises the v1 - // fallback path in `ScenarioStore::load_from_json`. - let v2: serde_json::Value = serde_json::from_str(&json_str).unwrap(); - let mut session = v2["scenarios"]["main"].clone(); - assert!(!session.is_null(), "v2 save must contain scenarios.main"); - { - let steps = session["steps"].as_array_mut().unwrap(); - for step in steps { - let obj = step.as_object_mut().unwrap(); - obj.remove("flow_tree"); - obj.remove("trace_config"); - if let Some(muts) = obj.get_mut("mutations").and_then(|v| v.as_array_mut()) { - for m in muts { - if let Some(m_obj) = m.as_object_mut() { - m_obj.remove("flow_step_id"); - m_obj.remove("scope"); - } - } - } - } - } - let stripped = serde_json::to_string(&session).unwrap(); - - // 3. Load the stripped session — must succeed thanks to #[serde(default)]. - let res = client - .post(format!("http://127.0.0.1:{port}/api/cmd")) - .json(&serde_json::json!({ - "contract": "Staking", - "command": {"LoadSession": {"json": stripped}} - })) - .send().await.unwrap(); - assert!(res.status().is_success(), "load failed: {}", res.status()); - - // 4. `s` (state) — change lines should NOT have a `:N` flow ref because - // the legacy mutations have flow_step_id = None. - let res = client - .post(format!("http://127.0.0.1:{port}/api/cmd")) - .json(&serde_json::json!({"contract": "Staking", "command": "State"})) - .send().await.unwrap(); - assert!(res.status().is_success()); - let body: serde_json::Value = res.json().await.unwrap(); - let summary = body["StateView"]["summary"].as_array().unwrap(); - assert!(!summary.is_empty()); - for var in summary { - for change in var["changes"].as_array().unwrap() { - let s = change.as_str().unwrap(); - assert!(s.contains("(step "), "missing step ref: {:?}", s); - // Legacy format: 'step N,' (comma after digit) — NOT 'step N:M' - assert!( - !s.contains(":") || !s.split("step ").nth(1).map(|r| r.starts_with(|c: char| c.is_ascii_digit())).unwrap_or(false) - || s.split("(step ").nth(1).and_then(|r| r.split(',').next()) - .map(|n| !n.contains(':')).unwrap_or(true), - "legacy mutation should not have flow_step ref: {:?}", s - ); - } - } - - // 5. `seq` — must work but flow_summary must be null on every step. - let res = client - .get(format!("http://127.0.0.1:{port}/api/session/sequence")) - .send().await.unwrap(); - assert!(res.status().is_success()); - let narrative: serde_json::Value = res.json().await.unwrap(); - let steps = narrative["steps"].as_array().unwrap(); - assert_eq!(steps.len(), 2); - for step in steps { - assert!(step["flow_summary"].is_null(), - "legacy step should have null flow_summary: {:?}", step); - } - - // 6. `tr step 0` — must return 404 with the documented legacy message. - let res = client - .get(format!("http://127.0.0.1:{port}/api/session/step/0/trace")) - .send().await.unwrap(); - assert_eq!(res.status(), reqwest::StatusCode::NOT_FOUND); - let err = res.text().await.unwrap(); - assert!( - err.contains("no persisted trace") || err.contains("pre-Phase-2a"), - "expected legacy-session error message, got: {:?}", err - ); -} - -/// After 2 `c <func>` calls, `seq` must return a SequenceNarrative whose -/// every step has a populated `flow_summary` with sane counts. Verifies -/// Task 1.9 enrichment is wired through the API. -#[tokio::test] -async fn sequence_narrative_includes_flow_summary_per_step() { - let paths = vec![fixture("staking.sol")]; - let (_, port) = ilold_web::start_server(paths, 0, 2).await.unwrap(); - - let client = reqwest::Client::new(); - let post_call = |func: &'static str| { - let client = client.clone(); - async move { - client - .post(format!("http://127.0.0.1:{port}/api/cmd")) - .json(&serde_json::json!({"contract": "Staking", "command": {"Call": {"func": func}}})) - .send().await.unwrap() - } - }; - - assert!(post_call("deposit").await.status().is_success()); - assert!(post_call("withdraw").await.status().is_success()); - - let res = client - .get(format!("http://127.0.0.1:{port}/api/session/sequence")) - .send().await.unwrap(); - assert!(res.status().is_success()); - let narrative: serde_json::Value = res.json().await.unwrap(); - - let steps = narrative["steps"].as_array().unwrap(); - assert_eq!(steps.len(), 2); - - for step in steps { - let summary = &step["flow_summary"]; - assert!(!summary.is_null(), - "step {:?} has null flow_summary; should have been populated by get_sequence_narrative", - step["function"]); - assert!(summary["total_steps"].as_u64().unwrap() > 0); - assert!(summary["mutation_count"].as_u64().unwrap() > 0, - "expected at least one mutation in {:?}", step["function"]); - // mutation_refs is a list mirroring mutation_count - let refs = summary["mutation_refs"].as_array().unwrap(); - assert_eq!(refs.len() as u64, summary["mutation_count"].as_u64().unwrap()); - // Each ref carries variable + flow_step_id + session_step_index - for r in refs { - assert!(!r["variable"].as_str().unwrap().is_empty()); - assert!(r["flow_step_id"].as_u64().is_some()); - } - } -} - -/// `GET /api/session/step/{N}/trace` returns the persisted FlowTree of -/// the session step. Verifies the new endpoint is wired correctly and -/// that the persisted tree round-trips through HTTP. -#[tokio::test] -async fn tr_step_endpoint_returns_persisted_flow_tree() { - let paths = vec![fixture("staking.sol")]; - let (_, port) = ilold_web::start_server(paths, 0, 2).await.unwrap(); - - let client = reqwest::Client::new(); - - // Add a step to the session. - let res = client - .post(format!("http://127.0.0.1:{port}/api/cmd")) - .json(&serde_json::json!({"contract": "Staking", "command": {"Call": {"func": "deposit"}}})) - .send().await.unwrap(); - assert!(res.status().is_success()); - - // GET the persisted trace. - let res = client - .get(format!("http://127.0.0.1:{port}/api/session/step/0/trace")) - .send().await.unwrap(); - assert!(res.status().is_success(), "endpoint failed: {}", res.status()); - - let tree: serde_json::Value = res.json().await.unwrap(); - assert_eq!(tree["function"], "deposit"); - assert_eq!(tree["max_depth"], 2); - let root = &tree["root"]; - assert!(root["children"].as_array().unwrap().len() > 0); -} - -/// Requesting an out-of-range step index returns 404. -#[tokio::test] -async fn tr_step_endpoint_404_on_unknown_step() { - let paths = vec![fixture("staking.sol")]; - let (_, port) = ilold_web::start_server(paths, 0, 2).await.unwrap(); - - let client = reqwest::Client::new(); - - // No steps added — index 0 doesn't exist. - let res = client - .get(format!("http://127.0.0.1:{port}/api/session/step/0/trace")) - .send().await.unwrap(); - assert_eq!(res.status(), reqwest::StatusCode::NOT_FOUND); -} - -/// After `c <func>`, the `s` (state) command's variable summaries must -/// include `step N:flow_id` references in each change line, proving the -/// new walker's flow_step_id is propagating through to the render layer. -#[tokio::test] -async fn state_view_renders_flow_step_refs() { - let paths = vec![fixture("staking.sol")]; - let (_, port) = ilold_web::start_server(paths, 0, 2).await.unwrap(); - - let client = reqwest::Client::new(); - - // Add a step. - let res = client - .post(format!("http://127.0.0.1:{port}/api/cmd")) - .json(&serde_json::json!({"contract": "Staking", "command": {"Call": {"func": "deposit"}}})) - .send().await.unwrap(); - assert!(res.status().is_success()); - - // Fetch the state view. - let res = client - .post(format!("http://127.0.0.1:{port}/api/cmd")) - .json(&serde_json::json!({"contract": "Staking", "command": "State"})) - .send().await.unwrap(); - assert!(res.status().is_success()); - let body: serde_json::Value = res.json().await.unwrap(); - let summary = body["StateView"]["summary"].as_array().unwrap(); - assert!(!summary.is_empty(), "deposit should produce state changes"); - - // Every change line must carry a `step 0:` prefix followed by a digit - // (the new format — `0:` is the session step, the digit is the - // flow_step_id from the canonical walker). - for var in summary { - let changes = var["changes"].as_array().unwrap(); - for change in changes { - let s = change.as_str().unwrap(); - let has_ref = s.split("step 0:").nth(1) - .map(|rest| rest.chars().next().is_some_and(|c| c.is_ascii_digit())) - .unwrap_or(false); - assert!( - has_ref, - "change line missing 'step 0:N' flow ref: {:?}", - s - ); - } - } -} - -/// Collect (step_id, FlowKind variant) pairs from a serialized FlowTree. -fn collect_step_id_kinds( - node: &serde_json::Value, - out: &mut std::collections::HashMap<u64, String>, -) { - if let Some(id) = node.get("step_id").and_then(|v| v.as_u64()) { - let variant = match node.get("kind") { - Some(serde_json::Value::String(s)) => s.clone(), - Some(serde_json::Value::Object(map)) => { - map.keys().next().cloned().unwrap_or_default() - } - _ => String::new(), - }; - out.insert(id, variant); - } - if let Some(children) = node.get("children").and_then(|c| c.as_array()) { - for child in children { - collect_step_id_kinds(child, out); - } - } -} diff --git a/crates/ilold-web/tests/scenario_commands_test.rs b/crates/ilold-web/tests/scenario_commands_test.rs deleted file mode 100644 index b32edea..0000000 --- a/crates/ilold-web/tests/scenario_commands_test.rs +++ /dev/null @@ -1,596 +0,0 @@ -//! Integration tests for scenario lifecycle commands (Phase S2 / Task 2.12). -//! -//! Each test starts a fresh server on port 0 (parallel-safe) and POSTs -//! commands through `/api/cmd`, asserting on the JSON response body. The -//! helper `cmd()` keeps each test body focused on the scenario semantics -//! rather than HTTP plumbing. - -use std::path::PathBuf; - -fn fixture(name: &str) -> PathBuf { - PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .parent() - .unwrap() - .parent() - .unwrap() - .join("tests/fixtures") - .join(name) -} - -async fn start() -> (reqwest::Client, u16) { - let paths = vec![fixture("staking.sol")]; - let (_state, port) = ilold_web::start_server(paths, 0, 2).await.unwrap(); - (reqwest::Client::new(), port) -} - -async fn cmd( - client: &reqwest::Client, - port: u16, - contract: &str, - command: serde_json::Value, -) -> serde_json::Value { - let res = client - .post(format!("http://127.0.0.1:{port}/api/cmd")) - .json(&serde_json::json!({ "contract": contract, "command": command })) - .send() - .await - .expect("POST /api/cmd failed"); - assert!( - res.status().is_success(), - "POST /api/cmd returned {}", - res.status() - ); - res.json().await.expect("response was not JSON") -} - -fn scenario_new(name: &str) -> serde_json::Value { - serde_json::json!({ "Scenario": { "sub": { "New": { "name": name } } } }) -} - -fn scenario_list() -> serde_json::Value { - serde_json::json!({ "Scenario": { "sub": "List" } }) -} - -fn scenario_switch(name: &str) -> serde_json::Value { - serde_json::json!({ "Scenario": { "sub": { "Switch": { "name": name } } } }) -} - -fn scenario_fork(name: &str) -> serde_json::Value { - serde_json::json!({ "Scenario": { "sub": { "Fork": { "name": name } } } }) -} - -fn scenario_fork_at(name: &str, at_step: usize) -> serde_json::Value { - serde_json::json!({ "Scenario": { "sub": { "Fork": { "name": name, "at_step": at_step } } } }) -} - -fn scenario_delete(name: &str) -> serde_json::Value { - serde_json::json!({ "Scenario": { "sub": { "Delete": { "name": name } } } }) -} - -fn call(func: &str) -> serde_json::Value { - serde_json::json!({ "Call": { "func": func } }) -} - -/// Extract the ScenarioList items from a CommandResult JSON body. -fn list_items(r: &serde_json::Value) -> &Vec<serde_json::Value> { - r.get("ScenarioList") - .and_then(|v| v.get("items")) - .and_then(|v| v.as_array()) - .expect("ScenarioList.items array") -} - -fn find_scenario<'a>(items: &'a [serde_json::Value], name: &str) -> &'a serde_json::Value { - items - .iter() - .find(|it| it.get("name").and_then(|n| n.as_str()) == Some(name)) - .unwrap_or_else(|| panic!("scenario '{name}' not in list: {items:?}")) -} - -#[tokio::test] -async fn scenario_new_creates_empty_scenario() { - let (client, port) = start().await; - - let r = cmd(&client, port, "Staking", scenario_new("alt1")).await; - assert_eq!( - r.get("ScenarioCreated") - .and_then(|v| v.get("name")) - .and_then(|n| n.as_str()), - Some("alt1"), - "expected ScenarioCreated{{name: alt1}}, got: {r}" - ); - - let r = cmd(&client, port, "Staking", scenario_list()).await; - let items = list_items(&r); - assert_eq!(items.len(), 2, "expected 2 scenarios, got: {items:?}"); - let main = find_scenario(items, "main"); - assert_eq!(main.get("active").and_then(|v| v.as_bool()), Some(true)); - let alt1 = find_scenario(items, "alt1"); - assert_eq!(alt1.get("active").and_then(|v| v.as_bool()), Some(false)); - assert_eq!( - alt1.get("step_count").and_then(|v| v.as_u64()), - Some(0), - "alt1 should start empty" - ); -} - -#[tokio::test] -async fn scenario_list_shows_all_with_active_marker() { - let (client, port) = start().await; - - let r = cmd(&client, port, "Staking", scenario_list()).await; - let items = list_items(&r); - assert_eq!(items.len(), 1); - let main = find_scenario(items, "main"); - assert_eq!(main.get("active").and_then(|v| v.as_bool()), Some(true)); - assert_eq!(main.get("step_count").and_then(|v| v.as_u64()), Some(0)); - - cmd(&client, port, "Staking", call("deposit")).await; - - let r = cmd(&client, port, "Staking", scenario_list()).await; - let items = list_items(&r); - let main = find_scenario(items, "main"); - assert_eq!( - main.get("step_count").and_then(|v| v.as_u64()), - Some(1), - "main should have 1 step after deposit" - ); -} - -#[tokio::test] -async fn scenario_switch_changes_active() { - let (client, port) = start().await; - - cmd(&client, port, "Staking", scenario_new("alt1")).await; - - let r = cmd(&client, port, "Staking", scenario_switch("alt1")).await; - let switched = r.get("ScenarioSwitched").expect("ScenarioSwitched variant"); - assert_eq!( - switched.get("from").and_then(|v| v.as_str()), - Some("main") - ); - assert_eq!(switched.get("to").and_then(|v| v.as_str()), Some("alt1")); - - cmd(&client, port, "Staking", call("deposit")).await; - - let r = cmd(&client, port, "Staking", scenario_list()).await; - let items = list_items(&r); - let alt1 = find_scenario(items, "alt1"); - assert_eq!(alt1.get("active").and_then(|v| v.as_bool()), Some(true)); - assert_eq!(alt1.get("step_count").and_then(|v| v.as_u64()), Some(1)); - let main = find_scenario(items, "main"); - assert_eq!(main.get("active").and_then(|v| v.as_bool()), Some(false)); - assert_eq!(main.get("step_count").and_then(|v| v.as_u64()), Some(0)); -} - -#[tokio::test] -async fn scenario_fork_clones_prefix_at_current_step() { - let (client, port) = start().await; - - cmd(&client, port, "Staking", call("deposit")).await; - - let r = cmd(&client, port, "Staking", scenario_fork("alt1")).await; - let forked = r.get("ScenarioForked").expect("ScenarioForked variant"); - assert_eq!(forked.get("from").and_then(|v| v.as_str()), Some("main")); - assert_eq!(forked.get("to").and_then(|v| v.as_str()), Some("alt1")); - assert_eq!(forked.get("at_step").and_then(|v| v.as_u64()), Some(1)); - - let r = cmd(&client, port, "Staking", scenario_list()).await; - let items = list_items(&r); - assert_eq!( - find_scenario(items, "main") - .get("step_count") - .and_then(|v| v.as_u64()), - Some(1) - ); - assert_eq!( - find_scenario(items, "alt1") - .get("step_count") - .and_then(|v| v.as_u64()), - Some(1), - "fork should copy the prefix" - ); -} - -#[tokio::test] -async fn scenario_fork_emits_branch_created_journal_entry() { - let (client, port) = start().await; - - cmd(&client, port, "Staking", call("deposit")).await; - cmd(&client, port, "Staking", scenario_fork("alt1")).await; - cmd(&client, port, "Staking", scenario_switch("alt1")).await; - - // SaveSession returns the serialized session; the journal.entries array - // is the source of truth for BranchCreated. - let r = cmd( - &client, - port, - "Staking", - serde_json::json!("SaveSession"), - ) - .await; - let json_str = r - .get("SessionSaved") - .and_then(|v| v.get("json")) - .and_then(|v| v.as_str()) - .expect("SessionSaved.json"); - - let saved: serde_json::Value = - serde_json::from_str(json_str).expect("SaveSession payload was not JSON"); - // Save format is v2 — journal lives under scenarios.<active>.journal. - let active = saved - .get("active") - .and_then(|v| v.as_str()) - .expect("active scenario name"); - let entries = saved - .pointer(&format!("/scenarios/{active}/journal/entries")) - .and_then(|e| e.as_array()) - .expect("scenarios.<active>.journal.entries array"); - - let branch = entries - .iter() - .find_map(|e| e.get("BranchCreated")) - .unwrap_or_else(|| panic!("no BranchCreated entry in {entries:?}")); - assert_eq!( - branch.get("from_function").and_then(|v| v.as_str()), - Some("main") - ); - assert_eq!( - branch.get("branch_function").and_then(|v| v.as_str()), - Some("alt1") - ); -} - -#[tokio::test] -async fn scenario_delete_refuses_when_active() { - let (client, port) = start().await; - - // `main` is active by default — deletion must fail. - let r = cmd(&client, port, "Staking", scenario_delete("main")).await; - let msg = r - .get("Error") - .and_then(|v| v.get("message")) - .and_then(|v| v.as_str()) - .expect("Error.message"); - assert!( - msg.contains("Cannot delete active scenario"), - "unexpected error message: {msg}" - ); -} - -/// The "only remaining" branch of `execute_scenario` is defensive — it is -/// unreachable via HTTP because, when only one scenario exists, it is by -/// definition the active one and the "active" check fires first. We still -/// verify that calling delete on the sole `main` scenario surfaces the -/// active-guard error (not a panic or a "does not exist" error). The internal -/// `ScenarioStore::remove` path for the only-remaining case is exercised by -/// unit tests in the core crate. -#[tokio::test] -async fn scenario_delete_only_remaining_is_guarded_by_active_check() { - let (client, port) = start().await; - - let r = cmd(&client, port, "Staking", scenario_delete("main")).await; - let msg = r - .get("Error") - .and_then(|v| v.get("message")) - .and_then(|v| v.as_str()) - .expect("Error.message"); - assert!( - msg.contains("Cannot delete active scenario"), - "expected active-guard error, got: {msg}" - ); -} - -#[tokio::test] -async fn scenario_delete_removes_other_scenario() { - let (client, port) = start().await; - - cmd(&client, port, "Staking", scenario_new("alt1")).await; - cmd(&client, port, "Staking", scenario_new("alt2")).await; - cmd(&client, port, "Staking", scenario_switch("alt1")).await; - - let r = cmd(&client, port, "Staking", scenario_delete("alt2")).await; - assert_eq!( - r.get("ScenarioDeleted") - .and_then(|v| v.get("name")) - .and_then(|n| n.as_str()), - Some("alt2"), - "expected ScenarioDeleted{{name: alt2}}, got: {r}" - ); - - let r = cmd(&client, port, "Staking", scenario_list()).await; - let items = list_items(&r); - assert_eq!(items.len(), 2); - assert_eq!( - find_scenario(items, "alt1") - .get("active") - .and_then(|v| v.as_bool()), - Some(true) - ); - assert_eq!( - find_scenario(items, "main") - .get("active") - .and_then(|v| v.as_bool()), - Some(false) - ); -} - -#[tokio::test] -async fn scenario_name_validation_rejects_invalid() { - let (client, port) = start().await; - - for bad in [ - "InvalidName!", - "1starts-with-digit", - "", - "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - ] { - let r = cmd(&client, port, "Staking", scenario_new(bad)).await; - let msg = r - .get("Error") - .and_then(|v| v.get("message")) - .and_then(|v| v.as_str()) - .unwrap_or_else(|| panic!("expected Error for name {bad:?}, got: {r}")); - assert!( - msg.contains("Invalid scenario name"), - "unexpected error for {bad:?}: {msg}" - ); - } -} - -// ── Phase S7: fork-at-step-N ──────────────────────────────────────────────── - -#[tokio::test] -async fn scenario_fork_at_step_truncates_to_n() { - let (client, port) = start().await; - - // Build main with 3 steps, then fork at step 2. - cmd(&client, port, "Staking", call("deposit")).await; - cmd(&client, port, "Staking", call("deposit")).await; - cmd(&client, port, "Staking", call("withdraw")).await; - - let r = cmd(&client, port, "Staking", scenario_fork_at("alt1", 2)).await; - let forked = r.get("ScenarioForked").expect("ScenarioForked variant"); - assert_eq!(forked.get("at_step").and_then(|v| v.as_u64()), Some(2)); - - let r = cmd(&client, port, "Staking", scenario_list()).await; - let items = list_items(&r); - assert_eq!( - find_scenario(items, "main") - .get("step_count") - .and_then(|v| v.as_u64()), - Some(3), - "main must be unchanged" - ); - assert_eq!( - find_scenario(items, "alt1") - .get("step_count") - .and_then(|v| v.as_u64()), - Some(2), - "alt1 must be truncated to first 2 steps" - ); -} - -#[tokio::test] -async fn scenario_fork_at_step_zero_creates_empty() { - let (client, port) = start().await; - - cmd(&client, port, "Staking", call("deposit")).await; - cmd(&client, port, "Staking", call("withdraw")).await; - - let r = cmd(&client, port, "Staking", scenario_fork_at("alt1", 0)).await; - assert_eq!( - r.get("ScenarioForked") - .and_then(|v| v.get("at_step")) - .and_then(|v| v.as_u64()), - Some(0) - ); - - let r = cmd(&client, port, "Staking", scenario_list()).await; - let items = list_items(&r); - assert_eq!( - find_scenario(items, "alt1") - .get("step_count") - .and_then(|v| v.as_u64()), - Some(0), - "fork at 0 must yield an empty scenario" - ); -} - -#[tokio::test] -async fn scenario_fork_at_step_greater_than_length_errors() { - let (client, port) = start().await; - - cmd(&client, port, "Staking", call("deposit")).await; - - let r = cmd(&client, port, "Staking", scenario_fork_at("alt1", 5)).await; - let msg = r - .get("Error") - .and_then(|v| v.get("message")) - .and_then(|v| v.as_str()) - .unwrap_or_else(|| panic!("expected Error, got: {r}")); - assert!( - msg.contains("Cannot fork at step 5") && msg.contains("only 1 step"), - "unexpected error: {msg}" - ); - - // Verify no scenario was created on failure. - let r = cmd(&client, port, "Staking", scenario_list()).await; - let items = list_items(&r); - assert_eq!(items.len(), 1, "failed fork must not create a scenario"); -} - -#[tokio::test] -async fn scenario_name_reserved_main_cannot_be_recreated() { - let (client, port) = start().await; - - // `main` is auto-seeded on startup, so `scenario new main` collides on - // the duplicate check. - let r = cmd(&client, port, "Staking", scenario_new("main")).await; - let msg = r - .get("Error") - .and_then(|v| v.get("message")) - .and_then(|v| v.as_str()) - .unwrap_or_else(|| panic!("expected Error, got: {r}")); - assert!( - msg.contains("already exists"), - "unexpected error message: {msg}" - ); -} - -// ── Phase S11: save/load v2 ───────────────────────────────────────────────── - -fn save_session() -> serde_json::Value { - serde_json::json!("SaveSession") -} - -fn load_session(json: &str) -> serde_json::Value { - serde_json::json!({ "LoadSession": { "json": json } }) -} - -#[tokio::test] -async fn save_produces_v2_json_with_all_scenarios_and_active() { - let (client, port) = start().await; - - cmd(&client, port, "Staking", call("deposit")).await; - cmd(&client, port, "Staking", call("deposit")).await; - cmd(&client, port, "Staking", scenario_fork_at("alt1", 1)).await; - cmd(&client, port, "Staking", scenario_switch("alt1")).await; - - let r = cmd(&client, port, "Staking", save_session()).await; - let json_str = r - .get("SessionSaved") - .and_then(|v| v.get("json")) - .and_then(|v| v.as_str()) - .expect("SessionSaved.json"); - let parsed: serde_json::Value = - serde_json::from_str(json_str).expect("v2 save JSON must parse"); - - assert_eq!(parsed["version"], 2, "v2 save must declare version 2"); - assert_eq!(parsed["contract"], "Staking"); - assert_eq!( - parsed["active"], "alt1", - "active scenario at save time must be persisted" - ); - let scenarios = parsed["scenarios"] - .as_object() - .expect("scenarios must be an object"); - assert!(scenarios.contains_key("main") && scenarios.contains_key("alt1")); - assert_eq!( - parsed["order"] - .as_array() - .expect("order array") - .iter() - .map(|v| v.as_str().unwrap()) - .collect::<Vec<_>>(), - vec!["main", "alt1"], - ); - let alt1 = &scenarios["alt1"]; - assert_eq!(alt1["forked_from"]["scenario"], "main"); - assert_eq!(alt1["forked_from"]["at_step"], 1); -} - -#[tokio::test] -async fn load_v2_json_restores_all_scenarios_active_and_order() { - let (client, port) = start().await; - - // Build state, save, then mutate the live store so we can verify load - // restores the captured snapshot (not the post-save state). - cmd(&client, port, "Staking", call("deposit")).await; - cmd(&client, port, "Staking", scenario_fork_at("alt1", 1)).await; - let saved = cmd(&client, port, "Staking", save_session()).await; - let json = saved["SessionSaved"]["json"].as_str().unwrap().to_string(); - - // Mutate post-save: add a step and a new scenario that should disappear. - cmd(&client, port, "Staking", call("withdraw")).await; - cmd(&client, port, "Staking", scenario_new("ghost")).await; - - // Load → must replace the entire store with the saved snapshot. - cmd(&client, port, "Staking", load_session(&json)).await; - - let r = cmd(&client, port, "Staking", scenario_list()).await; - let items = list_items(&r); - let names: Vec<&str> = items - .iter() - .map(|it| it.get("name").and_then(|n| n.as_str()).unwrap()) - .collect(); - assert_eq!(names, vec!["main", "alt1"], "ghost scenario must be gone"); - assert_eq!( - find_scenario(items, "main") - .get("step_count") - .and_then(|v| v.as_u64()), - Some(1), - "main step count must reflect the snapshot, not post-save mutations" - ); - assert_eq!( - find_scenario(items, "alt1") - .get("step_count") - .and_then(|v| v.as_u64()), - Some(1) - ); - assert_eq!( - find_scenario(items, "main") - .get("active") - .and_then(|v| v.as_bool()), - Some(true), - "saved active was 'main'" - ); -} - -#[tokio::test] -async fn load_v1_json_wraps_as_single_main_scenario() { - let (client, port) = start().await; - - // A v1 file is a bare ExplorationSession JSON. Build one programmatically - // by saving a single-scenario state, then unwrapping the v2 envelope. - cmd(&client, port, "Staking", call("deposit")).await; - let saved = cmd(&client, port, "Staking", save_session()).await; - let v2_json = saved["SessionSaved"]["json"].as_str().unwrap(); - let v2: serde_json::Value = serde_json::from_str(v2_json).unwrap(); - let bare = v2["scenarios"]["main"].clone(); - let v1_json = serde_json::to_string(&bare).unwrap(); - - // Replace the store with something different first to prove load resets it. - cmd(&client, port, "Staking", scenario_new("alt1")).await; - cmd(&client, port, "Staking", load_session(&v1_json)).await; - - let r = cmd(&client, port, "Staking", scenario_list()).await; - let items = list_items(&r); - assert_eq!( - items.len(), - 1, - "v1 load must collapse the store to a single scenario, got: {items:?}" - ); - let only = &items[0]; - assert_eq!(only.get("name").and_then(|v| v.as_str()), Some("main")); - assert_eq!(only.get("step_count").and_then(|v| v.as_u64()), Some(1)); - assert_eq!(only.get("active").and_then(|v| v.as_bool()), Some(true)); -} - -#[tokio::test] -async fn load_replaces_existing_store_destructively() { - let (client, port) = start().await; - - // Save an empty fresh store. - let saved = cmd(&client, port, "Staking", save_session()).await; - let json = saved["SessionSaved"]["json"].as_str().unwrap().to_string(); - - // Build a rich state after saving. - cmd(&client, port, "Staking", call("deposit")).await; - cmd(&client, port, "Staking", call("withdraw")).await; - cmd(&client, port, "Staking", scenario_new("alt1")).await; - cmd(&client, port, "Staking", scenario_new("alt2")).await; - - // Load the empty snapshot — must wipe everything except `main` (empty). - cmd(&client, port, "Staking", load_session(&json)).await; - - let r = cmd(&client, port, "Staking", scenario_list()).await; - let items = list_items(&r); - assert_eq!(items.len(), 1, "load must drop alt1/alt2"); - assert_eq!( - find_scenario(items, "main") - .get("step_count") - .and_then(|v| v.as_u64()), - Some(0), - "main must be empty per snapshot" - ); -} diff --git a/crates/ilold-web/tests/scenario_endpoints_test.rs b/crates/ilold-web/tests/scenario_endpoints_test.rs deleted file mode 100644 index 26c1899..0000000 --- a/crates/ilold-web/tests/scenario_endpoints_test.rs +++ /dev/null @@ -1,454 +0,0 @@ -//! Integration tests verifying existing `/api/session/*` endpoints target the -//! ACTIVE scenario after the ScenarioStore refactor. -//! -//! These are regression tests: the behavior with the single (auto-seeded) -//! "main" scenario must be unchanged. The spec S12 case "switching active -//! scenario flips endpoint output" lands in Phase S2 when the switch command -//! exists. - -use std::path::PathBuf; - -fn fixture(name: &str) -> PathBuf { - PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .parent() - .unwrap() - .parent() - .unwrap() - .join("tests/fixtures") - .join(name) -} - -/// Fresh server with no auto-seeded steps. Mirrors `start()` in -/// `scenario_commands_test.rs` — duplicated because integration test files -/// cannot share helper modules without extra plumbing. -async fn start() -> (reqwest::Client, u16) { - let paths = vec![fixture("staking.sol")]; - let (_state, port) = ilold_web::start_server(paths, 0, 2).await.unwrap(); - (reqwest::Client::new(), port) -} - -/// POST /api/cmd helper. Same shape as `cmd()` in `scenario_commands_test.rs`. -async fn cmd( - client: &reqwest::Client, - port: u16, - contract: &str, - command: serde_json::Value, -) -> serde_json::Value { - let res = client - .post(format!("http://127.0.0.1:{port}/api/cmd")) - .json(&serde_json::json!({ "contract": contract, "command": command })) - .send() - .await - .expect("POST /api/cmd failed"); - assert!( - res.status().is_success(), - "POST /api/cmd returned {}", - res.status() - ); - res.json().await.expect("response was not JSON") -} - -async fn start_with_staking() -> (reqwest::Client, u16) { - let paths = vec![fixture("staking.sol")]; - let (_state, port) = ilold_web::start_server(paths, 0, 2).await.unwrap(); - let client = reqwest::Client::new(); - - // Add a step to the auto-seeded active "main" scenario. - let res = client - .post(format!("http://127.0.0.1:{port}/api/cmd")) - .json(&serde_json::json!({ - "contract": "Staking", - "command": { "Call": { "func": "deposit" } } - })) - .send() - .await - .expect("POST /api/cmd failed"); - assert!( - res.status().is_success(), - "seed POST /api/cmd returned {}", - res.status() - ); - - (client, port) -} - -#[tokio::test] -async fn existing_state_endpoint_targets_active_scenario() { - let (client, port) = start_with_staking().await; - - let res = client - .get(format!("http://127.0.0.1:{port}/api/session/state")) - .send() - .await - .unwrap(); - - assert!( - res.status().is_success(), - "GET /api/session/state returned {}", - res.status() - ); - let body: serde_json::Value = res.json().await.unwrap(); - assert!( - body.is_array(), - "response should be a JSON array of VariableSummary, got: {body}" - ); - // `deposit` mutates `balances` and `totalStaked` on the active scenario. - let arr = body.as_array().unwrap(); - assert!( - !arr.is_empty(), - "active scenario state should have at least one variable summary after `c deposit`" - ); -} - -#[tokio::test] -async fn existing_sequence_endpoint_targets_active_scenario() { - let (client, port) = start_with_staking().await; - - // `sequence` requires at least two steps in the active scenario. - let res = client - .post(format!("http://127.0.0.1:{port}/api/cmd")) - .json(&serde_json::json!({ - "contract": "Staking", - "command": { "Call": { "func": "withdraw" } } - })) - .send() - .await - .unwrap(); - assert!( - res.status().is_success(), - "seed second step POST returned {}", - res.status() - ); - - let res = client - .get(format!("http://127.0.0.1:{port}/api/session/sequence")) - .send() - .await - .unwrap(); - - assert!( - res.status().is_success(), - "GET /api/session/sequence returned {}", - res.status() - ); - let body: serde_json::Value = res.json().await.unwrap(); - assert!( - body.is_object(), - "response should be a SequenceNarrative object, got: {body}" - ); -} - -#[tokio::test] -async fn existing_timeline_endpoint_targets_active_scenario() { - let (client, port) = start_with_staking().await; - - // `deposit` writes `balances[msg.sender]`. - let res = client - .get(format!( - "http://127.0.0.1:{port}/api/session/timeline/balances" - )) - .send() - .await - .unwrap(); - - assert!( - res.status().is_success(), - "GET /api/session/timeline/balances returned {}", - res.status() - ); - let body: serde_json::Value = res.json().await.unwrap(); - assert!( - body.is_object(), - "response should be a VariableTimeline object, got: {body}" - ); -} - -#[tokio::test] -async fn existing_narrative_endpoint_targets_active_scenario() { - let (client, port) = start_with_staking().await; - - let res = client - .get(format!( - "http://127.0.0.1:{port}/api/session/step/0/narrative" - )) - .send() - .await - .unwrap(); - - assert!( - res.status().is_success(), - "GET /api/session/step/0/narrative returned {}", - res.status() - ); - let body: serde_json::Value = res.json().await.unwrap(); - assert!( - body.is_object(), - "response should be a FunctionNarrative object, got: {body}" - ); -} - -#[tokio::test] -async fn existing_trace_endpoint_targets_active_scenario() { - let (client, port) = start_with_staking().await; - - let res = client - .get(format!("http://127.0.0.1:{port}/api/session/step/0/trace")) - .send() - .await - .unwrap(); - - assert!( - res.status().is_success(), - "GET /api/session/step/0/trace returned {}", - res.status() - ); - let body: serde_json::Value = res.json().await.unwrap(); - assert!( - body.is_object(), - "response should be a FlowTree object, got: {body}" - ); -} - -// --------------------------------------------------------------------------- -// Phase S3: GET /api/scenarios and GET /api/scenarios/all -// --------------------------------------------------------------------------- - -fn scenario_new(name: &str) -> serde_json::Value { - serde_json::json!({ "Scenario": { "sub": { "New": { "name": name } } } }) -} - -fn scenario_switch(name: &str) -> serde_json::Value { - serde_json::json!({ "Scenario": { "sub": { "Switch": { "name": name } } } }) -} - -fn call(func: &str) -> serde_json::Value { - serde_json::json!({ "Call": { "func": func } }) -} - -async fn get_scenarios(client: &reqwest::Client, port: u16) -> serde_json::Value { - let res = client - .get(format!("http://127.0.0.1:{port}/api/scenarios")) - .send() - .await - .expect("GET /api/scenarios failed"); - assert!( - res.status().is_success(), - "GET /api/scenarios returned {}", - res.status() - ); - res.json().await.expect("response was not JSON") -} - -async fn get_scenarios_all(client: &reqwest::Client, port: u16) -> serde_json::Value { - let res = client - .get(format!("http://127.0.0.1:{port}/api/scenarios/all")) - .send() - .await - .expect("GET /api/scenarios/all failed"); - assert!( - res.status().is_success(), - "GET /api/scenarios/all returned {}", - res.status() - ); - res.json().await.expect("response was not JSON") -} - -#[tokio::test] -async fn get_scenarios_returns_main_on_fresh_server() { - let (client, port) = start().await; - - let body = get_scenarios(&client, port).await; - let arr = body - .as_array() - .unwrap_or_else(|| panic!("expected array, got: {body}")); - assert_eq!(arr.len(), 1, "expected single auto-seeded main scenario"); - assert_eq!(arr[0].get("name").and_then(|v| v.as_str()), Some("main")); - assert_eq!(arr[0].get("active").and_then(|v| v.as_bool()), Some(true)); - assert_eq!(arr[0].get("step_count").and_then(|v| v.as_u64()), Some(0)); -} - -#[tokio::test] -async fn get_scenarios_reflects_new_and_step_count() { - let (client, port) = start().await; - - cmd(&client, port, "Staking", scenario_new("alt1")).await; - cmd(&client, port, "Staking", call("deposit")).await; - - let body = get_scenarios(&client, port).await; - let arr = body - .as_array() - .unwrap_or_else(|| panic!("expected array, got: {body}")); - assert_eq!(arr.len(), 2, "expected main + alt1, got: {arr:?}"); - - let main = arr - .iter() - .find(|it| it.get("name").and_then(|n| n.as_str()) == Some("main")) - .expect("main entry missing"); - assert_eq!(main.get("active").and_then(|v| v.as_bool()), Some(true)); - assert_eq!(main.get("step_count").and_then(|v| v.as_u64()), Some(1)); - - let alt1 = arr - .iter() - .find(|it| it.get("name").and_then(|n| n.as_str()) == Some("alt1")) - .expect("alt1 entry missing"); - assert_eq!(alt1.get("active").and_then(|v| v.as_bool()), Some(false)); - assert_eq!(alt1.get("step_count").and_then(|v| v.as_u64()), Some(0)); -} - -#[tokio::test] -async fn get_scenarios_all_returns_ordered_snapshot() { - let (client, port) = start().await; - - cmd(&client, port, "Staking", scenario_new("alt1")).await; - cmd(&client, port, "Staking", scenario_new("alt2")).await; - cmd(&client, port, "Staking", call("deposit")).await; - - let body = get_scenarios_all(&client, port).await; - assert_eq!(body.get("active").and_then(|v| v.as_str()), Some("main")); - - let scenarios = body - .get("scenarios") - .and_then(|v| v.as_array()) - .unwrap_or_else(|| panic!("scenarios should be an array, got: {body}")); - assert_eq!(scenarios.len(), 3, "expected 3 scenarios, got: {scenarios:?}"); - - // Insertion order: main, alt1, alt2. Each entry is a ScenarioSnapshot - // object with `name`, `steps`, `forked_from` fields. - let names: Vec<&str> = scenarios - .iter() - .map(|entry| entry.get("name").and_then(|v| v.as_str()).expect("name")) - .collect(); - assert_eq!(names, vec!["main", "alt1", "alt2"], "insertion order broken"); - - let main_steps = scenarios[0] - .get("steps") - .and_then(|v| v.as_array()) - .expect("main steps array"); - assert_eq!(main_steps.len(), 1, "main should have 1 step"); - let step0 = &main_steps[0]; - assert_eq!( - step0.get("function").and_then(|v| v.as_str()), - Some("deposit") - ); - assert_eq!(step0.get("step_index").and_then(|v| v.as_u64()), Some(0)); - assert!( - step0.get("access").is_some(), - "SessionStepView must serialize an `access` field, got: {step0}" - ); - - // main was never forked; alt1/alt2 were created with `scenario new`, not - // `scenario fork`, so none of the three carry a forked_from marker. - for idx in 0..3 { - assert!( - scenarios[idx].get("forked_from").map(|v| v.is_null()).unwrap_or(true), - "scenarios[{idx}] ({}) should have forked_from = null, got: {}", - names[idx], - scenarios[idx] - ); - } - - for idx in [1usize, 2] { - let steps = scenarios[idx] - .get("steps") - .and_then(|v| v.as_array()) - .unwrap_or_else(|| panic!("scenarios[{idx}] missing steps array")); - assert!( - steps.is_empty(), - "scenarios[{idx}] ({}) should be empty, got: {steps:?}", - names[idx] - ); - } -} - -#[tokio::test] -async fn get_scenarios_all_tracks_active_after_switch() { - let (client, port) = start().await; - - cmd(&client, port, "Staking", scenario_new("alt1")).await; - cmd(&client, port, "Staking", scenario_switch("alt1")).await; - - let body = get_scenarios_all(&client, port).await; - assert_eq!( - body.get("active").and_then(|v| v.as_str()), - Some("alt1"), - "active should reflect post-switch scenario, got: {body}" - ); -} - -#[tokio::test] -async fn get_scenarios_all_exposes_fork_origin() { - let (client, port) = start().await; - - // Build main with 3 steps, fork alt1 at step 2. - cmd(&client, port, "Staking", call("deposit")).await; - cmd(&client, port, "Staking", call("deposit")).await; - cmd(&client, port, "Staking", call("withdraw")).await; - cmd( - &client, - port, - "Staking", - serde_json::json!({ "Scenario": { "sub": { "Fork": { "name": "alt1", "at_step": 2 } } } }), - ) - .await; - - let body = get_scenarios_all(&client, port).await; - let scenarios = body - .get("scenarios") - .and_then(|v| v.as_array()) - .expect("scenarios array"); - - let main = scenarios - .iter() - .find(|s| s.get("name").and_then(|v| v.as_str()) == Some("main")) - .expect("main snapshot"); - assert!( - main.get("forked_from").map(|v| v.is_null()).unwrap_or(true), - "main must have forked_from = null, got: {main}" - ); - - let alt1 = scenarios - .iter() - .find(|s| s.get("name").and_then(|v| v.as_str()) == Some("alt1")) - .expect("alt1 snapshot"); - let fork = alt1 - .get("forked_from") - .unwrap_or_else(|| panic!("alt1.forked_from missing: {alt1}")); - assert_eq!( - fork.get("scenario").and_then(|v| v.as_str()), - Some("main"), - "fork origin must be main, got: {fork}" - ); - assert_eq!( - fork.get("at_step").and_then(|v| v.as_u64()), - Some(2), - "fork at_step must be 2, got: {fork}" - ); -} - -#[tokio::test] -async fn get_scenarios_access_level_resolves_correctly() { - let (client, port) = start().await; - - cmd(&client, port, "Staking", call("deposit")).await; - - let body = get_scenarios_all(&client, port).await; - let scenarios = body - .get("scenarios") - .and_then(|v| v.as_array()) - .expect("scenarios array"); - let main_steps = scenarios[0] - .get("steps") - .and_then(|v| v.as_array()) - .expect("main steps array"); - let access = main_steps[0] - .get("access") - .expect("access field"); - // `AccessLevel::Public` is a unit variant, so serde serializes it as the - // bare string "Public" (non-unit variants like `Restricted { role }` - // would serialize as an object). - assert_eq!( - access.as_str(), - Some("Public"), - "deposit should be Public, got: {access}" - ); -} diff --git a/crates/ilold-web/tests/scenario_ws_test.rs b/crates/ilold-web/tests/scenario_ws_test.rs deleted file mode 100644 index c6ba4f1..0000000 --- a/crates/ilold-web/tests/scenario_ws_test.rs +++ /dev/null @@ -1,209 +0,0 @@ -use std::path::PathBuf; -use std::time::Duration; - -use futures_util::StreamExt; -use tokio::time::timeout; -use tokio_tungstenite::connect_async; -use tokio_tungstenite::tungstenite::Message; - -fn fixture(name: &str) -> PathBuf { - PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .parent() - .unwrap() - .parent() - .unwrap() - .join("tests/fixtures") - .join(name) -} - -/// Boot a server on an ephemeral port with the staking fixture and open a -/// WS connection. Returns the HTTP client, port, and the connected WS -/// stream. -async fn start_with_ws() -> ( - reqwest::Client, - u16, - tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>, -) { - let paths = vec![fixture("staking.sol")]; - let (_state, port) = ilold_web::start_server(paths, 0, 2).await.unwrap(); - let (ws, _) = connect_async(format!("ws://127.0.0.1:{port}/ws")) - .await - .expect("WS connection failed"); - (reqwest::Client::new(), port, ws) -} - -async fn post_cmd( - client: &reqwest::Client, - port: u16, - contract: &str, - command: serde_json::Value, -) { - let res = client - .post(format!("http://127.0.0.1:{port}/api/cmd")) - .json(&serde_json::json!({ "contract": contract, "command": command })) - .send() - .await - .expect("POST /api/cmd failed"); - assert!( - res.status().is_success(), - "POST /api/cmd returned {}", - res.status() - ); -} - -async fn next_ws_json<S>(ws: &mut S) -> serde_json::Value -where - S: StreamExt<Item = Result<Message, tokio_tungstenite::tungstenite::Error>> + Unpin, -{ - let msg = timeout(Duration::from_secs(5), ws.next()) - .await - .expect("timed out waiting for WS message") - .expect("WS stream ended") - .expect("WS error"); - let text = msg.into_text().expect("not a text message"); - serde_json::from_str(&text).expect("WS payload was not JSON") -} - -fn scenario_new(name: &str) -> serde_json::Value { - serde_json::json!({ "Scenario": { "sub": { "New": { "name": name } } } }) -} - -fn scenario_switch(name: &str) -> serde_json::Value { - serde_json::json!({ "Scenario": { "sub": { "Switch": { "name": name } } } }) -} - -fn scenario_delete(name: &str) -> serde_json::Value { - serde_json::json!({ "Scenario": { "sub": { "Delete": { "name": name } } } }) -} - -fn call(func: &str) -> serde_json::Value { - serde_json::json!({ "Call": { "func": func } }) -} - -#[tokio::test] -async fn ws_event_session_add_node_carries_scenario_field() { - let (client, port, mut ws) = start_with_ws().await; - - post_cmd(&client, port, "Staking", call("deposit")).await; - - let payload = next_ws_json(&mut ws).await; - assert_eq!(payload["type"], "session_add_node"); - assert_eq!(payload["scenario"], "main"); - assert_eq!(payload["function"], "deposit"); - assert!(payload["step_index"].is_number()); -} - -#[tokio::test] -async fn ws_event_scenario_created_broadcasts_on_new() { - let (client, port, mut ws) = start_with_ws().await; - - post_cmd(&client, port, "Staking", scenario_new("alt1")).await; - - let payload = next_ws_json(&mut ws).await; - assert_eq!( - payload["type"], "scenario_created", - "expected scenario_created, got: {payload}" - ); - assert_eq!(payload["name"], "alt1"); -} - -#[tokio::test] -async fn ws_event_scenario_switched_broadcasts_on_switch() { - let (client, port, mut ws) = start_with_ws().await; - - post_cmd(&client, port, "Staking", scenario_new("alt1")).await; - // consume the `scenario_created` event so the next read is the switch - let _created = next_ws_json(&mut ws).await; - - post_cmd(&client, port, "Staking", scenario_switch("alt1")).await; - - let payload = next_ws_json(&mut ws).await; - assert_eq!( - payload["type"], "scenario_switched", - "expected scenario_switched, got: {payload}" - ); - assert_eq!(payload["from"], "main"); - assert_eq!(payload["to"], "alt1"); -} - -#[tokio::test] -async fn ws_event_scenario_deleted_broadcasts_on_delete() { - let (client, port, mut ws) = start_with_ws().await; - - post_cmd(&client, port, "Staking", scenario_new("alt1")).await; - let _created = next_ws_json(&mut ws).await; - - // Delete a non-active scenario (active is still "main") - post_cmd(&client, port, "Staking", scenario_delete("alt1")).await; - - let payload = next_ws_json(&mut ws).await; - assert_eq!( - payload["type"], "scenario_deleted", - "expected scenario_deleted, got: {payload}" - ); - assert_eq!(payload["name"], "alt1"); -} - -#[tokio::test] -async fn canvas_patch_scenario_field_propagates_to_ws_handler() { - // Explicit check that the scenario field on a data event matches the - // active scenario at the moment the command was dispatched. We switch - // to a new scenario first, then `c deposit`; the `session_add_node` - // must carry `scenario: "alt1"`, not `"main"`. - let (client, port, mut ws) = start_with_ws().await; - - post_cmd(&client, port, "Staking", scenario_new("alt1")).await; - let _created = next_ws_json(&mut ws).await; - - post_cmd(&client, port, "Staking", scenario_switch("alt1")).await; - let _switched = next_ws_json(&mut ws).await; - - post_cmd(&client, port, "Staking", call("deposit")).await; - let payload = next_ws_json(&mut ws).await; - - assert_eq!(payload["type"], "session_add_node"); - assert_eq!( - payload["scenario"], "alt1", - "scenario field must track active scenario at call time, got: {payload}" - ); - assert_eq!(payload["function"], "deposit"); -} - -#[tokio::test] -async fn ws_event_scenario_store_reloaded_broadcasts_after_load() { - let (client, port, mut ws) = start_with_ws().await; - - // Build a tiny state and save it so we have a valid v2 JSON to load back. - post_cmd(&client, port, "Staking", call("deposit")).await; - let _added = next_ws_json(&mut ws).await; // session_add_node - - // SaveSession does not broadcast — POST and read the JSON inline. - let res = client - .post(format!("http://127.0.0.1:{port}/api/cmd")) - .json(&serde_json::json!({ - "contract": "Staking", - "command": "SaveSession", - })) - .send() - .await - .unwrap(); - let body: serde_json::Value = res.json().await.unwrap(); - let json = body["SessionSaved"]["json"].as_str().unwrap().to_string(); - - // LoadSession must broadcast scenario_store_reloaded with the active name. - post_cmd( - &client, - port, - "Staking", - serde_json::json!({ "LoadSession": { "json": json } }), - ) - .await; - let payload = next_ws_json(&mut ws).await; - - assert_eq!(payload["type"], "scenario_store_reloaded"); - assert_eq!( - payload["active"], "main", - "reload event must carry the post-load active scenario, got: {payload}" - ); -} - diff --git a/crates/ilold-web/tests/ws_broadcast_test.rs b/crates/ilold-web/tests/ws_broadcast_test.rs deleted file mode 100644 index 5c735e0..0000000 --- a/crates/ilold-web/tests/ws_broadcast_test.rs +++ /dev/null @@ -1,148 +0,0 @@ -use std::path::PathBuf; -use std::time::Duration; - -use futures_util::StreamExt; -use tokio_tungstenite::connect_async; - -fn fixture(name: &str) -> PathBuf { - PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .parent().unwrap() - .parent().unwrap() - .join("tests/fixtures") - .join(name) -} - -#[tokio::test] -async fn broadcast_add_node_on_call_command() { - let paths = vec![fixture("staking.sol")]; - let (_, port) = ilold_web::start_server(paths, 0, 2).await.unwrap(); - - let (mut ws, _) = connect_async(format!("ws://127.0.0.1:{port}/ws")) - .await - .expect("WS connection failed"); - - let client = reqwest::Client::new(); - let res = client - .post(format!("http://127.0.0.1:{port}/api/cmd")) - .json(&serde_json::json!({ - "contract": "Staking", - "command": { "Call": { "func": "deposit" } } - })) - .send() - .await - .expect("POST failed"); - - assert!(res.status().is_success(), "POST returned {}", res.status()); - - let msg = tokio::time::timeout(Duration::from_secs(5), ws.next()) - .await - .expect("Timed out waiting for WS message") - .expect("WS stream ended") - .expect("WS error"); - - let text = msg.into_text().expect("Not a text message"); - let payload: serde_json::Value = serde_json::from_str(&text).unwrap(); - - assert_eq!(payload["type"], "session_add_node"); - assert_eq!(payload["function"], "deposit"); - assert_eq!(payload["scenario"], "main"); - assert!(payload["step_index"].is_number()); - assert!(payload["access"].is_string() || payload["access"].is_object()); -} - -#[tokio::test] -async fn broadcast_clear_after_call_and_clear() { - let paths = vec![fixture("staking.sol")]; - let (_, port) = ilold_web::start_server(paths, 0, 2).await.unwrap(); - - let (mut ws, _) = connect_async(format!("ws://127.0.0.1:{port}/ws")) - .await - .expect("WS connection failed"); - - let client = reqwest::Client::new(); - - // First: call deposit - client - .post(format!("http://127.0.0.1:{port}/api/cmd")) - .json(&serde_json::json!({ - "contract": "Staking", - "command": { "Call": { "func": "deposit" } } - })) - .send() - .await - .unwrap(); - - // Consume the add_node message - let msg = tokio::time::timeout(Duration::from_secs(5), ws.next()) - .await.unwrap().unwrap().unwrap(); - let payload: serde_json::Value = serde_json::from_str(&msg.into_text().unwrap()).unwrap(); - assert_eq!(payload["type"], "session_add_node"); - - // Then: clear - client - .post(format!("http://127.0.0.1:{port}/api/cmd")) - .json(&serde_json::json!({ - "contract": "Staking", - "command": "Clear" - })) - .send() - .await - .unwrap(); - - let msg = tokio::time::timeout(Duration::from_secs(5), ws.next()) - .await.unwrap().unwrap().unwrap(); - let payload: serde_json::Value = serde_json::from_str(&msg.into_text().unwrap()).unwrap(); - assert_eq!(payload["type"], "session_clear"); - assert_eq!(payload["scenario"], "main"); -} - -#[tokio::test] -async fn no_broadcast_on_non_mutating_command() { - let paths = vec![fixture("staking.sol")]; - let (_, port) = ilold_web::start_server(paths, 0, 2).await.unwrap(); - - let (mut ws, _) = connect_async(format!("ws://127.0.0.1:{port}/ws")) - .await - .expect("WS connection failed"); - - let client = reqwest::Client::new(); - - // Functions command does NOT produce a CanvasPatch - let res = client - .post(format!("http://127.0.0.1:{port}/api/cmd")) - .json(&serde_json::json!({ - "contract": "Staking", - "command": "Functions" - })) - .send() - .await - .unwrap(); - - assert!(res.status().is_success()); - - // WS should NOT receive anything — timeout is expected - let result = tokio::time::timeout(Duration::from_millis(500), ws.next()).await; - assert!(result.is_err(), "Should have timed out — no broadcast expected"); -} - -#[tokio::test] -async fn who_command_returns_variable_info() { - let paths = vec![fixture("staking.sol")]; - let (_, port) = ilold_web::start_server(paths, 0, 2).await.unwrap(); - - let client = reqwest::Client::new(); - let res = client - .post(format!("http://127.0.0.1:{port}/api/cmd")) - .json(&serde_json::json!({ - "contract": "Staking", - "command": { "Who": { "variable": "balances" } } - })) - .send() - .await - .unwrap(); - - assert!(res.status().is_success()); - let body: serde_json::Value = res.json().await.unwrap(); - assert_eq!(body["VariableInfo"]["variable"], "balances"); - assert!(body["VariableInfo"]["writers"].is_array()); -} diff --git a/docs/guide/src/SUMMARY.md b/docs/guide/src/SUMMARY.md index 0fc65bb..6668fb6 100644 --- a/docs/guide/src/SUMMARY.md +++ b/docs/guide/src/SUMMARY.md @@ -8,21 +8,6 @@ - [What ilold does](./concepts/overview.md) - [Architecture](./concepts/architecture.md) -# Solidity Backend - -- [Overview](./solidity/overview.md) -- [CLI: analyze](./solidity/cli-analyze.md) -- [CLI: context](./solidity/cli-context.md) -- [REPL: Session](./solidity/repl/session.md) -- [REPL: Analysis](./solidity/repl/analysis.md) -- [REPL: Contract](./solidity/repl/contract.md) -- [REPL: Findings](./solidity/repl/findings.md) -- [REPL: Scenarios](./solidity/repl/scenarios.md) -- [REPL: Workspace](./solidity/repl/workspace.md) -- [Workflow: Audit walkthrough](./solidity/workflows/audit-walkthrough.md) -- [Workflow: Taint analysis](./solidity/workflows/taint-analysis.md) -- [Limitations](./solidity/limitations.md) - # Solana Backend - [Overview](./solana/overview.md) @@ -47,6 +32,5 @@ # Roadmap -- [Solidity: future work](./roadmap/solidity.md) - [Solana: Phase 2](./roadmap/solana.md) - [Cross-cutting](./roadmap/cross-cutting.md) diff --git a/docs/guide/src/concepts/architecture.md b/docs/guide/src/concepts/architecture.md index 3dfefb4..334b7a7 100644 --- a/docs/guide/src/concepts/architecture.md +++ b/docs/guide/src/concepts/architecture.md @@ -1,64 +1,44 @@ # Architecture -ilold is split into a handful of crates with clear responsibilities. The diagram below shows the pipeline for each backend; both meet at the shared web layer (`ilold-web`) and the REPL frontend (`ilold-cli`). +ilold is a small Rust workspace organised around one pipeline (Solana via LiteSVM), one HTTP/WS layer, one REPL, and one MCP server. The diagram below shows how a typed program model flows from the Anchor IDL down to the canvas and the LLM client. -## Solidity pipeline +## Pipeline ``` -.sol files - │ - ▼ -ilold-core::parse::solar_frontend (SolarParser) - │ - ▼ -ilold-core::model (Project, ContractDef, FunctionDef, ...) - │ - ▼ -ilold-core::cfg::builder (CfgBuilder) - │ - ▼ -ilold-core::pathtree::walker (build_path_tree) - │ - ▼ -ilold-core::sequence::analysis (analyze_sequences, analyze_project) +Anchor.toml + idls/<program>.json + target/deploy/<program>.so │ ▼ -ilold-core::narrative + slicing (info, trace, slice, timeline) -``` - -`analyze` and `context` run this pipeline once and print to stdout. `serve` and `explore` keep the model in memory and expose it through the HTTP/WS API in `ilold-web`. - -## Solana pipeline - -``` -Anchor.toml + idls/<program>.json + target/deploy/<program>.so +ilold-solana-core::ingest (detect, DetectedProject, AnchorProject) │ ▼ -ilold-solana-core::ingest (detect, AnchorProject) +ilold-solana-core::idl + model (IDL → ProgramDef → ProgramView) │ ▼ -ilold-solana-core::runtime (LiteSVM-backed engine) +ilold-solana-core::execute (VmHost: LiteSVM boot, snapshots, fork) │ ▼ -ilold-solana-core::exploration (SolanaCommand → SolanaCommandResult) - │ per-step CU, logs, account diffs, decoded timelines +ilold-solana-core::exploration (SolanaCommand → SolanaCommandResult) + │ per-step CU, logs, account diffs, decoded timelines ▼ -ilold-web (shared HTTP/WS layer) (/api/cmd, /api/program/*, /ws) +ilold-web (HTTP/WS + REST routes) (/api/cmd, /api/program/*, /api/scenarios, /ws) │ - ▼ -ilold-cli::explore (REPL, parses input, prints results) + ├─▶ ilold-cli::explore (REPL, parses input, prints results) + ├─▶ frontend (Svelte canvas) (subscribes to /ws, paints state) + └─▶ ilold-mcp (stdio JSON-RPC, exposes 30 tools) ``` -There is no static CFG or path tree on Solana today: programs are bytecode at this point. Anything that requires control-flow analysis (`slice`, `trace`, `sequence` narrative) is listed in [Solana: Limitations](../solana/limitations.md) and tracked under Phase 2 in the [Roadmap](../roadmap/solana.md). +There is no static CFG or path tree on Solana today: handlers are bytecode at this point. Anything that requires control-flow analysis (slicing, structural narratives, detector engine) is listed in [Limitations](../solana/limitations.md) and tracked under Phase 2 in the [Roadmap](../roadmap/solana.md). -## Shared layer +## Crates | Crate | Role | | --- | --- | -| `ilold-cli` | Argument parsing, REPL, output formatting, key bindings (`crates/ilold-cli/src/main.rs`, `explore.rs`, `help.rs`) | -| `ilold-web` | HTTP + WebSocket API consumed by both the REPL (via `--attach`) and the web canvas | -| `ilold-session-core` | Shared session abstractions (steps, scenarios, canvas patches) | -| `ilold-core` | Solidity model, CFG, slicer, narrative, sequence analysis | +| `ilold-cli` | Argument parsing, REPL, output formatting (`src/main.rs`, `explore.rs`, `help.rs`) | +| `ilold-web` | HTTP + WebSocket API consumed by the REPL (via `--attach`) and the web canvas | +| `ilold-session-core` | Backend-agnostic session abstractions (steps, scenarios, canvas patches, access levels, journal) | | `ilold-solana-core` | Anchor IDL ingest, LiteSVM runtime, instruction execution, timeline reconstruction | +| `ilold-mcp` | Model Context Protocol server exposing the REPL commands as 30 typed tools | +| `ilold-render` | Pretty printers shared by CLI and MCP (byte-identical to legacy CLI prints) | +| `ilold-help` | `SOLANA_HELP_BLOCKS` registry shared by REPL inline help and MCP tool descriptions | -The web canvas (`crates/ilold-web/frontend`) subscribes to the `/ws` stream and stays in sync with whatever the REPL does. +The web canvas (`crates/ilold-web/frontend`) subscribes to the `/ws` stream and stays in sync with whatever the REPL or MCP client does. diff --git a/docs/guide/src/concepts/overview.md b/docs/guide/src/concepts/overview.md index 4ddf8b9..8bc5014 100644 --- a/docs/guide/src/concepts/overview.md +++ b/docs/guide/src/concepts/overview.md @@ -1,20 +1,19 @@ # What ilold does -ilold is a smart-contract audit explorer. It loads a project, builds an internal model, and exposes that model through an interactive REPL. The auditor adds entry-point calls to a **session**, the tool tracks accumulated effects, and analysis commands answer questions about the code without requiring a separate run. +ilold is a Solana program audit explorer. It loads an Anchor project, builds an internal model from the IDL, boots a LiteSVM with the compiled program, and exposes that model through an interactive REPL. The auditor adds instruction calls to a **session**, the tool tracks accumulated effects, and analysis commands answer questions about the program without leaving the shell. -## Two backends, one shell +## Input and execution model -| Backend | Input | Execution model | What you get | -| --- | --- | --- | --- | -| Solidity | `.sol` sources (file or directory) | Symbolic (parser, CFG, path tree, slicer) | Function narratives, execution trees with modifier inlining, backward/forward dataflow slices, cross-step timelines | -| Solana | Project root with `Anchor.toml`, `idls/<program>.json`, `target/deploy/<program>.so` | Concrete (in-process execution via LiteSVM) | Per-call CU and logs, account diffs, decoded timelines, scenario-isolated VMs, time-warp on the `Clock` sysvar | +| Input | Execution model | What you get | +| --- | --- | --- | +| Project root with `Anchor.toml`, `idls/<program>.json`, `target/deploy/<program>.so` | Concrete (in-process execution via LiteSVM) | Per-call CU and logs, account diffs, decoded timelines, scenario-isolated VMs, time-warp on the `Clock` sysvar | -The REPL command surface is the same shell. Backend-specific commands are documented in [Solana: Solana runtime](../solana/repl/runtime.md) and [Solana: Scenarios](../solana/repl/scenarios.md). +Solana-specific commands are documented in [Solana runtime](../solana/repl/runtime.md) and [Scenarios](../solana/repl/scenarios.md). ## Sessions and scenarios -A **session** is the active scenario inside the active project. Adding a step means calling an entry point and recording its effects. A **scenario** is a named branch of the session timeline; scenarios can be created from scratch (`scenario new`) or forked from an existing one at a step boundary (`scenario fork`). On Solana, each scenario owns its own VM and user keypairs, so forks produce independent state. +A **session** is the active scenario inside the active program. Adding a step means calling an instruction and recording its effects. A **scenario** is a named branch of the session timeline; scenarios can be created from scratch (`scenario new`) or forked from an existing one at a step boundary (`scenario fork`). Each scenario owns its own VM and user keypairs, so forks produce independent state. ## What the tool does not do -ilold has no built-in vulnerability detectors. There is no checklist that fires "this is a reentrancy" or "this is a missing access control" automatically. The auditor uses `who`, `info`, `trace`, `slice`, `timeline`, `state`, `step` to investigate, and records findings via `finding` and `note`. See [Roadmap](../roadmap/solana.md) for the Phase 2 detector engine and AST extractor. +ilold has no built-in vulnerability detectors. There is no checklist that fires "this is a missing signer" or "this is account confusion" automatically. The auditor uses `who`, `info`, `state`, `timeline`, `step`, `coverage` to investigate, and records findings via `finding` and `note`. See [Roadmap](../roadmap/solana.md) for the Phase 2 detector engine and AST extractor. diff --git a/docs/guide/src/getting-started.md b/docs/guide/src/getting-started.md index ec3df85..28b37de 100644 --- a/docs/guide/src/getting-started.md +++ b/docs/guide/src/getting-started.md @@ -10,36 +10,7 @@ cd ilold cargo build --release ``` -The binary is at `target/release/ilold`. The four subcommands are `analyze`, `context`, `serve`, and `explore` (see `crates/ilold-cli/src/main.rs`). - -## Backend detection - -`serve` and `explore` auto-detect the backend from the path: - -- A directory or file containing `.sol` sources is treated as a Solidity project. -- A directory containing `Anchor.toml` (with `idls/<program>.json`) is treated as a Solana project. - -`analyze` and `context` are Solidity-only. - -## Running against a Solidity project - -``` -cargo run -- explore tests/fixtures/staking.sol -``` - -ilold parses the files, builds the model and per-function CFGs, and drops the auditor into the REPL: - -``` - ╭──────────────────────────────────────────╮ - │ ilold explore — Staking │ - │ 8 functions | Type ? for help │ - │ Web UI: http://localhost:52431 │ - ╰──────────────────────────────────────────╯ - -ilold[Staking]> -``` - -The port defaults to `0` (auto-assigned) for `explore` and `8080` for `serve`. Override with `--port`. +The binary is at `target/release/ilold`. The three subcommands are `serve`, `explore`, and `mcp` (see `crates/ilold-cli/src/main.rs`). ## Running against a Solana project @@ -47,72 +18,61 @@ The port defaults to `0` (auto-assigned) for `explore` and `8080` for `serve`. O cargo run -- explore tests/fixtures/solana/staking ``` -ilold loads the IDL under `idls/<program>.json`, boots a LiteSVM with the program binary from `target/deploy/<program>.so` (or `bin/<program>.so`), and opens the REPL: +ilold detects the project as Solana via `Anchor.toml`, loads the IDL under `idls/<program>.json`, boots a LiteSVM with the program binary from `target/deploy/<program>.so` (or `bin/<program>.so`), and opens the REPL: ``` ilold[staking]> ``` +The port defaults to `0` (auto-assigned) for `explore` and `8080` for `serve`. Override with `--port`. + Without a compiled `.so` the REPL still starts and IDL navigation (`f`, `i`, `pda`, `vars`) works, but commands that drive the VM (`call`, `state`, `inspect`) fail until the program is built. ## First session -A typical first exploration of the Solidity staking contract: +A typical first exploration of the staking fixture: ``` -ilold[Staking]> f +ilold[staking]> f - [P] deposit writes state, external calls - [P] withdraw writes state, external calls - [P] claimRewards writes state, external calls - [R] setRewardRate writes state - [R] pause writes state - [R] unpause writes state - [P] rewardPerToken view - [P] earned view + initialize_pool 0a 4acc 1sig 1pda + stake 1a 4acc 1sig 1pda + unstake 1a 4acc 1sig 1pda + claim_rewards 0a 4acc 1sig 1pda + set_reward_rate 1a 2acc 1sig 0pda ``` ``` -ilold[Staking]> c deposit +ilold[staking]> users new alice + ✓ alice created with 10 SOL +``` - + Step 0: deposit [P] external - State writes: - · balances[msg.sender] - · lastUpdateTime - · rewardPerTokenStored - · rewards[account] - · totalStaked - · userRewardPerTokenPaid[account] - Sequence: deposit ``` +ilold[staking]> c stake amount=1000 pool=pool user_stake=alice_stake user=alice + + Step 0: stake + accounts: pool, alice_stake (pda), alice (signer) + compute_units: 4823 + diffs: alice_stake.amount = 1000 ``` -ilold[Staking → deposit]> s - ════════════════════════════════════════════[ STATE ]═════════════════════════════════════════════ - balances[msg.sender] - += amount (step 0:15, deposit) - lastUpdateTime - = block.timestamp (step 0:8, deposit) - rewardPerTokenStored - = rewardPerToken() (step 0:7, deposit) - rewards[account] - = earned(account) (step 0:11, deposit) - totalStaked - += amount (step 0:16, deposit) - userRewardPerTokenPaid[account] - = rewardPerTokenStored (step 0:12, deposit) ``` +ilold[staking]> state -The full audit flow (`who`, `tr`, `sl`, `tl`, scenarios, findings, export) is covered in [Solidity: Audit walkthrough](./solidity/workflows/audit-walkthrough.md). For the Solana equivalent (`users new`, `call <ix>`, `state`, `step`, `timeline <pubkey>`) see [Solana: Audit walkthrough](./solana/workflows/audit-walkthrough.md). + alice_stake (UserStake) + amount = 1000 + last_update_ts = 1715789432 + pool (Pool) + total_staked = 1000 +``` + +The full audit flow (`users new`, `call <ix>`, `state`, `step`, `timeline <pubkey>`, scenarios, findings, export) is covered in [Audit walkthrough](./solana/workflows/audit-walkthrough.md). ## Inline help -Type `?` at the prompt for the full command reference. Append `?` to any command for its usage: +Type `?` at the prompt for the full command reference. Append `?` to any command for its structured usage block (syntax, flags, examples, return shape, related commands): ``` -ilold[Staking]> sl? - slice <func> <var> [--backward] Dataflow slice. Example: sl deposit totalStaked --backward +ilold[staking]> call? + ... structured help block ... ``` - -On Solana, appending `?` renders the structured help block for the command, including syntax, flags, examples, return shape, and related commands. diff --git a/docs/guide/src/introduction.md b/docs/guide/src/introduction.md index 8c1d5a5..5151124 100644 --- a/docs/guide/src/introduction.md +++ b/docs/guide/src/introduction.md @@ -1,32 +1,26 @@ # Introduction -ilold is an execution path analyzer and interactive security workbench for smart contracts. It maps every possible path through a protocol (every branch, function combination, and state mutation) and lets users navigate them visually, branch by branch, with an LLM reasoning over each path. +ilold is a Solana program execution path analyzer and interactive security workbench. It maps every reachable instruction path through an Anchor program, executes them on a real LiteSVM, and lets the auditor (or an LLM agent) navigate branches, fork scenarios, and ship a Markdown deliverable. -The tool loads a project, builds an in-memory model, and drops the auditor into a REPL backed by a live canvas. Each command answers a question about the protocol: list entry points, add a call to a session, inspect state changes, trace execution flow, slice data dependencies, record findings, export a report. The canvas reflects the same state in a visual graph that the user navigates by clicking, expanding, and forking. +The tool loads an Anchor workspace, builds an in-memory model from the IDL, boots a LiteSVM with the compiled `.so`, and drops the auditor into a REPL backed by a live canvas. Each command answers a question about the program: list instructions, add a call to a scenario, inspect decoded account state, fork the timeline, record a finding, export the report. The canvas reflects the same state in a visual graph that the user navigates by clicking, expanding, and forking. -ilold supports two backends: - -- **Solidity**: static analysis on top of `solar-compiler`. Contracts are parsed into a typed model; per-function CFGs and path trees drive `info`, `trace`, `slice`, `timeline`, and sequence narratives. -- **Solana**: concrete execution on top of LiteSVM. Programs run inside an in-process VM, accounts are decoded from the program IDL, and timelines are reconstructed from per-step account diffs. - -The REPL surface is the same shell for both backends, with backend-specific commands documented in their respective sections. +Programs run inside an in-process VM, accounts are decoded from the program IDL, and timelines are reconstructed from per-step account diffs. Every `call` runs the compiled program for real — actual compute units, actual logs, actual account state. ## Core concept -An audit is a conversation with the code. ilold models that conversation as a **session**: the auditor adds entry-point calls one at a time, and the tool tracks how state accumulates across the sequence. Every analysis command operates either on a single entry point or on the accumulated session state. +An audit is a conversation with the code. ilold models that conversation as a **session**: the auditor adds instruction calls one at a time, and the tool tracks how account state accumulates across the sequence. Every analysis command operates either on a single instruction or on the accumulated session state. -Sessions can be branched into named **scenarios**. A scenario is an independent timeline with its own state; on Solana each scenario also owns its own VM and user keypairs. +Sessions can be branched into named **scenarios**. A scenario is an independent timeline with its own VM, user keypairs, and account state. ## Key differentiator -ilold does not detect vulnerabilities automatically. It gives the auditor primitives to drive the analysis: build a sequence, inspect state changes, trace execution flow, slice dependencies, and record findings. The auditor leads, the tool answers questions grounded in the actual structure of the code (Solidity) or the actual runtime behaviour (Solana). +ilold does not detect vulnerabilities automatically. It gives the auditor primitives to drive the analysis: build a sequence, inspect state changes, fork scenarios, record findings. The auditor leads, the tool answers questions grounded in actual runtime behaviour against a real VM. ## Where to start - [Getting Started](./getting-started.md): install and first session. - [Concepts](./concepts/overview.md): what the tool does and the data pipeline. -- [Solidity Backend](./solidity/overview.md): Solidity REPL, CLI, workflows. -- [Solana Backend](./solana/overview.md): Solana REPL, runtime commands, workflows. +- [Solana Backend](./solana/overview.md): REPL, runtime commands, workflows. - [Reference](./reference/api-endpoints.md): HTTP API and WebSocket events. - [MCP server](./reference/mcp.md): drive ilold from an LLM agent. -- [Roadmap](./roadmap/solidity.md): known gaps and future work. +- [Roadmap](./roadmap/solana.md): known gaps and future work. diff --git a/docs/guide/src/reference/api-endpoints.md b/docs/guide/src/reference/api-endpoints.md index c5d41be..dc2eff0 100644 --- a/docs/guide/src/reference/api-endpoints.md +++ b/docs/guide/src/reference/api-endpoints.md @@ -1,191 +1,87 @@ # HTTP API Reference -ilold exposes an HTTP API on the configured port (default 3001). All endpoints return JSON. The API is split into three groups: command bus, session queries, and contract queries. +ilold exposes an HTTP API on the configured port (default `8080`). All endpoints return JSON. The routes are defined in `crates/ilold-web/src/lib.rs::build_router`. ## Command bus ### POST /api/cmd -Execute a session command. This is the single entry point for all state-mutating session operations. +Execute a session command against the active program and scenario. This is the single entry point for every state-mutating session operation. **Request body:** -``` +```json { - "contract": "Staking", // optional, defaults to session contract - "command": { "Call": { "func": "deposit" } } + "contract": "staking", + "command": { "Call": { "ix": "stake", "args": { "amount": 1000 }, "accounts": { "pool": "pool", "user_stake": "alice_stake", "user": "alice" }, "signers": ["alice"] } } } ``` -**Supported commands:** - -| Command | Payload | Description | -|---|---|---| -| `Call` | `{ "func": "deposit", "trace_config": null }` | Add a function call as a session step | -| `Back` | `"Back"` | Remove the last step | -| `Clear` | `"Clear"` | Remove all steps | -| `State` | `"State"` | Return accumulated state variable summary | -| `Functions` | `"Functions"` | List functions of the current contract | -| `FunctionsAll` | `"FunctionsAll"` | List all accessible functions including inherited | -| `StateVarsAll` | `"StateVarsAll"` | List all accessible state variables including inherited | -| `Who` | `{ "variable": "totalStaked" }` | Find all writers and readers of a variable | -| `Finding` | `{ "severity": "High", "title": "...", "description": "..." }` | Record an audit finding | -| `Note` | `{ "text": "..." }` | Record a free-text note | -| `Status` | `{ "func": "deposit", "status": "Reviewed" }` | Set review status for a function | -| `Session` | `"Session"` | Return session overview (contract, steps, finding count) | -| `Export` | `"Export"` | Export session journal as markdown | -| `SaveSession` | `"SaveSession"` | Serialize session to JSON | -| `LoadSession` | `{ "json": "..." }` | Restore session from serialized JSON | - -**Response:** A `CommandResult` variant matching the command type. Key variants: - -- `StepAdded`: `step_index`, `function`, `access`, `state_changed` (list of variable names) -- `StateView`: `summary` (list of variable summaries with writers per step) -- `FunctionList`: `functions` (name, access level, writes_state, has_external_calls) -- `VariableInfo`: `variable`, `writers` (name + access), `readers` (name + access) -- `Error`: `message` +`contract` is the program name. It defaults to the first program in the workspace when omitted. -## Session query endpoints +`command` carries a `SolanaCommand` variant (see `crates/ilold-solana-core/src/exploration/commands.rs`). Headline variants: -These endpoints read from the active session. They return 404 if no session exists (no `Call` command has been issued). +| Variant | Payload | Description | +| --- | --- | --- | +| `Call` | `{ ix, args, accounts, signers }` | Run an instruction against the LiteSVM and append a step | +| `Back` | `"Back"` | Remove the last step and rewind the VM | +| `Clear` | `"Clear"` | Reset scenario steps and the underlying VM | +| `Funcs` / `Vars` / `Info` / `Coupling` / `Coverage` | metadata | Inspect the typed model and runtime aggregates | +| `State` / `Session` / `Step` / `Timeline` / `Inspect` | session queries | Decoded views of the active scenario | +| `Users` / `UsersNew` / `Airdrop` / `TimeWarp` / `Pda` | runtime | LiteSVM controls | +| `Scenario` | `{ sub: New | List | Switch | Fork | Delete }` | Scenario management | +| `Finding` / `Note` / `Status` / `Findings` / `Export` | deliverable | Audit journal and Markdown export | +| `SaveSession` / `LoadSession` | persistence | JSON scenario store under `~/.ilold/sessions/` | -### GET /api/session/state +The response is a `SolanaCommandResult` variant (`StepAdded`, `CallFailed`, `StateView`, `Timeline`, `Coverage`, `Error`, …). -Returns the accumulated state variable summary across all session steps. +## Project endpoints -**Response:** Array of `VariableSummary` objects with variable name, type, and per-step write details. - -### GET /api/session/sequence - -Returns a sequence narrative describing the relationship between session steps. Requires at least 2 steps. +| Endpoint | Description | +| --- | --- | +| `GET /api/project` | Project summary: `kind: "solana"`, list of programs with instruction + account-type counts | +| `GET /api/project/map` | Full project map consumed by the web canvas (programs, instructions, account types) | -**Response:** `SequenceNarrative` with per-step summaries and flow summaries derived from persisted flow trees. +## Program endpoints -### GET /api/session/step/{index}/narrative +| Endpoint | Description | +| --- | --- | +| `GET /api/program/{name}/view` | Full `ProgramView` for the named program: instructions (typed args, accounts with flags, signers, PDAs, admin-gated flag, coupling hints), account types, discriminators | +| `GET /api/program/{name}/overlay` | Runtime overlay aggregated over the active scenario: calls-per-instruction, failures, CU stats, CPI edges (`?scenario=<name>` overrides the default) | +| `GET /api/program/{name}/{ix}/source` | Anchor handler source slice from `programs/<program>/src/lib.rs`, with `file_path`, `source`, and `span` (line/column range) | -Returns the function narrative for a specific session step. +## Users + scenarios -**Path params:** `index` -- zero-based step index. +| Endpoint | Description | +| --- | --- | +| `GET /api/users/{scenario}/labels` | Returns the keypair labels (pubkey → name) for the given scenario; used by the canvas to render `users new <name>` aliases on signer/payer pubkeys | +| `GET /api/scenarios` | Scenario list for the active program (name, active flag, step count) | +| `GET /api/scenarios/all` | Scenarios with their step lists across every program in the workspace | -**Response:** `FunctionNarrative` with paths, state writes, external calls, and conditions. +## Session step trace ### GET /api/session/step/{index}/trace -Returns the persisted FlowTree of a session step. This is the tree captured when `Call` was executed, not a recomputation. - -**Path params:** `index` -- zero-based step index. - -**Response:** `FlowTree` with step nodes, each containing operation type, target, conditions, and child steps. Returns 404 if the step has no persisted tree (pre-Phase-2a sessions). - -### GET /api/session/timeline/{variable} - -Returns a chronological timeline of every write to `variable` across all session steps. Matches by base name (e.g., `balances` matches `balances[msg.sender]`). - -**Path params:** `variable` -- state variable name. - -**Response:** `VariableTimeline` with `state_entries` and `local_entries`. Each entry contains: `session_step_index`, `function`, `target`, `operator`, `value_expr`, `reached_when` (path conditions), `via` (modifier name if applicable), `scope`. - -### GET /api/session/slice/{function}/{variable} - -Returns a dataflow slice for `variable` inside `function` of the session's current contract. - -**Path params:** `function` -- function name, `variable` -- variable name. - -**Query params:** - -| Param | Values | Default | Description | -|---|---|---|---| -| `direction` | `backward`, `forward`, `both`, `b`, `f`, `all` | `both` | Slice direction | - -**Response:** `SliceResult` with `backward` and `forward` arrays. Each entry contains: `path`, `span` (source location), `text` (rendered statement), `origin` (`FunctionBody` or `Modifier(name)`). - -### GET /api/session/trace/{contract}/{func} - -Returns a FlowTree for the given function, computed on demand from the current analysis data. - -**Path params:** `contract` -- contract name, `func` -- function name. - -**Query params:** +Returns the persisted runtime trace of a session step (logs, CU, inner instructions). 404 if the step has no trace recorded. -| Param | Type | Default | Description | -|---|---|---|---| -| `depth` | integer | 2 | Maximum inline depth for internal calls | -| `reverts` | boolean | false | Include revert paths in the tree | -| `expand` | string | empty | Comma-separated step IDs to force-inline beyond max depth (e.g., `17,24`) | - -**Response:** `FlowTree` with nested step nodes representing the execution flow. - -### GET /api/session/function/{contract}/{func} - -Returns a function narrative without requiring a session step. Useful for inspecting functions that have not been called in the session. - -**Path params:** `contract` -- contract name, `func` -- function name. - -**Response:** `FunctionNarrative` with paths, state effects, and behavioral summary. - -## Contract query endpoints - -These endpoints read from the static analysis data and do not require an active session. - -### GET /api/project - -Returns a project summary with file count and a list of contracts (name, kind, function count, state variable count, inheritance). - -### GET /api/project/map - -Returns the full project map with all contracts, their functions (with path counts and external call flags), state variables, and cross-contract relationships extracted from call graphs. - -### GET /api/contract/{name} - -Returns contract detail: name, kind, inheritance chain, functions (with path stats), state variables, and inherited functions and state variables. - -### GET /api/contract/{name}/callgraph - -Returns the call graph for a contract in Cytoscape-compatible JSON format. Nodes represent functions (with contract, type, external flag). Edges represent calls (with kind and count). - -### GET /api/contract/{name}/{func}/cfg - -Returns the control flow graph for a function in Cytoscape-compatible JSON format. Nodes represent basic blocks (entry, normal, return, revert, assembly, loop). Edges represent control flow transitions. - -### GET /api/contract/{name}/{func}/paths - -Returns the path tree for a function. Contains all execution paths with stats (total, happy, revert) and per-path annotations (state writes, external calls, events). - -### GET /api/contract/{name}/sequences - -Returns the sequence tree for a contract, showing function interaction patterns. - -### GET /api/contract/{name}/analysis - -Returns the sequence analysis for a contract: per-function behavior summaries (state writes, state reads, external calls, conditions) and inter-function transition information. - -### GET /api/contract/{name}/suggestions - -Returns search suggestions for the contract: function names, state variable names, event names, external call targets, and predefined categories (revert, return, assembly). - -## Solana-specific endpoints - -Solana shares `/api/cmd`, `/api/project`, `/api/project/map`, `/ws`, and most session endpoints with the Solidity backend. The following routes are Solana-only: +## Annotations | Endpoint | Description | | --- | --- | -| `GET /api/program/{name}/view` | Full `ProgramView` for the named program: instructions (with args, accounts, signers, PDAs, admin-gated flag, coupling hints), account types, discriminators. | -| `GET /api/program/{name}/overlay` | Runtime overlay aggregated over the active scenario: calls-per-instruction, failures, CU stats, CPI edges. | -| `GET /api/users/{scenario}/labels` | Returns the keypair labels for a given scenario (used by the web canvas to render `users new <name>` aliases). | -| `GET /api/scenarios` | Scenario list for the active program (active marker, step counts). | -| `GET /api/scenarios/all` | Scenario list across every program in the workspace. | - -`POST /api/cmd` carries a `SolanaCommand` payload (`Call`, `Users`, `UsersNew`, `Airdrop`, `TimeWarp`, `Pda`, `Inspect`, `Scenario`, `SaveSession`, `LoadSession`, …; see `crates/ilold-solana-core/src/exploration/commands.rs`). The response is a `SolanaCommandResult` variant (`StepAdded`, `CallFailed`, `StateView`, `Timeline`, `Coverage`, etc.). +| `GET /api/annotations` | List all canvas annotations | +| `POST /api/annotations` | Create an annotation | +| `PUT /api/annotations/{id}` | Update an annotation | +| `DELETE /api/annotations/{id}` | Delete an annotation | ## WebSocket `GET /ws` upgrades to a WebSocket connection. See [WebSocket events](./websocket.md) for the full event vocabulary and payload shapes. -A second WebSocket route `GET /ws/pty` provides a PTY bridge used by the embedded REPL in the web canvas. +`GET /ws/pty` provides a PTY bridge used by the embedded REPL in the web canvas. Binary passthrough; the wire format is documented inline in `crates/ilold-web/src/ws/pty.rs`. ## Related pages - [WebSocket events](./websocket.md) -- [Solidity REPL: Session](../solidity/repl/session.md) -- [Solana REPL: Session](../solana/repl/session.md) +- [MCP server](./mcp.md) +- [REPL: Session](../solana/repl/session.md) - [Known Limitations](./limitations.md) diff --git a/docs/guide/src/reference/limitations.md b/docs/guide/src/reference/limitations.md index 54fd247..9a82acb 100644 --- a/docs/guide/src/reference/limitations.md +++ b/docs/guide/src/reference/limitations.md @@ -1,8 +1,5 @@ # Known Limitations -Limitations are documented per backend, since the boundaries are very different: +See [Solana: Limitations](../solana/limitations.md) for the backend-specific list: no static CFG (so no `slice` / structural `trace` yet), heuristic `who`, `time-warp` semantics, keypair persistence model, CPI visibility. -- [Solidity: Limitations](../solidity/limitations.md): intraprocedural slicing, assignment-only DEF extraction, modifier placeholder split, tuple destructuring, etc. -- [Solana: Limitations](../solana/limitations.md): no static CFG (no `slice` / `trace` yet), heuristic `who`, `time-warp` semantics, keypair persistence, CPI visibility. - -For the planned remediations, see the [Roadmap](../roadmap/solidity.md). +For the planned remediations, see the [Roadmap](../roadmap/solana.md). diff --git a/docs/guide/src/reference/mcp.md b/docs/guide/src/reference/mcp.md index 52be72a..871ad83 100644 --- a/docs/guide/src/reference/mcp.md +++ b/docs/guide/src/reference/mcp.md @@ -238,7 +238,6 @@ Every step also fires a WebSocket patch from `ilold serve`, so a browser tab poi ## Limitations -- **Solana only.** The MCP server refuses to start when the backend is a Solidity project. Solidity support is in the [cross-cutting roadmap](../roadmap/cross-cutting.md). - **Single active program at a time.** The handler tracks one active program. Call `ilold_use <program>` to switch — the MCP subprocess does not need to be restarted to point at a different program in the same workspace. - **Static tool registry.** Tools are derived from `SOLANA_HELP_BLOCKS` once at startup. Reloading the backend project does not change the tool set; only the data behind the tools. - **No sandbox over the LLM.** Every tool that mutates the VM (`ilold_call`, `ilold_clear`, `ilold_back`, `ilold_scenario`) is invocable without confirmation from the server. Sandboxing is delegated to the MCP client: mature clients prompt the human before destructive tools (those whose names contain `clear`, `delete`, `reset`). @@ -250,7 +249,6 @@ Every step also fires a WebSocket patch from `ilold serve`, so a browser tab poi | Symptom | Likely cause | | --- | --- | | `Cannot reach Ilold server at <url>` on startup | `ilold serve` is not running, or `--server-url` points to the wrong port. | -| `Server at <url> is not a Solana project (kind=solidity)` | The backend was started against a Solidity workspace. Point `ilold serve` at a Solana project. | | Tools do not appear in the client | The client could not spawn `ilold`. Check that the binary is on `PATH` or use an absolute path in `command`. Inspect the client log (`~/Library/Logs/Claude/mcp-server-ilold.log` for Claude Desktop on macOS). | | `No active contract` from every tool but `ilold_programs` | The session has no active program. Call `ilold_use <program>` (or restart the subprocess with `--contract <name>`). | | Tool call returns `Error: ...` | The backend rejected the `SolanaCommand`. The error text is the same as the REPL would print; check the active program (`ilold_programs`) and the instruction arguments. | diff --git a/docs/guide/src/reference/websocket.md b/docs/guide/src/reference/websocket.md index 62a809d..6451157 100644 --- a/docs/guide/src/reference/websocket.md +++ b/docs/guide/src/reference/websocket.md @@ -6,10 +6,10 @@ The `/ws` route emits `ServerMessage` events (JSON, tagged on a `type` field) wh | Event | Fields | Trigger | | --- | --- | --- | -| `session_add_node` | `scenario`, `function`, `access`, `step_index`, optional `runtime` (CU + diffs + logs excerpt on Solana) | `call` adds a step | +| `session_add_node` | `scenario`, `function`, `access`, `step_index`, optional `runtime` (CU + diffs + logs excerpt) | `call` adds a step | | `session_remove_node` | `scenario` | `back` rewinds the last step | | `session_clear` | `scenario` | `clear` wipes the scenario | -| `session_highlight` | `scenario`, `function` | Auditor selects a step (web canvas) | +| `session_highlight` | `scenario`, `function` | Auditor selects a step from the web canvas | ## Scenario events @@ -21,7 +21,7 @@ The `/ws` route emits `ServerMessage` events (JSON, tagged on a `type` field) wh | `scenario_forked` | `from`, `to`, `at_step` | `scenario fork` | | `scenario_store_reloaded` | `active` | After `load`, when the entire scenario tree is rehydrated | -## Solana-only events +## Runtime events | Event | Fields | Trigger | | --- | --- | --- | @@ -30,14 +30,13 @@ The `/ws` route emits `ServerMessage` events (JSON, tagged on a `type` field) wh ## Client → server -The only message the client can send is a `search` query consumed by `crates/ilold-web/src/ws/search.rs`. Responses come back as `search_result` (one per match) and `search_complete` (`total` count). +The current `/ws` socket is server-push only: clients receive events, the only thing they may send back is a `Close` frame. Programmatic interaction with the backend goes through `POST /api/cmd`. ## PTY bridge -`GET /ws/pty` opens a PTY for the embedded REPL in the web canvas. The protocol is binary-passthrough; the wire format is documented inline in `crates/ilold-web/src/ws/pty.rs`. +`GET /ws/pty` opens a PTY for the embedded REPL in the web canvas. The protocol is binary passthrough; the wire format is documented inline in `crates/ilold-web/src/ws/pty.rs`. ## Related pages - [HTTP API](./api-endpoints.md) -- [Solana REPL: Scenarios](../solana/repl/scenarios.md): the source of the scenario events. -- [Solidity REPL: Scenarios](../solidity/repl/scenarios.md): same events on the Solidity side. +- [Scenarios](../solana/repl/scenarios.md): the source of the scenario events. diff --git a/docs/guide/src/roadmap/cross-cutting.md b/docs/guide/src/roadmap/cross-cutting.md index 2cae727..bbe62e0 100644 --- a/docs/guide/src/roadmap/cross-cutting.md +++ b/docs/guide/src/roadmap/cross-cutting.md @@ -6,5 +6,4 @@ Elozer is our in-house static analyzer. It produces a typed AST for smart-contra ## Related -- [Solidity: future work](./solidity.md) - [Solana: future work](./solana.md) diff --git a/docs/guide/src/roadmap/solana.md b/docs/guide/src/roadmap/solana.md index c3bf976..a7b7163 100644 --- a/docs/guide/src/roadmap/solana.md +++ b/docs/guide/src/roadmap/solana.md @@ -8,7 +8,7 @@ Plug Elozer, our in-house static analyzer, into ilold to produce a typed AST for ## CFG on top of the AST -Build the control-flow graph layer on the Elozer AST. Brings Solana to parity with the Solidity CFG view and unlocks `slice`, `trace`, and structural narratives. +Build the control-flow graph layer on the Elozer AST. Unlocks `slice`, `trace`, and structural narratives over Anchor handlers. ## Detector engine @@ -18,9 +18,9 @@ Detectors for known Sealevel attack patterns (missing signer checks, missing own Record concrete values at each VM instruction boundary so the dynamic trace can confirm or refute hypotheses produced by the static layer. -## CFG visual parity on the canvas +## CFG visual on the canvas -The web canvas renders Solana state today as a flat bipartite graph (instructions ↔ accounts). The redesigned view will mirror the Solidity CFG: per-instruction control flow, branch nodes, constraint annotations. +The web canvas renders Solana state today as a flat bipartite graph (instructions ↔ accounts). The redesigned view adds a per-instruction control-flow layer with branch nodes and constraint annotations once the CFG layer above is in place. ## CPI graph in the UI @@ -28,7 +28,7 @@ The runtime already records CPI edges (`coverage` surfaces them in text). A dedi ## Sequence narrative -`sequence` is aliased to `session` on Solana today. A true narrative engine reuses the existing `coupling` aggregate plus a renderer mirroring the Solidity output. +`sequence` is aliased to `session` today. A true narrative engine on top of the CFG + `coupling` aggregate is tracked under Phase 2. ## Open to ideas diff --git a/docs/guide/src/roadmap/solidity.md b/docs/guide/src/roadmap/solidity.md deleted file mode 100644 index ba80299..0000000 --- a/docs/guide/src/roadmap/solidity.md +++ /dev/null @@ -1,29 +0,0 @@ -# Solidity Roadmap - -Solidity covers the MVP scope. The items below are tracked enhancements; the section after them is open to ideas. - -## Slicer precision - -The slicer is intraprocedural and assignment-only. Mutations via `x++`, `delete x`, `arr.push(v)` show up as USEs but not DEFs; tuple destructuring may not surface; forward slices include lexical ancestors and can over-taint. - -## Cross-function dataflow - -The slicer stops at call boundaries. Following a value through a helper requires `tr <func>` (manual inlining) or a separate run on the helper. - -## Modifier placeholder split - -Modifier bodies are split at the first top-level `_;`. Nested placeholders fall back to "before" code. - -## Sequence depth bound - -`--max-seq-depth` defaults to 3. Deeper bounds grow combinatorially; no change planned. - -## Open to ideas - -The roadmap is not closed. Examples of integrations we have considered but not started: - -- **Foundry**: today ilold reads Foundry projects (the `multi/` and `recursive/` fixtures are Foundry layouts) but does not invoke `forge build` or `forge test`. Possible directions include using `forge build` artefacts as an alternative ingest path, replaying PoCs from findings via `forge test --debug`, or cross-linking traces. -- **Cross-tool reports**: emitting findings in a format consumable by other audit pipelines. -- **New analysis passes**: anything that fits the CFG + path-tree model. - -If you have a use case the current backend does not cover, open an issue or reach out. diff --git a/docs/guide/src/solana/limitations.md b/docs/guide/src/solana/limitations.md index 7d28382..46583b2 100644 --- a/docs/guide/src/solana/limitations.md +++ b/docs/guide/src/solana/limitations.md @@ -38,10 +38,9 @@ Cross-program CPI calls are exercised correctly by the VM and surface in logs, b ## Flat bipartite CFG visual -The web canvas renders Solana state as a flat bipartite graph (instructions ↔ accounts). The Solidity-style CFG visual is not implemented; See [Solana roadmap](../roadmap/solana.md) for CFG visual parity. +The web canvas renders Solana state as a flat bipartite graph (instructions ↔ accounts). A per-instruction control-flow view is not implemented; see [Solana roadmap](../roadmap/solana.md) for the planned CFG layer. ## Related pages - [Roadmap: Solana Phase 2](../roadmap/solana.md) -- [Solidity limitations](../solidity/limitations.md) - [Reference: HTTP API](../reference/api-endpoints.md) diff --git a/docs/guide/src/solana/overview.md b/docs/guide/src/solana/overview.md index 00ff96a..d4fa812 100644 --- a/docs/guide/src/solana/overview.md +++ b/docs/guide/src/solana/overview.md @@ -18,25 +18,25 @@ Two CLI entry points cover Solana: `ilold explore <project>` (REPL + API) and `i The committed fixtures live under `tests/fixtures/solana/staking` (single program) and `tests/fixtures/solana/cpi` (two programs that talk to each other through CPI). Both ship pre-built `bin/<program>.so` binaries so the suite runs without the Anchor toolchain. -## Solidity vs Solana mental model - -| Concept | Solidity | Solana | -| --- | --- | --- | -| Entry point | function on a contract | instruction on a program | -| Persistent state | contract state variables | accounts owned by the program | -| Caller identity | `msg.sender` (implicit) | signers passed by the client | -| `who <X>` | reads/writes of a state variable (CFG-based) | instructions that touch an account type (IDL heuristic) | -| `timeline <X>` | mutation history of a state variable | mutation history of an account pubkey, decoded | -| `step <i>` | re-renders the persisted flow tree | re-prints CU, logs, account diffs | -| `slice` / `trace` | full CFG-based analysis | not implemented (Phase 2) | -| `sequence` | narrative with cross-step dependencies | aliased to `session` (no narrative engine yet) | -| Execution | symbolic (CFG + paths) | concrete (in-process LiteSVM execution) | -| `back` | drops the step from the timeline | drops the step AND rewinds the VM to the pre-call snapshot | -| `save` / `load` | step list + persisted paths | step list + replay-driven VM reconstruction | +## Mental model + +| Concept | How ilold handles it | +| --- | --- | +| Entry point | instruction on a program | +| Persistent state | accounts owned by the program | +| Caller identity | signers passed by the client | +| `who <X>` | instructions that touch an account type (IDL heuristic for fields) | +| `timeline <X>` | mutation history of an account pubkey, decoded | +| `step <i>` | re-prints CU, logs, and account diffs of the step | +| `back` | drops the step AND rewinds the VM to the pre-call snapshot | +| `save` / `load` | step list + replay-driven VM reconstruction | +| Execution | concrete (in-process LiteSVM execution) | + +Structural commands (`slice`, `trace`, `sequence` narrative) are not implemented and are tracked in Phase 2. ## REPL command groups -The REPL command surface mirrors the Solidity one with backend-specific extensions. Each group has its own page: +Each group has its own page: - [Session](./repl/session.md): `c/call`, `b/back`, `cl/clear`, `s/session`, `state`, `st/step`. - [Programs and IDL](./repl/programs.md): `ct/programs`, `use`, `f/funcs`, `fa/funcs-all`, `i/info`, `v/vars`, `va/vars-all`. @@ -49,5 +49,5 @@ The REPL command surface mirrors the Solidity one with backend-specific extensio ## Workflows -- [Audit walkthrough](./workflows/audit-walkthrough.md): staking program end-to-end, paralleling the Solidity walkthrough. +- [Audit walkthrough](./workflows/audit-walkthrough.md): staking program end-to-end. - [Scenarios and forks](./workflows/scenarios.md): branching VMs, rewinding the clock, persisting bundles. diff --git a/docs/guide/src/solana/repl/analysis.md b/docs/guide/src/solana/repl/analysis.md index 6b1b72d..2ea4f36 100644 --- a/docs/guide/src/solana/repl/analysis.md +++ b/docs/guide/src/solana/repl/analysis.md @@ -127,5 +127,5 @@ Coverage is the closest current surrogate for "have I exercised every instructio ## Notes -- See [Solana: Limitations](../limitations.md) for the static-analysis gap (no CFG → no `slice` / `trace` yet). -- The Solidity counterparts of `who` and `timeline` work on state variables; the Solana versions work on account types and pubkeys respectively. The mental shift is the same ("what touches this piece of state?") but the units of state are different. +- See [Limitations](../limitations.md) for the static-analysis gap (no CFG → no `slice` / `trace` yet). +- `who` works against account types (and fields with a snake_case → PascalCase heuristic). `timeline` works against decoded account pubkeys. diff --git a/docs/guide/src/solana/repl/findings.md b/docs/guide/src/solana/repl/findings.md index 2187d29..0d1d2df 100644 --- a/docs/guide/src/solana/repl/findings.md +++ b/docs/guide/src/solana/repl/findings.md @@ -69,7 +69,7 @@ ilold[staking]> status claim_rewards finding ✓ status updated ``` -Note: Solana statuses are intentionally narrower than the Solidity equivalent: only `open`, `reviewed`, `finding` (alias `found`). Solidity supports `reviewed`, `suspicious`, `vulnerable`, `clean`, `inprogress`, `notreviewed`. +Note: the supported statuses are `open`, `reviewed`, `finding` (alias `found`). **Returns:** `StatusUpdated`. @@ -103,4 +103,3 @@ ilold[staking]> export --auditor="Alba S." --version=v1.2 --date=2026-05-09 ## Notes - Findings are scoped to the scenario they were recorded in but the export merges all of them. -- The Solidity equivalent (see [Solidity: Findings](../../solidity/repl/findings.md)) does not support the `--rec=`, `--auditor=`, `--version=`, `--date=` flags; the report there is simpler. diff --git a/docs/guide/src/solana/repl/help.md b/docs/guide/src/solana/repl/help.md index cf2adaa..105c47d 100644 --- a/docs/guide/src/solana/repl/help.md +++ b/docs/guide/src/solana/repl/help.md @@ -75,4 +75,4 @@ Exits the REPL. `Ctrl+D` and `Ctrl+C` also work. ## Notes - The full list of registered command aliases is enforced by the test `every_solana_command_has_a_help_block` in `crates/ilold-cli/src/help.rs`. New commands without a corresponding HelpBlock break the build. -- The Solidity REPL uses a flat one-line inline-help table (see `print_inline_help`); Solana uses the structured HelpBlock format above. Both respond to the `<cmd>?` trailing syntax. +- Append `?` to any command for its structured HelpBlock (syntax, flags, examples, return shape, related commands). diff --git a/docs/guide/src/solana/repl/programs.md b/docs/guide/src/solana/repl/programs.md index 57df9a2..123fe5d 100644 --- a/docs/guide/src/solana/repl/programs.md +++ b/docs/guide/src/solana/repl/programs.md @@ -116,5 +116,4 @@ Aliased to `vars` at the dispatcher level: same command, same output. ## Notes -- The Solidity counterpart of these commands is documented under [Contract](../../solidity/repl/contract.md). The shapes line up so an auditor moving between backends sees the same structure. - `use` clears the displayed step list for the previous program. The underlying scenario state for that program is preserved and reappears when switching back. diff --git a/docs/guide/src/solana/repl/runtime.md b/docs/guide/src/solana/repl/runtime.md index 42f781e..7e62413 100644 --- a/docs/guide/src/solana/repl/runtime.md +++ b/docs/guide/src/solana/repl/runtime.md @@ -1,6 +1,6 @@ # Solana Runtime Commands -These commands operate directly on the LiteSVM owned by the active scenario. They have no Solidity counterpart. +These commands operate directly on the LiteSVM owned by the active scenario. ## users diff --git a/docs/guide/src/solana/repl/scenarios.md b/docs/guide/src/solana/repl/scenarios.md index a495cbd..be66579 100644 --- a/docs/guide/src/solana/repl/scenarios.md +++ b/docs/guide/src/solana/repl/scenarios.md @@ -76,5 +76,4 @@ ilold[staking]> sc delete attack - `back` and `clear` rewind the VM of the **active scenario only**, never the fork's parent. Diverging via `fork` is the only way to keep both timelines side by side. - `time-warp` is a per-scenario side effect on the `Clock` sysvar, but is not reverted by `back`. -- See [Solana: Scenarios and forks](../workflows/scenarios.md) for an end-to-end workflow. -- The Solidity counterpart is documented at [Solidity: Scenarios](../../solidity/repl/scenarios.md); the command surface is identical, but Solidity scenarios share the parsed model (no VM clone needed). +- See [Scenarios and forks](../workflows/scenarios.md) for an end-to-end workflow. diff --git a/docs/guide/src/solana/repl/session.md b/docs/guide/src/solana/repl/session.md index ff2c3a6..db851a1 100644 --- a/docs/guide/src/solana/repl/session.md +++ b/docs/guide/src/solana/repl/session.md @@ -130,5 +130,5 @@ ilold[staking → initialize_pool → stake]> step 1 ## Notes -- The Solidity equivalent (`seq` / `sequence`) is currently aliased to `session` on Solana; there is no cross-step narrative engine yet. See [Roadmap](../../roadmap/solana.md). +- `seq` / `sequence` is currently aliased to `session`; a cross-step narrative engine is tracked in the [Roadmap](../../roadmap/solana.md). - `call` is the only command that drives the VM forward; everything else inspects or rewinds. diff --git a/docs/guide/src/solana/workflows/audit-walkthrough.md b/docs/guide/src/solana/workflows/audit-walkthrough.md index 2dd6411..1948104 100644 --- a/docs/guide/src/solana/workflows/audit-walkthrough.md +++ b/docs/guide/src/solana/workflows/audit-walkthrough.md @@ -1,6 +1,6 @@ # Solana Audit Walkthrough -This walkthrough mirrors the [Solidity audit walkthrough](../../solidity/workflows/audit-walkthrough.md) against the canonical Solana staking fixture under `tests/fixtures/solana/staking`. The program exposes five instructions — `initialize_pool`, `stake`, `unstake`, `add_rewards`, `claim_rewards` — and ships with a pre-built `bin/staking.so` so the suite runs without the Anchor toolchain. +This walkthrough exercises the canonical Solana staking fixture under `tests/fixtures/solana/staking`. The program exposes five instructions — `initialize_pool`, `stake`, `unstake`, `add_rewards`, `claim_rewards` — and ships with a pre-built `bin/staking.so` so the suite runs without the Anchor toolchain. ## Starting the session @@ -165,18 +165,16 @@ ilold[staking]> load my-audit `--with-keypairs` is mandatory when the audit relies on deterministic PDAs (which depend on signer pubkeys): without it, `load` regenerates fresh keypairs and PDAs come back at different addresses. -## Parallel to the Solidity walkthrough +## Flow recap -The flow is structurally identical to the Solidity one: - -1. `f` / `v` to map the surface (instructions and account types instead of functions and state variables). -2. `users new` + `call` to push the VM forward (vs. `c <func>` on a parsed CFG). +1. `f` / `v` to map the surface (instructions and account types). +2. `users new` + `call` to push the VM forward. 3. `state`, `step`, `timeline` to inspect what changed. -4. `who` to navigate cross-instruction relationships (vs. cross-function in Solidity). +4. `who` to navigate cross-instruction relationships. 5. `finding`, `note`, `status` to record observations. 6. `export`, `save`, `load` to ship the deliverable and resume later. -What is **not** available yet on Solana: `slice` and `trace`. Both require the Anchor handler AST and are tracked in [Roadmap: Solana Phase 2](../../roadmap/solana.md). +What is **not** available yet: `slice` and `trace`. Both require the Anchor handler AST and are tracked in [Roadmap: Solana Phase 2](../../roadmap/solana.md). ## Related pages diff --git a/docs/guide/src/solidity/cli-analyze.md b/docs/guide/src/solidity/cli-analyze.md deleted file mode 100644 index 7d57742..0000000 --- a/docs/guide/src/solidity/cli-analyze.md +++ /dev/null @@ -1,74 +0,0 @@ -# CLI: `analyze` - -`ilold analyze` parses a Solidity project, runs the full static-analysis pipeline (parser → CFG → path tree → sequence analysis with cross-contract transitive effects), and prints a structured summary to stdout. It is the one-shot view of what `explore` keeps in memory. - -## Synopsis - -``` -ilold analyze <path> [--contract <name>] [--max-seq-depth <N>] [--verbose] -``` - -| Flag | Default | Description | -| --- | --- | --- | -| `--contract <name>` | (all contracts) | Restrict output to a single contract | -| `--max-seq-depth <N>` | `3` | Depth bound for the sequence tree (call combinations up to N steps) | -| `--verbose` | off | Per-block CFG layout, call-graph edges, full function-behavior breakdown | - -`<path>` may be a single `.sol` file or a directory; the walker skips `out`, `cache`, `node_modules`, `lib`, `target`, and dot-prefixed directories. - -## Example: default output - -``` -$ ilold analyze tests/fixtures/staking.sol -Parsed 1 file(s), 2 contract(s) - -interface IERC20 (3 functions, 0 state vars) - [P] external transfer — 1 blocks, 0 edges, 0 paths (0 happy, 0 revert) - [P] external transferFrom — 1 blocks, 0 edges, 0 paths (0 happy, 0 revert) - [P] external balanceOf — 1 blocks, 0 edges, 0 paths (0 happy, 0 revert) - -contract Staking (9 functions, 11 state vars) - [S] internal constructor — 2 blocks, 1 edges, 1 paths (1 happy, 0 revert) - [P] external deposit — 8 blocks, 8 edges, 5 paths (2 happy, 3 revert) - [P] external withdraw — 8 blocks, 8 edges, 6 paths (2 happy, 4 revert) - [P] external claimRewards — 6 blocks, 7 edges, 4 paths (4 happy, 0 revert) - [R] external setRewardRate — 4 blocks, 3 edges, 2 paths (1 happy, 1 revert) - [R] external pause — 4 blocks, 3 edges, 2 paths (1 happy, 1 revert) - [R] external unpause — 4 blocks, 3 edges, 2 paths (1 happy, 1 revert) - [P] public rewardPerToken — 5 blocks, 4 edges, 2 paths (2 happy, 0 revert) - [P] public earned — 2 blocks, 1 edges, 1 paths (1 happy, 0 revert) - Sequences (depth 3): 584 total (8 functions: 6 state-changing, 2 read-only) -``` - -Each function line prints an access badge (`[P]` public/external, `[R]` restricted/admin-gated, `[S]` system/internal), visibility, block/edge counts from the CFG, and the path-tree breakdown (`total`, `happy`, `revert`). - -## Example: `--max-seq-depth` - -The sequence tree enumerates ordered combinations of entry-point calls up to depth N. Raising the bound surfaces longer interaction patterns that the static analyzer reasons about: - -``` -$ ilold analyze tests/fixtures/staking.sol --max-seq-depth 5 -... -Sequences (depth 5): 37448 total (8 functions: 6 state-changing, 2 read-only) -``` - -The sequence tree is consumed by the cross-step transitive-effect pass and feeds `seq` in the REPL. - -## Example: `--verbose` - -`--verbose` adds: - -- One line per CFG block (`[id] BlockKind (N stmts)`). -- One line per CFG edge (`src → dst EdgeKind`). -- The intra-contract call graph (`fn → contract.fn` with `internal | external | inherited`). -- A per-function behavior tree with `requires`, `writes`, `calls`, `emits`, and transitions to other functions, including the shared state variables that link them. - -The output is meant to be read top-down by a human; `context` (next page) is the machine-readable counterpart. - -## Notes - -- `analyze` does not require a configured project; it works on raw `.sol` files. -- Interfaces are listed in the contract header but have no sequence tree. -- Errors building the CFG for a single function are reported inline and do not abort the run. - -See [Solidity: Limitations](./limitations.md) for the analysis boundaries (intraprocedural slicing, modifier placeholder split, etc.). diff --git a/docs/guide/src/solidity/cli-context.md b/docs/guide/src/solidity/cli-context.md deleted file mode 100644 index ef73f14..0000000 --- a/docs/guide/src/solidity/cli-context.md +++ /dev/null @@ -1,67 +0,0 @@ -# CLI: `context` - -`ilold context` produces a structured narrative for a single function or for a comma-separated sequence of functions. It runs the same pipeline as `analyze` (parser → CFG → path tree → sequence analysis with cross-contract transitive effects) but emits a focused narrative instead of the project-wide pretty-print. - -## Synopsis - -``` -ilold context <path> [--contract <name>] [--function <name>] - [--sequence <f1,f2,...>] [--list] -``` - -| Flag | Description | -| --- | --- | -| `--contract <name>` | Pick the active contract. Required when the project has more than one. | -| `--function <name>` | Build a function narrative: paths, state reads/writes, internal/external calls, transitive effects, observations. | -| `--sequence <f1,f2,...>` | Build a sequence narrative across the listed functions. | -| `--list` | List functions of the resolved contract with access level and tags, then exit. | - -The function/sequence narratives are the same data structures the REPL renders for `i <func>` and `seq` respectively (see `crates/ilold-core/src/narrative/function.rs` and `narrative/sequence.rs`). - -## Example: list mode - -``` -$ ilold context tests/fixtures/staking.sol --list - Staking — 9 functions - - [P] deposit external - [P] withdraw external - [P] claimRewards external - [R] setRewardRate external - [R] pause external - [R] unpause external - [P] rewardPerToken public - [P] earned public - - Usage: - ilold context <path> --function <name> - ilold context <path> --sequence "fn1,fn2" - - Example: - ilold context <path> --function deposit - ilold context <path> --sequence "deposit,withdraw" -``` - -The badges line up with `analyze`: `[P]` public/external entry point, `[R]` restricted/admin-gated, `[S]` system/internal. - -## Example: single function - -``` -$ ilold context tests/fixtures/staking.sol --function withdraw -``` - -Output is the same `FunctionNarrative` printed by the REPL's `i withdraw`, including transitive effects through the call chain. - -## Example: a sequence - -``` -$ ilold context tests/fixtures/staking.sol --sequence deposit,withdraw,claimRewards -``` - -The output narrates the per-step writes and the cross-step dependencies (variables shared between consecutive steps). - -## Notes - -- `context` is read-only; it does not start the API server. -- `--list` short-circuits before computing path trees, so it is cheap on large projects. -- Use [`explore`](./repl/session.md) when you need to iterate; `context` is meant for scripts and one-off questions. diff --git a/docs/guide/src/solidity/limitations.md b/docs/guide/src/solidity/limitations.md deleted file mode 100644 index fb83df8..0000000 --- a/docs/guide/src/solidity/limitations.md +++ /dev/null @@ -1,40 +0,0 @@ -# Known Limitations - -This page documents the current analysis boundaries of ilold. Understanding these limitations is necessary for interpreting slice, timeline, and trace results correctly. - -## Intraprocedural slicing only - -The dataflow slicer operates within a single function body (plus its inlined modifiers). It does not follow values across function call boundaries. If `deposit` calls an internal helper `_updateBalance(amount)`, the slice for `amount` in `deposit` will show the call site but not the writes inside `_updateBalance`. Use `tr <func>` to inspect internal call bodies separately. - -## Assignment-only DEF extraction - -Only `Assignment` expressions (`x = ...`, `x += ...`, `x -= ...`) produce DEF entries in the slicer's use-def analysis. Solidity mutations that are not modeled as assignments -- specifically `x++`, `x--`, `++x`, `--x`, `delete x`, `arr.push(v)`, and `arr.pop()` -- are captured as USEs of the target variable but not as DEFs. A backward slice on a variable mutated exclusively through `.push()` will miss the mutating statement as a definition point. - -## Modifier placeholder split - -Modifier bodies are split at the first top-level `_;` (placeholder) statement to separate "before" code from "after" code. If the placeholder appears inside a nested block (e.g., inside an `if` branch), the entire modifier body is treated as "before" code. This is over-inclusive: statements that should execute after the function body will appear before it in the flattened view. In practice, most modifiers place `_;` at the top level, so this rarely triggers. - -## Forward slice over-tainting via ancestor merge - -When a statement is included in a forward slice, its lexical ancestors (enclosing `if`, `for`, `while` blocks) are also included so that the rendered slice shows control-flow context. The ancestor's condition variables are merged into the tainted set. This means an `if (unrelatedCondition)` enclosing a tainted write will add `unrelatedCondition` to the taint set, potentially pulling in unrelated statements in subsequent iterations. The result is a conservative (larger) slice rather than a precise one. - -## Tuple destructuring - -Tuple destructuring assignments such as `(a, b) = foo()` may not be recognized as DEFs depending on how the Solidity frontend lowers them. If the frontend does not emit a top-level `Assignment` node, the individual targets (`a`, `b`) are treated as USEs only. This can cause a backward slice to miss the destructuring as a definition point for `a` or `b`. - -## Timeline tracks state mutations only - -The `timeline` command tracks writes to state variables across session steps. Local variable assignments within a function body are recorded separately (`local_entries`) but are not visible in the default timeline output. If you need to trace a local variable, use `slice` within the specific function instead. - -## Session requires at least one call - -The `timeline`, `state`, and `sequence` endpoints require an active session with at least one `Call` step. The `timeline` and `state` commands return empty results if no steps have been added. The `sequence` command requires at least two steps. Use `tr <func>` for read-only inspection of a function's flow without adding it to the session. - -## Internal and private functions cannot be session entry points - -Session steps model real external transactions. Functions with `internal` or `private` visibility cannot be called from outside the contract, so they cannot be added as session steps via `c <func>`. Use `tr <func>` to inspect their execution flow, or call a public/external function that invokes them to see their effects through the modifier and internal-call inlining in the trace. - -## Related pages - -- [Taint Analysis](./workflows/taint-analysis.md) -- forward slice caveats in practice -- [HTTP API Reference](../reference/api-endpoints.md) diff --git a/docs/guide/src/solidity/overview.md b/docs/guide/src/solidity/overview.md deleted file mode 100644 index cfd4df9..0000000 --- a/docs/guide/src/solidity/overview.md +++ /dev/null @@ -1,39 +0,0 @@ -# Solidity Backend Overview - -The Solidity backend is built on top of `solar-compiler` (parser) and a set of analysis passes in `ilold-core`. It supports both a one-shot CLI (`analyze`, `context`) and the interactive REPL (`explore`, `serve`). - -## What it parses - -`crates/ilold-cli/src/main.rs::collect_sol_files` walks the input path: - -- A single `.sol` file is loaded as-is. -- A directory is walked recursively; the directories `out`, `cache`, `node_modules`, `lib`, `target`, `.git`, `.svelte-kit` and any dot-prefixed directory are skipped. - -Once the project is parsed, ilold builds a per-function CFG via `CfgBuilder::build_with_project` and a path tree via `build_path_tree` (see `crates/ilold-core/src/cfg/builder.rs` and `pathtree/walker.rs`). Inheritance is resolved transitively, so inherited functions and state variables show up in `funcs-all` / `vars-all` and in `info` output. - -## What you can do with it - -| Surface | Purpose | -| --- | --- | -| [`analyze`](./cli-analyze.md) | One-shot pretty-print of every contract: functions, CFG and path-tree stats, sequences up to `--max-seq-depth`, optional verbose function behavior breakdown | -| [`context`](./cli-context.md) | Generate machine-readable narratives for a function or a comma-separated sequence | -| [`serve`](./repl/workspace.md) | Start the HTTP/WS server only, no REPL: feed the web canvas | -| [`explore`](./repl/session.md) | Interactive REPL, with the HTTP/WS API running alongside | - -## REPL command groups - -The REPL has six command groups, all documented in their own pages: - -- [Session](./repl/session.md): `c/call`, `b/back`, `cl/clear`, `s/state`, `seq/sequence`, `st/step`, `ss/session`. -- [Analysis](./repl/analysis.md): `w/who`, `i/info`, `tr/trace`, `tl/timeline`, `sl/slice`. -- [Contract](./repl/contract.md): `f/functions`, `fa/funcs-all`, `v/vars`, `va/vars-all`, `ct/contracts`, `use`. -- [Findings](./repl/findings.md): `fi/finding`, `n/note`, `status`, `fl/findings`, `ex/export`. -- [Scenarios](./repl/scenarios.md): `sc/scenario` (`new`, `list`, `switch`, `fork`, `delete`). -- [Workspace](./repl/workspace.md): `save`, `load`, `browser`, `q/quit/exit`, `?/help`. - -## Workflows - -Two end-to-end walkthroughs are included: - -- [Audit walkthrough](./workflows/audit-walkthrough.md): full session against a Staking contract. -- [Taint analysis](./workflows/taint-analysis.md): forward slicing of user-controlled parameters. diff --git a/docs/guide/src/solidity/repl/analysis.md b/docs/guide/src/solidity/repl/analysis.md deleted file mode 100644 index 2b03e40..0000000 --- a/docs/guide/src/solidity/repl/analysis.md +++ /dev/null @@ -1,183 +0,0 @@ -# Analysis Commands - -Analysis commands query the contract model without modifying the session. Each command produces cross-reference hints at the bottom of its output, suggesting related commands to run next. - -## who - -`w <variable>` or `who <variable>` - -Shows which functions read and write a state variable, with their access level. Searches across the active contract and its ancestors. - -``` -ilold[Staking]> who totalStaked - - who: totalStaked - Writers: - [P] deposit - [P] withdraw - Readers: - [P] rewardPerToken - → sl deposit totalStaked, sl withdraw totalStaked - → tl totalStaked -``` - -The cross-reference hints suggest running [slice](#slice) for each writer and [timeline](#timeline) for the variable. Access badges: `[P]` public/external, `[R]` restricted (admin-gated), `[I]` internal, `[S]` special. - -**Returns:** `VariableInfo { variable, writers, readers }` where each writer/reader is a `(String, AccessLevel)` pair. - -## info - -`i <function>` or `info <function>` - -Displays a full function narrative: execution paths, state reads/writes, internal and external calls, transitive effects through the call chain, and observations. Works on any function including internal ones. - -``` -ilold[Staking]> i withdraw - - withdraw [public] — whenNotPaused, nonReentrant - ├── Paths: 3 total, 1 happy, 2 revert - ├── State reads: balances, totalStaked - ├── State writes: balances, totalStaked - ├── External calls: msg.sender.call{value: amount} - ├── Transitive effects: - │ via _updateRewards: - │ writes: rewardDebt - │ reads: rewardPerToken - └── Observations: - · External call after state writes (checks-effects-interactions followed) - → c withdraw, tr withdraw -``` - -The `info` command does not require the function to be in the session. It analyzes the function in isolation. - -## trace - -`tr <function> [--depth N] [--reverts] [+N...] [-i]` -`tr step <N>` - -Renders the execution flow tree for a function. Modifier bodies are inlined into the tree with `[from: modifier]` annotations. Internal calls are expanded up to the depth limit (default 2). - -``` -ilold[Staking]> tr withdraw - - ╭──────────────────────────────────────╮ - │ Staking::withdraw(uint256) │ - │ modifiers: whenNotPaused, nonReentrant│ - │ max inlining depth: 2 │ - ╰──────────────────────────────────────╯ - - 001 │ ▶ withdraw(uint256) - 002 │ ├─ ◇ require(!paused, "Paused") [from: whenNotPaused] - 003 │ ├─ ◇ require(!locked) [from: nonReentrant] - 004 │ ├─ ✏ locked = true [from: nonReentrant] - 005 │ ├─ ◇ require(amount <= balances[msg.sender]) - 006 │ ├─ ○ _updateRewards(msg.sender) [+8 ops, depth limited] - 007 │ ├─ ✏ balances[msg.sender] -= amount - 008 │ ├─ ✏ totalStaked -= amount - 009 │ ├─ → msg.sender.call{value: amount} - 010 │ ├─ ◆ emit Withdrawn(msg.sender, amount) - 011 │ └─ ✏ locked = false [from: nonReentrant] - - tip: expand with `tr <func> +N` — candidates: 6 - → sl withdraw balances, sl withdraw totalStaked -``` - -### Icon legend - -| Icon | Meaning | -|------|---------| -| `▶` | Function entry | -| `◇` | require/assert | -| `✏` | State write | -| `▸` | State read | -| `○` | Internal call | -| `→` | External call | -| `◆` | Event emission | -| `?` | Branch (if/else) | -| `↻` | Loop header | -| `✓` | Return | -| `✗` | Revert | - -### Options - -- `--depth N` -- Set max inlining depth for internal calls. Default is 2. -- `--reverts` -- Include revert paths in the tree. -- `+N` -- Force-expand a depth-limited internal call at step N. Multiple `+N` flags allowed. -- `-i` -- Open the trace in an interactive TUI with keyboard navigation. Increases default depth to 4. -- `step N` -- Re-render the persisted flow tree from session step N (depth/expand flags are ignored). - -``` -ilold[Staking]> tr withdraw +6 - - (same tree with _updateRewards fully expanded at step 6) -``` - -``` -ilold[Staking]> tr step 0 - - (renders the persisted trace from session step 0) -``` - -## timeline - -`tl <variable>` or `timeline <variable>` - -Shows the cross-step mutation history of a variable across the current session. Each mutation includes the operator, value expression, and path conditions (reached-when). - -``` -ilold[→ deposit → withdraw]> tl totalStaked - - totalStaked — mutation timeline - ════════════════════════════════════════════════════════════ - [state] - session step 0 deposit - ✏ totalStaked += msg.value [trace step 5] - session step 1 withdraw - ✏ totalStaked -= amount [trace step 8] - reached when: - · amount <= balances[msg.sender] - → sl deposit totalStaked, sl withdraw totalStaked -``` - -If the variable has no mutations in the current session, timeline tells you to add steps with `c <func>` first. - -## slice - -`sl <function> <variable> [--backward|--forward|--both]` - -Performs dataflow analysis on a variable within a function. Walks the function body and modifier bodies to find definitions (backward) and uses (forward) of the variable. Modifier entries are prefixed with `[mod name]`. - -The direction flags can appear in any position: `sl --backward deposit totalStaked` and `sl deposit totalStaked --backward` are equivalent. The default direction is `--both`. - -``` -ilold[Staking]> sl withdraw balances --backward - - withdraw · balances — dataflow slice - ════════════════════════════════════════════════════════════ - [backward] - L31 require(amount <= balances[msg.sender]) - L34 balances[msg.sender] -= amount - → tr withdraw | tl balances -``` - -``` -ilold[Staking]> sl withdraw totalStaked --both - - withdraw · totalStaked — dataflow slice - ════════════════════════════════════════════════════════════ - [backward] - L35 totalStaked -= amount - [forward] - L38 emit Withdrawn(msg.sender, amount) - → tr withdraw | tl totalStaked -``` - -Short flags are also accepted: `-b` for `--backward`, `-f` for `--forward`. - -When a variable is defined inside a modifier body, the entry shows its origin: - -``` - [backward] - L12 [mod whenNotPaused] require(!paused, "Paused") - L35 totalStaked -= amount -``` diff --git a/docs/guide/src/solidity/repl/contract.md b/docs/guide/src/solidity/repl/contract.md deleted file mode 100644 index fb1b3d8..0000000 --- a/docs/guide/src/solidity/repl/contract.md +++ /dev/null @@ -1,122 +0,0 @@ -# Contract Commands - -Contract commands inspect the structure of the loaded contracts without modifying the session. - -## functions - -`f` or `functions` - -Lists the callable functions in the active contract with their access level and tags. - -``` -ilold[Staking]> f - - [P] deposit writes state, external calls - [P] withdraw writes state, external calls - [P] claimRewards writes state, external calls - [R] setRewardRate writes state - [R] pause writes state - [R] unpause writes state - [P] rewardPerToken view - [P] earned view -``` - -Badges: `[P]` public/external, `[R]` restricted (admin-gated), `[I]` internal, `[S]` special. Tags indicate `writes state`, `external calls`, or `view` (read-only, no external calls). - -**Returns:** `FunctionList { functions: [FunctionEntry { name, access, writes_state, has_external_calls, is_read_only }] }`. - -## funcs-all - -`fa` or `funcs-all` - -Lists all accessible functions including those inherited from parent contracts. - -``` -ilold[Staking]> fa - - [P] deposit writes state, external calls - [P] withdraw writes state, external calls - [P] claimRewards writes state, external calls - [R] setRewardRate writes state - [R] pause writes state - [R] unpause writes state - [P] rewardPerToken view - [P] earned view - - inherited: - [P] owner from Ownable - [P] transferOwnership from Ownable -``` - -Inherited functions are listed separately with their origin contract. - -**Returns:** `FunctionListAll { functions: [AccessibleFunctionEntry { name, access, writes_state, has_external_calls, is_read_only, origin, is_inherited }] }`. - -## vars - -`v` or `vars` - -Lists the state variables of the active contract with their type and mutability tag. - -``` -ilold[Staking]> v - - mutable owner address - mutable paused bool - mutable rewardRate uint256 - mutable lastUpdateTime uint256 - mutable rewardPerTokenStored uint256 - mutable balances mapping(address => uint256) - mutable userRewardPerTokenPaid mapping(address => uint256) - mutable rewards mapping(address => uint256) - mutable totalStaked uint256 -``` - -Tags are `mutable`, `const`, or `immutable`. - -## vars-all - -`va` or `vars-all` - -Lists all accessible state variables including inherited ones. - -``` -ilold[Staking]> va - - mutable owner address - mutable paused bool - mutable rewardRate uint256 - ... -``` - -**Returns:** `StateVarListAll { state_vars: [AccessibleStateVarEntry { name, type_name, is_constant, is_immutable, origin, is_inherited }] }`. Inherited entries print under an `inherited:` section with `from <origin>`. - -## contracts - -`ct` or `contracts` - -Lists all contracts in the loaded project with their type badge, function count, state variable count, and `inherits` clause when present. - -``` -ilold[Staking]> ct - - [I] IERC20 3 functions, 0 state vars - [C] Staking 9 functions, 11 state vars ← current -``` - -Type badges: `[C]` contract, `[I]` interface, `[L]` library, `[A]` abstract. - -## use - -`use <contract>` - -Switches the active contract. Clears the current session steps. - -``` -ilold[Staking]> use Ownable - - ✓ Now using: Ownable - Cleared 2 step(s) from previous contract -``` - -After switching, all session and analysis commands operate on the new contract. diff --git a/docs/guide/src/solidity/repl/findings.md b/docs/guide/src/solidity/repl/findings.md deleted file mode 100644 index 290c7a4..0000000 --- a/docs/guide/src/solidity/repl/findings.md +++ /dev/null @@ -1,86 +0,0 @@ -# Findings Commands - -Findings commands let you record security observations during an audit session. Findings are tied to the current session state and can be exported as a markdown report. - -## finding - -`fi [severity] [title]` or `finding [severity] [title]` - -Records a security finding. Can be used in two modes: - -**Inline mode** -- pass severity and title directly: - -``` -ilold[→ deposit → withdraw]> fi high Reentrancy in withdraw before balance update - - ✓ Finding F-001 added -``` - -**Interactive mode** -- run `fi` with no arguments to be prompted: - -``` -ilold[→ deposit → withdraw]> fi - Severity (critical/high/medium/low/info): - > high - Title: - > Reentrancy in withdraw before balance update - Description (optional): - > The external call on L38 occurs before totalStaked is decremented. - ✓ Finding F-001 added -``` - -Valid severities: `critical`, `high`, `medium`, `low`, `info` (or `informational`). - -The finding captures the current session sequence automatically. - -## note - -`n <text>` or `note <text>` - -Attaches a free-text note to the current session step. Notes are included in the exported report. - -``` -ilold[→ deposit → withdraw]> n Check if msg.value can be zero here - - ✓ Note added -``` - -Scenarios are managed by the dedicated `sc | scenario` command family (`scenario new <name>`, `scenario fork <name> [at <N>]`, `scenario switch <name>`, `scenario list`, `scenario delete <name>`). See [Scenarios](./scenarios.md) for the full reference. - -## status - -`status <function> <status>` - -Sets the review status for a function. Useful for tracking audit progress. - -``` -ilold[Staking]> status deposit reviewed - - ✓ Status updated -``` - -Valid statuses: `reviewed`, `suspicious`, `vulnerable`, `clean`, `inprogress`, `notreviewed`. - -## findings - -`fl` or `findings` - -Lists the count of recorded findings. Use [export](#export) to see full details. - -``` -ilold[Staking]> fl - - 2 finding(s) recorded. Use export to export. -``` - -## export - -`ex` or `export` - -Exports all findings, notes, and status changes as a markdown report. The file is written to the current directory. - -``` -ilold[Staking]> ex - - ✓ Exported to ilold-report-Staking.md -``` diff --git a/docs/guide/src/solidity/repl/scenarios.md b/docs/guide/src/solidity/repl/scenarios.md deleted file mode 100644 index 0536f90..0000000 --- a/docs/guide/src/solidity/repl/scenarios.md +++ /dev/null @@ -1,81 +0,0 @@ -# Scenario Commands - -A scenario is a named branch of the session timeline. Every session starts on the default scenario `main`; the auditor can create more scenarios, switch between them, fork an existing one at a specific step, or delete a scenario that is no longer needed. The prompt shows the active scenario as `ilold[Contract/scenario]` when it is not `main`. - -## scenario new - -`scenario new <name>` (alias: `sc new`) - -Creates an empty scenario with no steps. The new scenario is not activated automatically. Use `scenario switch` to make it the active one. - -``` -ilold[Staking]> sc new reentrancy - ✓ Created scenario 'reentrancy' -``` - -**Returns:** `ScenarioCreated { name }`. - -## scenario list - -`scenario list` (aliases: `scenario ls`, `sc list`) - -Lists every scenario in the active session, marking the active one. - -``` -ilold[Staking]> sc list - scenarios — 2 total, active: main - name steps - → main 2 - reentrancy 0 -``` - -**Returns:** `ScenarioList { items: [ScenarioInfo { name, step_count, active }] }`. The CLI renders it inside a framed header box. - -## scenario switch - -`scenario switch <name>` (alias: `sc switch <name>`) - -Activates an existing scenario. All subsequent session and analysis commands operate against its step list. The prompt updates to reflect the new active scenario. - -``` -ilold[Staking]> sc switch reentrancy - ✓ Switched: 'main' → 'reentrancy' -ilold[Staking/reentrancy]> -``` - -**Returns:** `ScenarioSwitched { from, to }`. Switching to the active scenario is idempotent and prints `· Already on scenario '<name>'`. - -## scenario fork - -`scenario fork <name> [at <N>]` (alias: `sc fork`) - -Creates a new scenario branching from the active one. With `at <N>`, the new scenario inherits steps `0..N` from the source scenario; without it, the new scenario inherits the full step list of the source. After forking, the new scenario is activated. - -``` -ilold[Staking → deposit → withdraw → claimRewards]> sc fork attack-v2 at 1 - ✓ Forked 'main' → 'attack-v2' at step 1 -ilold[Staking/attack-v2 → deposit]> -``` - -**Returns:** `ScenarioForked { from, to, at_step }`. - -Forks are useful when the auditor wants to keep an existing line of reasoning intact while testing a divergent path. - -## scenario delete - -`scenario delete <name>` (aliases: `scenario rm <name>`, `sc delete`, `sc rm`) - -Removes a scenario. The active scenario cannot be deleted; switch first. - -``` -ilold[Staking]> sc delete reentrancy - ✓ Deleted scenario 'reentrancy' -``` - -**Returns:** `ScenarioDeleted { name }`. - -## Notes - -- Solidity scenarios share the same parsed model, so analysis commands stay cheap across forks. -- For the Solana counterpart (each scenario carries its own VM, signers and PDAs), see [Solana: Scenarios](../../solana/repl/scenarios.md). -- The full scenario tree is included in `save` / `load` and in the `export` report. diff --git a/docs/guide/src/solidity/repl/session.md b/docs/guide/src/solidity/repl/session.md deleted file mode 100644 index 9f0bc7b..0000000 --- a/docs/guide/src/solidity/repl/session.md +++ /dev/null @@ -1,139 +0,0 @@ -# Session Commands - -Session commands manage the call sequence -- the ordered list of function calls that represents an execution scenario you want to analyze. - -## call - -`c <function>` or `call <function>` - -Adds a function call to the session sequence. Only external and public functions are accepted; internal and private functions are rejected since they cannot be entry points for a real transaction. - -``` -ilold[Staking]> c deposit - - + Step 0: deposit [P] external - State writes: - · balances - · totalStaked - Sequence: deposit -``` - -``` -ilold[→ deposit]> c withdraw - - + Step 1: withdraw [P] external - State writes: - · balances - · totalStaked - Sequence: deposit → withdraw -``` - -**Returns:** `StepAdded { step_index, function, access, state_changed }`. `access` is one of `Public`, `Restricted { role }`, `Internal`, `Special { kind }`. - -Attempting to call an internal function: - -``` -ilold[Staking]> c _updateRewards - - '_updateRewards' is internal and cannot be called from outside the contract — - not a valid session entry point. Use `tr _updateRewards` to view its flow, - or `c <public_caller>` to trace a real entry point. -``` - -## back - -`b` or `back` - -Removes the last step from the session sequence. - -``` -ilold[→ deposit → withdraw]> b - - - Step removed. 1 remaining. - Sequence: deposit -``` - -## clear - -`cl` or `clear` - -Resets the session, removing all steps. Prompts for confirmation if steps exist. - -``` -ilold[→ deposit → withdraw]> cl - Clear 2 steps? (y/n) - y - Session cleared. -``` - -## state - -`s` or `state` - -Shows the accumulated state mutations across all steps in the session. Each variable lists every mutation with the operator symbol (`+=`, `-=`, `=`) and the originating function. - -``` -ilold[→ deposit → withdraw]> s - - ═══════════════════[ STATE ]═══════════════════ - balances - += msg.value (step 0, deposit) - -= amount (step 1, withdraw) - totalStaked - += msg.value (step 0, deposit) - -= amount (step 1, withdraw) -``` - -Each change line is `<operator> <value_expr> (step <N>, <function>)`, with an optional `via <modifier>` suffix when the mutation comes from a modifier body. - -**Returns:** `StateView { summary: [VariableSummary { variable, changes }] }`. If the session is empty, `state` tells you to add steps first. - -## sequence - -`seq` or `sequence` - -Displays a narrative of the current call sequence, including dependencies between steps and observations about the interaction pattern. Requires at least 2 steps. - -``` -ilold[→ deposit → withdraw]> seq - - Step 0: deposit - writes: balances, totalStaked - - Step 1: withdraw - writes: balances, totalStaked - depends on: deposit (shared state: balances, totalStaked) - - Observations: - · deposit and withdraw modify the same variables (balances, totalStaked) -``` - -## step - -`st <index>` or `step <index>` - -Re-inspects a specific session step, showing its full function narrative (same output as [info](./analysis.md#info)). You can also write `st0`, `st1` without a space. - -``` -ilold[→ deposit → withdraw]> st 0 - - deposit [public] — whenNotPaused - ├── Paths: 2 total, 1 happy, 1 revert - ├── State reads: balances - ├── State writes: balances, totalStaked - └── Events: Deposited -``` - -## session - -`ss` or `session` - -Shows the full session overview: active contract, current step sequence, and findings count. - -``` -ilold[→ deposit → withdraw]> ss - - Contract: Staking - Steps: deposit → withdraw - Findings: 0 -``` diff --git a/docs/guide/src/solidity/repl/workspace.md b/docs/guide/src/solidity/repl/workspace.md deleted file mode 100644 index a24a2f2..0000000 --- a/docs/guide/src/solidity/repl/workspace.md +++ /dev/null @@ -1,53 +0,0 @@ -# Workspace Commands - -Workspace commands handle session persistence and external tools. - -## save - -`save <name>` - -Saves the current session (steps, findings, notes, statuses) to a JSON file under `~/.ilold/sessions/`. - -``` -ilold[→ deposit → withdraw]> save staking-audit - - ✓ Saved to /Users/you/.ilold/sessions/staking-audit.json -``` - -You can resume this session later with [load](#load), even across different ilold runs, as long as the same contract files are loaded. - -## load - -`load <name>` - -Loads a previously saved session from `~/.ilold/sessions/`. - -``` -ilold[Staking]> load staking-audit - - ✓ Session loaded (2 steps) -``` - -The prompt updates to reflect the loaded steps. The session replaces whatever is currently in memory. - -## browser - -`browser` - -Prints the base URL of the HTTP API the REPL is talking to. `explore` runs the API in-process by default; pass `--attach <url>` to point the REPL at a separate `serve` instance instead. - -``` -ilold[Staking]> browser - - API running at http://127.0.0.1:52431/api/ -``` - -The web canvas (when running `serve` and opening the URL in a browser) subscribes to the same HTTP/WS endpoints. See [HTTP API Reference](../../reference/api-endpoints.md) for the full surface. - -## quit - -`q`, `quit`, or `exit` - -Exits the REPL. `Ctrl+D` and `Ctrl+C` also work. - -Unsaved session data is lost on exit. Use [save](#save) before quitting if the session needs to survive. diff --git a/docs/guide/src/solidity/workflows/audit-walkthrough.md b/docs/guide/src/solidity/workflows/audit-walkthrough.md deleted file mode 100644 index 27033ee..0000000 --- a/docs/guide/src/solidity/workflows/audit-walkthrough.md +++ /dev/null @@ -1,234 +0,0 @@ -# Full Audit Walkthrough - -This walkthrough demonstrates a realistic audit of a Staking contract using the ilold interactive REPL. The contract allows users to deposit tokens, withdraw them, and claim rewards, with owner-only administrative functions and a pause mechanism. - -## Starting the session - -Launch ilold against the Staking contract source files: - -``` -ilold explore contracts/Staking.sol -``` - -The REPL starts and auto-selects the main contract: - -``` -ilold explore -- Staking -8 functions | Type ? for help -Web UI: http://localhost:3001 - -Staking > -``` - -List available functions and state variables to orient yourself: - -``` -Staking > f - deposit External writes state - withdraw External writes state - claimRewards External writes state - setRewardRate External writes state onlyOwner - pause External writes state onlyOwner - unpause External writes state onlyOwner - rewardPerToken Public read-only - earned Public read-only - -Staking > v - stakingToken address - rewardToken address - owner address - paused bool - rewardRate uint256 - lastUpdateTime uint256 - rewardPerTokenStored uint256 - balances mapping(address => uint256) - userRewardPerTokenPaid mapping(address => uint256) - rewards mapping(address => uint256) - totalStaked uint256 -``` - -The function list shows access level, whether a function writes state, and which modifiers restrict access. Read-only functions (view/pure) are marked separately. This is your starting map. - -## Calling deposit -- observing state writes - -``` -Staking > c deposit - Step 0: deposit [External] - State changed: balances, totalStaked -``` - -The session records `deposit` as step 0. The engine analyzed the CFG and reports which state variables are mutated. Two writes: `balances` and `totalStaked`. - -## Investigating totalStaked with who, slice, trace - -The `who` command reveals which functions read and write a variable across the entire contract: - -``` -Staking > who totalStaked - Writers: - deposit External - withdraw External - Readers: - rewardPerToken Public -``` - -Both `deposit` and `withdraw` modify `totalStaked`, and `rewardPerToken` reads it. This tells you the impact surface: any bug in how `totalStaked` is updated will propagate to reward calculations. - -Now use `slice` to see the dataflow within `deposit`: - -``` -Staking > sl deposit totalStaked - Backward slice (sources -> totalStaked): - [0] stakingToken.transferFrom(msg.sender, address(this), amount) - [2] totalStaked += amount - - Forward slice (totalStaked -> sinks): - [2] totalStaked += amount -``` - -The backward slice shows that the `transferFrom` call precedes the state write. The forward slice is short because `totalStaked` is only written, not read within this function. The real consumers are in `rewardPerToken` -- the `who` output already told you that. - -Use `trace` to see the full execution flow: - -``` -Staking > tr deposit - 1. [mod whenNotPaused] require(!paused, "paused") - 2. [mod updateReward] rewardPerTokenStored = rewardPerToken() - 3. [mod updateReward] lastUpdateTime = block.timestamp - 4. [mod updateReward] userRewardPerTokenPaid[account] = rewardPerTokenStored - 5. [mod updateReward] rewards[account] = earned(account) - 6. require(amount > 0, "Cannot stake 0") - 7. stakingToken.transferFrom(msg.sender, address(this), amount) - 8. balances[msg.sender] += amount - 9. totalStaked += amount -``` - -Lines prefixed with `[mod ...]` come from modifiers inlined before the function body. The `updateReward` modifier writes four state variables before `deposit`'s own body runs. This is critical context: if you only looked at the function body, you would miss these writes. - -## Calling withdraw -- observing the sequence - -``` -Staking > c withdraw - Step 1: withdraw [External] - State changed: balances, totalStaked - -Staking[deposit > withdraw] > -``` - -The prompt now shows both steps. Use `state` to see the accumulated picture: - -``` -Staking[deposit > withdraw] > s - balances written by: deposit (+= amount), withdraw (-= amount) - totalStaked written by: deposit (+= amount), withdraw (-= amount) - rewardPerTokenStored written by: deposit (via updateReward), withdraw (via updateReward) - lastUpdateTime written by: deposit (via updateReward), withdraw (via updateReward) - userRewardPerTokenPaid written by: deposit (via updateReward), withdraw (via updateReward) - rewards written by: deposit (via updateReward), withdraw (via updateReward) -``` - -The state view aggregates every write from every step. Mutations introduced by modifiers are tagged with `via <modifier>`. You can see that `updateReward` runs in both `deposit` and `withdraw`, updating the reward accounting state each time. - -## Checking claimRewards -- modifier writes in the slice - -``` -Staking > c claimRewards - Step 2: claimRewards [External] - State changed: rewards -``` - -Slice `claimRewards` for the `rewards` variable: - -``` -Staking[deposit > withdraw > claimRewards] > sl claimRewards rewards --both - Backward slice (sources -> rewards): - [mod updateReward] rewards[account] = earned(account) - [5] uint256 reward = rewards[msg.sender] - - Forward slice (rewards -> sinks): - [mod updateReward] rewards[account] = earned(account) - [5] uint256 reward = rewards[msg.sender] - [7] rewards[msg.sender] = 0 - [8] rewardToken.transfer(msg.sender, reward) -``` - -Entries tagged `[mod updateReward]` are statements from the modifier body that touch `rewards`. The slicer walks both the function body and every applied modifier, so nothing is hidden. The forward slice shows that `rewards[msg.sender]` is read into a local, zeroed, and then transferred -- the standard claim pattern. - -## Using timeline to track a variable across all steps - -``` -Staking[deposit > withdraw > claimRewards] > tl balances - Variable: balances - - Step 0 deposit balances[msg.sender] += amount - Step 1 withdraw balances[msg.sender] -= amount -``` - -``` -Staking[deposit > withdraw > claimRewards] > tl rewardPerTokenStored - Variable: rewardPerTokenStored - - Step 0 deposit rewardPerTokenStored = rewardPerToken() via updateReward - Step 1 withdraw rewardPerTokenStored = rewardPerToken() via updateReward - Step 2 claimRewards rewardPerTokenStored = rewardPerToken() via updateReward -``` - -The timeline shows every mutation of a variable across the entire session in chronological order. Each entry includes the step index, the function that caused it, the assignment expression, and whether it came from a modifier. Path conditions (from branching logic) are included when the function has conditional writes. - -This gives you a cross-function view that no single-function analysis can provide. - -## Recording findings and exporting - -Record an observation while looking at the claim flow: - -``` -Staking[deposit > withdraw > claimRewards] > n claimRewards zeroes rewards before transfer -- CEI pattern followed -``` - -If you spot an issue, record a finding with severity: - -``` -Staking[deposit > withdraw > claimRewards] > fi medium No check that reward > 0 before transfer call - Finding F-001 added (Medium) -``` - -Mark functions as reviewed: - -``` -Staking[deposit > withdraw > claimRewards] > status deposit reviewed -Staking[deposit > withdraw > claimRewards] > status claimRewards suspicious -``` - -Export the session to a markdown report: - -``` -Staking[deposit > withdraw > claimRewards] > export - Exported to ilold-report-Staking.md -``` - -Save the session for later: - -``` -Staking[deposit > withdraw > claimRewards] > save staking-audit-day1 - Saved to ~/.ilold/sessions/staking-audit-day1.json -``` - -## How cross-reference hints guide exploration - -The output of each command naturally points to the next: - -1. `functions` shows which functions write state -- you call the important ones first. -2. `c deposit` reports `balances, totalStaked` changed -- you run `who totalStaked` to see the impact. -3. `who` shows `rewardPerToken` reads `totalStaked` -- you run `sl rewardPerToken totalStaked` or `tr rewardPerToken` to trace the dependency. -4. `slice` shows modifier-origin statements -- you trace the modifier with `tr deposit` to see full execution order. -5. `state` aggregates writes across steps -- variables with writes from multiple functions deserve `timeline` inspection. -6. `timeline` reveals the chronological mutation history -- unexpected patterns become findings. - -Each command's output contains the variable names, function names, and modifier names needed for the next query. The workflow is: call, observe, investigate, record, repeat. - -## Related pages - -- [Session commands](../repl/session.md) -- [Taint Analysis](./taint-analysis.md) -- [HTTP API Reference](../../reference/api-endpoints.md) -- [Known Limitations](../limitations.md) diff --git a/docs/guide/src/solidity/workflows/taint-analysis.md b/docs/guide/src/solidity/workflows/taint-analysis.md deleted file mode 100644 index 67cc4ac..0000000 --- a/docs/guide/src/solidity/workflows/taint-analysis.md +++ /dev/null @@ -1,103 +0,0 @@ -# Taint Analysis -- Tracing User Input - -Forward slicing traces how a variable's value propagates through a function body. When the starting variable is a user-controlled parameter, the slice acts as a taint analysis: it reveals every state variable, local variable, and external call that the attacker-controlled input can reach. - -## Identifying entry points - -List functions and note which ones are externally callable: - -``` -Staking > f - deposit External writes state - withdraw External writes state - claimRewards External writes state - setRewardRate External writes state onlyOwner - pause External writes state onlyOwner - unpause External writes state onlyOwner - rewardPerToken Public read-only - earned Public read-only -``` - -Functions marked `External` accept parameters from untrusted callers. The `onlyOwner` annotation means the function has an access-control modifier, but the parameter values themselves are still caller-supplied. For taint analysis, focus on functions where user-controlled parameters flow into state writes or external calls. - -In this contract, `deposit(uint256 amount)` and `withdraw(uint256 amount)` both take an `amount` parameter from the caller and write state. - -## Forward-slicing amount in deposit - -Use the forward slice to trace where `amount` goes: - -``` -Staking > sl deposit amount --forward - Forward slice (amount -> sinks): - [0] require(amount > 0, "Cannot stake 0") - [1] stakingToken.transferFrom(msg.sender, address(this), amount) - [2] balances[msg.sender] += amount - [3] totalStaked += amount -``` - -The slice shows four statements that depend on `amount`: - -1. A `require` check validates that `amount` is positive. -2. An external call to `stakingToken.transferFrom` uses `amount` directly. -3. `balances[msg.sender]` is incremented by `amount`. -4. `totalStaked` is incremented by `amount`. - -The user-controlled value reaches two state variables (`balances`, `totalStaked`) and one external call (`transferFrom`). The `require` at line 0 is the only validation gate. - -## Forward-slicing amount in withdraw - -``` -Staking > sl withdraw amount --forward - Forward slice (amount -> sinks): - [0] require(amount > 0, "Cannot withdraw 0") - [1] require(balances[msg.sender] >= amount, "Insufficient balance") - [2] balances[msg.sender] -= amount - [3] totalStaked -= amount - [4] stakingToken.transfer(msg.sender, amount) -``` - -The `withdraw` slice is similar but has an additional check: `balances[msg.sender] >= amount`. This prevents withdrawing more than deposited. The subtraction operations mirror the additions in `deposit`. - -Compare the two slices side by side: - -| Statement type | deposit | withdraw | -|---|---|---| -| Validation | `amount > 0` | `amount > 0`, `balances >= amount` | -| External call | `transferFrom(sender, this, amount)` | `transfer(sender, amount)` | -| State writes | `balances += amount`, `totalStaked += amount` | `balances -= amount`, `totalStaked -= amount` | - -The asymmetry in validation is expected here: `deposit` relies on the ERC-20 `transferFrom` to enforce that the caller actually has the tokens, while `withdraw` must check the internal balance explicitly. - -## What the slice reveals about state variable control - -The forward slice of a user parameter tells you which state variables are directly controlled by external input. From the two slices above: - -- `balances` is written in both functions using `amount` directly. Any arithmetic error in the `+=` or `-=` operations would let an attacker manipulate their balance. -- `totalStaked` is written in both functions using `amount` directly. Since `rewardPerToken` reads `totalStaked` (visible via `who totalStaked`), a corrupted `totalStaked` would affect reward calculations for all users. -- Neither `rewardPerTokenStored` nor `rewards` appear in the forward slice of `amount`. These variables are written by the `updateReward` modifier, which derives values from `rewardPerToken()` and `earned()` -- not from the caller's `amount` parameter directly. - -## Mapping to vulnerability patterns - -Forward slice results map to common vulnerability classes: - -**Unchecked arithmetic.** If `balances[msg.sender] += amount` or `totalStaked += amount` can overflow, the attacker controls the input that triggers it. For Solidity 0.8+, the compiler inserts overflow checks automatically. For older versions, look for SafeMath usage in the slice. - -**Missing validation.** If the slice shows a state write or external call with no preceding `require` that bounds the parameter, the input flows unchecked. In `deposit`, the only check is `amount > 0` -- there is no upper bound. Whether this is a problem depends on the token's `transferFrom` behavior. - -**External call with user input.** Both slices show external calls (`transferFrom`, `transfer`) that use `amount`. If the token contract is untrusted or implements callbacks (e.g., ERC-777), the attacker controls the value passed to a potentially re-entrant call. Check whether the state writes happen before or after the external call (CEI pattern). - -**Cross-function impact.** Use `who` to find all readers of a tainted state variable, then forward-slice the reader to see downstream effects. For example, `rewardPerToken` reads `totalStaked`, so a manipulated `totalStaked` propagates into every user's reward calculation. - -## Practical workflow - -1. Run `f` to list entry points. -2. For each external function with parameters, run `sl <func> <param> --forward`. -3. Note which state variables appear in each forward slice. -4. Run `who <var>` on each affected state variable to find cross-function readers. -5. Run `sl <reader> <var> --forward` to trace second-order propagation. -6. Record findings with `fi` when a tainted path reaches a sensitive sink without adequate validation. - -## Related pages - -- [Full Audit Walkthrough](./audit-walkthrough.md) -- [Known Limitations](../limitations.md) -- forward slice caveats diff --git a/images/diagram_solidity.png b/images/diagram_solidity.png deleted file mode 100644 index 26c003a..0000000 Binary files a/images/diagram_solidity.png and /dev/null differ diff --git a/tests/fixtures/erc20.sol b/tests/fixtures/erc20.sol deleted file mode 100644 index 503d7cf..0000000 --- a/tests/fixtures/erc20.sol +++ /dev/null @@ -1,55 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.20; - -contract ERC20 { - string public name; - string public symbol; - uint8 public decimals; - uint256 public totalSupply; - - mapping(address => uint256) public balanceOf; - mapping(address => mapping(address => uint256)) public allowance; - - event Transfer(address indexed from, address indexed to, uint256 value); - event Approval(address indexed owner, address indexed spender, uint256 value); - - constructor(string memory _name, string memory _symbol, uint8 _decimals) { - name = _name; - symbol = _symbol; - decimals = _decimals; - } - - function transfer(address to, uint256 amount) public returns (bool) { - require(to != address(0), "Transfer to zero address"); - require(balanceOf[msg.sender] >= amount, "Insufficient balance"); - - balanceOf[msg.sender] -= amount; - balanceOf[to] += amount; - - emit Transfer(msg.sender, to, amount); - return true; - } - - function approve(address spender, uint256 amount) public returns (bool) { - require(spender != address(0), "Approve to zero address"); - - allowance[msg.sender][spender] = amount; - - emit Approval(msg.sender, spender, amount); - return true; - } - - function transferFrom(address from, address to, uint256 amount) public returns (bool) { - require(from != address(0), "Transfer from zero address"); - require(to != address(0), "Transfer to zero address"); - require(balanceOf[from] >= amount, "Insufficient balance"); - require(allowance[from][msg.sender] >= amount, "Insufficient allowance"); - - balanceOf[from] -= amount; - balanceOf[to] += amount; - allowance[from][msg.sender] -= amount; - - emit Transfer(from, to, amount); - return true; - } -} diff --git a/tests/fixtures/governor.sol b/tests/fixtures/governor.sol deleted file mode 100644 index 98872b9..0000000 --- a/tests/fixtures/governor.sol +++ /dev/null @@ -1,140 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.20; - -interface IVotes { - function getVotes(address account) external view returns (uint256); -} - -contract TimelockController { - uint256 public delay; - mapping(bytes32 => bool) public executed; - address public admin; - - modifier onlyAdmin() { - require(msg.sender == admin, "Not admin"); - _; - } - - function schedule(bytes32 id, uint256 eta) external onlyAdmin { - require(eta >= block.timestamp + delay, "ETA too soon"); - } - - function execute(bytes32 id) external { - require(executed[id] == false, "Already executed"); - executed[id] = true; - } -} - -contract Governor is TimelockController { - IVotes public token; - uint256 public proposalThreshold; - uint256 public votingPeriod; - - struct Proposal { - address proposer; - uint256 startBlock; - uint256 endBlock; - uint256 forVotes; - uint256 againstVotes; - bool executed; - bool canceled; - } - - enum ProposalState { Pending, Active, Defeated, Succeeded, Executed, Canceled } - - mapping(uint256 => Proposal) public proposals; - mapping(uint256 => mapping(address => bool)) public hasVoted; - uint256 public proposalCount; - - event ProposalCreated(uint256 id, address proposer); - event VoteCast(address voter, uint256 proposalId, bool support, uint256 weight); - event ProposalExecuted(uint256 id); - - error ProposalNotActive(uint256 proposalId); - error AlreadyVoted(address voter, uint256 proposalId); - error InsufficientVotingPower(uint256 required, uint256 actual); - - modifier onlyActiveProposal(uint256 proposalId) { - require(state(proposalId) == ProposalState.Active, "Not active"); - _; - } - - function propose() external returns (uint256) { - require( - token.getVotes(msg.sender) >= proposalThreshold, - "Below threshold" - ); - - proposalCount++; - uint256 proposalId = proposalCount; - - proposals[proposalId] = Proposal({ - proposer: msg.sender, - startBlock: block.number + 1, - endBlock: block.number + 1 + votingPeriod, - forVotes: 0, - againstVotes: 0, - executed: false, - canceled: false - }); - - emit ProposalCreated(proposalId, msg.sender); - return proposalId; - } - - function castVote(uint256 proposalId, bool support) - external - onlyActiveProposal(proposalId) - { - require(!hasVoted[proposalId][msg.sender], "Already voted"); - - uint256 weight = token.getVotes(msg.sender); - require(weight > 0, "No voting power"); - - hasVoted[proposalId][msg.sender] = true; - - if (support) { - proposals[proposalId].forVotes += weight; - } else { - proposals[proposalId].againstVotes += weight; - } - - emit VoteCast(msg.sender, proposalId, support, weight); - } - - function executeProposal(uint256 proposalId) external { - require(state(proposalId) == ProposalState.Succeeded, "Not succeeded"); - - proposals[proposalId].executed = true; - emit ProposalExecuted(proposalId); - } - - function cancelProposal(uint256 proposalId) external { - Proposal storage proposal = proposals[proposalId]; - require(msg.sender == proposal.proposer, "Not proposer"); - require(!proposal.executed, "Already executed"); - - proposal.canceled = true; - } - - function state(uint256 proposalId) public view returns (ProposalState) { - Proposal storage proposal = proposals[proposalId]; - - if (proposal.canceled) { - return ProposalState.Canceled; - } - if (proposal.executed) { - return ProposalState.Executed; - } - if (block.number <= proposal.startBlock) { - return ProposalState.Pending; - } - if (block.number <= proposal.endBlock) { - return ProposalState.Active; - } - if (proposal.forVotes > proposal.againstVotes) { - return ProposalState.Succeeded; - } - return ProposalState.Defeated; - } -} diff --git a/tests/fixtures/multi/.gitignore b/tests/fixtures/multi/.gitignore deleted file mode 100644 index 97544b8..0000000 --- a/tests/fixtures/multi/.gitignore +++ /dev/null @@ -1,4 +0,0 @@ -out/ -cache/ -broadcast/ -.env diff --git a/tests/fixtures/multi/foundry.toml b/tests/fixtures/multi/foundry.toml deleted file mode 100644 index 41f3dfb..0000000 --- a/tests/fixtures/multi/foundry.toml +++ /dev/null @@ -1,5 +0,0 @@ -[profile.default] -src = "src" -out = "out" -libs = ["lib"] -solc_version = "0.8.20" diff --git a/tests/fixtures/multi/src/Helper.sol b/tests/fixtures/multi/src/Helper.sol deleted file mode 100644 index 205426c..0000000 --- a/tests/fixtures/multi/src/Helper.sol +++ /dev/null @@ -1,22 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.20; - -contract Helper { - uint256 public counter; - address public owner; - - constructor() { - owner = msg.sender; - } - - function add(uint256 amount) external returns (uint256) { - require(amount > 0, "zero"); - counter += amount; - return counter; - } - - function reset() external { - require(msg.sender == owner, "not owner"); - counter = 0; - } -} diff --git a/tests/fixtures/multi/src/Main.sol b/tests/fixtures/multi/src/Main.sol deleted file mode 100644 index 1c7368c..0000000 --- a/tests/fixtures/multi/src/Main.sol +++ /dev/null @@ -1,24 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.20; - -import {Helper} from "./Helper.sol"; - -contract Main { - Helper public helper; - uint256 public total; - - constructor(address _helper) { - helper = Helper(_helper); - } - - function bumpAndStore(uint256 amount) external returns (uint256) { - uint256 newCounter = helper.add(amount); - total = newCounter * 2; - return total; - } - - function clear() external { - helper.reset(); - total = 0; - } -} diff --git a/tests/fixtures/simple_storage.sol b/tests/fixtures/simple_storage.sol deleted file mode 100644 index 004afbd..0000000 --- a/tests/fixtures/simple_storage.sol +++ /dev/null @@ -1,18 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.20; - -contract SimpleStorage { - uint256 private value; - - event ValueChanged(uint256 newValue); - - function get() public view returns (uint256) { - return value; - } - - function set(uint256 newValue) public { - require(newValue > 0, "Value must be positive"); - value = newValue; - emit ValueChanged(newValue); - } -} diff --git a/tests/fixtures/staking.sol b/tests/fixtures/staking.sol deleted file mode 100644 index 9fc8d3e..0000000 --- a/tests/fixtures/staking.sol +++ /dev/null @@ -1,117 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.20; - -interface IERC20 { - function transfer(address to, uint256 amount) external returns (bool); - function transferFrom(address from, address to, uint256 amount) external returns (bool); - function balanceOf(address account) external view returns (uint256); -} - -contract Staking { - IERC20 public stakingToken; - IERC20 public rewardToken; - address public owner; - bool public paused; - - uint256 public rewardRate; - uint256 public lastUpdateTime; - uint256 public rewardPerTokenStored; - - mapping(address => uint256) public balances; - mapping(address => uint256) public userRewardPerTokenPaid; - mapping(address => uint256) public rewards; - uint256 public totalStaked; - - event Staked(address indexed user, uint256 amount); - event Withdrawn(address indexed user, uint256 amount); - event RewardPaid(address indexed user, uint256 reward); - - error InsufficientBalance(uint256 requested, uint256 available); - error ZeroAmount(); - - struct StakeInfo { - uint256 amount; - uint256 timestamp; - } - - enum Status { Active, Paused, Closed } - - modifier onlyOwner() { - require(msg.sender == owner, "Not owner"); - _; - } - - modifier whenNotPaused() { - require(!paused, "Contract is paused"); - _; - } - - modifier updateReward(address account) { - rewardPerTokenStored = rewardPerToken(); - lastUpdateTime = block.timestamp; - if (account != address(0)) { - rewards[account] = earned(account); - userRewardPerTokenPaid[account] = rewardPerTokenStored; - } - _; - } - - constructor(address _stakingToken, address _rewardToken) { - stakingToken = IERC20(_stakingToken); - rewardToken = IERC20(_rewardToken); - owner = msg.sender; - } - - function deposit(uint256 amount) external whenNotPaused updateReward(msg.sender) { - require(amount > 0, "Cannot stake 0"); - - stakingToken.transferFrom(msg.sender, address(this), amount); - balances[msg.sender] += amount; - totalStaked += amount; - - emit Staked(msg.sender, amount); - } - - function withdraw(uint256 amount) external updateReward(msg.sender) { - require(amount > 0, "Cannot withdraw 0"); - require(balances[msg.sender] >= amount, "Insufficient stake"); - - balances[msg.sender] -= amount; - totalStaked -= amount; - stakingToken.transfer(msg.sender, amount); - - emit Withdrawn(msg.sender, amount); - } - - function claimRewards() external updateReward(msg.sender) { - uint256 reward = rewards[msg.sender]; - if (reward > 0) { - rewards[msg.sender] = 0; - rewardToken.transfer(msg.sender, reward); - emit RewardPaid(msg.sender, reward); - } - } - - function setRewardRate(uint256 _rate) external onlyOwner { - rewardRate = _rate; - } - - function pause() external onlyOwner { - paused = true; - } - - function unpause() external onlyOwner { - paused = false; - } - - function rewardPerToken() public view returns (uint256) { - if (totalStaked == 0) { - return rewardPerTokenStored; - } - return rewardPerTokenStored + (rewardRate * (block.timestamp - lastUpdateTime) * 1e18 / totalStaked); - } - - function earned(address account) public view returns (uint256) { - return (balances[account] * (rewardPerToken() - userRewardPerTokenPaid[account]) / 1e18) + rewards[account]; - } -} diff --git a/tests/fixtures/uniswap_v2_pair.sol b/tests/fixtures/uniswap_v2_pair.sol deleted file mode 100644 index da956ba..0000000 --- a/tests/fixtures/uniswap_v2_pair.sol +++ /dev/null @@ -1,138 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.20; - -interface IERC20 { - function balanceOf(address) external view returns (uint256); - function transfer(address to, uint256 amount) external returns (bool); -} - -interface IFactory { - function feeTo() external view returns (address); -} - -contract UniswapV2Pair { - address public factory; - address public token0; - address public token1; - - uint112 private reserve0; - uint112 private reserve1; - uint32 private blockTimestampLast; - - uint256 public totalSupply; - mapping(address => uint256) public balanceOf; - - uint256 private unlocked = 1; - - event Mint(address indexed sender, uint256 amount0, uint256 amount1); - event Burn(address indexed sender, uint256 amount0, uint256 amount1, address indexed to); - event Swap(address indexed sender, uint256 amount0In, uint256 amount1In, uint256 amount0Out, uint256 amount1Out, address indexed to); - event Sync(uint112 reserve0, uint112 reserve1); - - modifier lock() { - require(unlocked == 1, "LOCKED"); - unlocked = 0; - _; - unlocked = 1; - } - - function getReserves() public view returns (uint112, uint112, uint32) { - return (reserve0, reserve1, blockTimestampLast); - } - - function mint(address to) external lock returns (uint256 liquidity) { - uint256 balance0 = IERC20(token0).balanceOf(address(this)); - uint256 balance1 = IERC20(token1).balanceOf(address(this)); - uint256 amount0 = balance0 - reserve0; - uint256 amount1 = balance1 - reserve1; - - if (totalSupply == 0) { - liquidity = _sqrt(amount0 * amount1); - require(liquidity > 0, "INSUFFICIENT_LIQUIDITY_MINTED"); - } else { - uint256 l0 = amount0 * totalSupply / reserve0; - uint256 l1 = amount1 * totalSupply / reserve1; - liquidity = l0 < l1 ? l0 : l1; - } - - require(liquidity > 0, "INSUFFICIENT_LIQUIDITY_MINTED"); - balanceOf[to] += liquidity; - totalSupply += liquidity; - - _update(balance0, balance1); - emit Mint(msg.sender, amount0, amount1); - } - - function swap(uint256 amount0Out, uint256 amount1Out, address to) external lock { - require(amount0Out > 0 || amount1Out > 0, "INSUFFICIENT_OUTPUT_AMOUNT"); - require(amount0Out < reserve0 && amount1Out < reserve1, "INSUFFICIENT_LIQUIDITY"); - require(to != token0 && to != token1, "INVALID_TO"); - - if (amount0Out > 0) { - IERC20(token0).transfer(to, amount0Out); - } - if (amount1Out > 0) { - IERC20(token1).transfer(to, amount1Out); - } - - uint256 balance0 = IERC20(token0).balanceOf(address(this)); - uint256 balance1 = IERC20(token1).balanceOf(address(this)); - - uint256 amount0In = balance0 > reserve0 - amount0Out ? balance0 - (reserve0 - amount0Out) : 0; - uint256 amount1In = balance1 > reserve1 - amount1Out ? balance1 - (reserve1 - amount1Out) : 0; - require(amount0In > 0 || amount1In > 0, "INSUFFICIENT_INPUT_AMOUNT"); - - // k invariant check - unchecked { - uint256 balance0Adjusted = balance0 * 1000 - amount0In * 3; - uint256 balance1Adjusted = balance1 * 1000 - amount1In * 3; - require(balance0Adjusted * balance1Adjusted >= uint256(reserve0) * uint256(reserve1) * 1000000, "K"); - } - - _update(balance0, balance1); - emit Swap(msg.sender, amount0In, amount1In, amount0Out, amount1Out, to); - } - - function flashLoan(uint256 amount0, uint256 amount1, address to, bytes calldata data) external lock { - require(amount0 > 0 || amount1 > 0, "INSUFFICIENT_AMOUNT"); - - if (amount0 > 0) { - IERC20(token0).transfer(to, amount0); - } - if (amount1 > 0) { - IERC20(token1).transfer(to, amount1); - } - - // Callback — attacker could re-enter here - (bool success,) = to.call(data); - require(success, "CALLBACK_FAILED"); - - // Verify repayment - uint256 balance0 = IERC20(token0).balanceOf(address(this)); - uint256 balance1 = IERC20(token1).balanceOf(address(this)); - require(balance0 >= reserve0 + amount0 * 3 / 1000, "INSUFFICIENT_REPAYMENT_0"); - require(balance1 >= reserve1 + amount1 * 3 / 1000, "INSUFFICIENT_REPAYMENT_1"); - - _update(balance0, balance1); - } - - function _update(uint256 balance0, uint256 balance1) private { - reserve0 = uint112(balance0); - reserve1 = uint112(balance1); - blockTimestampLast = uint32(block.timestamp); - emit Sync(reserve0, reserve1); - } - - function _sqrt(uint256 y) private pure returns (uint256 z) { - if (y > 3) { - z = y; - uint256 x = y / 2 + 1; - while (x < z) { - z = x; - x = (y / x + x) / 2; - } - } else if (y != 0) { - z = 1; - } - } -}