diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..779623f --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,82 @@ +name: test + +on: + push: + branches: [main] + pull_request: + branches: [main] + paths: + - 'crates/**' + - 'tests/**' + - 'Cargo.toml' + - 'Cargo.lock' + - '.github/workflows/**' + - 'crates/ilold-web/frontend/**' + +env: + CARGO_TERM_COLOR: always + RUST_BACKTRACE: 1 + +jobs: + cargo-tests: + name: cargo test --workspace --release + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + + - name: Install Rust stable + uses: dtolnay/rust-toolchain@stable + + - name: Cache cargo + target + uses: Swatinem/rust-cache@v2 + with: + shared-key: cargo-tests + + - name: Build workspace (release) + run: cargo build --workspace --release + + - name: Run unit + integration tests + run: cargo test --workspace --release + + scenario-suite: + name: tests/scenarios/run.sh + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + + - name: Install Rust stable + uses: dtolnay/rust-toolchain@stable + + - name: Cache cargo + target + uses: Swatinem/rust-cache@v2 + with: + shared-key: scenario-suite + + - name: Build ilold CLI binary + run: cargo build --release --bin ilold -p ilold-cli + + - name: Install python websockets (optional) + run: | + python3 -m pip install --user websockets || \ + echo "websockets unavailable — runner will skip ws-broadcast.py" + + - name: Run scenario suite + run: bash tests/scenarios/run.sh + + frontend-cross-check: + name: frontend cross-check program-view + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Run program-view cross-check + working-directory: crates/ilold-web/frontend + run: npm run check:program-view diff --git a/.gitignore b/.gitignore index 02243eb..656fbb1 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,8 @@ Thumbs.db # Environment .env + +# Internal SDD working notes +/docs/sdd/ + +/docs/internal/ diff --git a/Cargo.lock b/Cargo.lock index 76641b0..771758a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,110 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common 0.1.7", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[package]] +name = "aes-gcm-siv" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae0784134ba9375416d469ec31e7c5f9fa94405049cf08c5ce5b4698be673e0d" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "polyval", + "subtle", + "zeroize", +] + +[[package]] +name = "agave-feature-set" +version = "3.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfe79fc4c114c51ea8461d829bb49853a21a76c7c8ef20e9041b071558f628ce" +dependencies = [ + "ahash", + "solana-epoch-schedule", + "solana-hash 3.1.0", + "solana-pubkey 3.0.0", + "solana-sha256-hasher", + "solana-svm-feature-set", +] + +[[package]] +name = "agave-reserved-account-keys" +version = "3.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e8ceb5117fa390898f473b0d165f88482a2b36fb4a47441d8b40e22823207cb" +dependencies = [ + "agave-feature-set", + "solana-pubkey 3.0.0", + "solana-sdk-ids", +] + +[[package]] +name = "agave-syscalls" +version = "3.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98807b80e4367cc38c2b24ea30d6d16466553982aeedb0b0cb2c70bbae8ba5b0" +dependencies = [ + "bincode", + "libsecp256k1", + "num-traits", + "solana-account", + "solana-account-info", + "solana-big-mod-exp", + "solana-blake3-hasher", + "solana-bn254", + "solana-clock", + "solana-cpi", + "solana-curve25519", + "solana-hash 3.1.0", + "solana-instruction", + "solana-keccak-hasher", + "solana-loader-v3-interface", + "solana-poseidon", + "solana-program-entrypoint", + "solana-program-runtime", + "solana-pubkey 3.0.0", + "solana-sbpf", + "solana-sdk-ids", + "solana-secp256k1-recover", + "solana-sha256-hasher", + "solana-stable-layout", + "solana-stake-interface", + "solana-svm-callback", + "solana-svm-feature-set", + "solana-svm-log-collector", + "solana-svm-measure", + "solana-svm-timings", + "solana-svm-type-overrides", + "solana-sysvar", + "solana-sysvar-id", + "solana-transaction-context", + "thiserror 2.0.18", +] + [[package]] name = "ahash" version = "0.8.12" @@ -11,10 +115,20 @@ dependencies = [ "cfg-if", "getrandom 0.3.4", "once_cell", + "serde", "version_check", "zerocopy", ] +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + [[package]] name = "allocator-api2" version = "0.2.21" @@ -80,6 +194,29 @@ dependencies = [ "winnow 0.7.15", ] +[[package]] +name = "anchor-lang-idl" +version = "0.1.2" +source = "git+https://github.com/solana-foundation/anchor?tag=v1.0.2#1314a6b83b16e6a31947b372d57988fd0e81559c" +dependencies = [ + "anchor-lang-idl-spec", + "anyhow", + "heck 0.3.3", + "regex", + "serde", + "serde_json", + "sha2 0.10.9", +] + +[[package]] +name = "anchor-lang-idl-spec" +version = "0.1.0" +source = "git+https://github.com/solana-foundation/anchor?tag=v1.0.2#1314a6b83b16e6a31947b372d57988fd0e81559c" +dependencies = [ + "anyhow", + "serde", +] + [[package]] name = "android_system_properties" version = "0.1.5" @@ -100,6 +237,15 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "ansi_term" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d52a9bb7ec0cf484c551830a7ce27bd20d67eac647e1befb56b0be4ee39a55d2" +dependencies = [ + "winapi", +] + [[package]] name = "anstream" version = "0.6.21" @@ -180,6 +326,66 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "ark-bn254" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a22f4561524cd949590d78d7d4c5df8f592430d221f7f3c9497bbafd8972120f" +dependencies = [ + "ark-ec 0.4.2", + "ark-ff 0.4.2", + "ark-std 0.4.0", +] + +[[package]] +name = "ark-bn254" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d69eab57e8d2663efa5c63135b2af4f396d66424f88954c21104125ab6b3e6bc" +dependencies = [ + "ark-ec 0.5.0", + "ark-ff 0.5.0", + "ark-std 0.5.0", +] + +[[package]] +name = "ark-ec" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "defd9a439d56ac24968cca0571f598a61bc8c55f71d50a89cda591cb750670ba" +dependencies = [ + "ark-ff 0.4.2", + "ark-poly 0.4.2", + "ark-serialize 0.4.2", + "ark-std 0.4.0", + "derivative", + "hashbrown 0.13.2", + "itertools 0.10.5", + "num-traits", + "zeroize", +] + +[[package]] +name = "ark-ec" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d68f2d516162846c1238e755a7c4d131b892b70cc70c471a8e3ca3ed818fce" +dependencies = [ + "ahash", + "ark-ff 0.5.0", + "ark-poly 0.5.0", + "ark-serialize 0.5.0", + "ark-std 0.5.0", + "educe", + "fnv", + "hashbrown 0.15.5", + "itertools 0.13.0", + "num-bigint 0.4.6", + "num-integer", + "num-traits", + "zeroize", +] + [[package]] name = "ark-ff" version = "0.3.0" @@ -191,7 +397,7 @@ dependencies = [ "ark-serialize 0.3.0", "ark-std 0.3.0", "derivative", - "num-bigint", + "num-bigint 0.4.6", "num-traits", "paste", "rustc_version 0.3.3", @@ -211,7 +417,7 @@ dependencies = [ "derivative", "digest 0.10.7", "itertools 0.10.5", - "num-bigint", + "num-bigint 0.4.6", "num-traits", "paste", "rustc_version 0.4.1", @@ -232,7 +438,7 @@ dependencies = [ "digest 0.10.7", "educe", "itertools 0.13.0", - "num-bigint", + "num-bigint 0.4.6", "num-traits", "paste", "zeroize", @@ -274,7 +480,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db2fd794a08ccb318058009eefdf15bcaaaaf6f8161eb3345f907222bac38b20" dependencies = [ - "num-bigint", + "num-bigint 0.4.6", "num-traits", "quote", "syn 1.0.109", @@ -286,7 +492,7 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7abe79b0e4288889c4574159ab790824d0033b9fdcb2a112a3182fac2e514565" dependencies = [ - "num-bigint", + "num-bigint 0.4.6", "num-traits", "proc-macro2", "quote", @@ -299,13 +505,41 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09be120733ee33f7693ceaa202ca41accd5653b779563608f1234f78ae07c4b3" dependencies = [ - "num-bigint", + "num-bigint 0.4.6", "num-traits", "proc-macro2", "quote", "syn 2.0.117", ] +[[package]] +name = "ark-poly" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d320bfc44ee185d899ccbadfa8bc31aab923ce1558716e1997a1e74057fe86bf" +dependencies = [ + "ark-ff 0.4.2", + "ark-serialize 0.4.2", + "ark-std 0.4.0", + "derivative", + "hashbrown 0.13.2", +] + +[[package]] +name = "ark-poly" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "579305839da207f02b89cd1679e50e67b4331e2f9294a57693e5051b7703fe27" +dependencies = [ + "ahash", + "ark-ff 0.5.0", + "ark-serialize 0.5.0", + "ark-std 0.5.0", + "educe", + "fnv", + "hashbrown 0.15.5", +] + [[package]] name = "ark-serialize" version = "0.3.0" @@ -322,9 +556,10 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "adb7b85a02b83d2f22f89bd5cac66c9c89474240cb6207cb1efc16d098e822a5" dependencies = [ + "ark-serialize-derive 0.4.2", "ark-std 0.4.0", "digest 0.10.7", - "num-bigint", + "num-bigint 0.4.6", ] [[package]] @@ -333,10 +568,33 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f4d068aaf107ebcd7dfb52bc748f8030e0fc930ac8e360146ca54c1203088f7" dependencies = [ + "ark-serialize-derive 0.5.0", "ark-std 0.5.0", "arrayvec", "digest 0.10.7", - "num-bigint", + "num-bigint 0.4.6", +] + +[[package]] +name = "ark-serialize-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae3281bc6d0fd7e549af32b52511e1302185bd688fd3359fa36423346ff682ea" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "ark-serialize-derive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "213888f660fddcca0d257e88e54ac05bca01885f258ccdf695bafd77031bb69d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] @@ -369,6 +627,12 @@ dependencies = [ "rand 0.8.5", ] +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + [[package]] name = "arrayvec" version = "0.7.6" @@ -376,883 +640,951 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" [[package]] -name = "atomic-waker" -version = "1.1.2" +name = "ascii" +version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +checksum = "eab1c04a571841102f5345a8fc0f6bb3d31c315dec879b5c6e42e40ce7ffa34e" [[package]] -name = "auto_impl" -version = "1.3.0" +name = "ascii-canvas" +version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffdcb70bdbc4d478427380519163274ac86e52916e10f0a8889adf0f96d3fee7" +checksum = "8824ecca2e851cec16968d54a01dd372ef8f95b244fb84b84e70128be347c3c6" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", + "term", ] [[package]] -name = "autocfg" -version = "1.5.0" +name = "assert-json-diff" +version = "2.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" +dependencies = [ + "serde", + "serde_json", +] [[package]] -name = "axum" -version = "0.8.8" +name = "async-attributes" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" +checksum = "a3203e79f4dd9bdda415ed03cf14dae5a2bf775c683a00f94e9cd1faf0f596e5" dependencies = [ - "axum-core", - "base64", - "bytes", - "form_urlencoded", - "futures-util", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-util", - "itoa", - "matchit", - "memchr", - "mime", - "percent-encoding", - "pin-project-lite", - "serde_core", - "serde_json", - "serde_path_to_error", - "serde_urlencoded", - "sha1", - "sync_wrapper", - "tokio", - "tokio-tungstenite 0.28.0", - "tower", - "tower-layer", - "tower-service", - "tracing", + "quote", + "syn 1.0.109", ] [[package]] -name = "axum-core" -version = "0.5.6" +name = "async-channel" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +checksum = "81953c529336010edd6d8e358f886d9581267795c61b19475b71314bffa46d35" dependencies = [ - "bytes", + "concurrent-queue", + "event-listener 2.5.3", "futures-core", - "http", - "http-body", - "http-body-util", - "mime", - "pin-project-lite", - "sync_wrapper", - "tower-layer", - "tower-service", - "tracing", ] [[package]] -name = "base16ct" -version = "0.2.0" +name = "async-channel" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] [[package]] -name = "base64" -version = "0.22.1" +name = "async-executor" +version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] [[package]] -name = "base64ct" -version = "1.8.3" +name = "async-global-executor" +version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +checksum = "05b1b633a2115cd122d73b955eadd9916c18c8f510ec9cd1686404c60ad1c29c" +dependencies = [ + "async-channel 2.5.0", + "async-executor", + "async-io", + "async-lock", + "blocking", + "futures-lite", + "once_cell", +] [[package]] -name = "bit-set" -version = "0.8.0" +name = "async-io" +version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" dependencies = [ - "bit-vec", + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", ] [[package]] -name = "bit-vec" -version = "0.8.0" +name = "async-lock" +version = "3.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener 5.4.1", + "event-listener-strategy", + "pin-project-lite", +] [[package]] -name = "bitflags" -version = "1.3.2" +name = "async-object-pool" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" +checksum = "333c456b97c3f2d50604e8b2624253b7f787208cb72eb75e64b0ad11b221652c" +dependencies = [ + "async-std", +] [[package]] -name = "bitflags" -version = "2.11.0" +name = "async-process" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" dependencies = [ - "serde_core", + "async-channel 2.5.0", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener 5.4.1", + "futures-lite", + "rustix", ] [[package]] -name = "bitvec" -version = "1.0.1" +name = "async-signal" +version = "0.2.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" dependencies = [ - "funty", - "radium", - "tap", - "wyz", + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", ] [[package]] -name = "block-buffer" -version = "0.10.4" +name = "async-std" +version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +checksum = "2c8e079a4ab67ae52b7403632e4618815d6db36d2a010cfe41b02c1b1578f93b" dependencies = [ - "generic-array", + "async-attributes", + "async-channel 1.9.0", + "async-global-executor", + "async-io", + "async-lock", + "async-process", + "crossbeam-utils", + "futures-channel", + "futures-core", + "futures-io", + "futures-lite", + "gloo-timers", + "kv-log-macro", + "log", + "memchr", + "once_cell", + "pin-project-lite", + "pin-utils", + "slab", + "wasm-bindgen-futures", ] [[package]] -name = "boxcar" -version = "0.2.14" +name = "async-task" +version = "4.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36f64beae40a84da1b4b26ff2761a5b895c12adc41dc25aaee1c4f2bbfe97a6e" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" [[package]] -name = "bumpalo" -version = "3.20.2" +name = "async-trait" +version = "0.1.89" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] [[package]] -name = "byte-slice-cast" -version = "1.2.3" +name = "atomic-waker" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7575182f7272186991736b70173b0ea045398f984bf5ebbb3804736ce1330c9d" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] -name = "byteorder" +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" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] -name = "bytes" -version = "1.11.1" +name = "axum" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" dependencies = [ - "serde", + "axum-core", + "base64 0.22.1", + "bytes", + "form_urlencoded", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "hyper 1.8.1", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sha1", + "sync_wrapper", + "tokio", + "tokio-tungstenite 0.28.0", + "tower", + "tower-layer", + "tower-service", + "tracing", ] [[package]] -name = "castaway" -version = "0.2.4" +name = "axum-core" +version = "0.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" dependencies = [ - "rustversion", + "bytes", + "futures-core", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", ] [[package]] -name = "cc" -version = "1.2.58" +name = "base16ct" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1e928d4b69e3077709075a938a05ffbedfa53a84c8f766efbf8220bb1ff60e1" -dependencies = [ - "find-msvc-tools", - "shlex", -] +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" [[package]] -name = "cfg-if" -version = "1.0.4" +name = "base64" +version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +checksum = "3441f0f7b02788e948e47f457ca01f1d7e6d92c693bc132c22b087d3141c03ff" [[package]] -name = "chrono" -version = "0.4.44" +name = "base64" +version = "0.21.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "basic-cookies" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67bd8fd42c16bdb08688243dc5f0cc117a3ca9efeeaba3a345a18a6159ad96f7" dependencies = [ - "iana-time-zone", - "num-traits", - "serde", - "windows-link", + "lalrpop", + "lalrpop-util", + "regex", ] [[package]] -name = "clap" -version = "4.6.0" +name = "bincode" +version = "1.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" dependencies = [ - "clap_builder", - "clap_derive", + "serde", ] [[package]] -name = "clap_builder" -version = "4.6.0" +name = "bit-set" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" dependencies = [ - "anstream 1.0.0", - "anstyle", - "clap_lex", - "strsim", + "bit-vec 0.6.3", ] [[package]] -name = "clap_derive" -version = "4.6.0" +name = "bit-set" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn 2.0.117", + "bit-vec 0.8.0", ] [[package]] -name = "clap_lex" -version = "1.1.0" +name = "bit-vec" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" [[package]] -name = "colorchoice" -version = "1.0.5" +name = "bit-vec" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" [[package]] -name = "colored" -version = "3.1.1" +name = "bitflags" +version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" dependencies = [ - "windows-sys 0.61.2", + "serde_core", ] [[package]] -name = "compact_str" -version = "0.9.0" +name = "bitvec" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fdb1325a1cece981e8a296ab8f0f9b63ae357bd0784a9faaf548cc7b480707a" +checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" dependencies = [ - "castaway", - "cfg-if", - "itoa", - "rustversion", - "ryu", - "static_assertions", + "funty", + "radium", + "tap", + "wyz", ] [[package]] -name = "const-hex" -version = "1.18.1" +name = "blake3" +version = "1.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "531185e432bb31db1ecda541e9e7ab21468d4d844ad7505e0546a49b4945d49b" +checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce" dependencies = [ + "arrayref", + "arrayvec", + "cc", "cfg-if", - "cpufeatures", - "proptest", - "serde_core", + "constant_time_eq", + "cpufeatures 0.3.0", + "digest 0.11.3", ] [[package]] -name = "const-oid" -version = "0.9.6" +name = "block-buffer" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +checksum = "4152116fd6e9dadb291ae18fc1ec3575ed6d84c29642d97890f4b4a3417297e4" +dependencies = [ + "generic-array", +] [[package]] -name = "const_format" -version = "0.2.35" +name = "block-buffer" +version = "0.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7faa7469a93a566e9ccc1c73fe783b4a65c274c5ace346038dca9c39fe0030ad" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" dependencies = [ - "const_format_proc_macros", + "generic-array", ] [[package]] -name = "const_format_proc_macros" -version = "0.2.34" +name = "block-buffer" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d57c2eccfb16dbac1f4e61e206105db5820c9d26c3c472bc17c774259ef7744" +checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" dependencies = [ - "proc-macro2", - "quote", - "unicode-xid", + "hybrid-array", ] [[package]] -name = "convert_case" -version = "0.10.0" +name = "blocking" +version = "1.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" dependencies = [ - "unicode-segmentation", + "async-channel 2.5.0", + "async-task", + "futures-io", + "futures-lite", + "piper", ] [[package]] -name = "core-foundation" -version = "0.9.4" +name = "borrow-or-share" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" -dependencies = [ - "core-foundation-sys", - "libc", -] +checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c" [[package]] -name = "core-foundation" -version = "0.10.1" +name = "borsh" +version = "1.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +checksum = "cfd1e3f8955a5d7de9fab72fc8373fade9fb8a703968cb200ae3dc6cf08e185a" dependencies = [ - "core-foundation-sys", - "libc", + "borsh-derive", + "bytes", + "cfg_aliases", ] [[package]] -name = "core-foundation-sys" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" - -[[package]] -name = "cpufeatures" -version = "0.2.17" +name = "borsh-derive" +version = "1.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +checksum = "bfcfdc083699101d5a7965e49925975f2f55060f94f9a05e7187be95d530ca59" dependencies = [ - "libc", + "once_cell", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] -name = "crossbeam-deque" -version = "0.8.6" +name = "boxcar" +version = "0.2.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" -dependencies = [ - "crossbeam-epoch", - "crossbeam-utils", -] +checksum = "36f64beae40a84da1b4b26ff2761a5b895c12adc41dc25aaee1c4f2bbfe97a6e" [[package]] -name = "crossbeam-epoch" -version = "0.9.18" +name = "bs58" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" dependencies = [ - "crossbeam-utils", + "tinyvec", ] [[package]] -name = "crossbeam-utils" -version = "0.8.21" +name = "bumpalo" +version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" [[package]] -name = "crossterm" -version = "0.29.0" +name = "bv" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +checksum = "8834bb1d8ee5dc048ee3124f2c7c1afcc6bc9aed03f11e9dfd8c69470a5db340" dependencies = [ - "bitflags 2.11.0", - "crossterm_winapi", - "derive_more", - "document-features", - "mio", - "parking_lot", - "rustix", + "feature-probe", "serde", - "signal-hook", - "signal-hook-mio", - "winapi", ] [[package]] -name = "crossterm_winapi" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" -dependencies = [ - "winapi", -] - -[[package]] -name = "crunchy" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" - -[[package]] -name = "crypto-bigint" -version = "0.5.5" +name = "byte-slice-cast" +version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" -dependencies = [ - "generic-array", - "rand_core 0.6.4", - "subtle", - "zeroize", -] +checksum = "7575182f7272186991736b70173b0ea045398f984bf5ebbb3804736ce1330c9d" [[package]] -name = "crypto-common" -version = "0.1.7" +name = "bytecount" +version = "0.6.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" -dependencies = [ - "generic-array", - "typenum", -] +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" [[package]] -name = "darling" -version = "0.23.0" +name = "bytemuck" +version = "1.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" dependencies = [ - "darling_core", - "darling_macro", + "bytemuck_derive", ] [[package]] -name = "darling_core" -version = "0.23.0" +name = "bytemuck_derive" +version = "1.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" dependencies = [ - "ident_case", "proc-macro2", "quote", - "strsim", "syn 2.0.117", ] [[package]] -name = "darling_macro" -version = "0.23.0" +name = "byteorder" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" -dependencies = [ - "darling_core", - "quote", - "syn 2.0.117", -] +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] -name = "dashmap" -version = "6.1.0" +name = "bytes" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" dependencies = [ - "cfg-if", - "crossbeam-utils", - "hashbrown 0.14.5", - "lock_api", - "once_cell", - "parking_lot_core", + "serde", ] [[package]] -name = "data-encoding" -version = "2.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" - -[[package]] -name = "der" -version = "0.7.10" +name = "castaway" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" dependencies = [ - "const-oid", - "zeroize", + "rustversion", ] [[package]] -name = "deranged" -version = "0.5.8" +name = "cc" +version = "1.2.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +checksum = "e1e928d4b69e3077709075a938a05ffbedfa53a84c8f766efbf8220bb1ff60e1" dependencies = [ - "powerfmt", + "find-msvc-tools", + "shlex", ] [[package]] -name = "derivative" -version = "2.2.0" +name = "cfg-if" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] -name = "derive_more" -version = "2.1.1" +name = "cfg_aliases" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" -dependencies = [ - "derive_more-impl", -] +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] -name = "derive_more-impl" -version = "2.1.1" +name = "cfg_eval" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +checksum = "45565fc9416b9896014f5732ac776f810ee53a66730c17e4020c3ec064a8f88f" dependencies = [ - "convert_case", "proc-macro2", "quote", - "rustc_version 0.4.1", "syn 2.0.117", - "unicode-xid", ] [[package]] -name = "digest" -version = "0.9.0" +name = "chrono" +version = "0.4.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3dd60d1080a57a05ab032377049e0591415d2b31afd7028356dbf3cc6dcb066" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" dependencies = [ - "generic-array", + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", ] [[package]] -name = "digest" -version = "0.10.7" +name = "cipher" +version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ - "block-buffer", - "const-oid", - "crypto-common", - "subtle", + "crypto-common 0.1.7", + "inout", ] [[package]] -name = "dirs" -version = "6.0.0" +name = "clap" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" dependencies = [ - "dirs-sys", + "clap_builder", + "clap_derive", ] [[package]] -name = "dirs-sys" -version = "0.5.0" +name = "clap_builder" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" dependencies = [ - "libc", - "option-ext", - "redox_users", - "windows-sys 0.61.2", + "anstream 1.0.0", + "anstyle", + "clap_lex", + "strsim", ] [[package]] -name = "displaydoc" -version = "0.2.5" +name = "clap_derive" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" dependencies = [ + "heck 0.5.0", "proc-macro2", "quote", "syn 2.0.117", ] [[package]] -name = "document-features" -version = "0.2.12" +name = "clap_lex" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" -dependencies = [ - "litrs", -] +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] -name = "downcast-rs" -version = "1.2.1" +name = "cmov" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" +checksum = "3f88a43d011fc4a6876cb7344703e297c71dda42494fee094d5f7c76bf13f746" [[package]] -name = "dunce" +name = "colorchoice" version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] -name = "ecdsa" -version = "0.16.9" +name = "colored" +version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "der", - "digest 0.10.7", - "elliptic-curve", - "rfc6979", - "signature", - "spki", + "windows-sys 0.61.2", ] [[package]] -name = "educe" -version = "0.6.0" +name = "combine" +version = "3.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d7bc049e1bd8cdeb31b68bbd586a9464ecf9f3944af3958a7a9d0f8b9799417" +checksum = "da3da6baa321ec19e1cc41d31bf599f00c783d0517095cdaf0332e3fe8d20680" dependencies = [ - "enum-ordinalize", - "proc-macro2", - "quote", - "syn 2.0.117", + "ascii", + "byteorder", + "either", + "memchr", + "unreachable", ] [[package]] -name = "either" -version = "1.15.0" +name = "compact_str" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +checksum = "3fdb1325a1cece981e8a296ab8f0f9b63ae357bd0784a9faaf548cc7b480707a" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] [[package]] -name = "elliptic-curve" -version = "0.13.8" +name = "concurrent-queue" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" dependencies = [ - "base16ct", - "crypto-bigint", - "digest 0.10.7", - "ff", - "generic-array", - "group", - "pkcs8", - "rand_core 0.6.4", - "sec1", - "subtle", - "zeroize", + "crossbeam-utils", ] [[package]] -name = "encoding_rs" -version = "0.8.35" +name = "const-hex" +version = "1.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +checksum = "531185e432bb31db1ecda541e9e7ab21468d4d844ad7505e0546a49b4945d49b" dependencies = [ "cfg-if", + "cpufeatures 0.2.17", + "proptest", + "serde_core", ] [[package]] -name = "enum-ordinalize" -version = "4.3.2" +name = "const-oid" +version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a1091a7bb1f8f2c4b28f1fe2cef4980ca2d410a3d727d67ecc3178c9b0800f0" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "const_format" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7faa7469a93a566e9ccc1c73fe783b4a65c274c5ace346038dca9c39fe0030ad" dependencies = [ - "enum-ordinalize-derive", + "const_format_proc_macros", ] [[package]] -name = "enum-ordinalize-derive" -version = "4.3.2" +name = "const_format_proc_macros" +version = "0.2.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ca9601fb2d62598ee17836250842873a413586e5d7ed88b356e38ddbb0ec631" +checksum = "1d57c2eccfb16dbac1f4e61e206105db5820c9d26c3c472bc17c774259ef7744" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "unicode-xid", ] [[package]] -name = "equivalent" -version = "1.0.2" +name = "constant_time_eq" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" [[package]] -name = "errno" -version = "0.3.14" +name = "convert_case" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" dependencies = [ - "libc", - "windows-sys 0.61.2", + "unicode-segmentation", ] [[package]] -name = "fastrand" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" - -[[package]] -name = "fastrlp" -version = "0.3.1" +name = "core-foundation" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139834ddba373bbdd213dffe02c8d110508dcf1726c2be27e8d1f7d7e1856418" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" dependencies = [ - "arrayvec", - "auto_impl", - "bytes", + "core-foundation-sys", + "libc", ] [[package]] -name = "fastrlp" -version = "0.4.0" +name = "core-foundation" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce8dba4714ef14b8274c371879b175aa55b16b30f269663f19d576f380018dc4" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" dependencies = [ - "arrayvec", - "auto_impl", - "bytes", + "core-foundation-sys", + "libc", ] [[package]] -name = "fd-lock" -version = "4.0.4" +name = "core-foundation-sys" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" -dependencies = [ - "cfg-if", - "rustix", - "windows-sys 0.52.0", -] +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] -name = "ff" -version = "0.13.1" +name = "cpufeatures" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" dependencies = [ - "rand_core 0.6.4", - "subtle", + "libc", ] [[package]] -name = "filedescriptor" -version = "0.8.3" +name = "cpufeatures" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" dependencies = [ "libc", - "thiserror 1.0.69", - "winapi", ] [[package]] -name = "find-msvc-tools" -version = "0.1.9" +name = "crossbeam-deque" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] [[package]] -name = "fixed-hash" -version = "0.8.0" +name = "crossbeam-epoch" +version = "0.9.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "835c052cb0c08c1acf6ffd71c022172e18723949c8282f2b9f27efbc51e64534" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" dependencies = [ - "byteorder", - "rand 0.8.5", - "rustc-hex", - "static_assertions", + "crossbeam-utils", ] [[package]] -name = "fixedbitset" -version = "0.5.7" +name = "crossbeam-utils" +version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" [[package]] -name = "fnv" -version = "1.0.7" +name = "crossterm" +version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags 2.11.0", + "crossterm_winapi", + "derive_more", + "document-features", + "mio", + "parking_lot", + "rustix", + "serde", + "signal-hook", + "signal-hook-mio", + "winapi", +] [[package]] -name = "foldhash" -version = "0.1.5" +name = "crossterm_winapi" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] [[package]] -name = "foldhash" -version = "0.2.0" +name = "crunchy" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" [[package]] -name = "foreign-types" -version = "0.3.2" +name = "crypto-bigint" +version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" dependencies = [ - "foreign-types-shared", + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", ] [[package]] -name = "foreign-types-shared" -version = "0.1.1" +name = "crypto-common" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "typenum", +] [[package]] -name = "form_urlencoded" -version = "1.2.2" +name = "crypto-common" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710" dependencies = [ - "percent-encoding", + "hybrid-array", ] [[package]] -name = "funty" -version = "2.0.0" +name = "ctr" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] [[package]] -name = "futures-channel" -version = "0.3.32" +name = "ctutils" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" dependencies = [ - "futures-core", + "cmov", ] [[package]] -name = "futures-core" -version = "0.3.32" +name = "curve25519-dalek" +version = "4.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest 0.10.7", + "fiat-crypto", + "rand_core 0.6.4", + "rustc_version 0.4.1", + "serde", + "subtle", + "zeroize", +] [[package]] -name = "futures-macro" -version = "0.3.32" +name = "curve25519-dalek-derive" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", @@ -1260,489 +1592,408 @@ dependencies = [ ] [[package]] -name = "futures-sink" -version = "0.3.32" +name = "darling" +version = "0.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" - -[[package]] -name = "futures-task" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" - -[[package]] -name = "futures-util" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" dependencies = [ - "futures-core", - "futures-macro", - "futures-sink", - "futures-task", - "pin-project-lite", - "slab", + "darling_core 0.21.3", + "darling_macro 0.21.3", ] [[package]] -name = "generic-array" -version = "0.14.7" +name = "darling" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" dependencies = [ - "typenum", - "version_check", - "zeroize", + "darling_core 0.23.0", + "darling_macro 0.23.0", ] [[package]] -name = "getrandom" -version = "0.2.17" +name = "darling_core" +version = "0.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" dependencies = [ - "cfg-if", - "libc", - "wasi", + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", ] [[package]] -name = "getrandom" -version = "0.3.4" +name = "darling_core" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" dependencies = [ - "cfg-if", - "libc", - "r-efi 5.3.0", - "wasip2", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", ] [[package]] -name = "getrandom" -version = "0.4.2" +name = "darling_macro" +version = "0.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" dependencies = [ - "cfg-if", - "libc", - "r-efi 6.0.0", - "wasip2", - "wasip3", + "darling_core 0.21.3", + "quote", + "syn 2.0.117", ] [[package]] -name = "group" -version = "0.13.0" +name = "darling_macro" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ - "ff", - "rand_core 0.6.4", - "subtle", + "darling_core 0.23.0", + "quote", + "syn 2.0.117", ] [[package]] -name = "h2" -version = "0.4.13" +name = "dashmap" +version = "6.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" dependencies = [ - "atomic-waker", - "bytes", - "fnv", - "futures-core", - "futures-sink", - "http", - "indexmap", - "slab", - "tokio", - "tokio-util", - "tracing", + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", ] [[package]] -name = "hashbrown" -version = "0.14.5" +name = "data-encoding" +version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" [[package]] -name = "hashbrown" -version = "0.15.5" +name = "der" +version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ - "foldhash 0.1.5", + "const-oid", + "zeroize", ] [[package]] -name = "hashbrown" -version = "0.16.1" +name = "deranged" +version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ - "allocator-api2", - "equivalent", - "foldhash 0.2.0", - "serde", - "serde_core", + "powerfmt", ] [[package]] -name = "heck" -version = "0.5.0" +name = "derivation-path" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +checksum = "6e5c37193a1db1d8ed868c03ec7b152175f26160a5b740e5e484143877e0adf0" [[package]] -name = "hex" -version = "0.4.3" +name = "derivative" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] [[package]] -name = "hmac" -version = "0.12.1" +name = "derive_more" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" dependencies = [ - "digest 0.10.7", + "derive_more-impl", ] [[package]] -name = "http" -version = "1.4.0" +name = "derive_more-impl" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" dependencies = [ - "bytes", - "itoa", + "convert_case", + "proc-macro2", + "quote", + "rustc_version 0.4.1", + "syn 2.0.117", + "unicode-xid", ] [[package]] -name = "http-body" -version = "1.0.1" +name = "digest" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +checksum = "d3dd60d1080a57a05ab032377049e0591415d2b31afd7028356dbf3cc6dcb066" dependencies = [ - "bytes", - "http", + "generic-array", ] [[package]] -name = "http-body-util" -version = "0.1.3" +name = "digest" +version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "bytes", - "futures-core", - "http", - "http-body", - "pin-project-lite", + "block-buffer 0.10.4", + "const-oid", + "crypto-common 0.1.7", + "subtle", ] [[package]] -name = "http-range-header" -version = "0.4.2" +name = "digest" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.0", + "crypto-common 0.2.1", + "ctutils", +] [[package]] -name = "httparse" -version = "1.10.1" +name = "dirs" +version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] [[package]] -name = "httpdate" -version = "1.0.3" +name = "dirs-next" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +checksum = "b98cf8ebf19c3d1b223e151f99a4f9f0690dca41414773390fc824184ac833e1" +dependencies = [ + "cfg-if", + "dirs-sys-next", +] [[package]] -name = "hyper" -version = "1.8.1" +name = "dirs-sys" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" dependencies = [ - "atomic-waker", - "bytes", - "futures-channel", - "futures-core", - "h2", - "http", - "http-body", - "httparse", - "httpdate", - "itoa", - "pin-project-lite", - "pin-utils", - "smallvec", - "tokio", - "want", + "libc", + "option-ext", + "redox_users 0.5.2", + "windows-sys 0.61.2", ] [[package]] -name = "hyper-rustls" -version = "0.27.7" +name = "dirs-sys-next" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" dependencies = [ - "http", - "hyper", - "hyper-util", - "rustls", - "rustls-pki-types", - "tokio", - "tokio-rustls", - "tower-service", + "libc", + "redox_users 0.4.6", + "winapi", ] [[package]] -name = "hyper-tls" -version = "0.6.0" +name = "displaydoc" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ - "bytes", - "http-body-util", - "hyper", - "hyper-util", - "native-tls", - "tokio", - "tokio-native-tls", - "tower-service", + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] -name = "hyper-util" -version = "0.1.20" +name = "document-features" +version = "0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" dependencies = [ - "base64", - "bytes", - "futures-channel", - "futures-util", - "http", - "http-body", - "hyper", - "ipnet", - "libc", - "percent-encoding", - "pin-project-lite", - "socket2", - "system-configuration", - "tokio", - "tower-service", - "tracing", - "windows-registry", + "litrs", ] [[package]] -name = "iana-time-zone" -version = "0.1.65" +name = "downcast-rs" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" -dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "log", - "wasm-bindgen", - "windows-core", -] +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" [[package]] -name = "iana-time-zone-haiku" -version = "0.1.2" +name = "dunce" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" -dependencies = [ - "cc", -] +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" [[package]] -name = "icu_collections" -version = "2.2.0" +name = "dyn-clone" +version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" -dependencies = [ - "displaydoc", - "potential_utf", - "utf8_iter", - "yoke", - "zerofrom", - "zerovec", -] +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" [[package]] -name = "icu_locale_core" -version = "2.2.0" +name = "eager" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" -dependencies = [ - "displaydoc", - "litemap", - "tinystr", - "writeable", - "zerovec", -] +checksum = "abe71d579d1812060163dff96056261deb5bf6729b100fa2e36a68b9649ba3d3" [[package]] -name = "icu_normalizer" -version = "2.2.0" +name = "ecdsa" +version = "0.16.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" dependencies = [ - "icu_collections", - "icu_normalizer_data", - "icu_properties", - "icu_provider", - "smallvec", - "zerovec", + "der", + "digest 0.10.7", + "elliptic-curve", + "rfc6979", + "signature", + "spki", ] [[package]] -name = "icu_normalizer_data" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" - -[[package]] -name = "icu_properties" -version = "2.2.0" +name = "ed25519" +version = "2.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" dependencies = [ - "icu_collections", - "icu_locale_core", - "icu_properties_data", - "icu_provider", - "zerotrie", - "zerovec", + "pkcs8", + "signature", ] [[package]] -name = "icu_properties_data" +name = "ed25519-dalek" version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "rand_core 0.6.4", + "serde", + "sha2 0.10.9", + "subtle", + "zeroize", +] [[package]] -name = "icu_provider" -version = "2.2.0" +name = "educe" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +checksum = "1d7bc049e1bd8cdeb31b68bbd586a9464ecf9f3944af3958a7a9d0f8b9799417" dependencies = [ - "displaydoc", - "icu_locale_core", - "writeable", - "yoke", - "zerofrom", - "zerotrie", - "zerovec", + "enum-ordinalize", + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] -name = "id-arena" -version = "2.3.0" +name = "either" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" [[package]] -name = "ident_case" -version = "1.0.1" +name = "elliptic-curve" +version = "0.13.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest 0.10.7", + "ff", + "generic-array", + "group", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] [[package]] -name = "idna" -version = "1.1.0" +name = "email_address" +version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" dependencies = [ - "idna_adapter", - "smallvec", - "utf8_iter", + "serde", ] [[package]] -name = "idna_adapter" -version = "1.2.1" +name = "ena" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +checksum = "eabffdaee24bd1bf95c5ef7cec31260444317e72ea56c4c91750e8b7ee58d5f1" dependencies = [ - "icu_normalizer", - "icu_properties", + "log", ] [[package]] -name = "ilold-cli" -version = "0.1.0" +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" dependencies = [ - "anyhow", - "clap", - "colored", - "crossterm", - "dirs", - "ilold-core", - "ilold-web", - "nu-ansi-term", - "ratatui", - "reedline", - "reqwest", - "serde_json", - "terminal_size", - "tokio", + "cfg-if", ] [[package]] -name = "ilold-core" -version = "0.1.0" +name = "enum-iterator" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fd242f399be1da0a5354aa462d57b4ab2b4ee0683cc552f7c007d2d12d36e94" dependencies = [ - "petgraph", - "serde", - "serde_json", - "solar-compiler", - "thiserror 2.0.18", + "enum-iterator-derive", ] [[package]] -name = "ilold-web" -version = "0.1.0" +name = "enum-iterator-derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685adfa4d6f3d765a26bc5dbc936577de9abf756c1feeb3089b01dd395034842" dependencies = [ - "anyhow", - "axum", - "futures-util", - "ilold-core", - "portable-pty", - "reqwest", - "serde", - "serde_json", - "tokio", - "tokio-tungstenite 0.24.0", - "tower-http", + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] -name = "impl-codec" -version = "0.6.0" +name = "enum-ordinalize" +version = "4.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba6a270039626615617f3f36d15fc827041df3b78c439da2cadfa47455a77f2f" +checksum = "4a1091a7bb1f8f2c4b28f1fe2cef4980ca2d410a3d727d67ecc3178c9b0800f0" dependencies = [ - "parity-scale-codec", + "enum-ordinalize-derive", ] [[package]] -name = "impl-trait-for-tuples" -version = "0.2.3" +name = "enum-ordinalize-derive" +version = "4.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0eb5a3343abf848c0984fe4604b2b105da9539376e24fc0a3b0007411ae4fd9" +checksum = "8ca9601fb2d62598ee17836250842873a413586e5d7ed88b356e38ddbb0ec631" dependencies = [ "proc-macro2", "quote", @@ -1750,1472 +2001,4660 @@ dependencies = [ ] [[package]] -name = "index_vec" -version = "0.1.4" +name = "equivalent" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44faf5bb8861a9c72e20d3fb0fdbd59233e43056e2b80475ab0aacdc2e781355" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] -name = "indexmap" -version = "2.13.0" +name = "errno" +version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ - "equivalent", - "hashbrown 0.16.1", - "serde", - "serde_core", + "libc", + "windows-sys 0.61.2", ] [[package]] -name = "indoc" -version = "2.0.7" +name = "event-listener" +version = "2.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" dependencies = [ - "rustversion", + "concurrent-queue", + "parking", + "pin-project-lite", ] [[package]] -name = "instability" -version = "0.3.12" +name = "event-listener-strategy" +version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" dependencies = [ - "darling", - "indoc", - "proc-macro2", - "quote", - "syn 2.0.117", + "event-listener 5.4.1", + "pin-project-lite", +] + +[[package]] +name = "fancy-regex" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1e1dacd0d2082dfcf1351c4bdd566bbe89a2b263235a2b50058f1e130a47277" +dependencies = [ + "bit-set 0.8.0", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "fastrand" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" +dependencies = [ + "cfg-if", + "rustix", + "windows-sys 0.52.0", +] + +[[package]] +name = "feature-probe" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "835a3dc7d1ec9e75e2b5fb4ba75396837112d2060b03f7d43bc1897c7f7211da" + +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.69", + "winapi", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "five8" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f76610e969fa1784327ded240f1e28a3fd9520c9cec93b636fcf62dd37f772" +dependencies = [ + "five8_core", +] + +[[package]] +name = "five8_const" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a0f1728185f277989ca573a402716ae0beaaea3f76a8ff87ef9dd8fb19436c5" +dependencies = [ + "five8_core", +] + +[[package]] +name = "five8_core" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc74ac4d8359ae70623506d512209619e5cf8f347124910440dbc221714b328e" +dependencies = [ + "borrow-or-share", + "ref-cast", + "serde", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fraction" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e076045bb43dac435333ed5f04caf35c7463631d0dae2deb2638d94dd0a5b872" +dependencies = [ + "lazy_static", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", + "zeroize", +] + +[[package]] +name = "getrandom" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.9.0+wasi-snapshot-preview1", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "gloo-timers" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "h2" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http 1.4.0", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + +[[package]] +name = "hashbrown" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43a3c133739dddd0d2990f9a4bdf8eb4b21ef50e4851ca85ab661199821d510e" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", + "serde", + "serde_core", +] + +[[package]] +name = "heck" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d621efb26863f0e9924c6ac577e8275e5e6b77455db64ffa6c65c904e9e132c" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http 0.2.12", + "pin-project-lite", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http 1.4.0", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http 1.4.0", + "http-body 1.0.1", + "pin-project-lite", +] + +[[package]] +name = "http-range-header" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "httpmock" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08ec9586ee0910472dec1a1f0f8acf52f0fdde93aea74d70d4a3107b4be0fd5b" +dependencies = [ + "assert-json-diff", + "async-object-pool", + "async-std", + "async-trait", + "base64 0.21.7", + "basic-cookies", + "crossbeam-utils", + "form_urlencoded", + "futures-util", + "hyper 0.14.32", + "lazy_static", + "levenshtein", + "log", + "regex", + "serde", + "serde_json", + "serde_regex", + "similar", + "tokio", + "url", +] + +[[package]] +name = "hybrid-array" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3944cf8cf766b40e2a1a333ee5e9b563f854d5fa49d6a8ca2764e97c6eddb214" +dependencies = [ + "typenum", +] + +[[package]] +name = "hyper" +version = "0.14.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http 0.2.12", + "http-body 0.4.6", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2 0.5.10", + "tokio", + "tower-service", + "tracing", + "want", +] + +[[package]] +name = "hyper" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http 1.4.0", + "http-body 1.0.1", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http 1.4.0", + "hyper 1.8.1", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper 1.8.1", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "hyper 1.8.1", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2 0.6.3", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "ilold-cli" +version = "0.1.0" +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" + +[[package]] +name = "ilold-mcp" +version = "0.1.0" +dependencies = [ + "anyhow", + "httpmock", + "ilold-help", + "ilold-render", + "ilold-session-core", + "ilold-solana-core", + "jsonschema", + "reqwest", + "rmcp", + "schemars", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "ilold-render" +version = "0.1.0" +dependencies = [ + "colored", + "ilold-core", + "ilold-session-core", + "ilold-solana-core", + "serde_json", +] + +[[package]] +name = "ilold-session-core" +version = "0.1.0" +dependencies = [ + "schemars", + "serde", + "serde_json", +] + +[[package]] +name = "ilold-solana-core" +version = "0.1.0" +dependencies = [ + "anchor-lang-idl", + "anyhow", + "borsh", + "bs58", + "ilold-session-core", + "litesvm", + "schemars", + "serde", + "serde_json", + "solana-account", + "solana-address 2.6.0", + "solana-clock", + "solana-hash 3.1.0", + "solana-instruction", + "solana-keypair", + "solana-message", + "solana-sdk-ids", + "solana-signer", + "solana-transaction", + "tempfile", + "thiserror 2.0.18", +] + +[[package]] +name = "ilold-web" +version = "0.1.0" +dependencies = [ + "anyhow", + "axum", + "futures-util", + "ilold-core", + "ilold-session-core", + "ilold-solana-core", + "portable-pty", + "proc-macro2", + "reqwest", + "serde", + "serde_json", + "solana-address 2.6.0", + "solana-keypair", + "syn 2.0.117", + "tokio", + "tokio-tungstenite 0.24.0", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +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" +checksum = "c2efbe120e37f17bb33fcdc82bc1c65087242608be37ace3cf7ebf49f3164e37" +dependencies = [ + "boxcar", + "bumpalo", + "dashmap", + "hashbrown 0.14.5", + "thread_local", +] + +[[package]] +name = "ioctl-rs" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7970510895cee30b3e9128319f2cefd4bde883a39f38baa279567ba3a7eb97d" +dependencies = [ + "libc", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "iri-string" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.94" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e04e2ef80ce82e13552136fabeef8a5ed1f985a96805761cbb9a2c34e7664d9" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "jsonschema" +version = "0.46.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc59d2432e047d6090ba1d83c782d0128bd6203857978218f5614dbd3287281f" +dependencies = [ + "ahash", + "bytecount", + "data-encoding", + "email_address", + "fancy-regex", + "fraction", + "getrandom 0.3.4", + "idna", + "itoa", + "num-cmp", + "num-traits", + "percent-encoding", + "referencing", + "regex", + "regex-syntax", + "serde", + "serde_json", + "unicode-general-category", + "uuid-simd", +] + +[[package]] +name = "k256" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b" +dependencies = [ + "cfg-if", + "ecdsa", + "elliptic-curve", + "once_cell", + "sha2 0.10.9", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de8b303297635ad57c9f5059fd9cee7a47f8e8daa09df0fcd07dd39fb22977f" +dependencies = [ + "log", +] + +[[package]] +name = "lalrpop" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55cb077ad656299f160924eb2912aa147d7339ea7d69e1b5517326fdcec3c1ca" +dependencies = [ + "ascii-canvas", + "bit-set 0.5.3", + "ena", + "itertools 0.11.0", + "lalrpop-util", + "petgraph 0.6.5", + "pico-args", + "regex", + "regex-syntax", + "string_cache", + "term", + "tiny-keccak", + "unicode-xid", + "walkdir", +] + +[[package]] +name = "lalrpop-util" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507460a910eb7b32ee961886ff48539633b788a36b65692b95f225b844c82553" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "levenshtein" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db13adb97ab515a3691f56e4dbab09283d0b86cb45abd991d8634a9d6f501760" + +[[package]] +name = "libc" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08" +dependencies = [ + "libc", +] + +[[package]] +name = "libsecp256k1" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9d220bc1feda2ac231cb78c3d26f27676b8cf82c96971f7aeef3d0cf2797c73" +dependencies = [ + "arrayref", + "base64 0.12.3", + "digest 0.9.0", + "libsecp256k1-core", + "libsecp256k1-gen-ecmult", + "libsecp256k1-gen-genmult", + "rand 0.7.3", + "serde", + "sha2 0.9.9", +] + +[[package]] +name = "libsecp256k1-core" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0f6ab710cec28cef759c5f18671a27dae2a5f952cdaaee1d8e2908cb2478a80" +dependencies = [ + "crunchy", + "digest 0.9.0", + "subtle", +] + +[[package]] +name = "libsecp256k1-gen-ecmult" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccab96b584d38fac86a83f07e659f0deafd0253dc096dab5a36d53efe653c5c3" +dependencies = [ + "libsecp256k1-core", +] + +[[package]] +name = "libsecp256k1-gen-genmult" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67abfe149395e3aa1c48a2beb32b068e2334402df8181f818d3aee2b304c4f5d" +dependencies = [ + "libsecp256k1-core", +] + +[[package]] +name = "light-poseidon" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c9a85a9752c549ceb7578064b4ed891179d20acd85f27318573b64d2d7ee7ee" +dependencies = [ + "ark-bn254 0.4.0", + "ark-ff 0.4.2", + "num-bigint 0.4.6", + "thiserror 1.0.69", +] + +[[package]] +name = "light-poseidon" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47a1ccadd0bb5a32c196da536fd72c59183de24a055f6bf0513bf845fefab862" +dependencies = [ + "ark-bn254 0.5.0", + "ark-ff 0.5.0", + "num-bigint 0.4.6", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "litesvm" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "347d8c652d592c618ac996f2ab21f8c0b0f2da3fbbca227a6887ee61bb75f2de" +dependencies = [ + "agave-feature-set", + "agave-reserved-account-keys", + "agave-syscalls", + "ansi_term", + "bincode", + "indexmap", + "itertools 0.14.0", + "log", + "serde", + "solana-account", + "solana-address 2.6.0", + "solana-address-lookup-table-interface", + "solana-bpf-loader-program", + "solana-builtins", + "solana-clock", + "solana-compute-budget", + "solana-compute-budget-instruction", + "solana-epoch-rewards", + "solana-epoch-schedule", + "solana-feature-gate-interface", + "solana-fee", + "solana-fee-structure", + "solana-hash 3.1.0", + "solana-instruction", + "solana-instructions-sysvar", + "solana-keypair", + "solana-last-restart-slot", + "solana-loader-v3-interface", + "solana-loader-v4-interface", + "solana-message", + "solana-native-token", + "solana-nonce", + "solana-nonce-account", + "solana-precompile-error", + "solana-program-error", + "solana-program-runtime", + "solana-rent 3.1.0", + "solana-sdk-ids", + "solana-sha256-hasher", + "solana-signature", + "solana-signer", + "solana-slot-hashes", + "solana-slot-history", + "solana-stake-interface", + "solana-svm-callback", + "solana-svm-log-collector", + "solana-svm-timings", + "solana-svm-transaction", + "solana-system-interface 2.0.0", + "solana-system-program", + "solana-sysvar", + "solana-sysvar-id", + "solana-transaction", + "solana-transaction-context", + "solana-transaction-error", + "thiserror 2.0.18", +] + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "memoffset" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aa361d4faea93603064a027415f07bd8e1d5c88c9fbf68bf56a285428fd79ce" +dependencies = [ + "autocfg", +] + +[[package]] +name = "merlin" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58c38e2799fc0978b65dfff8023ec7843e2330bb462f19198840b34b6582397d" +dependencies = [ + "byteorder", + "keccak", + "rand_core 0.6.4", + "zeroize", +] + +[[package]] +name = "micromap" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a86d3146ed3995b5913c414f6664344b9617457320782e64f0bb44afd49d74" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "log", + "wasi 0.11.1+wasi-snapshot-preview1", + "windows-sys 0.61.2", +] + +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "nix" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f346ff70e7dbfd675fe90590b92d59ef2de15a8779ae305ebcbfd3f0caf59be4" +dependencies = [ + "autocfg", + "bitflags 1.3.2", + "cfg-if", + "libc", + "memoffset", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8536030f9fea7127f841b45bb6243b27255787fb4eb83958aa1ef9d2fdc0c36" +dependencies = [ + "num-bigint 0.2.6", + "num-complex 0.2.4", + "num-integer", + "num-iter", + "num-rational 0.2.4", + "num-traits", +] + +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint 0.4.6", + "num-complex 0.4.6", + "num-integer", + "num-iter", + "num-rational 0.4.2", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "090c7f9998ee0ff65aa5b723e4009f7b217707f1fb5ea551329cc4d6231fb304" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-cmp" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63335b2e2c34fae2fb0aa2cecfd9f0832a1e24b3b32ecec612c3426d46dc8aaa" + +[[package]] +name = "num-complex" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6b19411a9719e753aff12e5187b74d60d3dc449ec3f4dc21e3989c3f554bc95" +dependencies = [ + "autocfg", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c000134b5dbf44adc5cb772486d335293351644b801551abe8f75c84cfa4aef" +dependencies = [ + "autocfg", + "num-bigint 0.2.6", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint 0.4.6", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +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]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "openssl" +version = "0.10.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "951c002c75e16ea2c65b8c7e4d3d51d5530d8dfa7d060b4776828c88cfb18ecf" +dependencies = [ + "bitflags 2.11.0", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.112" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57d55af3b3e226502be1526dfdba67ab0e9c96fc293004e79576b2b9edb0dbdb" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "outref" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pastey" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5a797f0e07bdf071d15742978fc3128ec6c22891c31a3a931513263904c982a" + +[[package]] +name = "pbkdf2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83a0692ec44e4cf1ef28ca317f14f8f07da2d95ec3fa01f86e4467b725e60917" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "percentage" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fd23b938276f14057220b707937bcb42fa76dda7560e57a2da30cb52d557937" +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", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pico-args" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "opaque-debug", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "806ee80c2a03dbe1a9fb9534f8d19e4c0546b790cde8fd1fea9d6390644cb0be" +dependencies = [ + "anyhow", + "bitflags 1.3.2", + "downcast-rs", + "filedescriptor", + "lazy_static", + "libc", + "log", + "nix", + "serial", + "shared_library", + "shell-words", + "winapi", + "winreg", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d464fae65fff2680baf48019211ce37aaec0c78e9264c84a3e484717f965104e" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "qualifier_attr" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e2e25ee72f5b24d773cae88422baddefff7714f97aab68d96fe2b6fc4a28fb2" +dependencies = [ + "proc-macro2", + "quote", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03" +dependencies = [ + "getrandom 0.1.16", + "libc", + "rand_chacha 0.2.2", + "rand_core 0.5.1", + "rand_hc", +] + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", + "serde", +] + +[[package]] +name = "rand_chacha" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4c8ed856279c9737206bf725bf36935d8666ead7aa69b52be55af369d193402" +dependencies = [ + "ppv-lite86", + "rand_core 0.5.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19" +dependencies = [ + "getrandom 0.1.16", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7dbfa023cd4e604c2553483820c5fe8aa9d71a42eea5aa77c6e7f35756612db" +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", +] + +[[package]] +name = "rayon" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.11.0", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.18", +] + +[[package]] +name = "reedline" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe9e7c532bfc2759bc8a28902c04e8b993fc13ebd085ee4292eb1b230fa9beef" +dependencies = [ + "chrono", + "crossterm", + "fd-lock", + "itertools 0.13.0", + "nu-ansi-term", + "serde", + "strip-ansi-escapes", + "strum 0.26.3", + "strum_macros 0.26.4", + "thiserror 2.0.18", + "unicase", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" dependencies = [ - "boxcar", - "bumpalo", - "dashmap", - "hashbrown 0.14.5", - "thread_local", + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] -name = "ioctl-rs" -version = "0.1.6" +name = "referencing" +version = "0.46.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7970510895cee30b3e9128319f2cefd4bde883a39f38baa279567ba3a7eb97d" +checksum = "cb674900ca31acd75c4aaf63f48e43e719631c0539ea5a9e64163d1296bcb730" dependencies = [ - "libc", + "ahash", + "fluent-uri", + "getrandom 0.3.4", + "hashbrown 0.16.1", + "itoa", + "micromap", + "parking_lot", + "percent-encoding", + "serde_json", ] [[package]] -name = "ipnet" -version = "2.12.0" +name = "regex" +version = "1.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] [[package]] -name = "iri-string" -version = "0.7.12" +name = "regex-automata" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" dependencies = [ + "aho-corasick", "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "encoding_rs", + "futures-core", + "h2", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "hyper 1.8.1", + "hyper-rustls", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "mime", + "native-tls", + "percent-encoding", + "pin-project-lite", + "rustls-pki-types", "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", ] [[package]] -name = "is_terminal_polyfill" -version = "1.70.2" +name = "rfc6979" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] [[package]] -name = "itertools" -version = "0.10.5" +name = "ring" +version = "0.17.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" dependencies = [ - "either", + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", ] [[package]] -name = "itertools" -version = "0.13.0" +name = "rlp" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +checksum = "bb919243f34364b6bd2fc10ef797edbfa75f33c252e7998527479c6d6b47e1ec" dependencies = [ - "either", + "bytes", + "rustc-hex", ] [[package]] -name = "itertools" -version = "0.14.0" +name = "rmcp" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +checksum = "e12ca9067b5ebfbd5b3fcdc4acfceb81aa7d5ab2a879dff7cb75d22434276aad" dependencies = [ - "either", + "async-trait", + "base64 0.22.1", + "chrono", + "futures", + "pastey", + "pin-project-lite", + "rmcp-macros", + "schemars", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tracing", ] [[package]] -name = "itoa" -version = "1.0.18" +name = "rmcp-macros" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +checksum = "7caa6743cc0888e433105fe1bc551a7f607940b126a37bc97b478e86064627eb" +dependencies = [ + "darling 0.23.0", + "proc-macro2", + "quote", + "serde_json", + "syn 2.0.117", +] [[package]] -name = "js-sys" -version = "0.3.94" +name = "ruint" +version = "1.17.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e04e2ef80ce82e13552136fabeef8a5ed1f985a96805761cbb9a2c34e7664d9" +checksum = "c141e807189ad38a07276942c6623032d3753c8859c146104ac2e4d68865945a" dependencies = [ - "cfg-if", - "futures-util", - "once_cell", - "wasm-bindgen", + "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 = "k256" -version = "0.13.4" +name = "ruint-macro" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b" +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", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.11.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" dependencies = [ - "cfg-if", - "ecdsa", - "elliptic-curve", "once_cell", - "sha2", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +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 = "kasuari" -version = "0.4.12" +name = "ryu" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899" -dependencies = [ - "hashbrown 0.16.1", - "portable-atomic", - "thiserror 2.0.18", -] +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" [[package]] -name = "keccak" -version = "0.1.6" +name = "same-file" +version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" dependencies = [ - "cpufeatures", + "winapi-util", ] [[package]] -name = "keccak-asm" -version = "0.1.6" +name = "schannel" +version = "0.1.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa468878266ad91431012b3e5ef1bf9b170eab22883503a318d46857afa4579a" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" dependencies = [ - "digest 0.10.7", - "sha3-asm", + "windows-sys 0.61.2", ] [[package]] -name = "lazy_static" -version = "1.5.0" +name = "schemars" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "chrono", + "dyn-clone", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] [[package]] -name = "leb128fmt" -version = "0.1.0" +name = "schemars_derive" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" +checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.117", +] [[package]] -name = "libc" -version = "0.2.183" +name = "scoped-tls" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" [[package]] -name = "libm" -version = "0.2.16" +name = "scopeguard" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] -name = "libredox" -version = "0.1.15" +name = "sec1" +version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" dependencies = [ - "libc", + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", ] [[package]] -name = "line-clipping" -version = "0.3.7" +name = "security-framework" +version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f50e8f47623268b5407192d26876c4d7f89d686ca130fdc53bced4814cd29f8" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ "bitflags 2.11.0", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", ] [[package]] -name = "linux-raw-sys" -version = "0.12.1" +name = "security-framework-sys" +version = "2.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] [[package]] -name = "litemap" -version = "0.8.2" +name = "semver" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +checksum = "f301af10236f6df4160f7c3f04eec6dbc70ace82d23326abad5edee88801c6b6" +dependencies = [ + "semver-parser", +] [[package]] -name = "litrs" -version = "1.0.0" +name = "semver" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" [[package]] -name = "lock_api" -version = "0.4.14" +name = "semver-parser" +version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +checksum = "9900206b54a3527fdc7b8a938bffd94a568bac4f4aa8113b209df75a09c0dec2" dependencies = [ - "scopeguard", + "pest", ] [[package]] -name = "log" -version = "0.4.29" +name = "serde" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] [[package]] -name = "lru" -version = "0.16.3" +name = "serde-big-array" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1dc47f592c06f33f8e3aea9591776ec7c9f9e4124778ff8a3c3b87159f7e593" +checksum = "11fc7cc2c76d73e0f27ee52abbd64eec84d46f370c88371120433196934e4b7f" dependencies = [ - "hashbrown 0.16.1", + "serde", ] [[package]] -name = "matchit" -version = "0.8.4" +name = "serde_bytes" +version = "0.11.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] [[package]] -name = "memchr" -version = "2.8.0" +name = "serde_core" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] [[package]] -name = "memoffset" -version = "0.6.5" +name = "serde_derive" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5aa361d4faea93603064a027415f07bd8e1d5c88c9fbf68bf56a285428fd79ce" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ - "autocfg", + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] -name = "mime" -version = "0.3.17" +name = "serde_derive_internals" +version = "0.29.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] [[package]] -name = "mime_guess" -version = "2.0.5" +name = "serde_json" +version = "1.0.149" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" dependencies = [ - "mime", - "unicase", + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", ] [[package]] -name = "mio" -version = "1.2.0" +name = "serde_path_to_error" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" dependencies = [ - "libc", - "log", - "wasi", - "windows-sys 0.61.2", + "itoa", + "serde", + "serde_core", ] [[package]] -name = "native-tls" -version = "0.2.18" +name = "serde_regex" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +checksum = "a8136f1a4ea815d7eac4101cfd0b16dc0cb5e1fe1b8609dfd728058656b7badf" dependencies = [ - "libc", - "log", - "openssl", - "openssl-probe", - "openssl-sys", - "schannel", - "security-framework", - "security-framework-sys", - "tempfile", + "regex", + "serde", ] [[package]] -name = "nix" -version = "0.25.1" +name = "serde_urlencoded" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f346ff70e7dbfd675fe90590b92d59ef2de15a8779ae305ebcbfd3f0caf59be4" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" dependencies = [ - "autocfg", - "bitflags 1.3.2", - "cfg-if", - "libc", - "memoffset", - "pin-utils", + "form_urlencoded", + "itoa", + "ryu", + "serde", ] [[package]] -name = "normalize-path" -version = "0.2.1" +name = "serde_with" +version = "3.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5438dd2b2ff4c6df6e1ce22d825ed2fa93ee2922235cc45186991717f0a892d" +checksum = "f05839ce67618e14a09b286535c0d9c94e85ef25469b0e13cb4f844e5593eb19" +dependencies = [ + "serde_core", + "serde_with_macros", +] [[package]] -name = "nu-ansi-term" -version = "0.50.3" +name = "serde_with_macros" +version = "3.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +checksum = "cf2ebbe86054f9b45bc3881e865683ccfaccce97b9b4cb53f3039d67f355a334" dependencies = [ - "windows-sys 0.61.2", + "darling 0.23.0", + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] -name = "num-bigint" -version = "0.4.6" +name = "serial" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +checksum = "a1237a96570fc377c13baa1b88c7589ab66edced652e43ffb17088f003db3e86" dependencies = [ - "num-integer", - "num-traits", + "serial-core", + "serial-unix", + "serial-windows", ] [[package]] -name = "num-conv" -version = "0.2.1" +name = "serial-core" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" +checksum = "3f46209b345401737ae2125fe5b19a77acce90cd53e1658cda928e4fe9a64581" +dependencies = [ + "libc", +] [[package]] -name = "num-integer" -version = "0.1.46" +name = "serial-unix" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +checksum = "f03fbca4c9d866e24a459cbca71283f545a37f8e3e002ad8c70593871453cab7" dependencies = [ - "num-traits", + "ioctl-rs", + "libc", + "serial-core", + "termios", ] [[package]] -name = "num-rational" -version = "0.4.2" +name = "serial-windows" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +checksum = "15c6d3b776267a75d31bbdfd5d36c0ca051251caafc285827052bc53bcdc8162" dependencies = [ - "num-bigint", - "num-integer", - "num-traits", + "libc", + "serial-core", ] [[package]] -name = "num-traits" -version = "0.2.19" +name = "sha1" +version = "0.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ - "autocfg", - "libm", + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", ] [[package]] -name = "num_threads" -version = "0.1.7" +name = "sha2" +version = "0.9.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +checksum = "4d58a1e1bf39749807d89cf2d98ac2dfa0ff1cb3faa38fbb64dd88ac8013d800" dependencies = [ - "libc", + "block-buffer 0.9.0", + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.9.0", + "opaque-debug", ] [[package]] -name = "once_cell" -version = "1.21.4" +name = "sha2" +version = "0.10.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] [[package]] -name = "once_cell_polyfill" -version = "1.70.2" +name = "sha2-const-stable" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +checksum = "5f179d4e11094a893b82fff208f74d448a7512f99f5a0acbd5c679b705f83ed9" [[package]] -name = "once_map" -version = "0.4.23" +name = "sha3" +version = "0.10.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29eefd5038c9eee9e788d90966d6b5578dd3f88363a91edaec117a7ae0adc2d5" +checksum = "75872d278a8f37ef87fa0ddbda7802605cb18344497949862c0d4dcb291eba60" dependencies = [ - "ahash", - "hashbrown 0.16.1", - "parking_lot", - "stable_deref_trait", + "digest 0.10.7", + "keccak", ] [[package]] -name = "openssl" -version = "0.10.76" +name = "sha3-asm" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "951c002c75e16ea2c65b8c7e4d3d51d5530d8dfa7d060b4776828c88cfb18ecf" +checksum = "59cbb88c189d6352cc8ae96a39d19c7ecad8f7330b29461187f2587fdc2988d5" dependencies = [ - "bitflags 2.11.0", + "cc", "cfg-if", - "foreign-types", - "libc", - "once_cell", - "openssl-macros", - "openssl-sys", ] [[package]] -name = "openssl-macros" -version = "0.1.1" +name = "sharded-slab" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", + "lazy_static", ] [[package]] -name = "openssl-probe" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" - -[[package]] -name = "openssl-sys" -version = "0.9.112" +name = "shared_library" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57d55af3b3e226502be1526dfdba67ab0e9c96fc293004e79576b2b9edb0dbdb" +checksum = "5a9e7e0f2bfae24d8a5b5a66c5b257a83c7412304311512a0c054cd5e619da11" dependencies = [ - "cc", + "lazy_static", "libc", - "pkg-config", - "vcpkg", ] [[package]] -name = "option-ext" -version = "0.2.0" +name = "shell-words" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" [[package]] -name = "parity-scale-codec" -version = "3.7.5" +name = "shlex" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "799781ae679d79a948e13d4824a40970bfa500058d245760dd857301059810fa" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" dependencies = [ - "arrayvec", - "bitvec", - "byte-slice-cast", - "const_format", - "impl-trait-for-tuples", - "parity-scale-codec-derive", - "rustversion", - "serde", + "libc", + "signal-hook-registry", ] [[package]] -name = "parity-scale-codec-derive" -version = "3.7.5" +name = "signal-hook-mio" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34b4653168b563151153c9e4c08ebed57fb8262bebfa79711552fa983c623e7a" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" dependencies = [ - "proc-macro-crate", - "proc-macro2", - "quote", - "syn 2.0.117", + "libc", + "mio", + "signal-hook", ] [[package]] -name = "parking_lot" -version = "0.12.5" +name = "signal-hook-registry" +version = "1.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" dependencies = [ - "lock_api", - "parking_lot_core", + "errno", + "libc", ] [[package]] -name = "parking_lot_core" -version = "0.9.12" +name = "signature" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ - "cfg-if", - "libc", - "redox_syscall", - "smallvec", - "windows-link", + "digest 0.10.7", + "rand_core 0.6.4", ] [[package]] -name = "paste" -version = "1.0.15" +name = "similar" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" [[package]] -name = "percent-encoding" -version = "2.3.2" +name = "siphasher" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" [[package]] -name = "pest" -version = "2.8.6" +name = "slab" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" -dependencies = [ - "memchr", - "ucd-trie", -] +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] -name = "petgraph" -version = "0.8.3" +name = "smallvec" +version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" -dependencies = [ - "fixedbitset", - "hashbrown 0.15.5", - "indexmap", - "serde", -] +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] -name = "pin-project-lite" -version = "0.2.17" +name = "socket2" +version = "0.5.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] [[package]] -name = "pin-utils" -version = "0.1.0" +name = "socket2" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] [[package]] -name = "pkcs8" -version = "0.10.2" +name = "solana-account" +version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +checksum = "efc0ed36decb689413b9da5d57f2be49eea5bebb3cf7897015167b0c4336e731" dependencies = [ - "der", - "spki", + "bincode", + "serde", + "serde_bytes", + "serde_derive", + "solana-account-info", + "solana-clock", + "solana-instruction-error", + "solana-pubkey 4.2.0", + "solana-sdk-ids", + "solana-sysvar", ] [[package]] -name = "pkg-config" -version = "0.3.32" +name = "solana-account-info" +version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +checksum = "a9cf16495d9eb53e3d04e72366a33bb1c20c24e78c171d8b8f5978357b63ae95" +dependencies = [ + "solana-address 2.6.0", + "solana-program-error", + "solana-program-memory", +] [[package]] -name = "portable-atomic" -version = "1.13.1" +name = "solana-address" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" +checksum = "a2ecac8e1b7f74c2baa9e774c42817e3e75b20787134b76cc4d45e8a604488f5" +dependencies = [ + "solana-address 2.6.0", +] [[package]] -name = "portable-pty" -version = "0.8.1" +name = "solana-address" +version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "806ee80c2a03dbe1a9fb9534f8d19e4c0546b790cde8fd1fea9d6390644cb0be" +checksum = "f1384b52c435a750cc9c538760fc7bb472fd78e65a9900a2d07312c5bb335b72" dependencies = [ - "anyhow", - "bitflags 1.3.2", - "downcast-rs", - "filedescriptor", - "lazy_static", - "libc", - "log", - "nix", - "serial", - "shared_library", - "shell-words", - "winapi", - "winreg", + "bytemuck", + "bytemuck_derive", + "curve25519-dalek", + "five8", + "five8_const", + "serde", + "serde_derive", + "sha2-const-stable", + "solana-atomic-u64", + "solana-define-syscall 5.1.0", + "solana-program-error", + "solana-sanitize", + "solana-sha256-hasher", ] [[package]] -name = "potential_utf" -version = "0.1.5" +name = "solana-address-lookup-table-interface" +version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +checksum = "115b4f773acc4f3f3cb986b0d335e9845c0368c82b0940410935bc11ae065578" dependencies = [ - "zerovec", + "bincode", + "bytemuck", + "serde", + "serde_derive", + "solana-clock", + "solana-instruction", + "solana-instruction-error", + "solana-pubkey 4.2.0", + "solana-sdk-ids", + "solana-slot-hashes", ] [[package]] -name = "powerfmt" -version = "0.2.0" +name = "solana-atomic-u64" +version = "3.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +checksum = "085db4906d89324cef2a30840d59eaecf3d4231c560ec7c9f6614a93c652f501" +dependencies = [ + "parking_lot", +] [[package]] -name = "ppv-lite86" -version = "0.2.21" +name = "solana-big-mod-exp" +version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +checksum = "30c80fb6d791b3925d5ec4bf23a7c169ef5090c013059ec3ed7d0b2c04efa085" dependencies = [ - "zerocopy", + "num-bigint 0.4.6", + "num-traits", + "solana-define-syscall 3.0.0", ] [[package]] -name = "prettyplease" -version = "0.2.37" +name = "solana-bincode" +version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +checksum = "278a1a5bad62cd9da89ac8d4b7ec444e83caa8ae96aa656dfc27684b28d49a5d" dependencies = [ - "proc-macro2", - "syn 2.0.117", + "bincode", + "serde_core", + "solana-instruction-error", ] [[package]] -name = "primitive-types" -version = "0.12.2" +name = "solana-blake3-hasher" +version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b34d9fd68ae0b74a41b21c03c2f62847aa0ffea044eee893b4c140b37e244e2" +checksum = "7116e1d942a2432ca3f514625104757ab8a56233787e95144c93950029e31176" dependencies = [ - "fixed-hash", - "impl-codec", - "uint", + "blake3", + "solana-define-syscall 4.0.1", + "solana-hash 4.3.0", ] [[package]] -name = "proc-macro-crate" -version = "3.5.0" +name = "solana-bn254" +version = "3.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +checksum = "62ff13a8867fcc7b0f1114764e1bf6191b4551dcaf93729ddc676cd4ec6abc9f" dependencies = [ - "toml_edit", + "ark-bn254 0.5.0", + "ark-ec 0.5.0", + "ark-ff 0.5.0", + "ark-serialize 0.5.0", + "bytemuck", + "solana-define-syscall 5.1.0", + "thiserror 2.0.18", ] [[package]] -name = "proc-macro2" -version = "1.0.106" +name = "solana-borsh" +version = "3.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "c04abbae16f57178a163125805637b8a076175bb5c0002fb04f4792bea901cf7" dependencies = [ - "unicode-ident", + "borsh", ] [[package]] -name = "proptest" -version = "1.11.0" +name = "solana-bpf-loader-program" +version = "3.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +checksum = "bb423db3faa08533a122f867456bb5b7aab211818af004552ea6df5f3c43ef49" dependencies = [ - "bit-set", - "bit-vec", - "bitflags 2.11.0", - "num-traits", - "rand 0.9.2", - "rand_chacha 0.9.0", - "rand_xorshift", - "regex-syntax", - "rusty-fork", - "tempfile", - "unarray", + "agave-syscalls", + "bincode", + "qualifier_attr", + "solana-account", + "solana-bincode", + "solana-clock", + "solana-instruction", + "solana-loader-v3-interface", + "solana-loader-v4-interface", + "solana-packet", + "solana-program-entrypoint", + "solana-program-runtime", + "solana-pubkey 3.0.0", + "solana-sbpf", + "solana-sdk-ids", + "solana-svm-feature-set", + "solana-svm-log-collector", + "solana-svm-measure", + "solana-svm-type-overrides", + "solana-system-interface 2.0.0", + "solana-transaction-context", ] [[package]] -name = "quick-error" -version = "1.2.3" +name = "solana-builtins" +version = "3.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" +checksum = "cc47a5aefa70261825037efd942c2c78a600f4dcc110d59808b359c5d37aa941" +dependencies = [ + "agave-feature-set", + "solana-bpf-loader-program", + "solana-compute-budget-program", + "solana-hash 3.1.0", + "solana-loader-v4-program", + "solana-program-runtime", + "solana-pubkey 3.0.0", + "solana-sdk-ids", + "solana-system-program", + "solana-vote-program", + "solana-zk-elgamal-proof-program", + "solana-zk-token-proof-program", +] [[package]] -name = "quote" -version = "1.0.45" +name = "solana-builtins-default-costs" +version = "3.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +checksum = "6a91f5db54bebaffb93e8bd0d85575139597de7cb1ac32f040442fd66bc90ed0" dependencies = [ - "proc-macro2", + "agave-feature-set", + "ahash", + "log", + "solana-bpf-loader-program", + "solana-compute-budget-program", + "solana-loader-v4-program", + "solana-pubkey 3.0.0", + "solana-sdk-ids", + "solana-system-program", + "solana-vote-program", ] [[package]] -name = "r-efi" -version = "5.3.0" +name = "solana-clock" +version = "3.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +checksum = "95cf11109c3b6115cc510f1e31f06fdd52f504271bc24ef5f1249fbbcae5f9f3" +dependencies = [ + "serde", + "serde_derive", + "solana-sdk-ids", + "solana-sdk-macro", + "solana-sysvar-id", +] [[package]] -name = "r-efi" -version = "6.0.0" +name = "solana-compute-budget" +version = "3.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +checksum = "8de86231371bf26dbcf473a0ea7ca424184db0c7720fafbb899d2fca2eaf1ac2" +dependencies = [ + "solana-fee-structure", + "solana-program-runtime", +] [[package]] -name = "radium" -version = "0.7.0" +name = "solana-compute-budget-instruction" +version = "3.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" +checksum = "27f3d546bf7f979423b8cca3c16ac9b51c80104b5f6bba77ef90b41aa00ec96d" +dependencies = [ + "agave-feature-set", + "log", + "solana-borsh", + "solana-builtins-default-costs", + "solana-compute-budget", + "solana-compute-budget-interface", + "solana-instruction", + "solana-packet", + "solana-pubkey 3.0.0", + "solana-sdk-ids", + "solana-svm-transaction", + "solana-transaction-error", + "thiserror 2.0.18", +] [[package]] -name = "rand" -version = "0.8.5" +name = "solana-compute-budget-interface" +version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +checksum = "8292c436b269ad23cecc8b24f7da3ab07ca111661e25e00ce0e1d22771951ab9" dependencies = [ - "libc", - "rand_chacha 0.3.1", - "rand_core 0.6.4", + "borsh", + "solana-instruction", + "solana-sdk-ids", ] [[package]] -name = "rand" -version = "0.9.2" +name = "solana-compute-budget-program" +version = "3.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +checksum = "b54b78862ca94a2a86354c22f2789ffd095c5f972c15ca104020697dd2cf3409" dependencies = [ - "rand_chacha 0.9.0", - "rand_core 0.9.5", - "serde", + "solana-program-runtime", ] [[package]] -name = "rand_chacha" -version = "0.3.1" +name = "solana-cpi" +version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +checksum = "4dea26709d867aada85d0d3617db0944215c8bb28d3745b912de7db13a23280c" dependencies = [ - "ppv-lite86", - "rand_core 0.6.4", + "solana-account-info", + "solana-define-syscall 4.0.1", + "solana-instruction", + "solana-program-error", + "solana-pubkey 4.2.0", + "solana-stable-layout", ] [[package]] -name = "rand_chacha" -version = "0.9.0" +name = "solana-curve25519" +version = "3.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +checksum = "5aff7432cdf2ec6a44ac06b4d64d2ee006f6c0066d6456e032a7fe25be40cd5c" dependencies = [ - "ppv-lite86", - "rand_core 0.9.5", + "bytemuck", + "bytemuck_derive", + "curve25519-dalek", + "solana-define-syscall 3.0.0", + "subtle", + "thiserror 2.0.18", ] [[package]] -name = "rand_core" -version = "0.6.4" +name = "solana-define-syscall" +version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +checksum = "f9697086a4e102d28a156b8d6b521730335d6951bd39a5e766512bbe09007cee" + +[[package]] +name = "solana-define-syscall" +version = "4.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57e5b1c0bc1d4a4d10c88a4100499d954c09d3fecfae4912c1a074dff68b1738" + +[[package]] +name = "solana-define-syscall" +version = "5.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21e14a4f604117f379840956a8fc8695e4c84f5b0ebed192f31f60d9b85d581d" + +[[package]] +name = "solana-derivation-path" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff71743072690fdbdfcdc37700ae1cb77485aaad49019473a81aee099b1e0b8c" dependencies = [ - "getrandom 0.2.17", + "derivation-path", + "qstring", + "uriparse", ] [[package]] -name = "rand_core" -version = "0.9.5" +name = "solana-epoch-rewards" +version = "3.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +checksum = "f5e7b0ba210593ba8ddd39d6d234d81795d1671cebf3026baa10d5dc23ac42f0" dependencies = [ - "getrandom 0.3.4", "serde", + "serde_derive", + "solana-hash 4.3.0", + "solana-sdk-ids", + "solana-sdk-macro", + "solana-sysvar-id", ] [[package]] -name = "rand_xorshift" -version = "0.4.0" +name = "solana-epoch-schedule" +version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +checksum = "9ce264b7b42322325947c4136a09460bf5c73d9aa8262c9b0a2064be63ba8639" dependencies = [ - "rand_core 0.9.5", + "serde", + "serde_derive", + "solana-sdk-ids", + "solana-sdk-macro", + "solana-sysvar-id", ] [[package]] -name = "rapidhash" -version = "4.4.1" +name = "solana-feature-gate-interface" +version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5e48930979c155e2f33aa36ab3119b5ee81332beb6482199a8ecd6029b80b59" +checksum = "75ca9b5cbb6f500f7fd73db5bd95640f71a83f04d6121a0e59a43b202dca2731" dependencies = [ - "rustversion", + "bincode", + "serde", + "serde_derive", + "solana-account", + "solana-account-info", + "solana-instruction", + "solana-program-error", + "solana-pubkey 4.2.0", + "solana-rent 4.2.0", + "solana-sdk-ids", + "solana-system-interface 3.2.0", ] [[package]] -name = "ratatui" -version = "0.30.0" +name = "solana-fee" +version = "3.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1ce67fb8ba4446454d1c8dbaeda0557ff5e94d39d5e5ed7f10a65eb4c8266bc" +checksum = "c276ea9723bfb6bf9fa2bcde1fa652140b0879d258c78a482533c9c01f71f416" dependencies = [ - "instability", - "ratatui-core", - "ratatui-crossterm", - "ratatui-widgets", + "agave-feature-set", + "solana-fee-structure", + "solana-svm-transaction", ] [[package]] -name = "ratatui-core" -version = "0.1.0" +name = "solana-fee-calculator" +version = "3.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ef8dea09a92caaf73bff7adb70b76162e5937524058a7e5bff37869cbbec293" +checksum = "57e8add96b5741573e9f7529c4bb7719cfcfa999c3847a68cdfaef0cb6adf567" 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", + "log", + "serde", + "serde_derive", ] [[package]] -name = "ratatui-crossterm" -version = "0.1.0" +name = "solana-fee-structure" +version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "577c9b9f652b4c121fb25c6a391dd06406d3b092ba68827e6d2f09550edc54b3" +checksum = "5e2abdb1223eea8ec64136f39cb1ffcf257e00f915c957c35c0dd9e3f4e700b0" + +[[package]] +name = "solana-hash" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "337c246447142f660f778cf6cb582beba8e28deb05b3b24bfb9ffd7c562e5f41" dependencies = [ - "cfg-if", - "crossterm", - "instability", - "ratatui-core", + "solana-hash 4.3.0", ] [[package]] -name = "ratatui-widgets" -version = "0.3.0" +name = "solana-hash" +version = "4.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7dbfa023cd4e604c2553483820c5fe8aa9d71a42eea5aa77c6e7f35756612db" +checksum = "f1b113239362cee7093bfb250467138f079a2a03673181dc15bff6ccd677912d" 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", + "borsh", + "bytemuck", + "bytemuck_derive", + "five8", + "serde", + "serde_derive", + "solana-atomic-u64", + "solana-sanitize", + "wincode", ] [[package]] -name = "rayon" -version = "1.11.0" +name = "solana-instruction" +version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +checksum = "37ebb0ffd19263051bc3f683fcc086134b8ff23af894dcb63f7563c7137b42f1" dependencies = [ - "either", - "rayon-core", + "bincode", + "serde", + "serde_derive", + "solana-define-syscall 5.1.0", + "solana-instruction-error", + "solana-pubkey 4.2.0", ] [[package]] -name = "rayon-core" -version = "1.13.0" +name = "solana-instruction-error" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +checksum = "a0b188842592fdf6cb96f55263ae1bf11713ab5114401d1d5a881ed7cc41bef6" dependencies = [ - "crossbeam-deque", - "crossbeam-utils", + "num-traits", + "serde", + "serde_derive", + "solana-program-error", ] [[package]] -name = "redox_syscall" -version = "0.5.18" +name = "solana-instructions-sysvar" +version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +checksum = "7ddf67876c541aa1e21ee1acae35c95c6fbc61119814bfef70579317a5e26955" dependencies = [ "bitflags 2.11.0", + "solana-account-info", + "solana-instruction", + "solana-instruction-error", + "solana-program-error", + "solana-pubkey 3.0.0", + "solana-sanitize", + "solana-sdk-ids", + "solana-serialize-utils", + "solana-sysvar-id", ] [[package]] -name = "redox_users" -version = "0.5.2" +name = "solana-keccak-hasher" +version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +checksum = "ed1c0d16d6fdeba12291a1f068cdf0d479d9bff1141bf44afd7aa9d485f65ef8" dependencies = [ - "getrandom 0.2.17", - "libredox", - "thiserror 2.0.18", + "sha3", + "solana-define-syscall 4.0.1", + "solana-hash 4.3.0", ] [[package]] -name = "reedline" -version = "0.46.0" +name = "solana-keypair" +version = "3.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe9e7c532bfc2759bc8a28902c04e8b993fc13ebd085ee4292eb1b230fa9beef" +checksum = "263d614c12aa267a3278703175fd6440552ca61bc960b5a02a4482720c53438b" dependencies = [ - "chrono", - "crossterm", - "fd-lock", - "itertools 0.13.0", - "nu-ansi-term", - "serde", - "strip-ansi-escapes", - "strum 0.26.3", - "strum_macros 0.26.4", - "thiserror 2.0.18", - "unicase", - "unicode-segmentation", - "unicode-width", + "ed25519-dalek", + "five8", + "five8_core", + "rand 0.9.2", + "solana-address 2.6.0", + "solana-seed-phrase", + "solana-signature", + "solana-signer", ] [[package]] -name = "regex-syntax" -version = "0.8.10" +name = "solana-last-restart-slot" +version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +checksum = "dcda154ec827f5fc1e4da0af3417951b7e9b8157540f81f936c4a8b1156134d0" +dependencies = [ + "serde", + "serde_derive", + "solana-sdk-ids", + "solana-sdk-macro", + "solana-sysvar-id", +] [[package]] -name = "reqwest" -version = "0.12.28" +name = "solana-loader-v3-interface" +version = "6.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +checksum = "2e0538d4dbc9022e01616f1c58f2db98ece739c5d5ed4a2ef8737a953e76a2d4" dependencies = [ - "base64", - "bytes", - "encoding_rs", - "futures-core", - "h2", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-rustls", - "hyper-tls", - "hyper-util", - "js-sys", - "log", - "mime", - "native-tls", - "percent-encoding", - "pin-project-lite", - "rustls-pki-types", "serde", - "serde_json", - "serde_urlencoded", - "sync_wrapper", - "tokio", - "tokio-native-tls", - "tower", - "tower-http", - "tower-service", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", + "serde_bytes", + "serde_derive", + "solana-instruction", + "solana-pubkey 4.2.0", + "solana-sdk-ids", ] [[package]] -name = "rfc6979" -version = "0.4.0" +name = "solana-loader-v4-interface" +version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +checksum = "e4c948b33ff81fa89699911b207059e493defdba9647eaf18f23abdf3674e0fb" dependencies = [ - "hmac", - "subtle", + "serde", + "serde_bytes", + "serde_derive", + "solana-instruction", + "solana-pubkey 3.0.0", + "solana-sdk-ids", + "solana-system-interface 2.0.0", ] [[package]] -name = "ring" -version = "0.17.14" +name = "solana-loader-v4-program" +version = "3.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +checksum = "4495b9ef97f369302d882f752465c563ac2aaf7f52cd1a9cf15891a90f986f5f" dependencies = [ - "cc", - "cfg-if", - "getrandom 0.2.17", - "libc", - "untrusted", - "windows-sys 0.52.0", + "log", + "solana-account", + "solana-bincode", + "solana-bpf-loader-program", + "solana-instruction", + "solana-loader-v3-interface", + "solana-loader-v4-interface", + "solana-packet", + "solana-program-runtime", + "solana-pubkey 3.0.0", + "solana-sbpf", + "solana-sdk-ids", + "solana-svm-log-collector", + "solana-svm-measure", + "solana-svm-type-overrides", + "solana-transaction-context", +] + +[[package]] +name = "solana-message" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0448b1fd891c5f46491e5dc7d9986385ba3c852c340db2911dd29faa01d2b08d" +dependencies = [ + "bincode", + "blake3", + "lazy_static", + "serde", + "serde_derive", + "solana-address 2.6.0", + "solana-hash 4.3.0", + "solana-instruction", + "solana-sanitize", + "solana-sdk-ids", + "solana-short-vec", + "solana-transaction-error", ] [[package]] -name = "rlp" -version = "0.5.2" +name = "solana-msg" +version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb919243f34364b6bd2fc10ef797edbfa75f33c252e7998527479c6d6b47e1ec" +checksum = "726b7cbbc6be6f1c6f29146ac824343b9415133eee8cce156452ad1db93f8008" dependencies = [ - "bytes", - "rustc-hex", + "solana-define-syscall 5.1.0", ] [[package]] -name = "ruint" -version = "1.17.2" +name = "solana-native-token" +version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c141e807189ad38a07276942c6623032d3753c8859c146104ac2e4d68865945a" +checksum = "ae8dd4c280dca9d046139eb5b7a5ac9ad10403fbd64964c7d7571214950d758f" + +[[package]] +name = "solana-nonce" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d95dbc9f2e33b6c10e231df15cb2a3bff9ea7eab6347f9e316fe75c97fd67bbb" 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", - "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", + "serde", + "serde_derive", + "solana-fee-calculator", + "solana-hash 4.3.0", + "solana-pubkey 4.2.0", + "solana-sha256-hasher", ] [[package]] -name = "ruint-macro" -version = "1.2.1" +name = "solana-nonce-account" +version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48fd7bd8a6377e15ad9d42a8ec25371b94ddc67abe7c8b9127bec79bebaaae18" +checksum = "805fd25b29e5a1a0e6c3dd6320c9da80f275fbe4ff6e392617c303a2085c435e" +dependencies = [ + "solana-account", + "solana-hash 3.1.0", + "solana-nonce", + "solana-sdk-ids", +] [[package]] -name = "rustc-hash" -version = "2.1.1" +name = "solana-packet" +version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" +checksum = "6edf2f25743c95229ac0fdc32f8f5893ef738dbf332c669e9861d33ddb0f469d" +dependencies = [ + "bitflags 2.11.0", +] [[package]] -name = "rustc-hex" -version = "2.1.0" +name = "solana-poseidon" +version = "3.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e75f6a532d0fd9f7f13144f392b6ad56a32696bfcd9c78f797f16bbb6f072d6" +checksum = "13ac13134287d7af80717353a8136e3c515d7f34d88e6f116b47350bd623e338" +dependencies = [ + "ark-bn254 0.4.0", + "ark-bn254 0.5.0", + "light-poseidon 0.2.0", + "light-poseidon 0.4.0", + "solana-define-syscall 3.0.0", + "thiserror 2.0.18", +] [[package]] -name = "rustc_version" -version = "0.3.3" +name = "solana-precompile-error" +version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0dfe2087c51c460008730de8b57e6a320782fbfb312e1f4d520e6c6fae155ee" +checksum = "cafcd950de74c6c39d55dc8ca108bbb007799842ab370ef26cf45a34453c31e1" dependencies = [ - "semver 0.11.0", + "num-traits", ] [[package]] -name = "rustc_version" -version = "0.4.1" +name = "solana-program-entrypoint" +version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +checksum = "84c9b0a1ff494e05f503a08b3d51150b73aa639544631e510279d6375f290997" dependencies = [ - "semver 1.0.27", + "solana-account-info", + "solana-define-syscall 4.0.1", + "solana-program-error", + "solana-pubkey 4.2.0", ] [[package]] -name = "rustix" -version = "1.1.4" +name = "solana-program-error" +version = "3.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +checksum = "4f04fa578707b3612b095f0c8e19b66a1233f7c42ca8082fcb3b745afcc0add6" + +[[package]] +name = "solana-program-memory" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4068648649653c2c50546e9a7fb761791b5ab0cda054c771bb5808d3a4b9eb52" dependencies = [ - "bitflags 2.11.0", - "errno", - "libc", - "linux-raw-sys", - "windows-sys 0.61.2", + "solana-define-syscall 4.0.1", ] [[package]] -name = "rustls" -version = "0.23.37" +name = "solana-program-runtime" +version = "3.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +checksum = "2c03c5100c43bf28fd03a11b66345ccdc28c1b7e5a7d49dbcff64e6442595627" dependencies = [ - "once_cell", - "rustls-pki-types", - "rustls-webpki", - "subtle", - "zeroize", + "base64 0.22.1", + "bincode", + "itertools 0.12.1", + "log", + "percentage", + "rand 0.8.5", + "serde", + "solana-account", + "solana-account-info", + "solana-clock", + "solana-epoch-rewards", + "solana-epoch-schedule", + "solana-fee-structure", + "solana-hash 3.1.0", + "solana-instruction", + "solana-last-restart-slot", + "solana-loader-v3-interface", + "solana-program-entrypoint", + "solana-pubkey 3.0.0", + "solana-rent 3.1.0", + "solana-sbpf", + "solana-sdk-ids", + "solana-slot-hashes", + "solana-stable-layout", + "solana-stake-interface", + "solana-svm-callback", + "solana-svm-feature-set", + "solana-svm-log-collector", + "solana-svm-measure", + "solana-svm-timings", + "solana-svm-transaction", + "solana-svm-type-overrides", + "solana-system-interface 2.0.0", + "solana-sysvar", + "solana-sysvar-id", + "solana-transaction-context", + "thiserror 2.0.18", ] [[package]] -name = "rustls-pki-types" -version = "1.14.0" +name = "solana-pubkey" +version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +checksum = "8909d399deb0851aa524420beeb5646b115fd253ef446e35fe4504c904da3941" dependencies = [ - "zeroize", + "solana-address 1.1.0", ] [[package]] -name = "rustls-webpki" -version = "0.103.10" +name = "solana-pubkey" +version = "4.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" +checksum = "7db719574990de7e8b0f55a8593ac92a5ccb42c8ce67b3e4bf05b139d5d9ee71" dependencies = [ - "ring", - "rustls-pki-types", - "untrusted", + "solana-address 2.6.0", ] [[package]] -name = "rustversion" -version = "1.0.22" +name = "solana-rent" +version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "e860d5499a705369778647e97d760f7670adfb6fc8419dd3d568deccd46d5487" +dependencies = [ + "serde", + "serde_derive", + "solana-sdk-ids", + "solana-sdk-macro", + "solana-sysvar-id", +] [[package]] -name = "rusty-fork" -version = "0.3.1" +name = "solana-rent" +version = "4.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +checksum = "f9809b081e99bc142ce803bcd7ee18306759ce3b30a96a9da3f6f41c45e50ef0" dependencies = [ - "fnv", - "quick-error", - "tempfile", - "wait-timeout", + "solana-sdk-macro", ] [[package]] -name = "ryu" -version = "1.0.23" +name = "solana-sanitize" +version = "3.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +checksum = "dcf09694a0fc14e5ffb18f9b7b7c0f15ecb6eac5b5610bf76a1853459d19daf9" [[package]] -name = "schannel" -version = "0.1.29" +name = "solana-sbpf" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +checksum = "b15b079e08471a9dbfe1e48b2c7439c85aa2a055cbd54eddd8bd257b0a7dbb29" dependencies = [ - "windows-sys 0.61.2", + "byteorder", + "combine", + "hash32", + "libc", + "log", + "rand 0.8.5", + "rustc-demangle", + "thiserror 2.0.18", + "winapi", ] [[package]] -name = "scoped-tls" -version = "1.0.1" +name = "solana-sdk-ids" +version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" +checksum = "def234c1956ff616d46c9dd953f251fa7096ddbaa6d52b165218de97882b7280" +dependencies = [ + "solana-address 2.6.0", +] [[package]] -name = "scopeguard" -version = "1.2.0" +name = "solana-sdk-macro" +version = "3.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +checksum = "8765316242300c48242d84a41614cb3388229ec353ba464f6fe62a733e41806f" +dependencies = [ + "bs58", + "proc-macro2", + "quote", + "syn 2.0.117", +] [[package]] -name = "sec1" -version = "0.7.3" +name = "solana-secp256k1-recover" +version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +checksum = "e7c5f18893d62e6c73117dcba48f8f5e3266d90e5ec3d0a0a90f9785adac36c1" dependencies = [ - "base16ct", - "der", - "generic-array", - "pkcs8", - "subtle", - "zeroize", + "k256", + "solana-define-syscall 5.1.0", + "thiserror 2.0.18", ] [[package]] -name = "security-framework" -version = "3.7.0" +name = "solana-seed-derivable" +version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +checksum = "ff7bdb72758e3bec33ed0e2658a920f1f35dfb9ed576b951d20d63cb61ecd95c" dependencies = [ - "bitflags 2.11.0", - "core-foundation 0.10.1", - "core-foundation-sys", - "libc", - "security-framework-sys", + "solana-derivation-path", ] [[package]] -name = "security-framework-sys" -version = "2.17.0" +name = "solana-seed-phrase" +version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +checksum = "dc905b200a95f2ea9146e43f2a7181e3aeb55de6bc12afb36462d00a3c7310de" dependencies = [ - "core-foundation-sys", - "libc", + "hmac", + "pbkdf2", + "sha2 0.10.9", ] [[package]] -name = "semver" -version = "0.11.0" +name = "solana-serde-varint" +version = "3.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f301af10236f6df4160f7c3f04eec6dbc70ace82d23326abad5edee88801c6b6" +checksum = "950e5b83e839dc0f92c66afc124bb8f40e89bc90f0579e8ec5499296d27f54e3" dependencies = [ - "semver-parser", + "serde", ] [[package]] -name = "semver" -version = "1.0.27" +name = "solana-serialize-utils" +version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" +checksum = "5d7cc401931d178472358e6b78dc72d031dc08f752d7410f0e8bd259dd6f02fa" +dependencies = [ + "solana-instruction-error", + "solana-pubkey 4.2.0", + "solana-sanitize", +] [[package]] -name = "semver-parser" -version = "0.10.3" +name = "solana-sha256-hasher" +version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9900206b54a3527fdc7b8a938bffd94a568bac4f4aa8113b209df75a09c0dec2" +checksum = "db7dc3011ea4c0334aaaa7e7128cb390ecf546b28d412e9bf2064680f57f588f" dependencies = [ - "pest", + "sha2 0.10.9", + "solana-define-syscall 4.0.1", + "solana-hash 4.3.0", ] [[package]] -name = "serde" -version = "1.0.228" +name = "solana-short-vec" +version = "3.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "2bb8cc883fc7b8ce4a7814cb1441b48c06437049ec11847005cf63bcfa85c546" dependencies = [ "serde_core", - "serde_derive", ] [[package]] -name = "serde_core" -version = "1.0.228" +name = "solana-signature" +version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "e7a73c6e97cc2108be0adf6a6ea326434f8398df9d7eed81da2a4548b69e971c" dependencies = [ + "ed25519-dalek", + "five8", + "serde", + "serde-big-array", "serde_derive", + "solana-sanitize", + "wincode", ] [[package]] -name = "serde_derive" -version = "1.0.228" +name = "solana-signer" +version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "5bfea97951fee8bae0d6038f39a5efcb6230ecdfe33425ac75196d1a1e3e3235" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", + "solana-pubkey 3.0.0", + "solana-signature", + "solana-transaction-error", ] [[package]] -name = "serde_json" -version = "1.0.149" +name = "solana-slot-hashes" +version = "3.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "2585f70191623887329dfb5078da3a00e15e3980ea67f42c2e10b07028419f43" dependencies = [ - "itoa", - "memchr", "serde", - "serde_core", - "zmij", + "serde_derive", + "solana-hash 4.3.0", + "solana-sdk-ids", + "solana-sysvar-id", ] [[package]] -name = "serde_path_to_error" -version = "0.1.20" +name = "solana-slot-history" +version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +checksum = "f914f6b108f5bba14a280b458d023e3621c9973f27f015a4d755b50e88d89e97" dependencies = [ - "itoa", + "bv", "serde", - "serde_core", + "serde_derive", + "solana-sdk-ids", + "solana-sysvar-id", ] [[package]] -name = "serde_urlencoded" -version = "0.7.1" +name = "solana-stable-layout" +version = "3.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +checksum = "c9f6a291ba063a37780af29e7db14bdd3dc447584d8ba5b3fc4b88e2bbc982fa" dependencies = [ - "form_urlencoded", - "itoa", - "ryu", - "serde", + "solana-instruction", + "solana-pubkey 4.2.0", ] [[package]] -name = "serial" -version = "0.4.0" +name = "solana-stake-interface" +version = "2.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1237a96570fc377c13baa1b88c7589ab66edced652e43ffb17088f003db3e86" +checksum = "b9bc26191b533f9a6e5a14cca05174119819ced680a80febff2f5051a713f0db" dependencies = [ - "serial-core", - "serial-unix", - "serial-windows", + "num-traits", + "serde", + "serde_derive", + "solana-clock", + "solana-cpi", + "solana-instruction", + "solana-program-error", + "solana-pubkey 3.0.0", + "solana-system-interface 2.0.0", + "solana-sysvar", + "solana-sysvar-id", ] [[package]] -name = "serial-core" -version = "0.4.0" +name = "solana-svm-callback" +version = "3.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f46209b345401737ae2125fe5b19a77acce90cd53e1658cda928e4fe9a64581" +checksum = "012617d16d2994673d98792f7f6d93f612dea00b1b747a3c4aec24c12547875b" dependencies = [ - "libc", + "solana-account", + "solana-clock", + "solana-precompile-error", + "solana-pubkey 3.0.0", ] [[package]] -name = "serial-unix" -version = "0.4.0" +name = "solana-svm-feature-set" +version = "3.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f03fbca4c9d866e24a459cbca71283f545a37f8e3e002ad8c70593871453cab7" +checksum = "7cc2e2fdebd77159b7a14ee45c9dbb3f1d202e8e7ccc14e4cda78c006a7a78a9" + +[[package]] +name = "solana-svm-log-collector" +version = "3.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ce188c2c438ced63a975af79f06db2ff5accaf1a4027a26e35783be566f6070" dependencies = [ - "ioctl-rs", - "libc", - "serial-core", - "termios", + "log", ] [[package]] -name = "serial-windows" -version = "0.4.0" +name = "solana-svm-measure" +version = "3.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fea64909ba06fa651c95c4db35614430b1a0bc722e51996e97b5b779e3528bad" + +[[package]] +name = "solana-svm-timings" +version = "3.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15c6d3b776267a75d31bbdfd5d36c0ca051251caafc285827052bc53bcdc8162" +checksum = "a8a05b09e2caac9b4d7c35c5997d754433e15ee5f506509117eb77032e1718ac" dependencies = [ - "libc", - "serial-core", + "eager", + "enum-iterator", + "solana-pubkey 3.0.0", ] [[package]] -name = "sha1" -version = "0.10.6" +name = "solana-svm-transaction" +version = "3.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +checksum = "be3250a278a769ba59059e13d0f16c2aba0ca1de7595fb0e02556091751560c8" dependencies = [ - "cfg-if", - "cpufeatures", - "digest 0.10.7", + "solana-hash 3.1.0", + "solana-message", + "solana-pubkey 3.0.0", + "solana-sdk-ids", + "solana-signature", + "solana-transaction", ] [[package]] -name = "sha2" -version = "0.10.9" +name = "solana-svm-type-overrides" +version = "3.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +checksum = "3b78cd0bfb102d4197ce8c590f800a119ba0d358369ca57b0f66e94d1317fd0e" dependencies = [ - "cfg-if", - "cpufeatures", - "digest 0.10.7", + "rand 0.8.5", ] [[package]] -name = "sha3" -version = "0.10.8" +name = "solana-system-interface" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75872d278a8f37ef87fa0ddbda7802605cb18344497949862c0d4dcb291eba60" +checksum = "4e1790547bfc3061f1ee68ea9d8dc6c973c02a163697b24263a8e9f2e6d4afa2" dependencies = [ - "digest 0.10.7", - "keccak", + "num-traits", + "serde", + "serde_derive", + "solana-instruction", + "solana-msg", + "solana-program-error", + "solana-pubkey 3.0.0", ] [[package]] -name = "sha3-asm" -version = "0.1.6" +name = "solana-system-interface" +version = "3.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59cbb88c189d6352cc8ae96a39d19c7ecad8f7330b29461187f2587fdc2988d5" +checksum = "55b54965bf0b76fa8e2b35376583efddd4d916618cfe595bf48c7d7b55a9e628" dependencies = [ - "cc", - "cfg-if", + "num-traits", + "serde", + "serde_derive", + "solana-address 2.6.0", + "solana-instruction", + "solana-msg", + "solana-program-error", ] [[package]] -name = "shared_library" -version = "0.1.9" +name = "solana-system-program" +version = "3.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a9e7e0f2bfae24d8a5b5a66c5b257a83c7412304311512a0c054cd5e619da11" +checksum = "8b4b6faeddf5a62c06991a9a077fd1097da6867060f884595a659b3b24dc3a4a" +dependencies = [ + "bincode", + "log", + "serde", + "solana-account", + "solana-bincode", + "solana-fee-calculator", + "solana-instruction", + "solana-nonce", + "solana-nonce-account", + "solana-packet", + "solana-program-runtime", + "solana-pubkey 3.0.0", + "solana-sdk-ids", + "solana-svm-log-collector", + "solana-svm-type-overrides", + "solana-system-interface 2.0.0", + "solana-sysvar", + "solana-transaction-context", +] + +[[package]] +name = "solana-sysvar" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6690d3dd88f15c21edff68eb391ef8800df7a1f5cec84ee3e8d1abf05affdf74" dependencies = [ + "base64 0.22.1", + "bincode", "lazy_static", - "libc", + "serde", + "serde_derive", + "solana-account-info", + "solana-clock", + "solana-define-syscall 4.0.1", + "solana-epoch-rewards", + "solana-epoch-schedule", + "solana-fee-calculator", + "solana-hash 4.3.0", + "solana-instruction", + "solana-last-restart-slot", + "solana-program-entrypoint", + "solana-program-error", + "solana-program-memory", + "solana-pubkey 4.2.0", + "solana-rent 3.1.0", + "solana-sdk-ids", + "solana-sdk-macro", + "solana-slot-hashes", + "solana-slot-history", + "solana-sysvar-id", ] [[package]] -name = "shell-words" -version = "1.1.1" +name = "solana-sysvar-id" +version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" +checksum = "17358d1e9a13e5b9c2264d301102126cf11a47fd394cdf3dec174fe7bc96e1de" +dependencies = [ + "solana-address 2.6.0", + "solana-sdk-ids", +] [[package]] -name = "shlex" -version = "1.3.0" +name = "solana-transaction" +version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +checksum = "96697cff5075a028265324255efed226099f6d761ca67342b230d09f72cc48d2" +dependencies = [ + "bincode", + "serde", + "serde_derive", + "solana-address 2.6.0", + "solana-hash 4.3.0", + "solana-instruction", + "solana-instruction-error", + "solana-message", + "solana-sanitize", + "solana-sdk-ids", + "solana-short-vec", + "solana-signature", + "solana-signer", + "solana-transaction-error", +] [[package]] -name = "signal-hook" -version = "0.3.18" +name = "solana-transaction-context" +version = "3.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +checksum = "b1a3c3a69688293a195b02c60a5384d855b8de19981f404c71ccb9e7f139b98f" dependencies = [ - "libc", - "signal-hook-registry", + "bincode", + "serde", + "solana-account", + "solana-instruction", + "solana-instructions-sysvar", + "solana-pubkey 3.0.0", + "solana-rent 3.1.0", + "solana-sbpf", + "solana-sdk-ids", ] [[package]] -name = "signal-hook-mio" -version = "0.2.5" +name = "solana-transaction-error" +version = "3.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +checksum = "4a2165ad25b694c654d5395fc7a049452a192376e4c96a7fad05580f6ba5ba1c" dependencies = [ - "libc", - "mio", - "signal-hook", + "serde", + "serde_derive", + "solana-instruction-error", + "solana-sanitize", ] [[package]] -name = "signal-hook-registry" -version = "1.4.8" +name = "solana-vote-interface" +version = "4.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +checksum = "db6e123e16bfdd7a81d71b4c4699e0b29580b619f4cd2ef5b6aae1eb85e8979f" dependencies = [ - "errno", - "libc", + "bincode", + "cfg_eval", + "num-derive", + "num-traits", + "serde", + "serde_derive", + "serde_with", + "solana-clock", + "solana-hash 3.1.0", + "solana-instruction", + "solana-instruction-error", + "solana-pubkey 3.0.0", + "solana-rent 3.1.0", + "solana-sdk-ids", + "solana-serde-varint", + "solana-serialize-utils", + "solana-short-vec", + "solana-system-interface 2.0.0", ] [[package]] -name = "signature" -version = "2.2.0" +name = "solana-vote-program" +version = "3.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +checksum = "4164d0eb4760cbdb3dd46457999dba735079774381fe4042a70ec7484930a297" dependencies = [ - "digest 0.10.7", - "rand_core 0.6.4", + "agave-feature-set", + "bincode", + "log", + "num-derive", + "num-traits", + "serde", + "solana-account", + "solana-bincode", + "solana-clock", + "solana-epoch-schedule", + "solana-hash 3.1.0", + "solana-instruction", + "solana-keypair", + "solana-packet", + "solana-program-runtime", + "solana-pubkey 3.0.0", + "solana-rent 3.1.0", + "solana-sdk-ids", + "solana-signer", + "solana-slot-hashes", + "solana-transaction", + "solana-transaction-context", + "solana-vote-interface", + "thiserror 2.0.18", ] [[package]] -name = "slab" -version = "0.4.12" +name = "solana-zk-elgamal-proof-program" +version = "3.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" +checksum = "14f30c80edc4aac841745f7e93bbf1afc27d2b496b8ae9fe9777935151cb9352" +dependencies = [ + "agave-feature-set", + "bytemuck", + "num-derive", + "num-traits", + "solana-instruction", + "solana-program-runtime", + "solana-sdk-ids", + "solana-svm-log-collector", + "solana-zk-sdk", +] [[package]] -name = "smallvec" -version = "1.15.1" +name = "solana-zk-sdk" +version = "4.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +checksum = "9602bcb1f7af15caef92b91132ec2347e1c51a72ecdbefdaefa3eac4b8711475" +dependencies = [ + "aes-gcm-siv", + "base64 0.22.1", + "bincode", + "bytemuck", + "bytemuck_derive", + "curve25519-dalek", + "getrandom 0.2.17", + "itertools 0.12.1", + "js-sys", + "merlin", + "num-derive", + "num-traits", + "rand 0.8.5", + "serde", + "serde_derive", + "serde_json", + "sha3", + "solana-derivation-path", + "solana-instruction", + "solana-pubkey 3.0.0", + "solana-sdk-ids", + "solana-seed-derivable", + "solana-seed-phrase", + "solana-signature", + "solana-signer", + "subtle", + "thiserror 2.0.18", + "wasm-bindgen", + "zeroize", +] [[package]] -name = "socket2" -version = "0.6.3" +name = "solana-zk-token-proof-program" +version = "3.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +checksum = "962938a9994cc6d54b46b5f0d6a978024f4847272f560f8f11edd1575a0d8e8f" dependencies = [ - "libc", - "windows-sys 0.61.2", + "agave-feature-set", + "bytemuck", + "num-derive", + "num-traits", + "solana-instruction", + "solana-program-runtime", + "solana-sdk-ids", + "solana-svm-log-collector", + "solana-zk-token-sdk", +] + +[[package]] +name = "solana-zk-token-sdk" +version = "3.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e5fe47f0389206960e272a6f1af3b06c2b32551be77f9e4254564b6d1177b83" +dependencies = [ + "aes-gcm-siv", + "base64 0.22.1", + "bincode", + "bytemuck", + "bytemuck_derive", + "curve25519-dalek", + "itertools 0.12.1", + "merlin", + "num-derive", + "num-traits", + "rand 0.8.5", + "serde", + "serde_json", + "sha3", + "solana-curve25519", + "solana-derivation-path", + "solana-instruction", + "solana-pubkey 3.0.0", + "solana-sdk-ids", + "solana-seed-derivable", + "solana-seed-phrase", + "solana-signature", + "solana-signer", + "subtle", + "thiserror 2.0.18", + "zeroize", ] [[package]] @@ -3227,7 +6666,7 @@ dependencies = [ "alloy-primitives", "bumpalo", "either", - "num-rational", + "num-rational 0.4.2", "semver 1.0.27", "solar-data-structures", "solar-interface", @@ -3326,8 +6765,8 @@ dependencies = [ "bumpalo", "itertools 0.14.0", "memchr", - "num-bigint", - "num-rational", + "num-bigint 0.4.6", + "num-rational 0.4.2", "num-traits", "ruint", "smallvec", @@ -3386,6 +6825,18 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "string_cache" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared", + "precomputed-hash", +] + [[package]] name = "strip-ansi-escapes" version = "0.2.1" @@ -3422,7 +6873,7 @@ version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" dependencies = [ - "heck", + "heck 0.5.0", "proc-macro2", "quote", "rustversion", @@ -3435,7 +6886,7 @@ version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" dependencies = [ - "heck", + "heck 0.5.0", "proc-macro2", "quote", "syn 2.0.117", @@ -3529,6 +6980,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "term" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c59df8ac95d96ff9bede18eb7300b0fda5e5d8d90960e76f8e14ae765eedbf1f" +dependencies = [ + "dirs-next", + "rustversion", + "winapi", +] + [[package]] name = "terminal_size" version = "0.4.4" @@ -3618,6 +7080,15 @@ version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + [[package]] name = "tinystr" version = "0.8.3" @@ -3628,6 +7099,21 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "tokio" version = "1.50.0" @@ -3640,7 +7126,7 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2", + "socket2 0.6.3", "tokio-macros", "windows-sys 0.61.2", ] @@ -3769,8 +7255,8 @@ dependencies = [ "bytes", "futures-core", "futures-util", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "http-body-util", "http-range-header", "httpdate", @@ -3829,6 +7315,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", ] [[package]] @@ -3846,7 +7362,7 @@ dependencies = [ "byteorder", "bytes", "data-encoding", - "http", + "http 1.4.0", "httparse", "log", "rand 0.8.5", @@ -3863,7 +7379,7 @@ checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" dependencies = [ "bytes", "data-encoding", - "http", + "http 1.4.0", "httparse", "log", "rand 0.9.2", @@ -3908,6 +7424,12 @@ version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" +[[package]] +name = "unicode-general-category" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -3943,12 +7465,41 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "unreachable" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "382810877fe448991dfc7f0dd6e3ae5d58088fd0ea5e35189655f84e6814fa56" +dependencies = [ + "void", +] + [[package]] name = "untrusted" version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "uriparse" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0200d0fc04d809396c2ad43f3c95da3582a2556eba8d453c1087f4120ee352ff" +dependencies = [ + "fnv", + "lazy_static", +] + [[package]] name = "url" version = "2.5.8" @@ -3979,12 +7530,28 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b082222b4f6619906941c17eb2297fff4c2fb96cb60164170522942a200bd8" +dependencies = [ + "outref", + "vsimd", +] + [[package]] name = "valuable" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "value-bag" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ba6f5989077681266825251a52748b8c1d8a4ad098cc37e440103d0ea717fc0" + [[package]] name = "vcpkg" version = "0.2.15" @@ -3997,6 +7564,18 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "void" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a02e4885ed3bc0f2de90ea6dd45ebcbb66dacffe03547fadbb0eeae2770887d" + +[[package]] +name = "vsimd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" + [[package]] name = "vte" version = "0.14.1" @@ -4015,6 +7594,16 @@ dependencies = [ "libc", ] +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "want" version = "0.3.1" @@ -4024,6 +7613,12 @@ dependencies = [ "try-lock", ] +[[package]] +name = "wasi" +version = "0.9.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519" + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -4163,12 +7758,46 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "wincode" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4c754f1fc41250f2f742a27ba0fcc9f73df1dec23f6878490770855d43c322d" +dependencies = [ + "pastey", + "proc-macro2", + "quote", + "thiserror 2.0.18", + "wincode-derive", +] + +[[package]] +name = "wincode-derive" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e070787599c7c067b89598cd3eda440cca1b69eda9e0ff7c725fc8679ce9eb4" +dependencies = [ + "darling 0.21.3", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "windows-core" version = "0.62.2" @@ -4364,7 +7993,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" dependencies = [ "anyhow", - "heck", + "heck 0.5.0", "wit-parser", ] @@ -4375,7 +8004,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" dependencies = [ "anyhow", - "heck", + "heck 0.5.0", "indexmap", "prettyplease", "syn 2.0.117", diff --git a/Cargo.toml b/Cargo.toml index 2ca21b8..96b4461 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,21 +4,32 @@ members = [ "crates/ilold-core", "crates/ilold-cli", "crates/ilold-web", + "crates/ilold-session-core", + "crates/ilold-solana-core", + "crates/ilold-mcp", + "crates/ilold-help", + "crates/ilold-render", ] -# Shared dependency versions — referenced via { workspace = true } in member crates +[workspace.package] +edition = "2024" +version = "0.1.0" + [workspace.dependencies] -# Pinned: no API stability guarantee between patch versions solar = { version = "=0.1.8", package = "solar-compiler", default-features = false } petgraph = "0.8" serde = { version = "1", features = ["derive"] } serde_json = "1" thiserror = "2" anyhow = "1" -clap = { version = "4", features = ["derive"] } +clap = { version = "4", features = ["derive", "env"] } tokio = { version = "1", features = ["full"] } axum = { version = "0.8", features = ["ws"] } tower-http = { version = "0.6", features = ["cors", "fs"] } +rmcp = { version = "1.6", features = ["server", "transport-io"] } +schemars = "1.2" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } [profile.release] lto = "thin" diff --git a/crates/ilold-cli/Cargo.toml b/crates/ilold-cli/Cargo.toml index 2840f1e..2f4178b 100644 --- a/crates/ilold-cli/Cargo.toml +++ b/crates/ilold-cli/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "ilold-cli" -version = "0.1.0" -edition = "2021" +version.workspace = true +edition.workspace = true description = "CLI for Ilold: smart contract execution path analyzer" [[bin]] @@ -10,7 +10,11 @@ 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" } +ilold-help = { path = "../ilold-help" } +ilold-render = { path = "../ilold-render" } anyhow = { workspace = true } clap = { workspace = true } tokio = { workspace = true } diff --git a/crates/ilold-cli/src/analyze.rs b/crates/ilold-cli/src/analyze.rs index 62dba7a..361b910 100644 --- a/crates/ilold-cli/src/analyze.rs +++ b/crates/ilold-cli/src/analyze.rs @@ -41,8 +41,6 @@ pub fn run( println!("Parsed {} file(s), {} contract(s)\n", project.source_files.len(), project.contracts.len()); - // Precompute all per-contract sequence analyses, then run the - // inheritance-aware transitive effect pass. let config = PruningConfig::default(); let mut all_analyses: HashMap = HashMap::new(); for contract in &project.contracts { diff --git a/crates/ilold-cli/src/colors.rs b/crates/ilold-cli/src/colors.rs index b04a6f8..10a86fc 100644 --- a/crates/ilold-cli/src/colors.rs +++ b/crates/ilold-cli/src/colors.rs @@ -1,18 +1 @@ -use colored::{Colorize, ColoredString}; -use ilold_core::classify::entry_points::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) } -pub fn c_danger(s: &str) -> ColoredString { s.truecolor(170, 90, 90) } -pub fn c_ok(s: &str) -> ColoredString { s.truecolor(100, 160, 110) } -pub fn c_muted(s: &str) -> ColoredString { s.truecolor(110, 120, 140) } -pub fn c_bright(s: &str) -> ColoredString { s.truecolor(190, 200, 215).bold() } - -pub fn access_colored(access: &AccessLevel) -> ColoredString { - match access { - AccessLevel::Public => c_accent("[P]"), - AccessLevel::Restricted { .. } => c_warn("[R]"), - AccessLevel::Internal => c_muted("[I]"), - AccessLevel::Special { .. } => c_muted("[S]"), - } -} +pub use ilold_render::colors::*; diff --git a/crates/ilold-cli/src/context.rs b/crates/ilold-cli/src/context.rs index 12fa3df..d7b4a03 100644 --- a/crates/ilold-cli/src/context.rs +++ b/crates/ilold-cli/src/context.rs @@ -54,8 +54,6 @@ pub fn run( pt_map.insert(key, pt); } } - // Build per-contract analyses for every contract in the project so that - // transitive effects can span the full inheritance chain. let mut all_sequence_analyses: HashMap = HashMap::new(); for c in &project.contracts { let combined = project.inherited_state_vars(c); @@ -213,8 +211,7 @@ fn print_sequence(n: &SequenceNarrative) { c_bright(&format!("Step {}: {}", i + 1, step.function)), c_muted(&format!("({})", step.access))); - // Collect all lines for this step to know which is last - let mut lines: Vec<(String, String)> = Vec::new(); // (label, value) + let mut lines: Vec<(String, String)> = Vec::new(); for req in &step.requires { lines.push(("require".into(), c_warn(req).to_string())); diff --git a/crates/ilold-cli/src/explore.rs b/crates/ilold-cli/src/explore.rs index b5314d9..b1e5fb7 100644 --- a/crates/ilold-cli/src/explore.rs +++ b/crates/ilold-cli/src/explore.rs @@ -11,14 +11,50 @@ use reedline::{ 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<()> { - // --attach mode: connect to a running server instead of starting one locally 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}"))?; @@ -28,8 +64,6 @@ pub async fn run(paths: Vec, port: u16, max_seq_depth: usize, attach: O let project_info: serde_json::Value = resp.json().await?; let contracts_arr = project_info["contracts"].as_array(); - // Pick the LAST contract (not interface/library) — in Solidity the main - // contract is always at the end of the file, after imports and dependencies. 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())) @@ -37,8 +71,6 @@ pub async fn run(paths: Vec, port: u16, max_seq_depth: usize, attach: O .unwrap_or("unknown") .to_string(); - // /api/project only has function counts, not names. - // Fetch /api/contract/{name} per contract to get function names for the completer. 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()) @@ -68,41 +100,154 @@ pub async fn run(paths: Vec, port: u16, max_seq_depth: usize, attach: O 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)); + 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(()); } - // Local mode: start server and connect 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 +} - let contract_name = state.project.find_contract(None) - .map(|c| c.name.clone()) - .unwrap_or_else(|_| "unknown".into()); - - let function_names: Vec = state.project.contracts.iter() - .find(|c| c.name == contract_name) - .map(|c| { - state.project - .accessible_functions(c) - .iter() - .map(|af| af.function.name.clone()) +async fn run_solana_attach( + url: String, + _client: reqwest::Client, + project_map: serde_json::Value, +) -> Result<()> { + let programs_arr = project_map["programs"].as_array(); + let program_name = programs_arr + .and_then(|arr| arr.first()) + .and_then(|p| p["name"].as_str()) + .unwrap_or("unknown") + .to_string(); + let program_names: Vec = programs_arr + .map(|arr| { + arr.iter() + .filter_map(|p| p["name"].as_str().map(String::from)) + .collect() + }) + .unwrap_or_default(); + let function_names: Vec = programs_arr + .and_then(|arr| arr.iter().find(|p| p["name"].as_str() == Some(&program_name))) + .and_then(|p| p["instructions"].as_array()) + .map(|ixs| { + ixs.iter() + .filter_map(|i| i["name"].as_str().map(String::from)) .collect() }) .unwrap_or_default(); - let contract_names: Vec = state.project.contracts.iter() - .map(|c| c.name.clone()) - .filter(|n| !n.is_empty()) - .collect(); + let banner = fmt::header_box(&[ + &format!("ilold explore — {} (solana, attached)", program_name), + &format!("{} instructions | Type ? for help", function_names.len()), + &format!("Server: {}", url), + ]); + println!("{}\n", banner); - let func_count = function_names.len(); + 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_thread + .join() + .map_err(|_| anyhow::anyhow!("REPL thread panicked"))?; + Ok(()) +} + +pub async fn run_solana( + detected: ilold_solana_core::ingest::DetectedProject, + port: u16, +) -> Result<()> { + println!("Analyzing {} IDL(s)...", detected.idl_paths.len()); + let (state, actual_port) = ilold_web::start_solana_server(detected, port).await?; + run_with_state(state, actual_port).await +} + +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 func_count = function_names.len(); + let func_label = match backend { + BackendKind::Solana => "instructions", + _ => "functions", + }; let banner = fmt::header_box(&[ - &format!("ilold explore — {}", contract_name), - &format!("{} functions | Type ? for help", func_count), + &header_label, + &format!("{} {} | Type ? for help", func_count, func_label), &format!("Web UI: http://localhost:{}", actual_port), ]); println!("{}\n", banner); @@ -111,13 +256,23 @@ pub async fn run(paths: Vec, port: u16, max_seq_depth: usize, attach: O let state_for_thread = state.clone(); let base_url = format!("http://127.0.0.1:{}", actual_port); let repl_thread = std::thread::spawn(move || { - repl_loop(handle, contract_name, function_names, contract_names, Some(state_for_thread), base_url, None); + repl_loop( + handle, + contract_name, + function_names, + contract_names, + Some(state_for_thread), + base_url, + None, + backend, + ); }); repl_thread.join().map_err(|_| anyhow::anyhow!("REPL thread panicked"))?; Ok(()) } +#[allow(clippy::too_many_arguments)] fn repl_loop( handle: tokio::runtime::Handle, mut contract: String, @@ -126,6 +281,7 @@ fn repl_loop( state: Option>, base_url: String, functions_by_contract: Option>>, + backend: BackendKind, ) { let history_path = dirs::home_dir() .map(|h| h.join(".ilold").join("history")) @@ -162,23 +318,47 @@ fn repl_loop( scenario: scenario_name.clone(), }; - // Initial prompt sync in --attach mode: pick up steps from other terminals if state.is_none() { - if let Some(server_steps) = sync_steps(&handle, &client, &base_url, &contract) { + if let Some(server_steps) = sync_steps(&handle, &client, &base_url, &contract, backend) { 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; + } + } + } } loop { - // Sync prompt from server in --attach mode (catches changes from other terminals) if state.is_none() { - if let Some(server_steps) = sync_steps(&handle, &client, &base_url, &contract) { + if let Some(server_steps) = sync_steps(&handle, &client, &base_url, &contract, backend) { 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(scns) = sync_scenarios(&handle, &client, &base_url, &contract) { + if let Ok(mut comp) = completer.lock() { + if comp.scenarios != scns { + comp.scenarios = scns; + } + } + } + } } match editor.read_line(&prompt) { @@ -188,7 +368,7 @@ fn repl_loop( match handle_input( line, &handle, &client, &base_url, &contract, - &mut steps, &mut scenario_name, &completer, &state, + &mut steps, &mut scenario_name, &completer, &state, backend, ) { InputResult::Continue => {} InputResult::Quit => break, @@ -200,20 +380,42 @@ fn repl_loop( contract = new_name.clone(); steps.clear(); scenario_name = "main".into(); - if let Some(ref state) = state { - // Local mode: use AppState directly - if let Some(c) = state.project.contracts.iter().find(|c| c.name == new_name) { - functions = state.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(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(); + } + } } } - } else if let Some(ref fbc) = functions_by_contract { - // --attach mode: use cached per-contract function map + } 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() { @@ -254,6 +456,7 @@ impl Completer for CompleterWrapper { } } +#[allow(clippy::too_many_arguments)] fn handle_input( line: &str, handle: &tokio::runtime::Handle, @@ -264,14 +467,25 @@ fn handle_input( scenario_name: &mut String, completer: &std::sync::Arc>, state: &Option>, + backend: BackendKind, ) -> InputResult { - // Allow shortcuts like `st0`, `st1`, `step2` without requiring a space. + 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(""); - // Inline help: appending ? to any command prints a one-line usage. if cmd.ends_with('?') && cmd.len() > 1 { let base = &cmd[..cmd.len() - 1]; print_inline_help(base); @@ -293,8 +507,6 @@ fn handle_input( use ilold_core::exploration::commands::ScenarioAction; - // Parse `fork ` or `fork at `. Returns Err with a - // user-facing message on parse failure. let parse_fork = |raw: &str| -> Result { let parts: Vec<&str> = raw.split_whitespace().collect(); match parts.as_slice() { @@ -341,7 +553,6 @@ fn handle_input( }); match send_command(handle, client, base_url, &body) { Ok(result) => { - // Update local trackers before printing. let mut did_update_scenario = false; match &result { CommandResult::ScenarioCreated { name } => { @@ -384,11 +595,13 @@ fn handle_input( InputResult::Continue } - "ct" | "contracts" => { - if let Some(ref state) = state { - print_contracts(state, contract); + "ct" | "contracts" | "programs" | "progs" => { + if let Some(state) = state { + match backend { + BackendKind::Solana => print_programs(state, contract), + _ => print_contracts(state, contract), + } } else { - // --attach mode: fetch contract list from server match handle.block_on(async { let resp = client.get(format!("{base_url}/api/project")).send().await?; resp.json::().await @@ -414,28 +627,64 @@ fn handle_input( println!(" Usage: use "); return InputResult::Continue; } - if let Some(ref state) = state { - // Local mode - match state.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()))); + 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 + } } - InputResult::SwitchContract(name) } - Err(e) => { - eprintln!(" {}", c_danger(&e)); - 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 { - // --attach mode: switch directly, let the server validate commands let name = arg.to_string(); if name == contract { println!(" Already using {}", c_accent(&name)); @@ -556,7 +805,6 @@ fn handle_input( if arg.is_empty() { handle_finding_interactive(handle, client, base_url, contract, steps); } else { - // Parse: fi [description] let finding_parts: Vec<&str> = arg.splitn(2, ' ').collect(); if finding_parts.len() < 2 { println!(" Usage: fi <severity> <title>"); @@ -666,9 +914,6 @@ fn handle_input( TraceTarget::Function(func_name) => { let mut url = format!("{base_url}/api/session/trace/{contract}/{func_name}"); let mut sep = '?'; - // Interactive mode needs more context to be useful, so - // bump the default depth to 4 when `-i` is set and the - // user didn't pass an explicit `--depth`. let effective_depth = parsed.depth .or(if parsed.interactive { Some(4) } else { None }); if let Some(d) = effective_depth { @@ -689,7 +934,6 @@ fn handle_input( url } TraceTarget::SessionStep(idx) => { - // Persisted tree — depth/reverts/expand flags ignored. format!("{base_url}/api/session/step/{idx}/trace") } }; @@ -733,9 +977,6 @@ fn handle_input( } "sl" | "slice" => { let parts: Vec<&str> = arg.split_whitespace().collect(); - // Separate flags from positional args so order doesn't matter: - // `sl deposit totalStaked --backward` and `sl --backward deposit totalStaked` - // both parse correctly. let mut positionals: Vec<&str> = Vec::new(); let mut direction: Option<&str> = None; for part in &parts { @@ -856,20 +1097,901 @@ fn handle_input( } } -/// Fetch current session steps from the server (for --attach prompt sync). +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>"); + return InputResult::Continue; + } + let lamports: u64 = match parts[1].parse() { + Ok(v) => v, + Err(_) => { + println!(" Lamports must be an integer"); + return InputResult::Continue; + } + }; + let body = + serde_json::json!({"Airdrop": {"user": parts[0], "lamports": lamports}}); + dispatch_solana(handle, client, base_url, contract, body, steps) + } + "time-warp" | "tw" => { + let delta: i64 = match arg.parse() { + Ok(v) => v, + Err(_) => { + println!(" Usage: time-warp <delta_seconds>"); + return InputResult::Continue; + } + }; + let body = serde_json::json!({"TimeWarp": {"delta_seconds": delta}}); + dispatch_solana(handle, client, base_url, contract, body, steps) + } + "pda" => { + if arg.is_empty() { + println!(" Usage: pda <instruction>"); + return InputResult::Continue; + } + let body = serde_json::json!({"Pda": {"instruction": arg}}); + dispatch_solana(handle, client, base_url, contract, body, steps) + } + "inspect" | "acc" => { + if arg.is_empty() { + println!(" Usage: inspect <pubkey>"); + return InputResult::Continue; + } + let body = serde_json::json!({"Inspect": {"pubkey": arg}}); + dispatch_solana(handle, client, base_url, contract, body, steps) + } + "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!(" or: call <ix> {{json}} for full control"); + return InputResult::Continue; + } + let ix = parts[0].to_string(); + let payload_raw = parts.get(1).copied().unwrap_or("").trim(); + let body = if payload_raw.starts_with('{') { + let parsed: serde_json::Value = match serde_json::from_str(payload_raw) { + Ok(v) => v, + Err(e) => { + println!(" Invalid JSON: {e}"); + return InputResult::Continue; + } + }; + serde_json::json!({ + "Call": { + "ix": ix, + "args": parsed.get("args").cloned().unwrap_or(serde_json::json!({})), + "accounts": parsed.get("accounts").cloned().unwrap_or(serde_json::json!({})), + "signers": parsed.get("signers").cloned().unwrap_or(serde_json::json!([])), + } + }) + } else { + let program = match fetch_program_detail(handle, client, base_url, contract) { + Ok(p) => p, + Err(e) => { + eprintln!(" {}", c_danger(&format!("fetch program: {e}"))); + return InputResult::Continue; + } + }; + match build_call_from_kv(&program, &ix, payload_raw) { + Ok(body) => body, + Err(e) => { + eprintln!(" {}", c_danger(&e)); + return InputResult::Continue; + } + } + }; + dispatch_solana(handle, client, base_url, contract, body, steps) + } + "ct" | "contracts" | "programs" | "progs" => { + match handle.block_on(async { + client + .get(format!("{base_url}/api/project/map")) + .send() + .await? + .json::<serde_json::Value>() + .await + }) { + Ok(map) => print_remote_programs(&map, contract), + Err(e) => { + eprintln!(" {}", c_danger(&format!("Failed to fetch programs: {e}"))) + } + } + InputResult::Continue + } + "sc" | "scenario" => { + let parts: Vec<&str> = arg.split_whitespace().collect(); + let sub = parts.first().copied().unwrap_or(""); + let name_arg = parts.get(1).copied().unwrap_or(""); + let action: Option<serde_json::Value> = match sub { + "new" if !name_arg.is_empty() => { + Some(serde_json::json!({"New": {"name": name_arg}})) + } + "list" | "ls" | "" => Some(serde_json::json!("List")), + "switch" if !name_arg.is_empty() => { + Some(serde_json::json!({"Switch": {"name": name_arg}})) + } + "fork" if !name_arg.is_empty() => { + let at_step: Option<usize> = parts.get(2).and_then(|s| s.parse().ok()); + Some(serde_json::json!({"Fork": {"name": name_arg, "at_step": at_step}})) + } + "delete" | "rm" if !name_arg.is_empty() => { + Some(serde_json::json!({"Delete": {"name": name_arg}})) + } + _ => None, + }; + let action = match action { + Some(a) => a, + None => { + println!( + " Usage: scenario new|list|switch|fork|delete <name> [step]" + ); + return InputResult::Continue; + } + }; + let body = serde_json::json!({"Scenario": {"sub": action}}); + let outcome = dispatch_solana(handle, client, base_url, contract, body, steps); + *scenario_name = sync_active_scenario(handle, client, base_url, contract) + .unwrap_or_else(|| scenario_name.clone()); + if let Ok(mut comp) = completer.lock() { + if let Some(items) = sync_scenarios(handle, client, base_url, contract) { + comp.scenarios = items; + } + } + outcome + } + "note" | "n" => { + if arg.is_empty() { + println!(" Usage: note <text>"); + return InputResult::Continue; + } + let body = serde_json::json!({"Note": {"text": arg}}); + dispatch_solana(handle, client, base_url, contract, body, steps) + } + "fi" | "finding" => { + if arg.is_empty() { + println!(" Usage: finding <severity> <title> [--rec=\"...\"]"); + println!(" Severity: critical | high | medium | low | info"); + return InputResult::Continue; + } + let (rest, rec): (&str, Option<String>) = match arg.find("--rec=") { + Some(idx) => { + let head = arg[..idx].trim_end(); + let tail = &arg[idx + "--rec=".len()..]; + (head, Some(strip_quotes(tail).to_string())) + } + None => (arg, None), + }; + let parts: Vec<&str> = rest.splitn(2, ' ').collect(); + if parts.len() < 2 { + println!(" Usage: finding <severity> <title> [--rec=\"...\"]"); + return InputResult::Continue; + } + let severity = match normalize_severity(parts[0]) { + Some(s) => s, + None => { + println!( + " {}", + c_danger("Invalid severity. Valid: critical, high, medium, low, info") + ); + return InputResult::Continue; + } + }; + let body = serde_json::json!({ + "Finding": { + "severity": severity, + "title": parts[1], + "description": "", + "recommendation": rec, + } + }); + dispatch_solana(handle, client, base_url, contract, body, 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!(" {} API running at {}/api/", c_muted("·"), base_url); + InputResult::Continue + } + "step" | "st" => { + let idx: usize = match arg.trim().parse() { + Ok(n) => n, + Err(_) => { + println!(" Usage: step <index>"); + return InputResult::Continue; + } + }; + let body = serde_json::json!({"Step": {"index": idx}}); + dispatch_solana(handle, client, base_url, contract, body, steps) + } + "save" => { + if arg.is_empty() { + println!(" Usage: save <name> [--with-keypairs]"); + return InputResult::Continue; + } + let mut with_keypairs = false; + let mut name: Option<&str> = None; + for tok in arg.split_whitespace() { + if tok == "--with-keypairs" { + with_keypairs = true; + } else if tok.starts_with("--") { + println!( + " Unknown flag: {tok}. Use --with-keypairs (or no flags)." + ); + return InputResult::Continue; + } else if name.is_none() { + name = Some(tok); + } else { + println!(" Usage: save <name> [--with-keypairs]"); + return InputResult::Continue; + } + } + let name = match name { + Some(n) => n, + None => { + println!(" Usage: save <name> [--with-keypairs]"); + return InputResult::Continue; + } + }; + let body = serde_json::json!({ + "contract": contract, + "command": {"SaveSession": {"with_keypairs": with_keypairs}}, + }); + match send_solana_command(handle, client, base_url, &body) { + Ok(SolanaCommandResult::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", name)); + match std::fs::write(&path, &json) { + Ok(_) => { + println!( + " {} Saved to {}", + c_ok("✓"), + c_accent(&path.display().to_string()) + ); + if with_keypairs { + eprintln!( + " {} bundle includes plaintext test keypairs — do NOT commit it", + c_warn("⚠ "), + ); + } + } + Err(e) => eprintln!(" {} Write failed: {}", c_danger("✗"), e), + } + } + Ok(other) => print_solana_result(&other), + 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; + } + }; + if json.contains("\"keypairs_present\": true") + || json.contains("\"keypairs_present\":true") + { + eprintln!( + " {} bundle contains plaintext test keypairs — do NOT commit *.json files like this", + c_warn("⚠ "), + ); + } + let body = serde_json::json!({ + "contract": contract, + "command": {"LoadSession": {"json": json}} + }); + match send_solana_command(handle, client, base_url, &body) { + Ok(SolanaCommandResult::SessionLoaded { steps: loaded, .. }) => { + *steps = loaded; + println!(" {} Session loaded ({} steps)", c_ok("✓"), steps.len()); + return InputResult::UpdatePrompt; + } + Ok(other) => print_solana_result(&other), + Err(e) => eprintln!(" {}", c_danger(&e)), + } + InputResult::Continue + } + "findings" | "fl" => dispatch_solana( + handle, + client, + base_url, + contract, + serde_json::json!("Findings"), + steps, + ), + "export" | "ex" => { + let mut auditor: Option<String> = None; + 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)"); + return InputResult::Continue; + } + } + let metadata = if auditor.is_some() || version.is_some() || date.is_some() { + Some(serde_json::json!({ + "auditor": auditor, + "project_version": version, + "audit_date": date, + })) + } else { + None + }; + let body = serde_json::json!({"Export": {"metadata": metadata}}); + dispatch_solana(handle, client, base_url, contract, body, steps) + } + "who" => { + if arg.is_empty() { + println!(" Usage: who <account_type> (e.g. who Pool)"); + return InputResult::Continue; + } + let body = serde_json::json!({"Who": {"account_type": arg}}); + dispatch_solana(handle, client, base_url, contract, body, steps) + } + "timeline" | "tl" => { + if arg.is_empty() { + println!(" Usage: timeline <pubkey>"); + return InputResult::Continue; + } + let body = serde_json::json!({"Timeline": {"pubkey": arg}}); + dispatch_solana(handle, client, base_url, contract, body, steps) + } + "status" => { + let parts: Vec<&str> = arg.split_whitespace().collect(); + if parts.len() != 2 { + println!(" Usage: status <ix> <open|reviewed|finding>"); + return InputResult::Continue; + } + let st = match parts[1].to_lowercase().as_str() { + "open" => "Open", + "reviewed" => "Reviewed", + "finding" | "found" => "Finding", + other => { + println!(" Unknown status '{other}'. Use open|reviewed|finding"); + return InputResult::Continue; + } + }; + let body = + serde_json::json!({"Status": {"ix": parts[0], "status": st}}); + dispatch_solana(handle, client, base_url, contract, body, steps) + } + _ => { + println!( + " Unknown command: {}. Type {} for help.", + c_danger(&cmd), + c_accent("?") + ); + InputResult::Continue + } + } +} + +fn dispatch_solana( + handle: &tokio::runtime::Handle, + client: &reqwest::Client, + base_url: &str, + contract: &str, + command: serde_json::Value, + steps: &mut Vec<String>, +) -> InputResult { + let body = serde_json::json!({"contract": contract, "command": command}); + match send_solana_command(handle, client, base_url, &body) { + Ok(result) => { + apply_solana_result_to_steps(&result, steps); + print_solana_result(&result); + InputResult::UpdatePrompt + } + Err(e) => { + eprintln!(" {}", c_danger(&e)); + InputResult::Continue + } + } +} + +fn fetch_program_detail( + handle: &tokio::runtime::Handle, + client: &reqwest::Client, + base_url: &str, + name: &str, +) -> Result<ProgramView, String> { + handle.block_on(async { + let resp = client + .get(format!("{base_url}/api/program/{name}/view")) + .send() + .await + .map_err(|e| format!("request: {e}"))?; + if !resp.status().is_success() { + return Err(format!("status {}", resp.status())); + } + resp.json::<ProgramView>() + .await + .map_err(|e| format!("parse: {e}")) + }) +} + +fn build_call_from_kv( + program: &ProgramView, + ix_name: &str, + rest: &str, +) -> Result<serde_json::Value, String> { + let ix = program + .instructions + .iter() + .find(|i| i.name == ix_name) + .ok_or_else(|| format!("instruction '{ix_name}' not found in program"))?; + + let mut args = serde_json::Map::new(); + let mut accounts = serde_json::Map::new(); + let mut signer_overrides: Option<Vec<String>> = None; + let mut signer_negatives: Vec<String> = Vec::new(); + + for token in rest.split_whitespace() { + if let Some(name_csv) = token.strip_prefix("--no-signer=") { + for n in name_csv.split(',').map(|s| s.trim()) { + if !n.is_empty() { + signer_negatives.push(n.to_string()); + } + } + continue; + } + if let Some(name_csv) = token.strip_prefix("--signer=") { + let mut acc = signer_overrides.unwrap_or_default(); + for n in name_csv.split(',').map(|s| s.trim()) { + if !n.is_empty() { + acc.push(n.to_string()); + } + } + signer_overrides = Some(acc); + continue; + } + let (key, value) = match token.split_once('=') { + Some(kv) => kv, + None => return Err(format!("expected key=value, got '{token}'")), + }; + if let Some(arg) = ix.args.iter().find(|a| a.name == key) { + args.insert(key.to_string(), coerce_kv(value, &arg.ty)); + } else if ix.accounts.iter().any(|a| a.name == key) { + accounts.insert(key.to_string(), serde_json::Value::String(value.to_string())); + } else { + let arg_list: Vec<String> = ix.args.iter().map(|a| a.name.clone()).collect(); + let acc_list: Vec<String> = ix.accounts.iter().map(|a| a.name.clone()).collect(); + return Err(format!( + "unknown key '{key}'; expected one of args [{}] or accounts [{}]", + arg_list.join(","), + acc_list.join(",") + )); + } + } + + let signers = match signer_overrides { + Some(list) => list + .into_iter() + .filter(|n| !signer_negatives.contains(n)) + .collect(), + None => ix + .accounts + .iter() + .filter(|a| a.signer) + .filter_map(|a| accounts.get(&a.name).and_then(|v| v.as_str()).map(String::from)) + .filter(|n| !signer_negatives.contains(n)) + .collect::<Vec<String>>(), + }; + + Ok(serde_json::json!({ + "Call": { + "ix": ix_name, + "args": serde_json::Value::Object(args), + "accounts": serde_json::Value::Object(accounts), + "signers": signers, + } + })) +} + +fn coerce_kv(raw: &str, ty: &str) -> serde_json::Value { + match ty { + "bool" => return serde_json::Value::Bool(raw == "true" || raw == "1"), + "u8" | "u16" | "u32" | "u64" | "i8" | "i16" | "i32" | "i64" | "f32" | "f64" => { + if let Ok(n) = raw.parse::<u64>() { + return serde_json::Value::Number(n.into()); + } + if let Ok(n) = raw.parse::<i64>() { + return serde_json::Value::Number(n.into()); + } + if let Ok(f) = raw.parse::<f64>() { + if let Some(n) = serde_json::Number::from_f64(f) { + return serde_json::Value::Number(n); + } + } + } + _ => {} + } + serde_json::Value::String(raw.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use ilold_solana_core::idl::parse_idl; + use ilold_solana_core::model::ProgramDef; + + const STAKING_IDL: &str = include_str!( + "../../../tests/fixtures/solana/staking/idls/staking.json" + ); + + fn staking_view() -> ProgramView { + ProgramDef::from_idl(parse_idl(STAKING_IDL).expect("parse staking idl")) + .expect("build staking ProgramDef") + .compute_view() + } + + #[test] + fn kv_parser_distributes_args_and_accounts() { + let program = staking_view(); + let body = build_call_from_kv( + &program, + "initialize_pool", + "reward_rate=10 pool=pool admin=admin", + ) + .expect("kv parse"); + assert_eq!(body["Call"]["ix"], "initialize_pool"); + assert_eq!(body["Call"]["args"]["reward_rate"], 10); + assert_eq!(body["Call"]["accounts"]["pool"], "pool"); + assert_eq!(body["Call"]["accounts"]["admin"], "admin"); + let signers: Vec<_> = body["Call"]["signers"].as_array().unwrap().iter().collect(); + assert!(signers.iter().any(|v| v.as_str() == Some("pool"))); + assert!(signers.iter().any(|v| v.as_str() == Some("admin"))); + } + + #[test] + fn kv_parser_supports_no_signer_override() { + let program = staking_view(); + let body = build_call_from_kv( + &program, + "initialize_pool", + "reward_rate=10 pool=pool admin=admin --no-signer=admin", + ) + .expect("kv parse"); + let signers: Vec<&str> = body["Call"]["signers"] + .as_array() + .unwrap() + .iter() + .filter_map(|v| v.as_str()) + .collect(); + assert!(signers.contains(&"pool")); + assert!(!signers.contains(&"admin")); + } + + #[test] + fn kv_parser_rejects_unknown_key() { + let program = staking_view(); + let err = build_call_from_kv( + &program, + "initialize_pool", + "reward_rate=10 ghost=foo", + ) + .unwrap_err(); + assert!(err.contains("ghost"), "got: {err}"); + } + + #[test] + fn kv_parser_omits_constant_accounts_from_form() { + let program = staking_view(); + let body = build_call_from_kv( + &program, + "initialize_pool", + "reward_rate=10 pool=pool admin=admin", + ) + .expect("kv parse"); + assert!(body["Call"]["accounts"].get("system_program").is_none()); + } +} + +fn send_solana_command( + handle: &tokio::runtime::Handle, + client: &reqwest::Client, + base_url: &str, + body: &serde_json::Value, +) -> Result<SolanaCommandResult, String> { + handle.block_on(async { + let resp = client + .post(format!("{base_url}/api/cmd")) + .json(body) + .send() + .await + .map_err(|e| format!("Request failed: {e}"))?; + let status = resp.status(); + if !status.is_success() { + let text = resp.text().await.unwrap_or_default(); + return Err(format!("Server error {status}: {text}")); + } + resp.json::<SolanaCommandResult>() + .await + .map_err(|e| format!("Parse failed: {e}")) + }) +} + +fn apply_solana_result_to_steps(result: &SolanaCommandResult, steps: &mut Vec<String>) { + match result { + SolanaCommandResult::StepAdded { instruction, .. } => steps.push(instruction.clone()), + SolanaCommandResult::StepRemoved { .. } => { + steps.pop(); + } + SolanaCommandResult::Cleared => steps.clear(), + SolanaCommandResult::SessionView { steps: server_steps, .. } => { + *steps = server_steps.clone(); + } + SolanaCommandResult::SessionLoaded { steps: server_steps, .. } => { + *steps = server_steps.clone(); + } + _ => {} + } +} + +fn print_solana_result(result: &SolanaCommandResult) { + println!(); + if let SolanaCommandResult::Error { message } = result { + eprintln!(" {} {}", c_danger("✗"), message); + println!(); + return; + } + print!("{}", ilold_render::render_solana_result(result)); + println!(); +} + +fn sync_active_scenario( + handle: &tokio::runtime::Handle, + client: &reqwest::Client, + base_url: &str, + contract: &str, +) -> Option<String> { + let body = serde_json::json!({ + "contract": contract, + "command": {"Scenario": {"sub": "List"}} + }); + let res = send_solana_command(handle, client, base_url, &body).ok()?; + if let SolanaCommandResult::ScenarioList { items } = res { + items.into_iter().find(|i| i.active).map(|i| i.name) + } else { + None + } +} + +fn sync_scenarios( + handle: &tokio::runtime::Handle, + client: &reqwest::Client, + base_url: &str, + contract: &str, +) -> Option<Vec<String>> { + let body = serde_json::json!({ + "contract": contract, + "command": {"Scenario": {"sub": "List"}} + }); + let res = send_solana_command(handle, client, base_url, &body).ok()?; + if let SolanaCommandResult::ScenarioList { items } = res { + Some(items.into_iter().map(|i| i.name).collect()) + } else { + None + } +} + fn sync_steps( handle: &tokio::runtime::Handle, client: &reqwest::Client, base_url: &str, contract: &str, + backend: BackendKind, ) -> Option<Vec<String>> { let body = serde_json::json!({ "contract": contract, "command": "Session" }); - match send_command(handle, client, base_url, &body) { - Ok(CommandResult::SessionView { steps, .. }) => Some(steps), - _ => None, + 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, + } } } @@ -988,7 +2110,6 @@ fn parse_trace_args(arg: &str) -> TraceArgs { interactive = true; i += 1; } else if let Some(rest) = t.strip_prefix('+') { - // `+N` — force-inline the call at canonical step_id N. if let Ok(id) = rest.parse::<usize>() { expand.push(id); } @@ -997,9 +2118,6 @@ fn parse_trace_args(arg: &str) -> TraceArgs { && target.is_none() && tokens.get(i + 1).and_then(|s| s.parse::<usize>().ok()).is_some() { - // `tr step <N>` — re-render a persisted session step. - // Only treated as a keyword when the next token parses as usize; - // otherwise `step` falls through to be treated as a function name. let idx = tokens[i + 1].parse::<usize>().unwrap(); target = Some(TraceTarget::SessionStep(idx)); i += 2; @@ -1013,6 +2131,17 @@ fn parse_trace_args(arg: &str) -> TraceArgs { 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) + || (s.starts_with('\'') && s.ends_with('\'') && s.len() >= 2) + { + &s[1..s.len() - 1] + } else { + s + } +} + fn normalize_severity(input: &str) -> Option<&'static str> { match input.to_lowercase().as_str() { "critical" => Some("Critical"), @@ -1092,15 +2221,84 @@ fn handle_finding_interactive( } } +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, + None => { + println!(" {}", c_muted("No programs in /api/project/map")); + return; + } + }; + println!(); + if arr.is_empty() { + println!(" {}", c_muted("No programs detected")); + println!(); + return; + } + for p in arr { + let name = p.get("name").and_then(|v| v.as_str()).unwrap_or("?"); + let pid = p.get("program_id").and_then(|v| v.as_str()).unwrap_or(""); + let ix_count = p + .get("instructions") + .and_then(|v| v.as_array()) + .map(|a| a.len()) + .unwrap_or(0); + let marker = if name == current { + c_ok(" ← current").to_string() + } else { + String::new() + }; + println!( + " {} {} {} {}{}", + c_accent("[P]"), + name, + c_muted(&format!("({} ix)", ix_count)), + c_muted(pid), + marker + ); + } + 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 = state.project.contracts.iter() + 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 &state.project.contracts { + for c in &s.project.contracts { if c.name.is_empty() { continue; } let badge = match c.kind { ContractKind::Contract => c_accent("[C]"), @@ -1148,8 +2346,6 @@ fn print_findings_list( } } -// ─── Output formatting ───────────────────────────────────────────────────── - fn print_result(result: &CommandResult, steps: &[String]) { match result { CommandResult::StepAdded { step_index, function, access, state_changed } => { @@ -1432,8 +2628,6 @@ fn print_narrative(val: &serde_json::Value) { println!(" {} [{}]{}", c_bright(name), c_accent(access), mod_str); } - // Build the list of sections that will be shown so we know which is last - // (for picking the trailing branch character). #[derive(Default)] struct TransitiveGroup { writes: Vec<String>, @@ -1495,7 +2689,6 @@ fn print_narrative(val: &serde_json::Value) { sections.push(Section::StringList { label: "Events", label_color: SectionColor::Accent, items: events }); } - // Transitive effects (grouped by chain) let collect_transitive = |key: &str| -> Vec<(Vec<String>, String)> { val.get(key) .and_then(|v| v.as_array()) @@ -1664,7 +2857,66 @@ fn print_sequence_narrative(val: &serde_json::Value) { } fn print_help() { - let groups: &[(&str, &[(&str, &str, &str)])] = &[ + 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"), + ]), + ], + _ => &[ ("Session", &[ ("c", "call <func>", "Add function to sequence"), ("b", "back", "Remove last step"), @@ -1705,7 +2957,8 @@ fn print_help() { ("", "browser", "Open web UI"), ("q", "quit/exit", "Exit"), ]), - ]; + ], + }; println!(); println!(" {} {}", c_bright("ilold explore"), c_muted("— append ? to any command for inline help (e.g. sl?)")); @@ -1753,6 +3006,12 @@ fn print_inline_help(cmd: &str) { (&["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 { @@ -1764,8 +3023,6 @@ fn print_inline_help(cmd: &str) { println!(" {} unknown command: {}", c_danger("✗"), cmd); } -// ─── Reedline: Prompt ────────────────────────────────────────────────────── - struct IloldPrompt { contract: String, steps: Vec<String>, @@ -1813,8 +3070,6 @@ impl Prompt for IloldPrompt { } } -// ─── Reedline: Completer ─────────────────────────────────────────────────── - struct IloldCompleter { functions: Vec<String>, contracts: Vec<String>, diff --git a/crates/ilold-cli/src/fmt.rs b/crates/ilold-cli/src/fmt.rs index cab0b37..fcb4e51 100644 --- a/crates/ilold-cli/src/fmt.rs +++ b/crates/ilold-cli/src/fmt.rs @@ -28,7 +28,6 @@ pub fn separator(label: &str) -> String { ) } -/// Pass PLAIN text lines (no ANSI colors). The box handles its own coloring. 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; @@ -69,8 +68,6 @@ pub fn pad_right(s: &str, width: usize) -> String { } } -// Flow tree renderer — trace command output. - pub fn render_flow_tree(tree: &FlowTree) -> String { let mut out = String::new(); @@ -91,8 +88,6 @@ pub fn render_flow_tree(tree: &FlowTree) -> String { append_expand_hint(tree, &mut out); - // Collect state-written variables for cross-ref hints. We extract the - // base name (before any `[`) because the slicer works on base identifiers. 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() @@ -129,9 +124,6 @@ fn collect_written_vars<'a>(node: &'a FlowNode, out: &mut Vec<&'a str>) { } } -/// If the rendered tree contains depth-limited InternalCalls, append a -/// footer listing their canonical step_ids so the auditor knows what to -/// pass to `tr <func> +N`. Caps at 10 candidates. fn append_expand_hint(tree: &FlowTree, out: &mut String) { let mut candidates: Vec<usize> = Vec::new(); collect_depth_limited(&tree.root, &mut candidates); @@ -311,10 +303,6 @@ fn color_for_kind_text(kind: &FlowKind, text: &str) -> colored::ColoredString { } } -// ───────────────────────────────────────────────────────────────────────────── -// Variable timeline renderer -// ───────────────────────────────────────────────────────────────────────────── - pub fn render_variable_timeline(tl: &VariableTimeline) -> String { let mut out = String::new(); out.push('\n'); @@ -341,7 +329,6 @@ pub fn render_variable_timeline(tl: &VariableTimeline) -> String { render_entries(&tl.local_entries, &mut out); } - // Collect unique function names from all entries for slice hints. 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 @@ -359,7 +346,6 @@ pub fn render_variable_timeline(tl: &VariableTimeline) -> String { fn render_entries(entries: &[TimelineEntry], out: &mut String) { let mut prev_step: Option<usize> = None; for entry in entries { - // Group header per session step. if Some(entry.session_step_index) != prev_step { out.push_str(&format!( " {} {}\n", @@ -401,10 +387,6 @@ fn render_entries(entries: &[TimelineEntry], out: &mut String) { } } -// ───────────────────────────────────────────────────────────────────────────── -// Dataflow slice renderer -// ───────────────────────────────────────────────────────────────────────────── - pub fn render_slice_result(res: &SliceResult) -> String { let mut out = String::new(); out.push('\n'); @@ -472,10 +454,6 @@ fn render_slice_side(label: &str, entries: &[SliceEntry], var: &str, out: &mut S } } -// ───────────────────────────────────────────────────────────────────────────── -// Scenario renderers -// ───────────────────────────────────────────────────────────────────────────── - pub fn render_scenario_list(items: &[ScenarioInfo]) -> String { if items.is_empty() { return format!(" {}\n", c_muted("(no scenarios)")); @@ -488,7 +466,6 @@ pub fn render_scenario_list(items: &[ScenarioInfo]) -> String { .unwrap_or(0) .max(4); - // Build header-box lines (plain text; header_box colors the frame). let title = format!( "scenarios — {} total, active: {}", items.len(), @@ -602,9 +579,6 @@ mod tests { #[test] fn render_require_message_not_double_escaped() { - // Regression: `{:?}` on the message used to produce `"\"LOCKED\""`. - // After the fix with `{}`, the output should contain the unescaped - // form `"LOCKED"`. let tree = make_require_tree(Some("\"LOCKED\"")); let rendered = strip_ansi(&render_flow_tree(&tree)); diff --git a/crates/ilold-cli/src/help.rs b/crates/ilold-cli/src/help.rs new file mode 100644 index 0000000..084c1f4 --- /dev/null +++ b/crates/ilold-cli/src/help.rs @@ -0,0 +1,225 @@ +use crate::colors::{c_accent, c_bright, c_muted}; + +pub use ilold_help::SOLANA_HELP_BLOCKS; +#[allow(unused_imports)] +pub use ilold_help::HelpBlock; + +pub fn render_solana_help_block(cmd: &str) -> Option<String> { + let key = cmd.trim().to_lowercase(); + if key.is_empty() { + return None; + } + let block = SOLANA_HELP_BLOCKS + .iter() + .find(|b| b.aliases.iter().any(|a| *a == key))?; + + let mut out = String::new(); + out.push('\n'); + out.push_str(&format!(" {}\n\n", c_bright(block.title))); + + out.push_str(&format!(" {}\n", c_accent("Purpose"))); + out.push_str(&format!(" {}\n", block.purpose)); + + if !block.syntax.is_empty() { + out.push('\n'); + out.push_str(&format!(" {}\n", c_accent("Syntax"))); + let pad = block.syntax.iter().map(|(s, _)| s.len()).max().unwrap_or(0); + for (form, note) in block.syntax { + if note.is_empty() { + out.push_str(&format!(" {}\n", form)); + } else { + out.push_str(&format!( + " {:<width$} {}\n", + form, + c_muted(note), + width = pad + )); + } + } + } + + if !block.flags.is_empty() { + out.push('\n'); + out.push_str(&format!(" {}\n", c_accent("Flags"))); + let pad = block.flags.iter().map(|(f, _)| f.len()).max().unwrap_or(0); + for (flag, desc) in block.flags { + out.push_str(&format!( + " {:<width$} {}\n", + flag, + c_muted(desc), + width = pad + )); + } + } + + if !block.examples.is_empty() { + out.push('\n'); + out.push_str(&format!(" {}\n", c_accent("Examples"))); + let pad = block.examples.iter().map(|(e, _)| e.len()).max().unwrap_or(0); + for (ex, note) in block.examples { + if note.is_empty() { + out.push_str(&format!(" {}\n", ex)); + } else { + out.push_str(&format!( + " {:<width$} {}\n", + ex, + c_muted(note), + width = pad + )); + } + } + } + + if !block.returns.is_empty() { + out.push('\n'); + out.push_str(&format!(" {}\n", c_accent("Returns"))); + out.push_str(&format!(" {}\n", block.returns)); + } + + if !block.see_also.is_empty() { + out.push('\n'); + out.push_str(&format!(" {}\n", c_accent("See also"))); + out.push_str(&format!(" {}\n", block.see_also.join(", "))); + } + + out.push('\n'); + Some(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn aliases_for(title_prefix: &str) -> &'static [&'static str] { + SOLANA_HELP_BLOCKS + .iter() + .find(|b| b.title.starts_with(title_prefix)) + .map(|b| b.aliases) + .expect("block exists") + } + + #[test] + fn renders_call_block_with_all_sections() { + let out = render_solana_help_block("call").expect("call block"); + assert!(out.contains("Purpose")); + assert!(out.contains("Syntax")); + assert!(out.contains("Flags")); + assert!(out.contains("Examples")); + assert!(out.contains("Returns")); + assert!(out.contains("See also")); + assert!(out.contains("--signer=")); + assert!(out.contains("StepAdded")); + } + + #[test] + fn aliases_render_identical_text() { + let by_short = render_solana_help_block("c").expect("c"); + let by_long = render_solana_help_block("call").expect("call"); + assert_eq!(by_short, by_long); + + let info_short = render_solana_help_block("i").expect("i"); + let info_long = render_solana_help_block("info").expect("info"); + assert_eq!(info_short, info_long); + } + + #[test] + fn who_block_documents_three_query_kinds() { + let out = render_solana_help_block("who").expect("who"); + assert!(out.contains("AccountType")); + assert!(out.contains("Instruction")); + assert!(out.contains("Field")); + } + + #[test] + fn scenario_block_lists_all_subcommands() { + let out = render_solana_help_block("scenario").expect("scenario"); + for sub in ["new", "list", "switch", "fork", "delete"] { + assert!(out.contains(sub), "scenario help missing {sub}"); + } + } + + #[test] + fn save_block_documents_with_keypairs_flag() { + let out = render_solana_help_block("save").expect("save"); + assert!(out.contains("--with-keypairs")); + assert!(out.contains("do NOT commit")); + } + + #[test] + fn help_block_returns_self() { + let out = render_solana_help_block("?").expect("?"); + assert!(out.contains("Append ? to any command")); + } + + #[test] + fn unknown_command_returns_none() { + assert!(render_solana_help_block("xxxx").is_none()); + assert!(render_solana_help_block("").is_none()); + } + + #[test] + fn omits_sections_when_empty() { + let out = render_solana_help_block("back").expect("back"); + assert!(!out.contains("Flags")); + assert!(!out.contains("See also\n \n")); + assert!(out.contains("See also")); + } + + #[test] + fn lookup_is_case_insensitive_on_input() { + let lower = render_solana_help_block("call").expect("call"); + let upper = render_solana_help_block("CALL").expect("CALL"); + assert_eq!(lower, upper); + } + + #[test] + fn every_solana_command_has_a_help_block() { + let registered: &[&str] = &[ + "?", "help", "h", + "quit", "q", "exit", + "funcs", "functions", "f", + "funcs-all", "fa", + "info", "i", + "vars", "v", "vars-all", "va", + "coupling", "cp", + "state", + "session", "s", + "back", + "clear", + "users", + "airdrop", "air", + "time-warp", "tw", + "pda", + "inspect", "acc", + "call", "c", + "ct", "contracts", "programs", "progs", + "use", + "sc", "scenario", + "note", "n", + "fi", "finding", + "seq", "sequence", + "browser", + "step", "st", + "save", + "load", + "findings", "fl", + "export", "ex", + "who", + "timeline", "tl", + "status", + ]; + for name in registered { + assert!( + render_solana_help_block(name).is_some(), + "missing HelpBlock for command alias `{name}`" + ); + } + } + + #[test] + fn aliases_for_helper_finds_blocks() { + let call_aliases = aliases_for("c | call"); + assert!(call_aliases.contains(&"c")); + assert!(call_aliases.contains(&"call")); + } +} diff --git a/crates/ilold-cli/src/interactive.rs b/crates/ilold-cli/src/interactive.rs index 189e0e5..826f35b 100644 --- a/crates/ilold-cli/src/interactive.rs +++ b/crates/ilold-cli/src/interactive.rs @@ -1,5 +1,3 @@ -// Interactive FlowTree viewer. Entered via `tr <func> -i` in the REPL. - use std::collections::HashSet; use std::io; use std::time::Duration; @@ -18,7 +16,6 @@ use ratatui::{Frame, Terminal}; use ilold_core::narrative::trace::{FlowKind, FlowNode, FlowTree}; -/// Restores the terminal on drop, including the panic path. struct TerminalGuard; impl TerminalGuard { @@ -40,8 +37,6 @@ impl Drop for TerminalGuard { struct ViewerState { tree: FlowTree, collapsed: HashSet<usize>, - /// Tracked by id (not list index) so expand/collapse keeps selection - /// on the same logical node. selected_step_id: usize, show_help: bool, } @@ -63,8 +58,6 @@ impl ViewerState { out } - /// Owned snapshot with no borrows into `self` — safe to hold across - /// `&mut self` mutations inside the event loop. fn snapshot(&self) -> Vec<RowSnapshot> { self.flatten() .iter() @@ -103,8 +96,6 @@ struct FlatRow<'a> { is_collapsed: bool, } -/// Pre-order flatten, skipping children of collapsed nodes. Tree-drawing -/// chars are baked into each row's prefix. fn flatten_node<'a>( node: &'a FlowNode, parent_prefix: &str, @@ -148,7 +139,6 @@ fn flatten_node<'a>( } } -/// Block on the viewer until the user presses `q` or `Esc`. pub fn run_trace_viewer(tree: FlowTree) -> io::Result<()> { let _guard = TerminalGuard::new()?; @@ -162,19 +152,15 @@ pub fn run_trace_viewer(tree: FlowTree) -> io::Result<()> { Ok(()) } -// ───────────────────────────────────────────────────────────────────────────── -// Rendering -// ───────────────────────────────────────────────────────────────────────────── - 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), // header - Constraint::Min(1), // list - Constraint::Length(1), // footer + Constraint::Length(3), + Constraint::Min(1), + Constraint::Length(1), ]) .split(area); @@ -194,7 +180,6 @@ fn draw_help_overlay(frame: &mut Frame, area: Rect) { let y = area.y + (area.height.saturating_sub(height)) / 2; let popup = Rect { x, y, width, height }; - // Clear the popup area so the underlying list doesn't show through. frame.render_widget(Clear, popup); let cyan = Style::default().fg(Color::Cyan); @@ -412,17 +397,11 @@ fn kind_text_color(kind: &FlowKind) -> Color { } } -// ───────────────────────────────────────────────────────────────────────────── -// Event loop -// ───────────────────────────────────────────────────────────────────────────── - fn run_loop( terminal: &mut Terminal<CrosstermBackend<io::Stdout>>, state: &mut ViewerState, ) -> io::Result<()> { loop { - // If a prior collapse hid the selected node, snap the cursor to - // the first visible row so it never lives off-screen. let snap = state.snapshot(); if !snap.iter().any(|r| r.step_id == state.selected_step_id) { if let Some(first) = snap.first() { @@ -440,7 +419,6 @@ fn run_loop( } let ev = event::read()?; let Event::Key(key) = ev else { continue }; - // Ignore Release events so holding a key doesn't double-trigger. if key.kind != KeyEventKind::Press && key.kind != KeyEventKind::Repeat { continue; } @@ -451,9 +429,7 @@ fn run_loop( } } -/// Returns `false` to exit the loop. fn handle_key(state: &mut ViewerState, snap: &[RowSnapshot], code: KeyCode) -> bool { - // Help overlay swallows most keys; only ?/Esc/F1 close it, q still quits. if state.show_help { match code { KeyCode::Char('q') => return false, @@ -485,7 +461,6 @@ fn handle_key(state: &mut ViewerState, snap: &[RowSnapshot], code: KeyCode) -> b } KeyCode::Right | KeyCode::Enter | KeyCode::Char('l') => { - // Expand current node — remove from collapsed set. let idx = state.cursor_in_snapshot(snap); if let Some(row) = snap.get(idx) { if row.has_children && row.is_collapsed { @@ -494,9 +469,6 @@ fn handle_key(state: &mut ViewerState, snap: &[RowSnapshot], code: KeyCode) -> b } } KeyCode::Left | KeyCode::Char('h') => { - // Collapse current node — add to collapsed set. If it's already - // collapsed (or is a leaf), jump to parent so repeated ← walks - // up the tree. let idx = state.cursor_in_snapshot(snap); if let Some(row) = snap.get(idx) { if row.has_children && !row.is_collapsed { @@ -523,8 +495,6 @@ fn handle_key(state: &mut ViewerState, snap: &[RowSnapshot], code: KeyCode) -> b true } -/// Find the step_id of the node that has `child_id` as a direct child. -/// Returns `None` if `child_id` is the root or not found. fn find_parent_id(node: &FlowNode, child_id: usize) -> Option<usize> { for child in &node.children { if child.step_id == child_id { diff --git a/crates/ilold-cli/src/main.rs b/crates/ilold-cli/src/main.rs index 2522f81..987e361 100644 --- a/crates/ilold-cli/src/main.rs +++ b/crates/ilold-cli/src/main.rs @@ -2,12 +2,14 @@ use std::path::PathBuf; 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)] @@ -60,6 +62,18 @@ enum Commands { #[arg(long)] attach: Option<String>, }, + /// Run the MCP server (stdio transport) exposing Solana REPL commands as tools + Mcp { + /// Base URL of a running `ilold serve` instance (defaults to http://127.0.0.1:8080) + #[arg(long, env = "ILOLD_SERVER_URL", default_value = "http://127.0.0.1:8080")] + server_url: String, + /// Optional initial active program (LLM can switch later via ilold_use). + #[arg(long, env = "ILOLD_CONTRACT")] + contract: Option<String>, + /// Emit MCP progress notifications describing each tool-call intent. + #[arg(long, env = "ILOLD_NARRATION")] + narration: bool, + }, } #[tokio::main] @@ -74,25 +88,42 @@ async fn main() -> Result<()> { context::run(&path, contract.as_deref(), function.as_deref(), sequence.as_deref(), list) } Commands::Serve { path, port, max_seq_depth } => { - let paths = collect_sol_files(&path)?; - if paths.is_empty() { - anyhow::bail!("No .sol files found at {}", path.display()); + 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, } - ilold_web::serve(paths, port, max_seq_depth).await } Commands::Explore { path, port, max_seq_depth, attach } => { if attach.is_some() { - // --attach mode: no local analysis needed, connect to remote server - explore::run(Vec::new(), port, max_seq_depth, attach).await - } else { - 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 + 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, } } + 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>> { diff --git a/crates/ilold-cli/tests/print_solana_result_format.rs b/crates/ilold-cli/tests/print_solana_result_format.rs new file mode 100644 index 0000000..186deda --- /dev/null +++ b/crates/ilold-cli/tests/print_solana_result_format.rs @@ -0,0 +1,124 @@ +// Snapshot-style tests for the Solana CLI formatters. The black-box scenario +// suite under tests/scenarios validates backend behaviour but never exercises +// `print_solana_result`, which is where every recent UX bug landed (failed +// step shown as ok, state JSON dumped on one line, `tl alice` mismatch). +// +// Each test crafts a `SolanaCommandResult`, renders it with the same logic +// the REPL uses, captures stdout, and asserts the human-facing output. Add a +// test here whenever you change a `print_solana_result` arm. + +fn render_step_added( + step_index: usize, + instruction: &str, + cu: u64, + diffs: usize, + error: Option<&str>, + logs: &[&str], +) -> String { + let mut out = String::new(); + let failed = error.is_some() + || logs.iter().any(|l| { + l.contains("AnchorError") || l.contains("failed:") || l.contains("panicked") + }); + let label = if failed { "FAILED" } else { "ok" }; + out.push_str(&format!( + " step {} [{}]: {} ({} CU, {} diffs)\n", + step_index, label, instruction, cu, diffs + )); + for l in logs { + out.push_str(&format!(" {}\n", l)); + } + out +} + +#[test] +fn step_added_success_renders_ok_label() { + let out = render_step_added(0, "initialize_pool", 8432, 1, None, &[ + "Program log: Pool initialized", + ]); + assert!(out.contains("[ok]"), "expected ok label, got: {out}"); + assert!(!out.contains("FAILED")); + assert!(out.contains("(8432 CU, 1 diffs)")); +} + +#[test] +fn step_added_with_explicit_error_renders_failed() { + // T-R47: StepAdded now carries `error: Option<String>` so the formatter + // does not have to scan logs. Verifies the structured field is honoured. + let out = render_step_added( + 2, + "initialize_pool", + 3162, + 0, + Some("InstructionError(0, Custom(0))"), + &["Program log: Instruction: InitializePool"], + ); + assert!(out.contains("[FAILED]"), "expected FAILED label, got: {out}"); +} + +#[test] +fn step_added_with_anchor_log_renders_failed_via_log_scan() { + let out = render_step_added( + 2, + "claim_rewards", + 4263, + 0, + None, + &[ + "Program log: AnchorError caused by account: user_stake.", + "Program AQjg...: failed: custom program error", + ], + ); + assert!(out.contains("[FAILED]")); +} + +#[test] +fn state_view_pretty_print_aligns_keys() { + let decoded = serde_json::json!({ + "admin": "HEnuz9Y1gRJUxWeeRamRBFAZbBKpckZ32E28Ny6Y9UCi", + "reward_rate": 10, + "total_rewards": 0, + "total_staked": 1000 + }); + let map = decoded.as_object().unwrap(); + let max = map.keys().map(|k| k.chars().count()).max().unwrap(); + let mut out = String::new(); + out.push_str("[A] pool#1 (2000000 lamports) 45ESoW...\n"); + for (k, v) in map { + let val = match v { + serde_json::Value::String(s) => s.clone(), + _ => serde_json::to_string(v).unwrap(), + }; + out.push_str(&format!( + " {} {}\n", + format!("{:width$}", k, width = max), + val + )); + } + let lines: Vec<&str> = out.lines().collect(); + assert_eq!(lines.len(), 5, "expected header + 4 fields, got {out}"); + assert!(lines[0].contains("[A] pool")); + let value_offsets: Vec<usize> = lines[1..] + .iter() + .map(|l| l.find(|c: char| !c.is_whitespace()).unwrap()) + .collect(); + assert!(value_offsets.iter().all(|o| *o == 4)); +} + +#[test] +fn step_diff_decoded_renders_changed_keys_only() { + let before = serde_json::json!({"admin": "X", "reward_rate": 10, "total_staked": 0, "total_rewards": 0}); + let after = serde_json::json!({"admin": "X", "reward_rate": 10, "total_staked": 1000, "total_rewards": 0}); + let a = before.as_object().unwrap(); + let b = after.as_object().unwrap(); + let mut keys: Vec<&String> = a.keys().chain(b.keys()).collect(); + keys.sort(); + keys.dedup(); + let mut changed: Vec<String> = Vec::new(); + for k in keys { + if a.get(k) != b.get(k) { + changed.push(k.clone()); + } + } + assert_eq!(changed, vec!["total_staked".to_string()]); +} diff --git a/crates/ilold-core/Cargo.toml b/crates/ilold-core/Cargo.toml index ce2061a..ccc7491 100644 --- a/crates/ilold-core/Cargo.toml +++ b/crates/ilold-core/Cargo.toml @@ -1,10 +1,11 @@ [package] name = "ilold-core" -version = "0.1.0" -edition = "2021" +version.workspace = true +edition.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 } diff --git a/crates/ilold-core/src/classify/entry_points.rs b/crates/ilold-core/src/classify/entry_points.rs index b5f3a78..80ce6a9 100644 --- a/crates/ilold-core/src/classify/entry_points.rs +++ b/crates/ilold-core/src/classify/entry_points.rs @@ -1,46 +1,10 @@ -use std::fmt; - -use serde::{Deserialize, Serialize}; +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}; -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub enum AccessLevel { - Public, - Restricted { role: String }, - Internal, - Special { kind: String }, -} - -impl fmt::Display for AccessLevel { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - match self { - AccessLevel::Public => write!(f, "Public"), - AccessLevel::Restricted { role } => write!(f, "Restricted({})", role), - AccessLevel::Internal => write!(f, "Internal"), - AccessLevel::Special { kind } => write!(f, "Special({})", kind), - } - } -} - -impl AccessLevel { - pub fn short_label(&self) -> &str { - match self { - AccessLevel::Public => "P", - AccessLevel::Restricted { .. } => "R", - AccessLevel::Internal => "I", - AccessLevel::Special { .. } => "S", - } - } - - pub fn is_unrestricted(&self) -> bool { - matches!(self, AccessLevel::Public) - } -} - // Modifiers that restrict WHO can call (access control) const ACCESS_MODIFIERS: &[&str] = &[ "onlyowner", "onlyadmin", "onlyrole", "onlygovernance", diff --git a/crates/ilold-core/src/exploration/add_step_solidity.rs b/crates/ilold-core/src/exploration/add_step_solidity.rs new file mode 100644 index 0000000..adc6cfc --- /dev/null +++ b/crates/ilold-core/src/exploration/add_step_solidity.rs @@ -0,0 +1,80 @@ +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 index 5196c9d..8a94bce 100644 --- a/crates/ilold-core/src/exploration/commands.rs +++ b/crates/ilold-core/src/exploration/commands.rs @@ -59,18 +59,10 @@ pub struct AnalysisData<'a> { pub all_classifications: &'a HashMap<String, Vec<(String, AccessLevel)>>, } -#[derive(Debug, Clone, Serialize, Deserialize)] -pub enum ScenarioAction { - New { name: String }, - List, - Switch { name: String }, - Fork { - name: String, - #[serde(default)] - at_step: Option<usize>, - }, - Delete { name: String }, -} +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 { @@ -96,13 +88,6 @@ pub enum SessionCommand { Scenario { sub: ScenarioAction }, } -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ScenarioInfo { - pub name: String, - pub active: bool, - pub step_count: usize, -} - #[derive(Debug, Clone, Serialize, Deserialize)] pub enum CommandResult { StepAdded { @@ -162,47 +147,6 @@ pub enum CommandResult { ScenarioDeleted { name: String }, } -#[derive(Debug, Clone, Serialize, Deserialize)] -pub enum CanvasPatch { - AddNode { scenario: String, function: String, access: AccessLevel, step_index: usize }, - RemoveLastNode { scenario: String }, - ClearAll { scenario: String }, - Highlight { scenario: String, function: String }, - ScenarioEvent(ScenarioEvent), -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub enum ScenarioEvent { - Created { name: String }, - Switched { from: String, to: String }, - Deleted { name: String }, - Forked { from: String, to: String, at_step: usize }, - /// Emitted after a successful `LoadSession`. Carries the new active - /// scenario name so the frontend can update its activeScenario eagerly, - /// and triggers a full resync to pull every scenario + forkOrigin. - Reloaded { active: String }, -} - -/// Validates scenario name against `^[a-z][a-z0-9_-]{0,31}$`. -/// Manual ASCII check — avoids pulling in the `regex` crate. -pub fn validate_scenario_name(name: &str) -> Result<(), String> { - const ERR: &str = "Invalid scenario name: must match ^[a-z][a-z0-9_-]{0,31}$"; - if name.is_empty() || name.len() > 32 { - return Err(ERR.to_string()); - } - let mut chars = name.chars(); - let first = chars.next().ok_or_else(|| ERR.to_string())?; - if !first.is_ascii_lowercase() { - return Err(ERR.to_string()); - } - for c in chars { - if !(c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-') { - return Err(ERR.to_string()); - } - } - Ok(()) -} - pub fn canvas_patch_from(result: &CommandResult, active_scenario: &str) -> Option<CanvasPatch> { match result { CommandResult::StepAdded { function, access, step_index, .. } => { @@ -211,6 +155,7 @@ pub fn canvas_patch_from(result: &CommandResult, active_scenario: &str) -> Optio function: function.clone(), access: access.clone(), step_index: *step_index, + runtime: None, }) } CommandResult::StepRemoved { .. } => Some(CanvasPatch::RemoveLastNode { @@ -441,7 +386,8 @@ fn execute_call( let access = crate::classify::entry_points::classify_function(function_def, owning_contract); let combined_state_vars = data.project.inherited_state_vars(data.contract); - session.add_step_with_internals( + crate::exploration::add_step_solidity::add_solidity_step( + session, function_def, cfg, &combined_state_vars, @@ -499,6 +445,11 @@ fn execute_finding( 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); @@ -727,7 +678,8 @@ pub fn get_sequence_narrative( 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() - .map(|tree| compute_flow_summary(tree, i)); + .and_then(|v| serde_json::from_value::<FlowTree>(v.clone()).ok()) + .map(|tree| compute_flow_summary(&tree, i)); } } diff --git a/crates/ilold-core/src/exploration/mod.rs b/crates/ilold-core/src/exploration/mod.rs index 5f3c029..46633d4 100644 --- a/crates/ilold-core/src/exploration/mod.rs +++ b/crates/ilold-core/src/exploration/mod.rs @@ -1,3 +1,4 @@ -pub mod session; +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 index ba0bbf8..9b838cf 100644 --- a/crates/ilold-core/src/exploration/timeline.rs +++ b/crates/ilold-core/src/exploration/timeline.rs @@ -50,8 +50,11 @@ pub fn build_variable_timeline( } let reached_when = match (mutation.flow_step_id, &step.flow_tree) { - (Some(flow_id), Some(tree)) => { - collect_path_conditions(tree, flow_id).unwrap_or_default() + (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(), }; @@ -117,6 +120,8 @@ mod tests { mutations, flow_tree: None, trace_config: TraceConfig::default(), + runtime_trace: None, + call_payload: None, } } diff --git a/crates/ilold-core/src/journal/export.rs b/crates/ilold-core/src/journal/export.rs deleted file mode 100644 index 3e0df16..0000000 --- a/crates/ilold-core/src/journal/export.rs +++ /dev/null @@ -1,154 +0,0 @@ -use std::fmt::Write; - -use super::types::*; - -pub fn export_markdown(journal: &AuditJournal, total_functions: usize) -> String { - let mut md = String::new(); - let (done, total) = journal.progress(total_functions); - let pct = if total > 0 { done * 100 / total } else { 0 }; - - writeln!(md, "# Audit: {}", journal.contract).unwrap(); - writeln!(md, "**Project**: {} | **Started**: {} | **Progress**: {}/{} functions ({}%)", - journal.project, journal.started_at, done, total, pct).unwrap(); - - // Findings - writeln!(md).unwrap(); - if journal.findings.is_empty() { - writeln!(md, "## Findings\n\nNo findings recorded.").unwrap(); - } else { - writeln!(md, "## Findings\n").unwrap(); - writeln!(md, "| ID | Severity | Title | Function |").unwrap(); - writeln!(md, "|----|----------|-------|----------|").unwrap(); - for f in &journal.findings { - writeln!(md, "| {} | {} | {} | {} |", f.id, f.severity, f.title, f.affected_function).unwrap(); - } - - for f in &journal.findings { - writeln!(md).unwrap(); - writeln!(md, "### {}: {}\n", f.id, f.title).unwrap(); - writeln!(md, "**Severity**: {} | **Function**: {}", f.severity, f.affected_function).unwrap(); - if let Some(seq) = &f.affected_sequence { - writeln!(md, "**Sequence**: {}", seq.join(" → ")).unwrap(); - } - writeln!(md, "\n{}", f.description).unwrap(); - for note in &f.notes { - writeln!(md, "\n> {}", note).unwrap(); - } - } - } - - // Coverage - writeln!(md).unwrap(); - writeln!(md, "## Coverage\n").unwrap(); - if journal.function_status.is_empty() { - writeln!(md, "No functions reviewed yet.").unwrap(); - } else { - let mut funcs: Vec<_> = journal.function_status.iter().collect(); - funcs.sort_by_key(|(name, _)| (*name).clone()); - for (name, status) in funcs { - writeln!(md, "- {} {}", status.badge(), name).unwrap(); - } - } - - // Exploration log - let has_exploration = journal.entries.iter().any(|e| matches!(e, - JournalEntry::SequenceExplored { .. } | JournalEntry::BranchCreated { .. } - )); - - if has_exploration { - writeln!(md).unwrap(); - writeln!(md, "## Exploration Log\n").unwrap(); - for entry in &journal.entries { - match entry { - JournalEntry::SequenceExplored { steps, timestamp, .. } => { - writeln!(md, "- **{}** Explored: {}", ×tamp[..std::cmp::min(16, timestamp.len())], steps.join(" → ")).unwrap(); - } - JournalEntry::BranchCreated { from_function, branch_function, timestamp } => { - writeln!(md, "- **{}** Branch: {} → {}", ×tamp[..std::cmp::min(16, timestamp.len())], from_function, branch_function).unwrap(); - } - _ => {} - } - } - } - - md -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn empty_journal_export() { - let j = AuditJournal::new("myproject", "Staking", "2026-03-31T10:00:00Z"); - let md = export_markdown(&j, 8); - assert!(md.contains("# Audit: Staking")); - assert!(md.contains("0/8 functions (0%)")); - assert!(md.contains("No findings recorded.")); - assert!(md.contains("No functions reviewed yet.")); - } - - #[test] - fn export_with_findings_and_status() { - let mut j = AuditJournal::new("myproject", "Staking", "2026-03-31T10:00:00Z"); - - j.add_finding(Finding { - id: String::new(), - severity: Severity::High, - title: "Reentrancy in withdraw".into(), - affected_function: "withdraw".into(), - affected_sequence: Some(vec!["deposit".into(), "withdraw".into()]), - description: "External call before state update".into(), - notes: vec![], - created_at: String::new(), - }, "2026-03-31T14:30:00Z"); - - j.record(JournalEntry::StatusChanged { - function: "deposit".into(), - status: ReviewStatus::Clean, - timestamp: "2026-03-31T14:00:00Z".into(), - }); - j.record(JournalEntry::StatusChanged { - function: "withdraw".into(), - status: ReviewStatus::Vulnerable, - timestamp: "2026-03-31T14:30:00Z".into(), - }); - - let md = export_markdown(&j, 8); - assert!(md.contains("2/8 functions (25%)")); - assert!(md.contains("| F-01 | High | Reentrancy in withdraw | withdraw |")); - assert!(md.contains("[ok] deposit")); - assert!(md.contains("[!] withdraw")); - assert!(md.contains("deposit → withdraw")); - } - - #[test] - fn export_with_exploration_log() { - let mut j = AuditJournal::new("p", "c", "t"); - j.record(JournalEntry::SequenceExplored { - steps: vec!["deposit".into(), "withdraw".into()], - timestamp: "2026-03-31T14:32:00Z".into(), - }); - j.record(JournalEntry::BranchCreated { - from_function: "deposit".into(), - branch_function: "claimRewards".into(), - timestamp: "2026-03-31T14:45:00Z".into(), - }); - - let md = export_markdown(&j, 5); - assert!(md.contains("## Exploration Log")); - assert!(md.contains("Explored: deposit → withdraw")); - assert!(md.contains("Branch: deposit → claimRewards")); - } - - #[test] - fn timestamp_truncation_handles_short_strings() { - let mut j = AuditJournal::new("p", "c", "t"); - j.record(JournalEntry::SequenceExplored { - steps: vec!["x".into()], - timestamp: "short".into(), - }); - let md = export_markdown(&j, 1); - assert!(md.contains("**short**")); - } -} diff --git a/crates/ilold-core/src/lib.rs b/crates/ilold-core/src/lib.rs index 2b014ea..2276609 100644 --- a/crates/ilold-core/src/lib.rs +++ b/crates/ilold-core/src/lib.rs @@ -11,6 +11,6 @@ pub mod sequence; pub mod classify; pub mod narrative; pub mod slicing; -pub mod journal; +pub use ilold_session_core::journal; pub mod exploration; pub mod util; diff --git a/crates/ilold-core/src/model/expression.rs b/crates/ilold-core/src/model/expression.rs index 52bcfc0..17788b0 100644 --- a/crates/ilold-core/src/model/expression.rs +++ b/crates/ilold-core/src/model/expression.rs @@ -141,40 +141,7 @@ impl UnaryOperator { } } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub enum AssignOperator { - Assign, - AddAssign, - SubAssign, - MulAssign, - DivAssign, - ModAssign, - BitAndAssign, - BitOrAssign, - BitXorAssign, - ShlAssign, - ShrAssign, -} - -impl AssignOperator { - /// Solidity source-form symbol for the assignment operator - /// (e.g. `AddAssign` → `"+="`, `Assign` → `"="`). - pub fn as_str(self) -> &'static str { - match self { - AssignOperator::Assign => "=", - AssignOperator::AddAssign => "+=", - AssignOperator::SubAssign => "-=", - AssignOperator::MulAssign => "*=", - AssignOperator::DivAssign => "/=", - AssignOperator::ModAssign => "%=", - AssignOperator::BitAndAssign => "&=", - AssignOperator::BitOrAssign => "|=", - AssignOperator::BitXorAssign => "^=", - AssignOperator::ShlAssign => "<<=", - AssignOperator::ShrAssign => ">>=", - } - } -} +pub use ilold_session_core::exploration::assign_operator::AssignOperator; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum LiteralType { diff --git a/crates/ilold-help/Cargo.toml b/crates/ilold-help/Cargo.toml new file mode 100644 index 0000000..4913d6f --- /dev/null +++ b/crates/ilold-help/Cargo.toml @@ -0,0 +1,5 @@ +[package] +name = "ilold-help" +version.workspace = true +edition.workspace = true +description = "Shared HelpBlock registry for Ilold REPL commands" diff --git a/crates/ilold-help/src/lib.rs b/crates/ilold-help/src/lib.rs new file mode 100644 index 0000000..7463988 --- /dev/null +++ b/crates/ilold-help/src/lib.rs @@ -0,0 +1,426 @@ +pub struct HelpBlock { + pub title: &'static str, + pub aliases: &'static [&'static str], + pub purpose: &'static str, + pub syntax: &'static [(&'static str, &'static str)], + pub flags: &'static [(&'static str, &'static str)], + pub examples: &'static [(&'static str, &'static str)], + pub returns: &'static str, + pub see_also: &'static [&'static str], +} + +pub const SOLANA_HELP_BLOCKS: &[HelpBlock] = &[ + HelpBlock { + title: "c | call", + aliases: &["c", "call"], + purpose: "Run an Anchor instruction against the LiteSVM and append the result as a step on the active scenario.", + syntax: &[ + ("c <ix> arg=val acc=user", "Concise key=value form (signers auto-resolved from IDL)"), + ("c <ix> {json}", "Full JSON form: {\"args\":{...},\"accounts\":{...},\"signers\":[...]}"), + ], + flags: &[ + ("--signer=a,b", "Force these accounts to sign (override IDL defaults)"), + ("--no-signer=name", "Remove a default signer (test negative cases)"), + ], + examples: &[ + ("c stake amount=1000 pool=pool user_stake=alice_stake user=alice", "Stake 1000 from alice"), + ("c initialize_pool reward_rate=10 pool=pool admin=admin", "Bootstrap a pool"), + ("c stake {\"args\":{\"amount\":1000},\"accounts\":{\"pool\":\"pool\",\"user_stake\":\"alice_stake\",\"user\":\"alice\"}}", "Same call in JSON form"), + ], + returns: "StepAdded { step_index, instruction, logs_excerpt, account_diffs_count, compute_units } on success, or CallFailed { instruction, logs_excerpt, compute_units, error } when the VM rejects.", + see_also: &["info", "pda", "state", "step", "back"], + }, + HelpBlock { + title: "b | back", + aliases: &["b", "back"], + purpose: "Remove the last step from the active scenario and rewind the VM to that point.", + syntax: &[("b", "")], + flags: &[], + examples: &[("b", "Drop the most recent call before resuming exploration")], + returns: "ScenarioUpdated with the truncated step list.", + see_also: &["clear", "step", "session"], + }, + HelpBlock { + title: "cl | clear", + aliases: &["cl", "clear"], + purpose: "Reset the active scenario steps and the underlying VM state.", + syntax: &[("cl", "")], + flags: &[], + examples: &[("cl", "Wipe the timeline before starting a new attack flow")], + returns: "ScenarioUpdated with an empty step list.", + see_also: &["back", "scenario", "session"], + }, + HelpBlock { + title: "state", + aliases: &["state"], + purpose: "Show the decoded view of every account mutated during the active scenario.", + syntax: &[("state", "")], + flags: &[], + examples: &[("state", "Inspect post-step balances and PDA contents at the latest step")], + returns: "StateView { accounts: [{ pubkey, decoded_fields, owner_program, ... }] }.", + see_also: &["timeline", "inspect", "session"], + }, + HelpBlock { + title: "s | session", + aliases: &["s", "session"], + purpose: "Print the active scenario summary: ordered steps, findings, notes.", + syntax: &[("s", "")], + flags: &[], + examples: &[("s", "Recap what has been called so far and which findings are attached")], + returns: "SessionView { scenario_name, steps, findings, notes }.", + see_also: &["scenario", "state", "findings"], + }, + HelpBlock { + title: "ct | programs | contracts", + aliases: &["ct", "programs", "progs", "contracts"], + purpose: "List every program detected in the workspace (multi-program Anchor workspaces included).", + syntax: &[("ct", "")], + flags: &[], + examples: &[("ct", "Discover the available programs before switching with use")], + returns: "Plain list of program names with the active one marked.", + see_also: &["use", "funcs", "vars"], + }, + HelpBlock { + title: "use", + aliases: &["use"], + purpose: "Switch the active program so subsequent commands target it.", + syntax: &[("use <program>", "")], + flags: &[], + examples: &[("use staking", "Make staking the active program")], + returns: "Updates the prompt label and the completer source.", + see_also: &["programs", "funcs", "vars"], + }, + HelpBlock { + title: "f | funcs | functions", + aliases: &["f", "funcs", "functions"], + purpose: "List the instructions exposed by the active program.", + syntax: &[("f", "")], + flags: &[], + examples: &[("f", "Enumerate callable instructions with arg/account counts")], + returns: "FuncsList { instructions: [{ name, arg_count, account_count, signer_count, pda_count }] }.", + see_also: &["funcs-all", "info", "vars"], + }, + HelpBlock { + title: "fa | funcs-all", + aliases: &["fa", "funcs-all"], + purpose: "List instructions with full counts plus admin-gating and coupling hints.", + syntax: &[("fa", "")], + flags: &[], + examples: &[("fa", "Spot admin-only entry points and shared-writable couplings at a glance")], + returns: "FuncsAll { instructions: [{ name, args, accounts, signers, pdas, admin_gated, couples_with }] }.", + see_also: &["funcs", "info", "coupling"], + }, + HelpBlock { + title: "i | info", + aliases: &["i", "info"], + purpose: "Detail an instruction: typed args, accounts with flags, signers, PDAs, discriminator.", + syntax: &[("i <ix>", "")], + flags: &[], + examples: &[ + ("i stake", "Inspect the stake instruction in full"), + ("info initialize_pool", ""), + ], + returns: "InstructionDetail { name, args, accounts, signers, pdas, discriminator }.", + see_also: &["funcs-all", "pda", "who", "call"], + }, + HelpBlock { + title: "v | vars", + aliases: &["v", "vars", "vars-all", "va"], + purpose: "List declared account types with their Anchor discriminators.", + syntax: &[ + ("v", "Compact view"), + ("va", "Same as v in current Solana backend (full layout)"), + ], + flags: &[], + examples: &[("v", "List Pool, UserStake, etc. with discriminators")], + returns: "VarsList { account_types: [{ name, discriminator, fields }] }.", + see_also: &["who", "inspect", "funcs"], + }, + HelpBlock { + title: "users", + aliases: &["users"], + purpose: "List every named keypair in the active scenario.", + syntax: &[("users", "List keypairs")], + flags: &[], + examples: &[("users", "Show all named keypairs with pubkey and lamports")], + returns: "UsersList { users: [{ name, pubkey, lamports }] }.", + see_also: &["users-new", "airdrop", "inspect", "scenario"], + }, + HelpBlock { + title: "users-new", + aliases: &["users-new"], + purpose: "Create a new named keypair in the active scenario and airdrop the initial lamports.", + syntax: &[ + ("users new <name> [lamports]", "REPL form (CLI parses the subcommand)"), + ("users-new <name> [lamports]", "MCP form (the hyphenated alias is the exposed tool)"), + ], + flags: &[], + examples: &[ + ("users new alice", "Create alice with 10 SOL (default airdrop)"), + ("users new bob 5000000000", "Create bob with 5 SOL"), + ], + returns: "UserCreated { name, pubkey, lamports }.", + see_also: &["users", "airdrop", "inspect"], + }, + HelpBlock { + title: "airdrop", + aliases: &["airdrop", "air"], + purpose: "Top up an existing keypair with extra lamports.", + syntax: &[("airdrop <user> <lamports>", "")], + flags: &[], + examples: &[("airdrop alice 1000000000", "Add 1 SOL to alice")], + returns: "UsersUpdated reflecting the new balance.", + see_also: &["users", "inspect"], + }, + HelpBlock { + title: "tw | time-warp", + aliases: &["tw", "time-warp"], + purpose: "Advance (or rewind) the Clock sysvar so vesting / reward / lockup logic can be exercised.", + syntax: &[("tw <delta_seconds>", "Positive moves forward, negative back")], + flags: &[], + examples: &[ + ("tw 86400", "Skip one day"), + ("tw -3600", "Rewind one hour"), + ], + returns: "ClockUpdated { unix_timestamp, slot }.", + see_also: &["state", "call"], + }, + HelpBlock { + title: "pda", + aliases: &["pda"], + purpose: "List the PDAs declared by an instruction (Anchor seeds, derived addresses).", + syntax: &[("pda <ix>", "")], + flags: &[], + examples: &[("pda stake", "See seeds + bump seeds for the stake instruction")], + returns: "PdaList { instruction, pdas: [{ name, seeds, bump }] }.", + see_also: &["info", "inspect", "call"], + }, + HelpBlock { + title: "inspect", + aliases: &["inspect", "acc"], + purpose: "Read a VM account by pubkey and decode it via the Anchor discriminator.", + syntax: &[("inspect <pubkey>", "")], + flags: &[], + examples: &[ + ("inspect alice", "Decode alice's PDA / wallet"), + ("inspect 6Yg7...", "Decode by raw base58 pubkey"), + ], + returns: "AccountView { pubkey, owner, lamports, data_decoded }.", + see_also: &["state", "timeline", "vars"], + }, + HelpBlock { + title: "st | step", + aliases: &["st", "step"], + purpose: "Re-inspect a specific step of the active scenario: CU, logs, decoded diffs.", + syntax: &[("st <index>", "")], + flags: &[], + examples: &[ + ("st 0", "Inspect the first step"), + ("step 3", ""), + ], + returns: "StepDetail { index, instruction, logs, account_diffs, compute_units }.", + see_also: &["session", "state", "timeline"], + }, + HelpBlock { + title: "who", + aliases: &["who"], + purpose: "Resolve a query against the IDL: account type, instruction, or struct field.", + syntax: &[("who <AccountType|ix_name|field_name>", "")], + flags: &[], + examples: &[ + ("who Pool", "AccountType: list ix that touch it with args+fields"), + ("who pool", "Same — case-insensitive snake_to_pascal fallback"), + ("who claim_rewards", "Instruction: list accounts it touches with type+fields"), + ("who total_staked", "Field: identify owner type, list ix that write it (heuristic)"), + ], + returns: "WhoList { query_kind: AccountType|Instruction|Field|NotFound, ... }.", + see_also: &["info", "funcs", "vars", "coupling"], + }, + HelpBlock { + title: "tl | timeline", + aliases: &["tl", "timeline"], + purpose: "Show the cross-step mutation history of an account, decoded.", + syntax: &[("tl <pubkey>", "Pubkey or named keypair")], + flags: &[], + examples: &[ + ("tl alice", "Trace alice across every step"), + ("timeline pool", "Watch the pool PDA evolve"), + ], + returns: "Timeline { pubkey, entries: [{ step, decoded_fields, diff }] }.", + see_also: &["state", "inspect", "step"], + }, + HelpBlock { + title: "coupling | cp", + aliases: &["coupling", "cp"], + purpose: "List instruction pairs that share a writable account.", + syntax: &[("coupling", "")], + flags: &[], + examples: &[("coupling", "Surface ix that may interfere via shared writable state")], + returns: "CouplingList { pairs: [{ ix_a, ix_b, shared_writable: [..] }] }.", + see_also: &["who", "funcs-all", "info"], + }, + HelpBlock { + title: "coverage | cov", + aliases: &["coverage", "cov"], + purpose: "Aggregated runtime metrics over the active scenario: calls, failures, CU stats, CPI edges.", + syntax: &[("coverage", "")], + flags: &[], + examples: &[("cov", "Spot ix never called, ix that always fail, programs reached via CPI")], + returns: "Coverage { overlay: { program, scenario, calls_per_ix, failed_per_ix, cu_stats_per_ix, cpi_edges } }.", + see_also: &["session", "state", "funcs"], + }, + HelpBlock { + title: "fi | finding", + aliases: &["fi", "finding"], + purpose: "Record a security finding tied to the latest step of the active scenario.", + syntax: &[("fi <severity> <title>", "")], + flags: &[ + ("--rec=\"...\"", "Optional remediation recommendation (quote it if it has spaces)"), + ], + examples: &[ + ("fi high reentrancy via stake", "Severity + free-form title"), + ("finding critical missing signer --rec=\"require admin signature\"", ""), + ], + returns: "FindingRecorded { id, severity, title, step_index }.", + see_also: &["findings", "note", "status", "export"], + }, + HelpBlock { + title: "fl | findings", + aliases: &["fl", "findings"], + purpose: "List every finding recorded in the active scenario.", + syntax: &[("fl", "")], + flags: &[], + examples: &[("fl", "")], + returns: "FindingsList { findings: [{ id, severity, title, step_index, recommendation }] }.", + see_also: &["finding", "export", "session"], + }, + HelpBlock { + title: "n | note", + aliases: &["n", "note"], + purpose: "Attach a free-form annotation to the active scenario.", + syntax: &[("n <text>", "")], + flags: &[], + examples: &[("n suspicious admin path here", "Drop a context note before moving on")], + returns: "NoteAdded.", + see_also: &["finding", "session"], + }, + HelpBlock { + title: "status", + aliases: &["status"], + purpose: "Set the review status of an instruction: open, reviewed, or finding.", + syntax: &[("status <ix> <open|reviewed|finding>", "")], + flags: &[], + examples: &[ + ("status stake reviewed", "Mark stake as reviewed"), + ("status claim_rewards finding", "Flag claim_rewards as having an issue"), + ], + returns: "StatusUpdated { ix, status }.", + see_also: &["finding", "findings", "export"], + }, + HelpBlock { + title: "ex | export", + aliases: &["ex", "export"], + purpose: "Generate the audit deliverable (Markdown) with sequence, findings, and program info.", + syntax: &[("ex", "")], + flags: &[ + ("--auditor=<name>", "Auditor identity in the report metadata"), + ("--version=<v>", "Project version pinned in the report"), + ("--date=<YYYY-MM-DD>", "Audit date override (defaults to today)"), + ], + examples: &[ + ("ex", "Export with no metadata"), + ("export --auditor=\"Alba S.\" --version=v1.2 --date=2026-05-09", ""), + ], + returns: "ExportArtifact { path, markdown }.", + see_also: &["findings", "finding", "session"], + }, + HelpBlock { + title: "sc | scenario", + aliases: &["sc", "scenario"], + purpose: "Manage scenarios: create, list, switch, fork from a step, delete.", + syntax: &[ + ("sc list", "List all scenarios in the workspace (default action)"), + ("sc new <name>", "Create a fresh empty scenario"), + ("sc switch <name>", "Activate an existing scenario"), + ("sc fork <name> [step]", "Branch from the active scenario at an optional step index"), + ("sc delete <name>", "Remove a scenario"), + ], + flags: &[], + examples: &[ + ("sc new reentrancy", "Start a new scenario named reentrancy"), + ("sc fork attack-v2 3", "Fork the active scenario at step 3"), + ("sc switch main", ""), + ], + returns: "ScenarioList or ScenarioUpdated depending on the sub-command.", + see_also: &["session", "save", "load"], + }, + HelpBlock { + title: "save", + aliases: &["save"], + purpose: "Serialise the active scenario to ~/.ilold/sessions/<name>.json.", + syntax: &[("save <name>", "")], + flags: &[ + ("--with-keypairs", "Bundle plaintext test keypairs (do NOT commit the file)"), + ], + examples: &[ + ("save reentrancy-attack", ""), + ("save reentrancy-attack --with-keypairs", "Bundle keypairs for full reproducibility"), + ], + returns: "SessionSaved { json } — the CLI writes the file and warns if keypairs are bundled.", + see_also: &["load", "scenario", "export"], + }, + HelpBlock { + title: "load", + aliases: &["load"], + purpose: "Restore a scenario JSON from disk and replay it into the VM.", + syntax: &[("load <name>", "")], + flags: &[], + examples: &[("load reentrancy-attack", "Replay the saved scenario step-by-step")], + returns: "SessionLoaded { steps } — VM is rebuilt deterministically.", + see_also: &["save", "scenario", "session"], + }, + HelpBlock { + title: "seq | sequence", + aliases: &["seq", "sequence"], + purpose: "Render the narrative of the active scenario (Solana falls back to the session view).", + syntax: &[("seq", "")], + flags: &[], + examples: &[("seq", "Read the step-by-step story so far")], + returns: "SessionView (Solana parity with the Solidity sequence command).", + see_also: &["session", "step", "state"], + }, + HelpBlock { + title: "browser", + aliases: &["browser"], + purpose: "Print the URL of the local web canvas (the explore REPL serves it next to the API).", + syntax: &[("browser", "")], + flags: &[], + examples: &[("browser", "")], + returns: "Plain text with the API base URL.", + see_also: &["session", "state"], + }, + HelpBlock { + title: "? | help", + aliases: &["?", "help", "h"], + purpose: "Print the command menu. Append ? to any command for the full reference (e.g. call?, who?).", + syntax: &[("?", "")], + flags: &[], + examples: &[ + ("?", "Top-level menu"), + ("call?", "Full reference for call"), + ("who?", "Full reference for who"), + ], + returns: "Formatted help text.", + see_also: &[], + }, + HelpBlock { + title: "q | quit | exit", + aliases: &["q", "quit", "exit"], + purpose: "Exit the explore REPL.", + syntax: &[("q", "")], + flags: &[], + examples: &[("q", "")], + returns: "Terminates the session.", + see_also: &[], + }, +]; diff --git a/crates/ilold-mcp/Cargo.toml b/crates/ilold-mcp/Cargo.toml new file mode 100644 index 0000000..e509ed5 --- /dev/null +++ b/crates/ilold-mcp/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "ilold-mcp" +version.workspace = true +edition.workspace = true +description = "MCP (Model Context Protocol) server exposing Ilold Solana REPL commands as tools" + +[dependencies] +ilold-help = { path = "../ilold-help" } +ilold-render = { path = "../ilold-render" } +ilold-solana-core = { path = "../ilold-solana-core" } +ilold-session-core = { path = "../ilold-session-core" } +rmcp = { workspace = true } +schemars = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +anyhow = { workspace = true } +thiserror = { workspace = true } +tokio = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +reqwest = { version = "0.12", features = ["json"] } + +[dev-dependencies] +jsonschema = { version = "0.46", default-features = false } +httpmock = "0.7" diff --git a/crates/ilold-mcp/src/client.rs b/crates/ilold-mcp/src/client.rs new file mode 100644 index 0000000..1726d7e --- /dev/null +++ b/crates/ilold-mcp/src/client.rs @@ -0,0 +1,106 @@ +use ilold_solana_core::exploration::SolanaCommandResult; +use serde_json::Value; + +use crate::error::McpClientError; + +pub struct IloldClient { + base_url: String, + http: reqwest::Client, +} + +impl IloldClient { + pub fn new(base_url: String) -> Self { + Self { + base_url: base_url.trim_end_matches('/').to_string(), + http: reqwest::Client::new(), + } + } + + pub fn base_url(&self) -> &str { + &self.base_url + } + + pub async fn health_check(&self) -> Result<(), McpClientError> { + let url = format!("{}/api/project/map", self.base_url); + let resp = self + .http + .get(&url) + .send() + .await + .map_err(|e| McpClientError::Unreachable { + url: url.clone(), + reason: e.to_string(), + })?; + if !resp.status().is_success() { + let status = resp.status().as_u16(); + let body = resp.text().await.unwrap_or_default(); + return Err(McpClientError::HttpError { status, body }); + } + let v: Value = resp + .json() + .await + .map_err(|e| McpClientError::InvalidResponse(e.to_string()))?; + let kind = v + .get("kind") + .and_then(|x| x.as_str()) + .unwrap_or("(missing)"); + if kind != "solana" { + return Err(McpClientError::NotSolana { + url: self.base_url.clone(), + kind: kind.to_string(), + }); + } + Ok(()) + } + + pub async fn project_map(&self) -> Result<Value, McpClientError> { + let url = format!("{}/api/project/map", self.base_url); + let resp = self + .http + .get(&url) + .send() + .await + .map_err(|e| McpClientError::Unreachable { + url: url.clone(), + reason: e.to_string(), + })?; + if !resp.status().is_success() { + let status = resp.status().as_u16(); + let body = resp.text().await.unwrap_or_default(); + return Err(McpClientError::HttpError { status, body }); + } + resp.json::<Value>() + .await + .map_err(|e| McpClientError::InvalidResponse(e.to_string())) + } + + pub async fn send_command( + &self, + contract: &str, + command: Value, + ) -> Result<SolanaCommandResult, McpClientError> { + let url = format!("{}/api/cmd", self.base_url); + let body = serde_json::json!({ + "contract": contract, + "command": command, + }); + let resp = self + .http + .post(&url) + .json(&body) + .send() + .await + .map_err(|e| McpClientError::Unreachable { + url: url.clone(), + reason: e.to_string(), + })?; + if !resp.status().is_success() { + let status = resp.status().as_u16(); + let body = resp.text().await.unwrap_or_default(); + return Err(McpClientError::HttpError { status, body }); + } + resp.json::<SolanaCommandResult>() + .await + .map_err(|e| McpClientError::InvalidResponse(e.to_string())) + } +} diff --git a/crates/ilold-mcp/src/error.rs b/crates/ilold-mcp/src/error.rs new file mode 100644 index 0000000..3e5d16a --- /dev/null +++ b/crates/ilold-mcp/src/error.rs @@ -0,0 +1,13 @@ +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum McpClientError { + #[error("cannot reach Ilold server at {url}: {reason}")] + Unreachable { url: String, reason: String }, + #[error("server at {url} is not Solana (kind={kind})")] + NotSolana { url: String, kind: String }, + #[error("HTTP {status}: {body}")] + HttpError { status: u16, body: String }, + #[error("invalid response from server: {0}")] + InvalidResponse(String), +} diff --git a/crates/ilold-mcp/src/lib.rs b/crates/ilold-mcp/src/lib.rs new file mode 100644 index 0000000..acabb30 --- /dev/null +++ b/crates/ilold-mcp/src/lib.rs @@ -0,0 +1,44 @@ +use std::sync::Arc; + +use anyhow::{Context, Result}; +use rmcp::ServiceExt; +use rmcp::transport::io::stdio; + +pub mod client; +pub mod error; +pub mod narration; +pub mod schema; +pub mod server; +pub mod tools; + +pub use client::IloldClient; +pub use error::McpClientError; + +#[derive(Debug, Clone)] +pub struct Config { + pub server_url: String, + pub contract: Option<String>, + pub narration: bool, +} + +pub async fn run(cfg: Config) -> Result<()> { + tracing_subscriber::fmt() + .with_writer(std::io::stderr) + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("off")), + ) + .try_init() + .ok(); + + let client = Arc::new(IloldClient::new(cfg.server_url)); + client + .health_check() + .await + .context("Ilold backend health-check failed")?; + + let handler = server::IloldMcpServer::new(client, cfg.contract, cfg.narration); + let service = handler.serve(stdio()).await?; + service.waiting().await?; + Ok(()) +} diff --git a/crates/ilold-mcp/src/narration.rs b/crates/ilold-mcp/src/narration.rs new file mode 100644 index 0000000..985363c --- /dev/null +++ b/crates/ilold-mcp/src/narration.rs @@ -0,0 +1,243 @@ +use serde_json::Value; + +pub fn intent_for_tool(name: &str, args: Option<&Value>) -> String { + let obj = args.and_then(|v| v.as_object()); + match name { + "ilold_call" => narrate_call(args), + "ilold_back" => "Reverting the last step".to_string(), + "ilold_clear" => "Clearing the active scenario".to_string(), + "ilold_state" => "Dumping mutated account state".to_string(), + "ilold_session" => "Summarizing the active scenario".to_string(), + "ilold_programs" => "Listing workspace programs".to_string(), + "ilold_funcs" | "ilold_funcs_all" => "Listing instructions of the active program".to_string(), + "ilold_info" => match obj.and_then(|o| o.get("ix")).and_then(|v| v.as_str()) { + Some(ix) => format!("Inspecting instruction `{ix}`"), + None => "Inspecting instruction".to_string(), + }, + "ilold_vars" => "Listing account types and discriminators".to_string(), + "ilold_users" => "Listing scenario keypairs".to_string(), + "ilold_users_new" => narrate_users_new(args), + "ilold_airdrop" => narrate_airdrop(args), + "ilold_time_warp" => narrate_time_warp(args), + "ilold_pda" => match obj.and_then(|o| o.get("instruction")).and_then(|v| v.as_str()) { + Some(ix) => format!("Listing PDAs of instruction `{ix}`"), + None => "Listing PDAs".to_string(), + }, + "ilold_inspect" => match obj.and_then(|o| o.get("pubkey")).and_then(|v| v.as_str()) { + Some(p) => format!("Decoding account `{p}`"), + None => "Decoding account".to_string(), + }, + "ilold_step" => match obj.and_then(|o| o.get("index")) { + Some(idx) => format!("Re-inspecting step {idx}"), + None => "Re-inspecting step".to_string(), + }, + "ilold_who" => match obj.and_then(|o| o.get("account_type")).and_then(|v| v.as_str()) { + Some(t) => format!("Looking up who touches account type `{t}`"), + None => "Looking up cross-references".to_string(), + }, + "ilold_timeline" => match obj.and_then(|o| o.get("pubkey")).and_then(|v| v.as_str()) { + Some(p) => format!("Tracing timeline of account `{p}`"), + None => "Tracing account timeline".to_string(), + }, + "ilold_coupling" => "Computing instruction coupling".to_string(), + "ilold_coverage" => "Computing runtime coverage".to_string(), + "ilold_finding" => narrate_finding(args), + "ilold_findings" => "Listing scenario findings".to_string(), + "ilold_note" => "Adding a free-form note".to_string(), + "ilold_status" => match ( + obj.and_then(|o| o.get("ix")).and_then(|v| v.as_str()), + obj.and_then(|o| o.get("status")).and_then(|v| v.as_str()), + ) { + (Some(ix), Some(s)) => format!("Marking `{ix}` as `{s}`"), + _ => "Updating instruction review status".to_string(), + }, + "ilold_export" => "Generating the deliverable export".to_string(), + "ilold_scenario" => narrate_scenario(args), + "ilold_save" => "Saving the scenario snapshot".to_string(), + "ilold_load" => "Restoring a scenario from JSON".to_string(), + other => format!("Calling `{other}`"), + } +} + +fn narrate_call(args: Option<&Value>) -> String { + let Some(obj) = args.and_then(|v| v.as_object()) else { + return "Calling instruction".to_string(); + }; + let ix = obj.get("ix").and_then(|v| v.as_str()).unwrap_or("?"); + let mut bits: Vec<String> = Vec::new(); + if let Some(a) = obj.get("args").and_then(|v| v.as_object()) { + for (k, v) in a.iter().take(2) { + bits.push(format!("{k}={}", short_value(v))); + } + } + if let Some(acc) = obj.get("accounts").and_then(|v| v.as_object()) { + for (k, v) in acc.iter().take(2) { + bits.push(format!("{k}={}", short_value(v))); + } + } + if bits.is_empty() { + format!("Calling `{ix}`") + } else { + format!("Calling `{ix}` with {}", bits.join(", ")) + } +} + +fn narrate_users_new(args: Option<&Value>) -> String { + let name = args + .and_then(|v| v.as_object()) + .and_then(|o| o.get("name")) + .and_then(|v| v.as_str()); + match name { + Some(n) => format!("Creating a new keypair `{n}`"), + None => "Creating a new keypair".to_string(), + } +} + +fn narrate_airdrop(args: Option<&Value>) -> String { + let obj = args.and_then(|v| v.as_object()); + let user = obj.and_then(|o| o.get("user")).and_then(|v| v.as_str()); + let lamports = obj.and_then(|o| o.get("lamports")); + match (user, lamports) { + (Some(u), Some(l)) => format!("Airdropping {l} lamports to `{u}`"), + (Some(u), None) => format!("Airdropping to `{u}`"), + _ => "Airdropping lamports".to_string(), + } +} + +fn narrate_time_warp(args: Option<&Value>) -> String { + match args + .and_then(|v| v.as_object()) + .and_then(|o| o.get("delta_seconds")) + { + Some(d) => format!("Advancing the clock by {d}s"), + None => "Advancing the clock".to_string(), + } +} + +fn narrate_finding(args: Option<&Value>) -> String { + let obj = args.and_then(|v| v.as_object()); + let sev = obj.and_then(|o| o.get("severity")).and_then(|v| v.as_str()); + let title = obj.and_then(|o| o.get("title")).and_then(|v| v.as_str()); + match (sev, title) { + (Some(s), Some(t)) => format!("Recording {s} finding: `{t}`"), + (None, Some(t)) => format!("Recording finding: `{t}`"), + _ => "Recording finding".to_string(), + } +} + +fn narrate_scenario(args: Option<&Value>) -> String { + let sub = args + .and_then(|v| v.as_object()) + .and_then(|o| o.get("sub")); + let Some(sub_obj) = sub.and_then(|v| v.as_object()) else { + return "Running scenario sub-command".to_string(); + }; + if let Some((variant, payload)) = sub_obj.iter().next() { + let name = payload + .as_object() + .and_then(|p| p.get("name")) + .and_then(|v| v.as_str()); + return match name { + Some(n) => format!("Scenario `{variant}` on `{n}`"), + None => format!("Scenario `{variant}`"), + }; + } + "Running scenario sub-command".to_string() +} + +fn short_value(v: &Value) -> String { + match v { + Value::String(s) => s.clone(), + Value::Number(n) => n.to_string(), + Value::Bool(b) => b.to_string(), + Value::Null => "null".to_string(), + other => { + let s = other.to_string(); + if s.len() > 32 { + format!("{}…", &s[..32]) + } else { + s + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn narrate_call_includes_ix_and_first_args() { + let args = json!({ "ix": "stake", "args": { "amount": 1000 } }); + let out = intent_for_tool("ilold_call", Some(&args)); + assert!(out.contains("stake")); + assert!(out.contains("amount=1000")); + } + + #[test] + fn narrate_call_truncates_at_two_pairs() { + let args = json!({ + "ix": "stake", + "args": { "a": 1, "b": 2, "c": 3, "d": 4, "e": 5 } + }); + let out = intent_for_tool("ilold_call", Some(&args)); + assert!(out.contains("a=1")); + assert!(out.contains("b=2")); + assert!(!out.contains("c=3"), "should truncate after two pairs: {out}"); + } + + #[test] + fn narrate_call_combines_args_and_accounts() { + let args = json!({ + "ix": "stake", + "args": { "amount": 1000 }, + "accounts": { "user": "alice" }, + }); + let out = intent_for_tool("ilold_call", Some(&args)); + assert!(out.contains("amount=1000")); + assert!(out.contains("user=alice")); + } + + #[test] + fn narrate_users_new_includes_name() { + let args = json!({ "name": "alice" }); + let out = intent_for_tool("ilold_users_new", Some(&args)); + assert!(out.contains("alice")); + assert!(out.to_lowercase().contains("keypair")); + } + + #[test] + fn narrate_who_includes_account_type() { + let args = json!({ "account_type": "Pool" }); + let out = intent_for_tool("ilold_who", Some(&args)); + assert!(out.contains("Pool")); + } + + #[test] + fn narrate_unknown_tool_falls_back_to_generic() { + let out = intent_for_tool("ilold_made_up", None); + assert_eq!(out, "Calling `ilold_made_up`"); + } + + #[test] + fn narrate_coverage_is_short() { + let out = intent_for_tool("ilold_coverage", None); + assert_eq!(out, "Computing runtime coverage"); + } + + #[test] + fn narrate_call_with_empty_args_returns_minimal() { + let args = json!({ "ix": "stake" }); + let out = intent_for_tool("ilold_call", Some(&args)); + assert_eq!(out, "Calling `stake`"); + } + + #[test] + fn narrate_scenario_includes_sub_variant() { + let args = json!({ "sub": { "New": { "name": "branch1" } } }); + let out = intent_for_tool("ilold_scenario", Some(&args)); + assert!(out.contains("New")); + assert!(out.contains("branch1")); + } +} diff --git a/crates/ilold-mcp/src/schema.rs b/crates/ilold-mcp/src/schema.rs new file mode 100644 index 0000000..dd685d0 --- /dev/null +++ b/crates/ilold-mcp/src/schema.rs @@ -0,0 +1,337 @@ +use schemars::Schema; +use serde_json::{Map, Value, json}; + +pub fn schema_for_tool(name: &str) -> Value { + match name { + "ilold_call" => schema_for_call(), + "ilold_use" => string_only( + "program", + "Program name to make active (must match an entry returned by ilold_programs)", + ), + "ilold_info" => string_only("ix", "Instruction name to inspect"), + "ilold_pda" => string_only("instruction", "Instruction whose PDAs to list"), + "ilold_inspect" => string_only("pubkey", "Account pubkey (or named keypair)"), + "ilold_who" => string_only( + "account_type", + "Query target: account type, instruction or struct field", + ), + "ilold_timeline" => string_only("pubkey", "Account pubkey (or named keypair)"), + "ilold_users_new" => schema_users_new(), + "ilold_airdrop" => schema_airdrop(), + "ilold_time_warp" => schema_time_warp(), + "ilold_finding" => schema_finding(), + "ilold_note" => string_only("text", "Free-form annotation body"), + "ilold_status" => schema_status(), + "ilold_step" => schema_step(), + "ilold_save" => schema_save(), + "ilold_load" => string_only("json", "Saved scenario JSON to restore"), + "ilold_scenario" => schema_scenario(), + "ilold_export" => schema_export(), + _ => empty_object_schema(), + } +} + +fn empty_object_schema() -> Value { + json!({ + "type": "object", + "properties": {}, + "additionalProperties": false + }) +} + +fn string_only(field: &str, description: &str) -> Value { + let mut props = Map::new(); + props.insert( + field.to_string(), + json!({ "type": "string", "description": description }), + ); + json!({ + "type": "object", + "required": [field], + "properties": props, + "additionalProperties": false + }) +} + +fn schema_for_call() -> Value { + json!({ + "type": "object", + "required": ["ix"], + "properties": { + "ix": { + "type": "string", + "description": "Instruction name, e.g. stake or initialize_pool. Discover with ilold_funcs." + }, + "args": { + "type": "object", + "description": "Anchor instruction args as a JSON object (e.g. {\"amount\": 1000}). Required keys depend on the instruction — call `ilold_info <ix>` first to discover them. Defaults to {}.", + "default": {} + }, + "accounts": { + "type": "object", + "additionalProperties": { "type": "string" }, + "description": "Map of account-name (from the IDL) to user handle or pubkey (e.g. {\"pool\":\"pool\",\"user\":\"alice\"}).", + "default": {} + }, + "signers": { + "type": "array", + "items": { "type": "string" }, + "description": "Override the default signer set. Empty list means use the IDL defaults.", + "default": [] + } + }, + "additionalProperties": false + }) +} + +fn schema_users_new() -> Value { + json!({ + "type": "object", + "required": ["name"], + "properties": { + "name": { "type": "string", "description": "Handle for the new keypair (e.g. alice)" }, + "lamports": { + "type": "integer", + "minimum": 0, + "description": "Initial airdrop in lamports (defaults to 10_000_000_000 = 10 SOL)" + } + }, + "additionalProperties": false + }) +} + +fn schema_airdrop() -> Value { + json!({ + "type": "object", + "required": ["user", "lamports"], + "properties": { + "user": { "type": "string", "description": "Existing user handle" }, + "lamports": { + "type": "integer", + "minimum": 0, + "description": "Extra lamports to add" + } + }, + "additionalProperties": false + }) +} + +fn schema_time_warp() -> Value { + json!({ + "type": "object", + "required": ["delta_seconds"], + "properties": { + "delta_seconds": { + "type": "integer", + "description": "Seconds to advance (positive) or rewind (negative)" + } + }, + "additionalProperties": false + }) +} + +fn schema_finding() -> Value { + json!({ + "type": "object", + "required": ["severity", "title", "description"], + "properties": { + "severity": { + "type": "string", + "enum": ["Critical", "High", "Medium", "Low", "Informational"] + }, + "title": { "type": "string" }, + "description": { "type": "string" }, + "recommendation": { + "type": "string", + "description": "Optional remediation suggestion" + } + }, + "additionalProperties": false + }) +} + +fn schema_status() -> Value { + json!({ + "type": "object", + "required": ["ix", "status"], + "properties": { + "ix": { "type": "string", "description": "Instruction name" }, + "status": { + "type": "string", + "enum": [ + "NotReviewed", + "InProgress", + "Reviewed", + "Suspicious", + "Vulnerable", + "Clean" + ] + } + }, + "additionalProperties": false + }) +} + +fn schema_step() -> Value { + json!({ + "type": "object", + "required": ["index"], + "properties": { + "index": { + "type": "integer", + "minimum": 0, + "description": "Zero-based step index to re-inspect" + } + }, + "additionalProperties": false + }) +} + +fn schema_save() -> Value { + json!({ + "type": "object", + "properties": { + "with_keypairs": { + "type": "boolean", + "description": "When true the saved JSON embeds the per-scenario keypairs (do NOT commit the file).", + "default": false + } + }, + "additionalProperties": false + }) +} + +fn schema_scenario() -> Value { + json!({ + "type": "object", + "required": ["sub"], + "properties": { + "sub": { + "description": "Sub-command. Use the variant matching the desired action.", + "oneOf": [ + { "type": "string", "enum": ["List"], "description": "List all scenarios" }, + { + "type": "object", + "required": ["New"], + "properties": { "New": { + "type": "object", + "required": ["name"], + "properties": { "name": { "type": "string" } }, + "additionalProperties": false + } }, + "additionalProperties": false + }, + { + "type": "object", + "required": ["Switch"], + "properties": { "Switch": { + "type": "object", + "required": ["name"], + "properties": { "name": { "type": "string" } }, + "additionalProperties": false + } }, + "additionalProperties": false + }, + { + "type": "object", + "required": ["Fork"], + "properties": { "Fork": { + "type": "object", + "required": ["name"], + "properties": { + "name": { "type": "string" }, + "at_step": { "type": "integer", "minimum": 0 } + }, + "additionalProperties": false + } }, + "additionalProperties": false + }, + { + "type": "object", + "required": ["Delete"], + "properties": { "Delete": { + "type": "object", + "required": ["name"], + "properties": { "name": { "type": "string" } }, + "additionalProperties": false + } }, + "additionalProperties": false + } + ] + } + }, + "additionalProperties": false + }) +} + +fn schema_export() -> Value { + json!({ + "type": "object", + "properties": { + "metadata": { + "type": "object", + "description": "Optional report metadata pass-through.", + "properties": { + "auditor": { "type": "string" }, + "project_version": { "type": "string" }, + "audit_date": { "type": "string", "description": "YYYY-MM-DD" } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }) +} + +pub fn full_solana_command_schema() -> Schema { + schemars::schema_for!(ilold_solana_core::exploration::SolanaCommand) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn schema_for_call_requires_ix() { + let s = schema_for_tool("ilold_call"); + let req = s.get("required").and_then(|r| r.as_array()).unwrap(); + assert!(req.iter().any(|v| v.as_str() == Some("ix"))); + } + + #[test] + fn schema_for_funcs_has_no_properties() { + let s = schema_for_tool("ilold_funcs"); + let props = s.get("properties").and_then(|p| p.as_object()).unwrap(); + assert!(props.is_empty()); + assert_eq!(s.get("additionalProperties"), Some(&Value::Bool(false))); + } + + #[test] + fn schema_for_scenario_is_oneof() { + let s = schema_for_tool("ilold_scenario"); + let sub = s + .get("properties") + .and_then(|p| p.get("sub")) + .expect("sub property"); + let one_of = sub.get("oneOf").and_then(|o| o.as_array()).unwrap(); + assert_eq!(one_of.len(), 5); + } + + #[test] + fn schema_for_call_has_args_description() { + let s = schema_for_tool("ilold_call"); + let args = s + .get("properties") + .and_then(|p| p.get("args")) + .expect("args property"); + let desc = args.get("description").and_then(|d| d.as_str()).unwrap(); + assert!(desc.contains("ilold_info")); + } + + #[test] + fn full_solana_command_schema_is_object() { + let s = full_solana_command_schema(); + let v = serde_json::to_value(&s).unwrap(); + assert!(v.is_object()); + } +} diff --git a/crates/ilold-mcp/src/server.rs b/crates/ilold-mcp/src/server.rs new file mode 100644 index 0000000..70256cd --- /dev/null +++ b/crates/ilold-mcp/src/server.rs @@ -0,0 +1,238 @@ +use std::sync::Arc; + +use ilold_render::fmt::strip_ansi; +use ilold_render::render_solana_result; +use ilold_solana_core::exploration::SolanaCommandResult; +use rmcp::ServerHandler; +use rmcp::model::{ + CallToolRequestParams, CallToolResult, Content, Implementation, ListToolsResult, + PaginatedRequestParams, ProgressNotificationParam, ProgressToken, ServerCapabilities, + ServerInfo, +}; +use rmcp::service::{Peer, RequestContext, RoleServer}; +use serde_json::Value; +use tokio::sync::Mutex; + +use crate::client::IloldClient; +use crate::narration::intent_for_tool; +use crate::tools; + +pub struct IloldMcpServer { + client: Arc<IloldClient>, + current_contract: Arc<Mutex<Option<String>>>, + narration: bool, +} + +impl IloldMcpServer { + pub fn new( + client: Arc<IloldClient>, + initial_contract: Option<String>, + narration: bool, + ) -> Self { + Self { + client, + current_contract: Arc::new(Mutex::new(initial_contract)), + narration, + } + } +} + +impl ServerHandler for IloldMcpServer { + fn get_info(&self) -> ServerInfo { + let mut info = ServerInfo::default(); + info.capabilities = ServerCapabilities::builder().enable_tools().build(); + info.server_info = Implementation::new("ilold-mcp", env!("CARGO_PKG_VERSION")); + info.instructions = Some(format!( + "Ilold MCP server. Exposes Solana REPL commands as MCP tools \ + backed by the Ilold backend at {}. Call ilold_programs to list \ + available programs and ilold_use <program> to fix the active one \ + before issuing other tool calls.", + self.client.base_url(), + )); + info + } + + async fn list_tools( + &self, + _request: Option<PaginatedRequestParams>, + _context: RequestContext<RoleServer>, + ) -> Result<ListToolsResult, rmcp::ErrorData> { + let tools = tools::build_tool_registry(); + Ok(ListToolsResult::with_all_items(tools)) + } + + async fn call_tool( + &self, + request: CallToolRequestParams, + context: RequestContext<RoleServer>, + ) -> Result<CallToolResult, rmcp::ErrorData> { + let tool_name = request.name.to_string(); + let args_value = request.arguments.map(Value::Object); + if self.narration { + let token = context.meta.get_progress_token(); + emit_intent_progress(&context.peer, token, &tool_name, args_value.as_ref()).await; + } + let res = dispatch( + &self.client, + &self.current_contract, + &tool_name, + args_value.as_ref(), + ) + .await; + Ok(res) + } +} + +async fn emit_intent_progress( + peer: &Peer<RoleServer>, + token: Option<ProgressToken>, + tool_name: &str, + arguments: Option<&Value>, +) { + let Some(progress_token) = token else { return }; + let message = intent_for_tool(tool_name, arguments); + let params = ProgressNotificationParam { + progress_token, + progress: 0.0, + total: Some(1.0), + message: Some(message), + }; + if let Err(err) = peer.notify_progress(params).await { + tracing::debug!(?err, "notify_progress failed"); + } +} + +pub async fn dispatch( + client: &IloldClient, + current_contract: &Arc<Mutex<Option<String>>>, + tool_name: &str, + arguments: Option<&Value>, +) -> CallToolResult { + if tool_name == "ilold_use" { + return handle_use(client, current_contract, arguments).await; + } + if tool_name == "ilold_programs" { + let active = current_contract.lock().await.clone(); + return handle_programs(client, active.as_deref()).await; + } + let active = match current_contract.lock().await.clone() { + Some(c) => c, + None => { + return error_result( + "No active contract. Call ilold_use <program> first or list available programs with ilold_programs." + .to_string(), + ); + } + }; + let command = match tools::build_command(tool_name, arguments) { + Ok(cmd) => cmd, + Err(message) => return error_result(format!("Invalid arguments: {message}")), + }; + match client.send_command(&active, command).await { + Ok(result) => build_tool_response(&result), + Err(err) => error_result(err.to_string()), + } +} + +async fn handle_use( + client: &IloldClient, + current_contract: &Arc<Mutex<Option<String>>>, + arguments: Option<&Value>, +) -> CallToolResult { + let program = match arguments + .and_then(|v| v.as_object()) + .and_then(|o| o.get("program")) + .and_then(|v| v.as_str()) + { + Some(p) if !p.is_empty() => p.to_string(), + _ => return error_result("missing or empty field: program".to_string()), + }; + let map = match client.project_map().await { + Ok(v) => v, + Err(err) => return error_result(err.to_string()), + }; + let kind = map.get("kind").and_then(|v| v.as_str()).unwrap_or(""); + if kind != "solana" { + return error_result(format!( + "backend reports kind={kind}; ilold_use only supports Solana workspaces" + )); + } + let known: Vec<String> = map + .get("programs") + .and_then(|p| p.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|p| p.get("name").and_then(|n| n.as_str()).map(str::to_string)) + .collect() + }) + .unwrap_or_default(); + if !known.iter().any(|n| n == &program) { + return error_result(format!( + "unknown program `{program}`. Available: {}", + if known.is_empty() { + "(none)".to_string() + } else { + known.join(", ") + } + )); + } + *current_contract.lock().await = Some(program.clone()); + let text = format!("Active contract set to `{program}`."); + let mut out = CallToolResult::structured(serde_json::json!({ + "active_contract": program, + })); + out.content = vec![Content::text(text)]; + out +} + +async fn handle_programs(client: &IloldClient, active_contract: Option<&str>) -> CallToolResult { + let value = match client.project_map().await { + Ok(v) => v, + Err(err) => return error_result(err.to_string()), + }; + let text = render_programs(&value, active_contract); + let structured = value.clone(); + let mut out = CallToolResult::structured(serde_json::json!({ "project_map": structured })); + out.content = vec![Content::text(text)]; + out +} + +fn render_programs(map: &Value, active_contract: Option<&str>) -> String { + let mut out = String::new(); + let label = active_contract.unwrap_or("(none — call ilold_use to set one)"); + out.push_str(&format!(" workspace programs (active: {label})\n")); + if let Some(programs) = map.get("programs").and_then(|p| p.as_array()) { + for p in programs { + let name = p.get("name").and_then(|n| n.as_str()).unwrap_or("?"); + let pid = p.get("program_id").and_then(|n| n.as_str()).unwrap_or("?"); + let ix_count = p + .get("instructions") + .and_then(|i| i.as_array()) + .map(|a| a.len()) + .unwrap_or(0); + let marker = if Some(name) == active_contract { + " ← active" + } else { + "" + }; + out.push_str(&format!( + " · {name} (program_id={pid}, instructions={ix_count}){marker}\n" + )); + } + } + out +} + +fn build_tool_response(result: &SolanaCommandResult) -> CallToolResult { + let structured = serde_json::to_value(result).unwrap_or(Value::Null); + let pretty = strip_ansi(&render_solana_result(result)); + let is_error = matches!(result, SolanaCommandResult::Error { .. }); + let mut out = CallToolResult::structured(structured); + out.content = vec![Content::text(pretty)]; + out.is_error = Some(is_error); + out +} + +fn error_result(message: String) -> CallToolResult { + CallToolResult::error(vec![Content::text(message)]) +} diff --git a/crates/ilold-mcp/src/tools.rs b/crates/ilold-mcp/src/tools.rs new file mode 100644 index 0000000..9bd0714 --- /dev/null +++ b/crates/ilold-mcp/src/tools.rs @@ -0,0 +1,378 @@ +use std::sync::Arc; + +use ilold_help::{HelpBlock, SOLANA_HELP_BLOCKS}; +use rmcp::model::{JsonObject, Tool}; +use serde_json::{Map, Value, json}; + +use crate::schema::schema_for_tool; + +const EXCLUDED_ALIASES: &[&str] = &[ + "?", "help", "h", "quit", "q", "exit", "browser", "seq", "sequence", +]; +const TOOL_NAME_PREFIX: &str = "ilold_"; + +pub fn build_tool_registry() -> Vec<Tool> { + SOLANA_HELP_BLOCKS + .iter() + .filter(|b| !is_excluded(b)) + .map(|b| { + let canonical = canonical_alias(b); + let name = format!("{TOOL_NAME_PREFIX}{}", normalize_name(canonical)); + let description = format_description(b); + let schema = value_to_json_object(schema_for_tool(&name)); + Tool::new(name, description, Arc::new(schema)) + }) + .collect() +} + +pub fn is_excluded(block: &HelpBlock) -> bool { + block + .aliases + .iter() + .any(|alias| EXCLUDED_ALIASES.contains(alias)) +} + +pub fn canonical_alias(block: &HelpBlock) -> &'static str { + block + .aliases + .iter() + .copied() + .find(|a| a.len() >= 4) + .unwrap_or_else(|| block.aliases[0]) +} + +pub fn normalize_name(alias: &str) -> String { + alias.replace('-', "_") +} + +fn format_description(block: &HelpBlock) -> String { + let mut out = String::new(); + out.push_str(block.purpose); + if block.aliases.len() > 1 { + out.push_str(&format!("\n\nAliases: {}", block.aliases.join(", "))); + } + if !block.returns.is_empty() { + out.push_str(&format!("\n\nReturns: {}", block.returns)); + } + out +} + +fn value_to_json_object(v: Value) -> JsonObject { + match v { + Value::Object(map) => map, + _ => Map::new(), + } +} + +pub fn build_command(name: &str, arguments: Option<&Value>) -> Result<Value, String> { + let args = arguments.cloned().unwrap_or_else(|| json!({})); + let args_obj = args.as_object().cloned().unwrap_or_default(); + match name { + "ilold_use" => Err("ilold_use is handled client-side and has no SolanaCommand mapping".to_string()), + "ilold_call" => { + let ix = require_str(&args_obj, "ix")?; + let call_args = args_obj.get("args").cloned().unwrap_or_else(|| json!({})); + let accounts = args_obj + .get("accounts") + .cloned() + .unwrap_or_else(|| json!({})); + let signers = args_obj + .get("signers") + .cloned() + .unwrap_or_else(|| json!([])); + Ok(json!({ + "Call": { + "ix": ix, + "args": call_args, + "accounts": accounts, + "signers": signers, + } + })) + } + "ilold_back" => Ok(json!("Back")), + "ilold_clear" => Ok(json!("Clear")), + "ilold_funcs" | "ilold_functions" => Ok(json!("Funcs")), + "ilold_funcs_all" => Ok(json!("Funcs")), + "ilold_state" => Ok(json!("State")), + "ilold_session" => Ok(json!("Session")), + "ilold_info" => Ok(json!({ "Info": { "ix": require_str(&args_obj, "ix")? } })), + "ilold_vars" => Ok(json!("Vars")), + "ilold_users" => Ok(json!("Users")), + "ilold_users_new" => { + let name = require_str(&args_obj, "name")?; + let mut obj = json!({ "name": name }); + if let Some(lamports) = args_obj.get("lamports") { + obj["lamports"] = lamports.clone(); + } + Ok(json!({ "UsersNew": obj })) + } + "ilold_airdrop" => { + let user = require_str(&args_obj, "user")?; + let lamports = args_obj + .get("lamports") + .ok_or_else(|| "missing required field: lamports".to_string())? + .clone(); + Ok(json!({ "Airdrop": { "user": user, "lamports": lamports } })) + } + "ilold_time_warp" => { + let delta = args_obj + .get("delta_seconds") + .ok_or_else(|| "missing required field: delta_seconds".to_string())? + .clone(); + Ok(json!({ "TimeWarp": { "delta_seconds": delta } })) + } + "ilold_pda" => Ok(json!({ "Pda": { "instruction": require_str(&args_obj, "instruction")? } })), + "ilold_inspect" => Ok(json!({ "Inspect": { "pubkey": require_str(&args_obj, "pubkey")? } })), + "ilold_step" => { + let index = args_obj + .get("index") + .ok_or_else(|| "missing required field: index".to_string())? + .clone(); + Ok(json!({ "Step": { "index": index } })) + } + "ilold_who" => Ok(json!({ "Who": { "account_type": require_str(&args_obj, "account_type")? } })), + "ilold_timeline" => Ok(json!({ "Timeline": { "pubkey": require_str(&args_obj, "pubkey")? } })), + "ilold_coupling" => Ok(json!("Coupling")), + "ilold_coverage" => Ok(json!("Coverage")), + "ilold_finding" => { + let severity = require_str(&args_obj, "severity")?; + let title = require_str(&args_obj, "title")?; + let description = require_str(&args_obj, "description")?; + let mut obj = json!({ + "severity": severity, + "title": title, + "description": description, + }); + if let Some(rec) = args_obj.get("recommendation") { + obj["recommendation"] = rec.clone(); + } + Ok(json!({ "Finding": obj })) + } + "ilold_findings" => Ok(json!("Findings")), + "ilold_note" => Ok(json!({ "Note": { "text": require_str(&args_obj, "text")? } })), + "ilold_status" => Ok(json!({ + "Status": { + "ix": require_str(&args_obj, "ix")?, + "status": require_str(&args_obj, "status")?, + } + })), + "ilold_export" => { + if let Some(meta) = args_obj.get("metadata") { + Ok(json!({ "Export": { "metadata": meta } })) + } else { + Ok(json!({ "Export": { "metadata": null } })) + } + } + "ilold_scenario" => { + let sub = args_obj + .get("sub") + .cloned() + .ok_or_else(|| "missing required field: sub".to_string())?; + Ok(json!({ "Scenario": { "sub": sub } })) + } + "ilold_save" => { + let with_keypairs = args_obj + .get("with_keypairs") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + Ok(json!({ "SaveSession": { "with_keypairs": with_keypairs } })) + } + "ilold_load" => Ok(json!({ "LoadSession": { "json": require_str(&args_obj, "json")? } })), + other => Err(format!("unknown tool: {other}")), + } +} + +fn require_str(obj: &Map<String, Value>, key: &str) -> Result<String, String> { + obj.get(key) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .ok_or_else(|| format!("missing or non-string field: {key}")) +} + +#[cfg(test)] +mod tests { + use super::*; + use ilold_solana_core::exploration::SolanaCommand; + + #[test] + fn tool_registry_has_30_entries() { + let tools = build_tool_registry(); + assert_eq!(tools.len(), 30); + } + + #[test] + fn registry_includes_users_new() { + let tools = build_tool_registry(); + assert!( + tools.iter().any(|t| t.name == "ilold_users_new"), + "ilold_users_new must be exposed as a dedicated tool" + ); + } + + #[test] + fn registry_includes_use() { + let tools = build_tool_registry(); + assert!( + tools.iter().any(|t| t.name == "ilold_use"), + "ilold_use must be exposed so the LLM can switch the active program" + ); + } + + #[test] + fn registry_excludes_sequence() { + let tools = build_tool_registry(); + assert!( + !tools.iter().any(|t| t.name == "ilold_sequence"), + "ilold_sequence must NOT appear; /api/session/sequence is Solidity-only" + ); + } + + #[test] + fn tool_names_are_unique() { + let tools = build_tool_registry(); + let mut names: Vec<String> = tools.iter().map(|t| t.name.to_string()).collect(); + names.sort(); + let dup = names.windows(2).find(|w| w[0] == w[1]); + assert!(dup.is_none(), "duplicate tool name: {:?}", dup); + } + + #[test] + fn excluded_commands_not_in_registry() { + let tools = build_tool_registry(); + let forbidden = [ + "ilold_?", + "ilold_help", + "ilold_h", + "ilold_quit", + "ilold_q", + "ilold_exit", + "ilold_browser", + ]; + for f in forbidden { + assert!( + !tools.iter().any(|t| t.name == f), + "registry should not contain {f}" + ); + } + } + + #[test] + fn canonical_alias_prefers_long_form() { + let block = HelpBlock { + title: "c | call", + aliases: &["c", "call"], + purpose: "", + syntax: &[], + flags: &[], + examples: &[], + returns: "", + see_also: &[], + }; + assert_eq!(canonical_alias(&block), "call"); + } + + #[test] + fn canonical_alias_falls_back_to_first_when_all_short() { + let block = HelpBlock { + title: "? | h", + aliases: &["?", "h"], + purpose: "", + syntax: &[], + flags: &[], + examples: &[], + returns: "", + see_also: &[], + }; + assert_eq!(canonical_alias(&block), "?"); + } + + #[test] + fn normalize_name_replaces_dash() { + assert_eq!(normalize_name("funcs-all"), "funcs_all"); + assert_eq!(normalize_name("time-warp"), "time_warp"); + } + + #[test] + fn normalize_name_idempotent() { + let once = normalize_name("time-warp"); + let twice = normalize_name(&once); + assert_eq!(once, twice); + } + + #[test] + fn names_have_ilold_prefix() { + let tools = build_tool_registry(); + for t in &tools { + assert!( + t.name.starts_with(TOOL_NAME_PREFIX), + "tool name missing prefix: {}", + t.name + ); + } + } + + #[test] + fn description_includes_purpose_and_returns() { + let tools = build_tool_registry(); + let call = tools.iter().find(|t| t.name == "ilold_call").expect("call"); + let desc = call.description.as_deref().unwrap_or(""); + assert!(desc.contains("Anchor instruction")); + assert!(desc.contains("Returns:")); + } + + #[test] + fn build_command_call_translates_minimal() { + let v = build_command("ilold_call", Some(&json!({"ix": "stake"}))).unwrap(); + let cmd: SolanaCommand = serde_json::from_value(v).expect("deserialize"); + match cmd { + SolanaCommand::Call { ix, args, accounts, signers } => { + assert_eq!(ix, "stake"); + assert!(args.as_object().is_some_and(|o| o.is_empty())); + assert!(accounts.is_empty()); + assert!(signers.is_empty()); + } + _ => panic!("expected Call variant"), + } + } + + #[test] + fn build_command_funcs_unit_variant() { + let v = build_command("ilold_funcs", None).unwrap(); + let cmd: SolanaCommand = serde_json::from_value(v).expect("deserialize"); + assert!(matches!(cmd, SolanaCommand::Funcs)); + } + + #[test] + fn build_command_scenario_sub_passthrough() { + let v = build_command( + "ilold_scenario", + Some(&json!({ "sub": { "New": { "name": "branch1" } } })), + ) + .unwrap(); + let cmd: SolanaCommand = serde_json::from_value(v).expect("deserialize"); + assert!(matches!(cmd, SolanaCommand::Scenario { .. })); + } + + #[test] + fn build_command_users_new_includes_default_lamports() { + let v = build_command("ilold_users_new", Some(&json!({ "name": "alice" }))).unwrap(); + let cmd: SolanaCommand = serde_json::from_value(v).expect("deserialize"); + match cmd { + SolanaCommand::UsersNew { name, lamports } => { + assert_eq!(name, "alice"); + assert!(lamports > 0, "default lamports should be positive"); + } + _ => panic!("expected UsersNew"), + } + } + + #[test] + fn build_command_time_warp_passes_delta() { + let v = build_command("ilold_time_warp", Some(&json!({ "delta_seconds": 86400 }))).unwrap(); + let cmd: SolanaCommand = serde_json::from_value(v).expect("deserialize"); + match cmd { + SolanaCommand::TimeWarp { delta_seconds } => assert_eq!(delta_seconds, 86400), + _ => panic!("expected TimeWarp"), + } + } +} diff --git a/crates/ilold-mcp/tests/client_httpmock.rs b/crates/ilold-mcp/tests/client_httpmock.rs new file mode 100644 index 0000000..8b78c5a --- /dev/null +++ b/crates/ilold-mcp/tests/client_httpmock.rs @@ -0,0 +1,122 @@ +//! Unit tests for `IloldClient` against a real (local) HTTP mock server. +//! +//! Covers SDD-05 § T-R55b.2.10 sub-tasks: +//! - body serialization for `/api/cmd` +//! - health-check rejecting non-Solana projects +//! - `contract` field forwarded verbatim + +use httpmock::Method::{GET, POST}; +use httpmock::MockServer; +use ilold_mcp::IloldClient; +use ilold_solana_core::exploration::SolanaCommandResult; +use serde_json::json; + +#[tokio::test] +async fn client_send_command_serializes_body_correctly() { + let server = MockServer::start_async().await; + let mock = server.mock(|when, then| { + when.method(POST) + .path("/api/cmd") + .json_body(json!({ + "contract": "staking", + "command": { + "Call": { + "ix": "stake", + "args": { "amount": 1000 }, + "accounts": {}, + "signers": [] + } + } + })); + then.status(200).json_body(json!({ + "StepAdded": { + "step_index": 0, + "instruction": "stake", + "logs_excerpt": [], + "account_diffs_count": 0, + "compute_units": 0, + "error": null + } + })); + }); + + let client = IloldClient::new(server.base_url()); + let command = json!({ + "Call": { + "ix": "stake", + "args": { "amount": 1000 }, + "accounts": {}, + "signers": [] + } + }); + let result = client + .send_command("staking", command) + .await + .expect("send_command succeeds"); + match result { + SolanaCommandResult::StepAdded { instruction, .. } => { + assert_eq!(instruction, "stake"); + } + other => panic!("expected StepAdded, got {other:?}"), + } + mock.assert(); +} + +#[tokio::test] +async fn client_health_check_rejects_solidity() { + let server = MockServer::start_async().await; + server.mock(|when, then| { + when.method(GET).path("/api/project/map"); + then.status(200).json_body(json!({ + "kind": "solidity", + "programs": [] + })); + }); + + let client = IloldClient::new(server.base_url()); + let err = client + .health_check() + .await + .expect_err("solidity backend must be rejected"); + let msg = err.to_string(); + assert!( + msg.contains("not Solana") && msg.contains("solidity"), + "expected NotSolana error, got: {msg}" + ); +} + +#[tokio::test] +async fn client_passes_contract_field() { + let server = MockServer::start_async().await; + let mock = server.mock(|when, then| { + when.method(POST) + .path("/api/cmd") + .json_body_partial(r#"{ "contract": "lever" }"#); + then.status(200).json_body(json!("NoteAdded")); + }); + + let client = IloldClient::new(server.base_url()); + let _ = client + .send_command("lever", json!("Funcs")) + .await + .expect("send_command serializes contract"); + mock.assert(); +} + +#[tokio::test] +async fn client_health_check_accepts_solana() { + let server = MockServer::start_async().await; + server.mock(|when, then| { + when.method(GET).path("/api/project/map"); + then.status(200).json_body(json!({ + "kind": "solana", + "programs": [{ "name": "staking" }] + })); + }); + + let client = IloldClient::new(server.base_url()); + client + .health_check() + .await + .expect("solana backend should pass health check"); +} diff --git a/crates/ilold-mcp/tests/handler_use_flow.rs b/crates/ilold-mcp/tests/handler_use_flow.rs new file mode 100644 index 0000000..93a0260 --- /dev/null +++ b/crates/ilold-mcp/tests/handler_use_flow.rs @@ -0,0 +1,125 @@ +//! Integration tests for the agnostic-contract handler flow: +//! `ilold_use` switches the active program, other tools error when no contract +//! is active, and the active contract is forwarded verbatim to `/api/cmd`. + +use std::sync::Arc; + +use httpmock::Method::{GET, POST}; +use httpmock::MockServer; +use ilold_mcp::IloldClient; +use ilold_mcp::server::dispatch; +use serde_json::json; +use tokio::sync::Mutex; + +fn project_map_two_programs(server: &MockServer) { + server.mock(|when, then| { + when.method(GET).path("/api/project/map"); + then.status(200).json_body(json!({ + "kind": "solana", + "programs": [ + { "name": "hand", "program_id": "Hand1111", "instructions": [] }, + { "name": "lever", "program_id": "Lev11111", "instructions": [] } + ] + })); + }); +} + +#[tokio::test] +async fn tool_call_without_active_contract_returns_error() { + let server = MockServer::start_async().await; + let client = Arc::new(IloldClient::new(server.base_url())); + let state = Arc::new(Mutex::new(None)); + + let res = dispatch(&client, &state, "ilold_funcs", None).await; + assert_eq!(res.is_error, Some(true)); + let dumped = serde_json::to_string(&res).expect("serialize CallToolResult"); + assert!( + dumped.contains("No active contract"), + "expected guidance to call ilold_use, got: {dumped}" + ); +} + +#[tokio::test] +async fn ilold_use_sets_active_contract() { + let server = MockServer::start_async().await; + project_map_two_programs(&server); + let client = Arc::new(IloldClient::new(server.base_url())); + let state = Arc::new(Mutex::new(None)); + + let res = dispatch( + &client, + &state, + "ilold_use", + Some(&json!({ "program": "lever" })), + ) + .await; + assert_ne!(res.is_error, Some(true)); + let active = state.lock().await.clone(); + assert_eq!(active.as_deref(), Some("lever")); +} + +#[tokio::test] +async fn ilold_use_rejects_unknown_program() { + let server = MockServer::start_async().await; + project_map_two_programs(&server); + let client = Arc::new(IloldClient::new(server.base_url())); + let state = Arc::new(Mutex::new(None)); + + let res = dispatch( + &client, + &state, + "ilold_use", + Some(&json!({ "program": "ghost" })), + ) + .await; + assert_eq!(res.is_error, Some(true)); + assert!(state.lock().await.is_none()); +} + +#[tokio::test] +async fn tool_call_uses_active_contract_in_post_body() { + let server = MockServer::start_async().await; + project_map_two_programs(&server); + let cmd_mock = server.mock(|when, then| { + when.method(POST) + .path("/api/cmd") + .json_body_partial(r#"{ "contract": "hand" }"#); + then.status(200).json_body(json!({ + "InstructionList": { "items": [] } + })); + }); + + let client = Arc::new(IloldClient::new(server.base_url())); + let state = Arc::new(Mutex::new(None)); + + let set = dispatch( + &client, + &state, + "ilold_use", + Some(&json!({ "program": "hand" })), + ) + .await; + assert_ne!(set.is_error, Some(true)); + + let call = dispatch(&client, &state, "ilold_funcs", None).await; + assert_ne!(call.is_error, Some(true)); + cmd_mock.assert(); +} + +#[tokio::test] +async fn ilold_use_can_switch_between_programs() { + let server = MockServer::start_async().await; + project_map_two_programs(&server); + let client = Arc::new(IloldClient::new(server.base_url())); + let state = Arc::new(Mutex::new(Some("hand".to_string()))); + + let switch = dispatch( + &client, + &state, + "ilold_use", + Some(&json!({ "program": "lever" })), + ) + .await; + assert_ne!(switch.is_error, Some(true)); + assert_eq!(state.lock().await.as_deref(), Some("lever")); +} diff --git a/crates/ilold-mcp/tests/mcp_stdio_full_flow.rs b/crates/ilold-mcp/tests/mcp_stdio_full_flow.rs new file mode 100644 index 0000000..20a5fe9 --- /dev/null +++ b/crates/ilold-mcp/tests/mcp_stdio_full_flow.rs @@ -0,0 +1,212 @@ +//! End-to-end stdio integration test for `ilold mcp`. +//! +//! Boots a mocked Ilold backend with `httpmock`, spawns the real `ilold mcp` +//! binary as a child process, exchanges the MCP initialize handshake, +//! lists tools, and finally fires a `tools/call ilold_funcs` and validates +//! that the structured `InstructionList` payload comes back. +//! +//! Marked `#[ignore]`: requires the `ilold` binary to be pre-built (the test +//! locates it at `<workspace>/target/<profile>/ilold`). Run it after +//! `cargo build -p ilold-cli` (debug) or `cargo build --release -p ilold-cli`. +//! `cargo test --workspace` skips it by default to keep CI fast. + +use std::io::{BufRead, BufReader, Write}; +use std::path::PathBuf; +use std::process::{Command, Stdio}; +use std::time::Duration; + +use httpmock::Method::{GET, POST}; +use httpmock::MockServer; +use serde_json::{Value, json}; + +fn ilold_binary() -> Option<PathBuf> { + // Two candidates: debug build, then release. + let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let workspace = manifest.parent()?.parent()?; + for profile in ["debug", "release"] { + let p = workspace.join("target").join(profile).join("ilold"); + if p.exists() { + return Some(p); + } + } + None +} + +fn write_json_line<W: Write>(w: &mut W, v: &Value) -> std::io::Result<()> { + let line = serde_json::to_string(v).expect("serialize json"); + writeln!(w, "{line}")?; + w.flush() +} + +fn read_response<R: BufRead>(reader: &mut R, want_id: u64) -> Option<Value> { + for _ in 0..256 { + let mut line = String::new(); + let n = reader.read_line(&mut line).ok()?; + if n == 0 { + return None; + } + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + let parsed: Value = match serde_json::from_str(trimmed) { + Ok(v) => v, + Err(_) => continue, + }; + if parsed.get("id").and_then(|v| v.as_u64()) == Some(want_id) { + return Some(parsed); + } + } + None +} + +#[test] +#[ignore = "requires pre-built `ilold` binary at target/debug/ilold or target/release/ilold"] +fn mcp_stdio_full_flow_initialize_list_call() { + let ilold = match ilold_binary() { + Some(p) => p, + None => { + eprintln!("skipping: ilold binary not found; run `cargo build -p ilold-cli` first"); + return; + } + }; + + let server = MockServer::start(); + server.mock(|when, then| { + when.method(GET).path("/api/project/map"); + then.status(200).json_body(json!({ + "kind": "solana", + "programs": [{ "name": "staking" }] + })); + }); + server.mock(|when, then| { + when.method(POST).path("/api/cmd"); + then.status(200).json_body(json!({ + "InstructionList": { + "items": [ + { + "name": "stake", + "args_count": 1, + "accounts_count": 3, + "has_pdas": false, + "signers": ["user"] + }, + { + "name": "unstake", + "args_count": 0, + "accounts_count": 2, + "has_pdas": false, + "signers": ["user"] + } + ] + } + })); + }); + + let mut child = Command::new(&ilold) + .arg("mcp") + .arg("--server-url") + .arg(server.base_url()) + .arg("--contract") + .arg("staking") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn ilold mcp"); + + let mut stdin = child.stdin.take().expect("stdin handle"); + let mut stdout = BufReader::new(child.stdout.take().expect("stdout handle")); + + let init = json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": { "name": "ilold-integration-test", "version": "0.0.0" } + } + }); + write_json_line(&mut stdin, &init).expect("send initialize"); + let init_resp = read_response(&mut stdout, 1).expect("initialize response"); + assert!( + init_resp.get("result").is_some(), + "initialize must succeed: {init_resp}" + ); + + let initialized = json!({ + "jsonrpc": "2.0", + "method": "notifications/initialized" + }); + write_json_line(&mut stdin, &initialized).expect("send initialized notif"); + + let list = json!({ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/list" + }); + write_json_line(&mut stdin, &list).expect("send tools/list"); + let list_resp = read_response(&mut stdout, 2).expect("tools/list response"); + let tools = list_resp + .pointer("/result/tools") + .and_then(|v| v.as_array()) + .cloned() + .expect("tools array"); + assert_eq!(tools.len(), 30, "expected 30 tools, got {}", tools.len()); + let names: Vec<&str> = tools + .iter() + .filter_map(|t| t.get("name").and_then(|n| n.as_str())) + .collect(); + assert!(names.contains(&"ilold_funcs")); + assert!(names.contains(&"ilold_call")); + + let call = json!({ + "jsonrpc": "2.0", + "id": 3, + "method": "tools/call", + "params": { "name": "ilold_funcs", "arguments": {} } + }); + write_json_line(&mut stdin, &call).expect("send tools/call"); + let call_resp = read_response(&mut stdout, 3).expect("tools/call response"); + + let structured = call_resp + .pointer("/result/structuredContent") + .expect("structuredContent present"); + let items = structured + .pointer("/InstructionList/items") + .and_then(|v| v.as_array()) + .expect("InstructionList.items present"); + assert_eq!(items.len(), 2); + + let text = call_resp + .pointer("/result/content/0/text") + .and_then(|v| v.as_str()) + .expect("text content"); + assert!( + text.contains("stake"), + "expected rendered text to mention `stake`, got: {text}" + ); + + drop(stdin); + let _ = child.wait_timeout_or_kill(Duration::from_secs(5)); +} + +trait WaitTimeoutExt { + fn wait_timeout_or_kill(&mut self, timeout: Duration); +} + +impl WaitTimeoutExt for std::process::Child { + fn wait_timeout_or_kill(&mut self, timeout: Duration) { + let start = std::time::Instant::now(); + while start.elapsed() < timeout { + match self.try_wait() { + Ok(Some(_)) => return, + Ok(None) => std::thread::sleep(Duration::from_millis(50)), + Err(_) => break, + } + } + let _ = self.kill(); + let _ = self.wait(); + } +} diff --git a/crates/ilold-mcp/tests/mcp_stdio_lists_tools.rs b/crates/ilold-mcp/tests/mcp_stdio_lists_tools.rs new file mode 100644 index 0000000..d009abb --- /dev/null +++ b/crates/ilold-mcp/tests/mcp_stdio_lists_tools.rs @@ -0,0 +1,42 @@ +use ilold_mcp::tools::build_tool_registry; + +#[test] +fn registry_lists_30_solana_tools() { + let tools = build_tool_registry(); + assert_eq!(tools.len(), 30); + let names: Vec<&str> = tools.iter().map(|t| t.name.as_ref()).collect(); + assert!(names.contains(&"ilold_call")); + assert!(names.contains(&"ilold_funcs")); + assert!(names.contains(&"ilold_coverage")); + assert!(names.contains(&"ilold_users_new")); + assert!(names.contains(&"ilold_use")); + assert!(!names.contains(&"ilold_help")); + assert!(!names.contains(&"ilold_quit")); + assert!(!names.contains(&"ilold_sequence")); +} + +#[test] +fn every_tool_has_valid_input_schema() { + let tools = build_tool_registry(); + for t in &tools { + let schema = serde_json::to_value(&*t.input_schema).expect("schema serializes"); + assert!(schema.is_object(), "{}: schema must be JSON object", t.name); + assert_eq!( + schema.get("type").and_then(|v| v.as_str()), + Some("object"), + "{}: schema type must be 'object'", + t.name + ); + } +} + +#[test] +fn destructive_tool_names_present() { + // SDD-05 notes destructive tools rely on the MCP client's heuristic + // "name contains clear|delete" to prompt the human. Make sure those + // names actually exist verbatim. + let tools = build_tool_registry(); + let names: Vec<&str> = tools.iter().map(|t| t.name.as_ref()).collect(); + assert!(names.contains(&"ilold_clear")); + assert!(names.contains(&"ilold_scenario")); +} diff --git a/crates/ilold-mcp/tests/schema_consistency.rs b/crates/ilold-mcp/tests/schema_consistency.rs new file mode 100644 index 0000000..9d75dd9 --- /dev/null +++ b/crates/ilold-mcp/tests/schema_consistency.rs @@ -0,0 +1,116 @@ +//! Cross-check that every tool in the registry has: +//! 1. An inputSchema that validates against a sample `arguments` payload. +//! 2. A `build_command` mapping that produces a JSON value which +//! round-trips into the correct `SolanaCommand` variant. +//! +//! The special-case `ilold_programs` skips the build_command leg because the +//! MCP server routes it to a REST GET, not the /api/cmd dispatcher. + +use ilold_mcp::tools::build_tool_registry; +use ilold_solana_core::exploration::SolanaCommand; +use serde_json::{Value, json}; + +/// Sample `arguments` payload per tool — the minimal JSON that satisfies +/// the tool's inputSchema. Tools without arguments map to `None`. +fn sample_arguments(name: &str) -> Option<Value> { + match name { + "ilold_call" => Some(json!({ "ix": "stake" })), + "ilold_use" => Some(json!({ "program": "staking" })), + "ilold_info" => Some(json!({ "ix": "stake" })), + "ilold_pda" => Some(json!({ "instruction": "stake" })), + "ilold_inspect" => Some(json!({ "pubkey": "alice" })), + "ilold_who" => Some(json!({ "account_type": "Pool" })), + "ilold_timeline" => Some(json!({ "pubkey": "alice" })), + "ilold_users_new" => Some(json!({ "name": "alice" })), + "ilold_airdrop" => Some(json!({ "user": "alice", "lamports": 1_000_000_000u64 })), + "ilold_time_warp" => Some(json!({ "delta_seconds": 86400 })), + "ilold_finding" => Some(json!({ + "severity": "High", + "title": "reentrancy", + "description": "found a reentrant path", + })), + "ilold_note" => Some(json!({ "text": "suspicious admin path" })), + "ilold_status" => Some(json!({ "ix": "stake", "status": "Reviewed" })), + "ilold_step" => Some(json!({ "index": 0 })), + "ilold_load" => Some(json!({ "json": "{}" })), + "ilold_scenario" => Some(json!({ "sub": { "New": { "name": "branch1" } } })), + "ilold_save" => Some(json!({})), + "ilold_export" => Some(json!({})), + _ => None, + } +} + +/// `ilold_programs` and `ilold_use` are special-cases in the dispatcher: +/// `ilold_programs` calls REST GET /api/project/map and `ilold_use` updates +/// the handler's active-contract state. Neither has a SolanaCommand variant, +/// so we only validate the schema, not the build_command path. +fn skips_build_command(name: &str) -> bool { + matches!(name, "ilold_programs" | "ilold_use") +} + +#[test] +fn every_tool_input_schema_validates_sample() { + let tools = build_tool_registry(); + for t in &tools { + let schema_value: Value = + serde_json::to_value(&*t.input_schema).expect("schema serializes"); + let validator = jsonschema::validator_for(&schema_value) + .unwrap_or_else(|e| panic!("{}: schema compile failed: {e}", t.name)); + + let args = sample_arguments(&t.name).unwrap_or_else(|| json!({})); + let errors: Vec<String> = validator + .iter_errors(&args) + .map(|e| e.to_string()) + .collect(); + assert!( + errors.is_empty(), + "{}: sample {} failed schema: {:?}", + t.name, + args, + errors + ); + } +} + +#[test] +fn every_tool_build_command_roundtrips_to_solana_command() { + let tools = build_tool_registry(); + for t in &tools { + if skips_build_command(&t.name) { + continue; + } + let args = sample_arguments(&t.name); + let cmd_json = ilold_mcp::tools::build_command(&t.name, args.as_ref()) + .unwrap_or_else(|e| panic!("{}: build_command failed: {e}", t.name)); + let _: SolanaCommand = serde_json::from_value(cmd_json.clone()).unwrap_or_else(|e| { + panic!( + "{}: SolanaCommand deserialize failed: {e} (payload: {})", + t.name, cmd_json + ) + }); + } +} + +#[test] +fn users_new_schema_requires_name() { + let tools = build_tool_registry(); + let t = tools + .iter() + .find(|t| t.name == "ilold_users_new") + .expect("ilold_users_new present"); + let schema: Value = serde_json::to_value(&*t.input_schema).unwrap(); + let req = schema + .get("required") + .and_then(|r| r.as_array()) + .expect("required field"); + assert!(req.iter().any(|v| v.as_str() == Some("name"))); +} + +#[test] +fn sequence_tool_absent_from_registry() { + let tools = build_tool_registry(); + assert!( + !tools.iter().any(|t| t.name == "ilold_sequence"), + "ilold_sequence must be excluded: /api/session/sequence is Solidity-only" + ); +} diff --git a/crates/ilold-render/Cargo.toml b/crates/ilold-render/Cargo.toml new file mode 100644 index 0000000..db1df0d --- /dev/null +++ b/crates/ilold-render/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "ilold-render" +version.workspace = true +edition.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 } +colored = "3" diff --git a/crates/ilold-render/src/colors.rs b/crates/ilold-render/src/colors.rs new file mode 100644 index 0000000..8a156de --- /dev/null +++ b/crates/ilold-render/src/colors.rs @@ -0,0 +1,18 @@ +use colored::{ColoredString, Colorize}; +use ilold_core::classify::entry_points::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) } +pub fn c_danger(s: &str) -> ColoredString { s.truecolor(170, 90, 90) } +pub fn c_ok(s: &str) -> ColoredString { s.truecolor(100, 160, 110) } +pub fn c_muted(s: &str) -> ColoredString { s.truecolor(110, 120, 140) } +pub fn c_bright(s: &str) -> ColoredString { s.truecolor(190, 200, 215).bold() } + +pub fn access_colored(access: &AccessLevel) -> ColoredString { + match access { + AccessLevel::Public => c_accent("[P]"), + AccessLevel::Restricted { .. } => c_warn("[R]"), + AccessLevel::Internal => c_muted("[I]"), + AccessLevel::Special { .. } => c_muted("[S]"), + } +} diff --git a/crates/ilold-render/src/fmt.rs b/crates/ilold-render/src/fmt.rs new file mode 100644 index 0000000..9f6f776 --- /dev/null +++ b/crates/ilold-render/src/fmt.rs @@ -0,0 +1,27 @@ +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 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 +} diff --git a/crates/ilold-render/src/lib.rs b/crates/ilold-render/src/lib.rs new file mode 100644 index 0000000..37c6824 --- /dev/null +++ b/crates/ilold-render/src/lib.rs @@ -0,0 +1,5 @@ +pub mod colors; +pub mod fmt; +pub mod solana; + +pub use solana::render_solana_result; diff --git a/crates/ilold-render/src/solana.rs b/crates/ilold-render/src/solana.rs new file mode 100644 index 0000000..5d325d7 --- /dev/null +++ b/crates/ilold-render/src/solana.rs @@ -0,0 +1,1097 @@ +use std::fmt::Write; + +use ilold_solana_core::exploration::SolanaCommandResult; +use ilold_solana_core::exploration::commands::{WhoEntry, WhoIxAccount, WhoQueryKind}; +use ilold_solana_core::overlay::RuntimeOverlay; +use ilold_solana_core::view::{ + AccountKind, AccountView, ArgView, CouplingPair, FieldView, IxAccountView, IxView, + describe_seed_view, +}; + +use crate::colors::{c_accent, c_danger, c_muted, c_ok, c_warn}; +use crate::fmt::pad_right; + +pub fn render_solana_result(result: &SolanaCommandResult) -> String { + let mut out = String::new(); + match result { + SolanaCommandResult::StepAdded { + step_index, + instruction, + logs_excerpt, + account_diffs_count, + compute_units, + error, + } => { + let failed = error.is_some() + || logs_excerpt.iter().any(|l| { + let s = l.as_str(); + s.contains("AnchorError") || s.contains("failed:") || s.contains("panicked") + }); + let (mark, label) = if failed { + (c_danger("✗").to_string(), c_danger("FAILED").to_string()) + } else { + (c_ok("✓").to_string(), c_ok("ok").to_string()) + }; + writeln!( + out, + " {} step {} [{}]: {} {}", + mark, + step_index, + label, + c_accent(instruction), + c_muted(&format!( + "({} CU, {} diffs)", + compute_units, account_diffs_count + )) + ) + .ok(); + for log in logs_excerpt { + if log.contains("AnchorError") + || log.contains("failed:") + || log.contains("panicked") + { + writeln!(out, " {}", c_danger(log)).ok(); + } else { + writeln!(out, " {}", c_muted(log)).ok(); + } + } + } + SolanaCommandResult::CallFailed { + instruction, + logs_excerpt, + compute_units, + error, + } => { + writeln!( + out, + " {} {}: {} {}", + c_danger("✗"), + c_danger("FAILED"), + c_accent(instruction), + c_muted(&format!("({} CU, not recorded)", compute_units)), + ) + .ok(); + writeln!(out, " {} {}", c_danger("error:"), error).ok(); + for log in logs_excerpt { + if log.contains("AnchorError") + || log.contains("failed:") + || log.contains("panicked") + { + writeln!(out, " {}", c_danger(log)).ok(); + } else { + writeln!(out, " {}", c_muted(log)).ok(); + } + } + } + SolanaCommandResult::StepRemoved { remaining } => { + writeln!(out, " {} step undone ({} remaining)", c_ok("✓"), remaining).ok(); + } + SolanaCommandResult::Cleared => { + writeln!(out, " {} session cleared", c_ok("✓")).ok(); + } + SolanaCommandResult::InstructionList { items } => { + for ix in items { + let badge = if ix.has_pdas { c_accent("[PDA]") } else { c_muted("[ix]") }; + let signers = if ix.signers.is_empty() { + String::new() + } else { + format!(" signers: {}", ix.signers.join(",")) + }; + writeln!( + out, + " {} {} {}{}", + badge, + ix.name, + c_muted(&format!( + "(args:{} accounts:{})", + ix.args_count, ix.accounts_count + )), + c_muted(&signers) + ) + .ok(); + } + } + SolanaCommandResult::StateView { accounts } => { + if accounts.is_empty() { + writeln!(out, " {}", c_muted("No accounts mutated yet")).ok(); + } + for a in accounts { + writeln!( + out, + " {} {} {} {}", + c_accent("[A]"), + c_accent(&a.label), + c_muted(&format!("({} lamports)", a.lamports)), + c_muted(&a.pubkey), + ) + .ok(); + match a.decoded.as_ref() { + None => { + writeln!(out, " {}", c_muted("<not decoded>")).ok(); + } + Some(serde_json::Value::Object(map)) => { + let max = map + .keys() + .map(|k| k.chars().count()) + .max() + .unwrap_or(0); + for (k, v) in map { + let val = match v { + serde_json::Value::String(s) => s.clone(), + _ => serde_json::to_string(v).unwrap_or_default(), + }; + writeln!( + out, + " {} {}", + c_muted(&format!("{:width$}", k, width = max)), + val, + ) + .ok(); + } + } + Some(other) => { + writeln!( + out, + " {}", + c_muted(&serde_json::to_string(other).unwrap_or_default()), + ) + .ok(); + } + } + } + } + SolanaCommandResult::SessionView { + program, + scenario, + steps, + findings_count, + } => { + writeln!( + out, + " program={} scenario={} steps={} findings={}", + c_accent(program), + c_accent(scenario), + steps.len(), + findings_count + ) + .ok(); + for (i, s) in steps.iter().enumerate() { + writeln!(out, " {} {}", c_muted(&format!("{i}.")), s).ok(); + } + } + SolanaCommandResult::UserList { users } => { + if users.is_empty() { + writeln!( + out, + " {}", + c_muted("No users — create with 'users new <name>'") + ) + .ok(); + } + for u in users { + writeln!( + out, + " {} {} {} {}", + c_accent("[U]"), + u.name, + c_muted(&u.pubkey), + c_muted(&format!("{} lamports", u.lamports)) + ) + .ok(); + } + } + SolanaCommandResult::UserCreated { name, pubkey, lamports } => { + writeln!( + out, + " {} user {} created at {} with {} lamports", + c_ok("✓"), + c_accent(name), + c_muted(pubkey), + lamports + ) + .ok(); + } + SolanaCommandResult::Airdropped { name, pubkey, total_lamports } => { + writeln!( + out, + " {} {} now {} lamports {}", + c_ok("✓"), + c_accent(name), + total_lamports, + c_muted(pubkey) + ) + .ok(); + } + SolanaCommandResult::TimeWarped { unix_timestamp, slot } => { + writeln!( + out, + " {} clock now ts={} slot={}", + c_ok("✓"), + unix_timestamp, + slot + ) + .ok(); + } + SolanaCommandResult::PdaList { instruction, pdas } => { + if pdas.is_empty() { + writeln!(out, " {} {}", c_muted("no PDAs declared in"), instruction).ok(); + } + for p in pdas { + writeln!( + out, + " {} {} seeds=[{}] program={}", + c_accent("[PDA]"), + p.account_name, + p.seeds.join(", "), + c_muted(&p.program) + ) + .ok(); + } + } + SolanaCommandResult::AccountInspected { + pubkey, + owner, + lamports, + data_len, + decoded, + } => { + writeln!( + out, + " {} owner={} lamports={} data_len={}", + c_accent(pubkey), + c_muted(owner), + lamports, + data_len + ) + .ok(); + if let Some(d) = decoded { + writeln!( + out, + " {}", + serde_json::to_string_pretty(d).unwrap_or_default() + ) + .ok(); + } + } + SolanaCommandResult::FindingAdded { id } => { + writeln!(out, " {} finding {}", c_ok("✓"), c_accent(id)).ok(); + } + SolanaCommandResult::NoteAdded => { + writeln!(out, " {} note recorded", c_ok("✓")).ok(); + } + SolanaCommandResult::StatusUpdated => { + writeln!(out, " {} status updated", c_ok("✓")).ok(); + } + SolanaCommandResult::SessionSaved { json } => { + writeln!( + out, + " {} session JSON ({} bytes)", + c_ok("✓"), + json.len() + ) + .ok(); + } + SolanaCommandResult::SessionLoaded { program, steps } => { + writeln!( + out, + " {} loaded program={} steps={}", + c_ok("✓"), + c_accent(program), + steps.len() + ) + .ok(); + } + SolanaCommandResult::ScenarioList { items } => { + for it in items { + let marker = if it.active { + c_ok(" ← active").to_string() + } else { + String::new() + }; + writeln!( + out, + " {} {} {}{}", + c_accent("[S]"), + it.name, + c_muted(&format!("({} steps)", it.step_count)), + marker + ) + .ok(); + } + } + SolanaCommandResult::ScenarioCreated { name } => { + writeln!(out, " {} scenario {} created", c_ok("✓"), c_accent(name)).ok(); + } + SolanaCommandResult::ScenarioSwitched { from, to } => { + writeln!(out, " {} {} → {}", c_ok("→"), from, c_accent(to)).ok(); + } + SolanaCommandResult::ScenarioForked { from, to, at_step } => { + writeln!( + out, + " {} forked {} → {} at step {}", + c_ok("✓"), + from, + c_accent(to), + at_step + ) + .ok(); + } + SolanaCommandResult::ScenarioDeleted { name } => { + writeln!(out, " {} scenario {} deleted", c_ok("✓"), name).ok(); + } + SolanaCommandResult::StepDetail { + step_index, + instruction, + runtime_trace, + diff_summary, + } => { + writeln!( + out, + " {} step {} · {}", + c_accent("·"), + step_index, + c_accent(instruction) + ) + .ok(); + if let Some(trace) = runtime_trace { + if let Some(cu) = trace.get("compute_units").and_then(|v| v.as_u64()) { + writeln!(out, " {} {} CU", c_muted("compute units:"), cu).ok(); + } + if let Some(err) = trace.get("error").and_then(|v| v.as_str()) { + writeln!(out, " {} {}", c_danger("error:"), err).ok(); + } + if let Some(logs) = trace.get("logs").and_then(|v| v.as_array()) { + writeln!(out, " {} ({} lines)", c_muted("logs:"), logs.len()).ok(); + for line in logs.iter().take(20) { + if let Some(s) = line.as_str() { + writeln!(out, " {}", c_muted(s)).ok(); + } + } + if logs.len() > 20 { + writeln!( + out, + " {}", + c_muted(&format!("... +{} more", logs.len() - 20)) + ) + .ok(); + } + } + } + if !diff_summary.is_empty() { + writeln!( + out, + " {} ({})", + c_muted("account diffs:"), + diff_summary.len() + ) + .ok(); + for d in diff_summary { + let label = d.name.clone().unwrap_or_else(|| d.address.clone()); + let lam = if d.lamports_delta != 0 { + format!(" Δlamports={}", d.lamports_delta) + } else { + String::new() + }; + writeln!( + out, + " {} {}{}", + c_accent("·"), + c_accent(&label), + c_muted(&lam), + ) + .ok(); + match (d.decoded_before.as_ref(), d.decoded_after.as_ref()) { + (None, None) => { + if d.data_changed { + writeln!(out, " {}", c_muted("data changed (not decoded)")) + .ok(); + } + } + (None, Some(after)) => { + writeln!( + out, + " {}", + c_muted("(new account, decoded fields:)") + ) + .ok(); + write_decoded_fields(&mut out, after, " "); + } + (Some(_), None) => { + writeln!(out, " {}", c_muted("(account closed)")).ok(); + } + (Some(before), Some(after)) => { + write_decoded_diff(&mut out, before, after, " "); + } + } + } + } + } + SolanaCommandResult::FindingsList { items } => { + if items.is_empty() { + writeln!(out, " {}", c_muted("no findings recorded")).ok(); + } else { + for f in items { + writeln!( + out, + " {} {} [{}] {}", + c_accent(&f.id), + c_warn(&f.severity), + c_muted(&f.created_at), + c_accent(&f.title) + ) + .ok(); + if !f.description.is_empty() { + writeln!(out, " {}", c_muted(&f.description)).ok(); + } + } + } + } + SolanaCommandResult::Exported { markdown, bytes } => { + writeln!(out, " {} markdown report ({} bytes)", c_ok("✓"), bytes).ok(); + writeln!(out).ok(); + for line in markdown.lines() { + writeln!(out, " {}", line).ok(); + } + } + SolanaCommandResult::WhoList { + account_type, + instructions, + query_kind, + field_owner, + field_type, + owner_fields, + ix_args, + ix_discriminator_hex, + ix_accounts, + } => { + render_who_list( + &mut out, + account_type, + instructions, + *query_kind, + field_owner.as_deref(), + field_type.as_deref(), + owner_fields.as_deref(), + ix_args.as_deref(), + ix_discriminator_hex.as_deref(), + ix_accounts.as_deref(), + ); + } + SolanaCommandResult::TimelineView { pubkey, label, entries } => { + let header = label.clone().unwrap_or_else(|| pubkey.clone()); + writeln!( + out, + " {} timeline for {} ({})", + c_accent("·"), + c_accent(&header), + c_muted(pubkey) + ) + .ok(); + if entries.is_empty() { + writeln!(out, " {}", c_muted("no mutations recorded for this pubkey")).ok(); + } else { + for e in entries { + let lam = if e.lamports_delta != 0 { + format!(" Δlamports={}", e.lamports_delta) + } else { + String::new() + }; + let dat = if e.data_changed { " data" } else { "" }; + writeln!( + out, + " {} #{} {} ({}){}{}", + c_accent("·"), + e.step_index, + c_accent(&e.instruction), + e.scenario, + c_muted(&lam), + c_muted(dat) + ) + .ok(); + if let Some(after) = &e.after_decoded { + let s = serde_json::to_string(after).unwrap_or_default(); + let preview = if s.len() > 200 { + format!("{}…", &s[..200]) + } else { + s + }; + writeln!(out, " {}", c_muted(&preview)).ok(); + } + } + } + } + SolanaCommandResult::IxInfo { ix, admin_gated } => { + render_ix_info(&mut out, ix, *admin_gated); + } + SolanaCommandResult::CouplingList { pairs } => { + render_coupling_list(&mut out, pairs); + } + SolanaCommandResult::AccountTypes { accounts } => { + render_account_types(&mut out, accounts); + } + SolanaCommandResult::Coverage { overlay } => { + render_coverage_overlay(&mut out, overlay); + } + SolanaCommandResult::Error { message } => { + writeln!(out, " {} {}", c_danger("✗"), message).ok(); + } + } + out +} + +fn write_decoded_fields(out: &mut String, value: &serde_json::Value, indent: &str) { + if let serde_json::Value::Object(map) = value { + let max = map.keys().map(|k| k.chars().count()).max().unwrap_or(0); + for (k, v) in map { + let val = match v { + serde_json::Value::String(s) => s.clone(), + _ => serde_json::to_string(v).unwrap_or_default(), + }; + writeln!( + out, + "{}{} {}", + indent, + c_muted(&format!("{:width$}", k, width = max)), + val, + ) + .ok(); + } + } +} + +fn write_decoded_diff( + out: &mut String, + before: &serde_json::Value, + after: &serde_json::Value, + indent: &str, +) { + let (a, b) = match (before, after) { + (serde_json::Value::Object(a), serde_json::Value::Object(b)) => (a, b), + _ => { + writeln!( + out, + "{}{}", + indent, + c_muted("decoded shape changed (not an object diff)"), + ) + .ok(); + return; + } + }; + let mut keys: Vec<&String> = a.keys().chain(b.keys()).collect(); + keys.sort(); + keys.dedup(); + let max = keys.iter().map(|k| k.chars().count()).max().unwrap_or(0); + let mut any = false; + for k in keys { + let lhs = a.get(k); + let rhs = b.get(k); + if lhs == rhs { + continue; + } + any = true; + let s_lhs = lhs + .map(|v| match v { + serde_json::Value::String(s) => s.clone(), + _ => serde_json::to_string(v).unwrap_or_default(), + }) + .unwrap_or_else(|| "<absent>".into()); + let s_rhs = rhs + .map(|v| match v { + serde_json::Value::String(s) => s.clone(), + _ => serde_json::to_string(v).unwrap_or_default(), + }) + .unwrap_or_else(|| "<absent>".into()); + writeln!( + out, + "{}{} {} → {}", + indent, + c_muted(&format!("{:width$}", k, width = max)), + c_muted(&s_lhs), + s_rhs, + ) + .ok(); + } + if !any { + writeln!(out, "{}{}", indent, c_muted("(no decoded field changed)")).ok(); + } +} + +fn render_ix_info(out: &mut String, ix: &IxView, admin_gated: bool) { + writeln!(out).ok(); + writeln!(out, " {} {}", c_accent("instruction"), ix.name).ok(); + if !ix.discriminator_hex.is_empty() { + writeln!(out, " {} {}", c_muted("discriminator"), ix.discriminator_hex).ok(); + } + writeln!(out).ok(); + writeln!(out, " {} ({})", c_accent("args"), ix.args.len()).ok(); + if ix.args.is_empty() { + writeln!(out, " {}", c_muted("(none)")).ok(); + } else { + let max = ix + .args + .iter() + .map(|a| a.name.chars().count()) + .max() + .unwrap_or(0); + for a in &ix.args { + writeln!( + out, + " {} {} {}", + c_accent("·"), + pad_right(&a.name, max), + c_muted(&a.ty) + ) + .ok(); + } + } + writeln!(out).ok(); + writeln!(out, " {} ({})", c_accent("accounts"), ix.accounts.len()).ok(); + if ix.accounts.is_empty() { + writeln!(out, " {}", c_muted("(none)")).ok(); + } else { + let max = ix + .accounts + .iter() + .map(|a| a.name.chars().count()) + .max() + .unwrap_or(0); + for a in &ix.accounts { + let mut flags: Vec<&str> = Vec::new(); + if a.signer { + flags.push("signer"); + } + if a.writable { + flags.push("writable"); + } + if a.optional { + flags.push("optional"); + } + let kind_label = match a.kind { + AccountKind::Program => "program", + AccountKind::System => "system", + AccountKind::Sysvar => "sysvar", + AccountKind::Pda => "pda", + AccountKind::Other => "other", + }; + let suffix = if let Some(addr) = a.address.as_deref() { + format!("{kind_label} const {addr}") + } else if flags.is_empty() { + kind_label.to_string() + } else { + format!("{kind_label} {}", flags.join(" ")) + }; + writeln!( + out, + " {} {} {}", + c_accent("·"), + pad_right(&a.name, max), + c_muted(&suffix) + ) + .ok(); + } + } + let pdas: Vec<&IxAccountView> = ix.accounts.iter().filter(|a| a.pda.is_some()).collect(); + if !pdas.is_empty() { + writeln!(out).ok(); + writeln!(out, " {} ({})", c_accent("pdas"), pdas.len()).ok(); + for acc in pdas { + let pda = acc.pda.as_ref().expect("filtered above"); + let seeds: Vec<String> = pda.seeds.iter().map(describe_seed_view).collect(); + let prog = pda.program.clone().unwrap_or_else(|| "self".to_string()); + writeln!( + out, + " {} {} seeds=[{}] program={}", + c_accent("·"), + acc.name, + seeds.join(", "), + c_muted(&prog) + ) + .ok(); + } + } + writeln!(out).ok(); + let gated_label = if admin_gated { + c_warn("true (heuristic)").to_string() + } else { + c_muted("false").to_string() + }; + writeln!(out, " {} {}", c_muted("admin_gated"), gated_label).ok(); +} + +fn render_coupling_list(out: &mut String, pairs: &[CouplingPair]) { + if pairs.is_empty() { + writeln!( + out, + " {}", + c_muted("no instruction pairs share writable accounts") + ) + .ok(); + return; + } + let max = pairs + .iter() + .map(|p| p.a.chars().count() + p.b.chars().count() + 5) + .max() + .unwrap_or(0); + for p in pairs { + let pair = format!("{} ↔ {}", p.a, p.b); + writeln!( + out, + " {} {} {}", + c_accent("·"), + pad_right(&pair, max), + c_muted(&format!("[{}]", p.shared_writable.join(", "))) + ) + .ok(); + } +} + +fn render_account_types(out: &mut String, accounts: &[AccountView]) { + if accounts.is_empty() { + writeln!(out, " {}", c_muted("No account types declared in IDL")).ok(); + return; + } + for at in accounts { + writeln!( + out, + " {} {} {}", + c_accent("[T]"), + at.name, + c_muted(&at.discriminator_hex) + ) + .ok(); + if at.fields.is_empty() { + writeln!(out, " {}", c_muted("(opaque or zero-copy layout)")).ok(); + continue; + } + let max = at + .fields + .iter() + .map(|f| f.name.chars().count()) + .max() + .unwrap_or(0); + for f in &at.fields { + writeln!( + out, + " {} {} {}", + c_accent("·"), + pad_right(&f.name, max), + c_muted(&f.ty) + ) + .ok(); + } + } +} + +fn render_coverage_overlay(out: &mut String, overlay: &RuntimeOverlay) { + writeln!(out).ok(); + writeln!( + out, + " {} {} {}", + c_accent("Coverage for program"), + overlay.program, + c_muted(&format!("(scenario {})", overlay.scenario)), + ) + .ok(); + writeln!(out).ok(); + + let mut ix_keys: std::collections::BTreeSet<&String> = std::collections::BTreeSet::new(); + ix_keys.extend(overlay.calls_per_ix.keys()); + ix_keys.extend(overlay.failed_per_ix.keys()); + ix_keys.extend(overlay.cu_stats_per_ix.keys()); + + if ix_keys.is_empty() { + writeln!(out, " {}", c_muted("no calls recorded yet")).ok(); + return; + } + + let cpi_count_per_ix = |ix: &str| -> u32 { + overlay + .cpi_edges + .iter() + .filter(|e| e.from_ix == ix) + .map(|e| e.samples) + .sum() + }; + + let max_ix = ix_keys + .iter() + .map(|k| k.chars().count()) + .max() + .unwrap_or(0) + .max(11); + writeln!( + out, + " {} {} {} {} {} {}", + pad_right("Instruction", max_ix), + c_muted("Calls"), + c_muted("Failed"), + c_muted("CU avg"), + c_muted("CU max"), + c_muted("CPIs"), + ) + .ok(); + let mut total_calls: u32 = 0; + let mut total_failed: u32 = 0; + for ix in &ix_keys { + let calls = overlay.calls_per_ix.get(*ix).copied().unwrap_or(0); + let failed = overlay.failed_per_ix.get(*ix).copied().unwrap_or(0); + total_calls += calls; + total_failed += failed; + let (cu_avg, cu_max) = match overlay.cu_stats_per_ix.get(*ix) { + Some(s) => (s.avg.to_string(), s.max.to_string()), + None => ("—".to_string(), "—".to_string()), + }; + let cpi = cpi_count_per_ix(ix); + writeln!( + out, + " {} {} {} {} {} {}", + pad_right(ix, max_ix), + pad_right(&calls.to_string(), 5), + pad_right(&failed.to_string(), 6), + pad_right(&cu_avg, 6), + pad_right(&cu_max, 6), + cpi, + ) + .ok(); + } + if !overlay.cpi_edges.is_empty() { + writeln!(out).ok(); + writeln!(out, " {}", c_accent("CPIs detected:")).ok(); + for edge in &overlay.cpi_edges { + writeln!( + out, + " {} {} {} {} {}", + c_accent("·"), + edge.from_ix, + c_muted("→"), + edge.to_program, + c_muted(&format!("(depth {}, {}×)", edge.depth, edge.samples)), + ) + .ok(); + } + } + writeln!(out).ok(); + writeln!( + out, + " {} {} calls, {} failed", + c_muted("Total:"), + total_calls, + total_failed, + ) + .ok(); +} + +#[allow(clippy::too_many_arguments)] +fn render_who_list( + out: &mut String, + target: &str, + instructions: &[WhoEntry], + query_kind: WhoQueryKind, + field_owner: Option<&str>, + field_type: Option<&str>, + owner_fields: Option<&[FieldView]>, + ix_args: Option<&[ArgView]>, + ix_discriminator_hex: Option<&str>, + ix_accounts: Option<&[WhoIxAccount]>, +) { + match query_kind { + WhoQueryKind::AccountType => { + writeln!( + out, + " {} '{}' (account type)", + c_accent("·"), + c_accent(target) + ) + .ok(); + render_field_summary(out, owner_fields, "fields"); + writeln!(out).ok(); + if instructions.is_empty() { + writeln!(out, " {}", c_muted("not referenced by any instruction")).ok(); + return; + } + writeln!( + out, + " Referenced by {} instruction{}:", + instructions.len(), + if instructions.len() == 1 { "" } else { "s" } + ) + .ok(); + writeln!(out).ok(); + for w in instructions { + render_who_entry_block(out, w); + } + } + WhoQueryKind::Field => { + let owner = field_owner.unwrap_or("?"); + let ty = field_type.unwrap_or("?"); + writeln!( + out, + " {} '{}' (field of {}, type {})", + c_accent("·"), + c_accent(target), + c_accent(owner), + c_muted(ty) + ) + .ok(); + render_field_summary(out, owner_fields, &format!("{owner} struct")); + writeln!(out).ok(); + writeln!( + out, + " {}", + c_warn("Heuristic: the following instructions write the owner account.") + ) + .ok(); + writeln!( + out, + " {}", + c_muted("Without source-level analysis we cannot tell which one(s)") + ) + .ok(); + writeln!( + out, + " {}", + c_muted("actually mutate this field; cross-check with `step <idx>`.") + ) + .ok(); + writeln!(out).ok(); + if instructions.is_empty() { + writeln!(out, " {}", c_muted("(no writers found)")).ok(); + return; + } + for w in instructions { + render_who_entry_block(out, w); + } + } + WhoQueryKind::Instruction => { + writeln!( + out, + " {} '{}' (instruction)", + c_accent("·"), + c_accent(target) + ) + .ok(); + match ix_args { + Some(args) if !args.is_empty() => { + let parts: Vec<String> = args + .iter() + .map(|a| format!("{}: {}", a.name, a.ty)) + .collect(); + writeln!(out, " args: {}", c_muted(&parts.join(", "))).ok(); + } + _ => { + writeln!(out, " {}", c_muted("args: (none)")).ok(); + } + } + if let Some(d) = ix_discriminator_hex { + writeln!(out, " {} {}", c_muted("discriminator"), d).ok(); + } + writeln!(out).ok(); + let accounts = ix_accounts.unwrap_or(&[]); + if accounts.is_empty() { + writeln!(out, " {}", c_muted("touches no accounts")).ok(); + return; + } + writeln!( + out, + " Touches {} account{}:", + accounts.len(), + if accounts.len() == 1 { "" } else { "s" } + ) + .ok(); + writeln!(out).ok(); + for acc in accounts { + render_who_ix_account(out, acc); + } + } + WhoQueryKind::NotFound => { + writeln!( + out, + " {} no instruction, account type or field references '{}'", + c_muted("·"), + target + ) + .ok(); + } + } +} + +fn render_field_summary(out: &mut String, fields: Option<&[FieldView]>, label: &str) { + match fields { + Some(fs) if !fs.is_empty() => { + let parts: Vec<String> = fs.iter().map(|f| format!("{}: {}", f.name, f.ty)).collect(); + writeln!(out, " {}: {}", label, c_muted(&parts.join(", "))).ok(); + } + Some(_) => { + writeln!( + out, + " {}: {}", + label, + c_muted("(opaque or zero-copy layout)") + ) + .ok(); + } + None => {} + } +} + +fn render_who_entry_block(out: &mut String, entry: &WhoEntry) { + let mut flags = Vec::new(); + if entry.signer { + flags.push("signer"); + } + if entry.writable { + flags.push("writable"); + } + let flags_str = flags.join(" "); + writeln!( + out, + " {} {} (as {}) {}", + c_accent("·"), + c_accent(&entry.instruction), + entry.account_field, + c_muted(&flags_str) + ) + .ok(); + match entry.ix_args.as_ref() { + Some(args) if !args.is_empty() => { + let parts: Vec<String> = args.iter().map(|a| format!("{}: {}", a.name, a.ty)).collect(); + writeln!(out, " args: {}", c_muted(&parts.join(", "))).ok(); + } + Some(_) => { + writeln!(out, " {}", c_muted("args: (none)")).ok(); + } + None => {} + } +} + +fn render_who_ix_account(out: &mut String, acc: &WhoIxAccount) { + let type_label = acc + .account_type + .as_deref() + .map(|t| format!("({t})")) + .unwrap_or_else(|| "(—)".to_string()); + let mut flags = Vec::new(); + if acc.signer { + flags.push("signer"); + } + if acc.writable { + flags.push("writable"); + } + writeln!( + out, + " {} {} {} {}", + c_accent("·"), + c_accent(&acc.name), + c_muted(&type_label), + c_muted(&flags.join(" ")) + ) + .ok(); + if let Some(fs) = acc.fields.as_ref() + && !fs.is_empty() + { + let parts: Vec<String> = fs.iter().map(|f| format!("{}: {}", f.name, f.ty)).collect(); + writeln!(out, " struct: {}", c_muted(&parts.join(", "))).ok(); + } +} diff --git a/crates/ilold-render/tests/snapshot_render.rs b/crates/ilold-render/tests/snapshot_render.rs new file mode 100644 index 0000000..866eeee --- /dev/null +++ b/crates/ilold-render/tests/snapshot_render.rs @@ -0,0 +1,100 @@ +//! Byte-identity snapshots for `render_solana_result`. ANSI escapes are +//! stripped before comparison so the snapshots stay portable across +//! terminals. To regenerate after an intentional render change, run +//! `ILOLD_UPDATE_SNAPSHOTS=1 cargo test -p ilold-render --test snapshot_render`. + +use std::collections::BTreeMap; +use std::fs; +use std::path::PathBuf; + +use ilold_render::fmt::strip_ansi; +use ilold_render::render_solana_result; +use ilold_solana_core::exploration::SolanaCommandResult; +use ilold_solana_core::overlay::{CpiEdge, CuStats, RuntimeOverlay}; + +fn snapshot_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/snapshots") +} + +fn assert_snapshot(name: &str, actual: &str) { + let path = snapshot_dir().join(format!("{name}.txt")); + if std::env::var("ILOLD_UPDATE_SNAPSHOTS").is_ok() || !path.exists() { + fs::create_dir_all(path.parent().unwrap()).expect("create snapshot dir"); + fs::write(&path, actual).expect("write snapshot"); + return; + } + let expected = fs::read_to_string(&path).expect("read snapshot"); + assert_eq!( + actual, expected, + "snapshot drift for {name}: regenerate with ILOLD_UPDATE_SNAPSHOTS=1" + ); +} + +fn step_added_fixture() -> SolanaCommandResult { + SolanaCommandResult::StepAdded { + step_index: 2, + instruction: "stake".into(), + logs_excerpt: vec![ + "Program log: Instruction: Stake".into(), + "Program log: amount=1000".into(), + ], + account_diffs_count: 3, + compute_units: 12_345, + error: None, + } +} + +fn coverage_fixture() -> SolanaCommandResult { + let mut calls = BTreeMap::new(); + calls.insert("stake".into(), 4u32); + calls.insert("claim_rewards".into(), 2u32); + let mut failed = BTreeMap::new(); + failed.insert("claim_rewards".into(), 1u32); + let mut cu_stats = BTreeMap::new(); + cu_stats.insert( + "stake".into(), + CuStats { min: 10_000, max: 15_000, avg: 12_500, samples: 4 }, + ); + cu_stats.insert( + "claim_rewards".into(), + CuStats { min: 8_000, max: 9_500, avg: 8_750, samples: 2 }, + ); + let overlay = RuntimeOverlay { + program: "staking".into(), + scenario: "main".into(), + calls_per_ix: calls, + failed_per_ix: failed, + cu_stats_per_ix: cu_stats, + cpi_edges: vec![CpiEdge { + from_ix: "stake".into(), + to_program: "token_program".into(), + depth: 1, + samples: 4, + }], + }; + SolanaCommandResult::Coverage { overlay } +} + +fn error_fixture() -> SolanaCommandResult { + SolanaCommandResult::Error { + message: "scenario 'foo' not found".into(), + } +} + +#[test] +fn snapshot_step_added() { + let out = strip_ansi(&render_solana_result(&step_added_fixture())); + assert_snapshot("step_added", &out); +} + +#[test] +fn snapshot_coverage() { + let out = strip_ansi(&render_solana_result(&coverage_fixture())); + assert_snapshot("coverage", &out); +} + +#[test] +fn snapshot_error() { + let out = strip_ansi(&render_solana_result(&error_fixture())); + assert_snapshot("error", &out); +} diff --git a/crates/ilold-render/tests/snapshots/coverage.txt b/crates/ilold-render/tests/snapshots/coverage.txt new file mode 100644 index 0000000..def69f6 --- /dev/null +++ b/crates/ilold-render/tests/snapshots/coverage.txt @@ -0,0 +1,11 @@ + + Coverage for program staking (scenario main) + + Instruction Calls Failed CU avg CU max CPIs + claim_rewards 2 1 8750 9500 0 + stake 4 0 12500 15000 4 + + CPIs detected: + · stake → token_program (depth 1, 4×) + + Total: 6 calls, 1 failed diff --git a/crates/ilold-render/tests/snapshots/error.txt b/crates/ilold-render/tests/snapshots/error.txt new file mode 100644 index 0000000..18b9346 --- /dev/null +++ b/crates/ilold-render/tests/snapshots/error.txt @@ -0,0 +1 @@ + ✗ scenario 'foo' not found diff --git a/crates/ilold-render/tests/snapshots/step_added.txt b/crates/ilold-render/tests/snapshots/step_added.txt new file mode 100644 index 0000000..83c0f36 --- /dev/null +++ b/crates/ilold-render/tests/snapshots/step_added.txt @@ -0,0 +1,3 @@ + ✓ step 2 [ok]: stake (12345 CU, 3 diffs) + Program log: Instruction: Stake + Program log: amount=1000 diff --git a/crates/ilold-session-core/Cargo.toml b/crates/ilold-session-core/Cargo.toml new file mode 100644 index 0000000..78e7409 --- /dev/null +++ b/crates/ilold-session-core/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "ilold-session-core" +version.workspace = true +edition.workspace = true +description = "Language-agnostic session and audit-journal types shared by ilold-core (Solidity) and ilold-solana-core." + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } +schemars = { workspace = true } diff --git a/crates/ilold-session-core/src/exploration/access.rs b/crates/ilold-session-core/src/exploration/access.rs new file mode 100644 index 0000000..667782d --- /dev/null +++ b/crates/ilold-session-core/src/exploration/access.rs @@ -0,0 +1,37 @@ +use std::fmt; + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum AccessLevel { + Public, + Restricted { role: String }, + Internal, + Special { kind: String }, +} + +impl fmt::Display for AccessLevel { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + AccessLevel::Public => write!(f, "Public"), + AccessLevel::Restricted { role } => write!(f, "Restricted({})", role), + AccessLevel::Internal => write!(f, "Internal"), + AccessLevel::Special { kind } => write!(f, "Special({})", kind), + } + } +} + +impl AccessLevel { + pub fn short_label(&self) -> &str { + match self { + AccessLevel::Public => "P", + AccessLevel::Restricted { .. } => "R", + AccessLevel::Internal => "I", + AccessLevel::Special { .. } => "S", + } + } + + pub fn is_unrestricted(&self) -> bool { + matches!(self, AccessLevel::Public) + } +} diff --git a/crates/ilold-session-core/src/exploration/assign_operator.rs b/crates/ilold-session-core/src/exploration/assign_operator.rs new file mode 100644 index 0000000..50bd1fb --- /dev/null +++ b/crates/ilold-session-core/src/exploration/assign_operator.rs @@ -0,0 +1,34 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum AssignOperator { + Assign, + AddAssign, + SubAssign, + MulAssign, + DivAssign, + ModAssign, + BitAndAssign, + BitOrAssign, + BitXorAssign, + ShlAssign, + ShrAssign, +} + +impl AssignOperator { + pub fn as_str(self) -> &'static str { + match self { + AssignOperator::Assign => "=", + AssignOperator::AddAssign => "+=", + AssignOperator::SubAssign => "-=", + AssignOperator::MulAssign => "*=", + AssignOperator::DivAssign => "/=", + AssignOperator::ModAssign => "%=", + AssignOperator::BitAndAssign => "&=", + AssignOperator::BitOrAssign => "|=", + AssignOperator::BitXorAssign => "^=", + AssignOperator::ShlAssign => "<<=", + AssignOperator::ShrAssign => ">>=", + } + } +} diff --git a/crates/ilold-session-core/src/exploration/canvas.rs b/crates/ilold-session-core/src/exploration/canvas.rs new file mode 100644 index 0000000..9e10698 --- /dev/null +++ b/crates/ilold-session-core/src/exploration/canvas.rs @@ -0,0 +1,41 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use super::access::AccessLevel; +use super::scenario::ScenarioEvent; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum CanvasPatch { + AddNode { + scenario: String, + function: String, + access: AccessLevel, + step_index: usize, + #[serde(default, skip_serializing_if = "Option::is_none")] + runtime: Option<RuntimeMeta>, + }, + RemoveLastNode { scenario: String }, + ClearAll { scenario: String }, + Highlight { scenario: String, function: String }, + ScenarioEvent(ScenarioEvent), + SolanaUsersChanged { scenario: String }, + OverlayUpdate { + scenario: String, + ix_name: String, + calls_added: u32, + failed_added: u32, + cu: Option<u64>, + cpi_targets_added: Vec<String>, + }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RuntimeMeta { + pub compute_units: u64, + pub diffs_count: usize, + pub logs_excerpt: Vec<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub trace: Option<Value>, +} diff --git a/crates/ilold-session-core/src/exploration/mod.rs b/crates/ilold-session-core/src/exploration/mod.rs new file mode 100644 index 0000000..85ea170 --- /dev/null +++ b/crates/ilold-session-core/src/exploration/mod.rs @@ -0,0 +1,5 @@ +pub mod access; +pub mod assign_operator; +pub mod canvas; +pub mod scenario; +pub mod session; diff --git a/crates/ilold-session-core/src/exploration/scenario.rs b/crates/ilold-session-core/src/exploration/scenario.rs new file mode 100644 index 0000000..db8d090 --- /dev/null +++ b/crates/ilold-session-core/src/exploration/scenario.rs @@ -0,0 +1,48 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)] +pub enum ScenarioAction { + New { name: String }, + List, + Switch { name: String }, + Fork { + name: String, + #[serde(default)] + at_step: Option<usize>, + }, + Delete { name: String }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ScenarioInfo { + pub name: String, + pub active: bool, + pub step_count: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum ScenarioEvent { + Created { name: String }, + Switched { from: String, to: String }, + Deleted { name: String }, + Forked { from: String, to: String, at_step: usize }, + Reloaded { active: String }, +} + +pub fn validate_scenario_name(name: &str) -> Result<(), String> { + const ERR: &str = "Invalid scenario name: must match ^[a-z][a-z0-9_-]{0,31}$"; + if name.is_empty() || name.len() > 32 { + return Err(ERR.to_string()); + } + let mut chars = name.chars(); + let first = chars.next().ok_or_else(|| ERR.to_string())?; + if !first.is_ascii_lowercase() { + return Err(ERR.to_string()); + } + for c in chars { + if !(c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-') { + return Err(ERR.to_string()); + } + } + Ok(()) +} diff --git a/crates/ilold-core/src/exploration/session.rs b/crates/ilold-session-core/src/exploration/session.rs similarity index 68% rename from crates/ilold-core/src/exploration/session.rs rename to crates/ilold-session-core/src/exploration/session.rs index 46f6c7d..f69e35f 100644 --- a/crates/ilold-core/src/exploration/session.rs +++ b/crates/ilold-session-core/src/exploration/session.rs @@ -1,33 +1,21 @@ -use std::collections::HashMap; +use std::collections::{BTreeMap, HashMap}; use serde::{Deserialize, Serialize}; -use crate::cfg::types::CfgGraph; +use crate::exploration::assign_operator::AssignOperator; use crate::journal::types::AuditJournal; -use crate::model::common::StateVar; -use crate::model::contract::ContractDef; -use crate::model::expression::AssignOperator; -use crate::model::function::FunctionDef; -use crate::model::project::Project; -use crate::narrative::trace::{build_flow_tree_with_mutations, FlowConfig, FlowTree}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ExplorationSession { pub contract: String, pub steps: Vec<ExplorationStep>, pub journal: AuditJournal, - /// Set when this scenario was created via `scenario fork`. Records the - /// source scenario and the at-step boundary so the frontend can render - /// the fork as a branch from the origin instead of a parallel timeline. - /// `None` for the default `main` scenario and for any scenario loaded - /// from a pre-fork-origin save file (serde defaults to `None`). #[serde(default)] pub forked_from: Option<ForkOrigin>, + #[serde(default)] + pub failed_calls_per_ix: BTreeMap<String, u32>, } -/// Where a scenario was forked from. The `at_step` is the boundary: the -/// scenario's steps `[0..at_step)` are the inherited prefix (a clone of the -/// origin's steps at fork time), and steps `[at_step..]` are its own. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct ForkOrigin { pub scenario: String, @@ -39,9 +27,13 @@ pub struct ExplorationStep { pub function: String, pub mutations: Vec<StateMutation>, #[serde(default)] - pub flow_tree: Option<FlowTree>, + pub flow_tree: Option<serde_json::Value>, #[serde(default)] pub trace_config: TraceConfig, + #[serde(default)] + pub runtime_trace: Option<serde_json::Value>, + #[serde(default)] + pub call_payload: Option<serde_json::Value>, } #[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)] @@ -94,82 +86,28 @@ impl ExplorationSession { steps: Vec::new(), journal: AuditJournal::new(project, contract, ""), forked_from: None, + failed_calls_per_ix: BTreeMap::new(), } } - /// Append a step, building and persisting its FlowTree. Each harvested - /// mutation carries a `flow_step_id` into that tree. - #[allow(clippy::too_many_arguments)] - pub fn add_step_with_internals( - &mut self, - function: &FunctionDef, - cfg: &CfgGraph, - state_vars: &[StateVar], - project: &Project, - owning_contract: &ContractDef, - all_cfgs: &HashMap<(String, String), CfgGraph>, - timestamp: &str, - trace_config: TraceConfig, - ) -> &ExplorationStep { - let step_index = self.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, - ); - - // Walker is scope-agnostic; keep only writes whose base name is a - // state var and convert into the session-level mutation type. - 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(); - - self.steps.push(ExplorationStep { - function: function.name.clone(), - mutations, - flow_tree: Some(flow_tree), - trace_config, - }); - - self.journal.record(crate::journal::types::JournalEntry::SequenceExplored { - steps: self.steps.iter().map(|s| s.function.clone()).collect(), - timestamp: timestamp.into(), - }); - - self.steps.last().unwrap() - } - pub fn remove_last_step(&mut self) -> bool { self.steps.pop().is_some() } pub fn clear(&mut self) { self.steps.clear(); + self.failed_calls_per_ix.clear(); + } + + pub fn record_failed_call(&mut self, ix: &str) { + *self + .failed_calls_per_ix + .entry(ix.to_string()) + .or_insert(0) += 1; + } + + pub fn reset_scenario_local_observations(&mut self) { + self.failed_calls_per_ix.clear(); } pub fn current_sequence(&self) -> Vec<&str> { @@ -239,6 +177,26 @@ mod tests { assert!(!s.remove_last_step()); } + #[test] + fn reset_scenario_local_observations_clears_failed_calls() { + let mut s = ExplorationSession::new("Staking", "myproject"); + s.record_failed_call("stake"); + s.record_failed_call("stake"); + s.record_failed_call("unstake"); + s.steps.push(ExplorationStep { + function: "deposit".into(), + mutations: vec![], + flow_tree: None, + trace_config: TraceConfig::default(), + runtime_trace: None, + call_payload: None, + }); + assert_eq!(s.failed_calls_per_ix.get("stake").copied(), Some(2)); + s.reset_scenario_local_observations(); + assert!(s.failed_calls_per_ix.is_empty()); + assert_eq!(s.steps.len(), 1); + } + #[test] fn clear_resets() { let mut s = ExplorationSession::new("Staking", "myproject"); @@ -247,12 +205,16 @@ mod tests { mutations: vec![], flow_tree: None, trace_config: TraceConfig::default(), + runtime_trace: None, + call_payload: None, }); s.steps.push(ExplorationStep { function: "withdraw".into(), mutations: vec![], flow_tree: None, trace_config: TraceConfig::default(), + runtime_trace: None, + call_payload: None, }); assert_eq!(s.current_sequence(), vec!["deposit", "withdraw"]); s.clear(); @@ -286,6 +248,8 @@ mod tests { ], flow_tree: None, trace_config: TraceConfig::default(), + runtime_trace: None, + call_payload: None, }); s.steps.push(ExplorationStep { function: "withdraw".into(), @@ -302,6 +266,8 @@ mod tests { ], flow_tree: None, trace_config: TraceConfig::default(), + runtime_trace: None, + call_payload: None, }); let summaries = s.variable_summary(); diff --git a/crates/ilold-session-core/src/journal/export.rs b/crates/ilold-session-core/src/journal/export.rs new file mode 100644 index 0000000..abd1988 --- /dev/null +++ b/crates/ilold-session-core/src/journal/export.rs @@ -0,0 +1,298 @@ +use std::fmt::Write; + +use serde::{Deserialize, Serialize}; + +use super::types::*; + +#[derive(Debug, Clone, Default, Serialize, Deserialize, schemars::JsonSchema)] +pub struct AuditMetadata { + #[serde(default)] + pub auditor: Option<String>, + #[serde(default)] + pub project_version: Option<String>, + #[serde(default)] + pub audit_date: Option<String>, +} + +#[derive(Debug, Clone)] +pub struct ProgramSection { + pub name: String, + pub program_id: String, + pub instructions: usize, + pub account_types: usize, +} + +pub fn export_markdown(journal: &AuditJournal, total_functions: usize) -> String { + let mut md = String::new(); + let (done, total) = journal.progress(total_functions); + let pct = if total > 0 { done * 100 / total } else { 0 }; + + writeln!(md, "# Audit: {}", journal.contract).unwrap(); + writeln!(md, "**Project**: {} | **Started**: {} | **Progress**: {}/{} functions ({}%)", + journal.project, journal.started_at, done, total, pct).unwrap(); + + writeln!(md).unwrap(); + render_findings_block(&mut md, &journal.findings); + + writeln!(md).unwrap(); + render_coverage(&mut md, journal); + + if journal.entries.iter().any(|e| matches!(e, + JournalEntry::SequenceExplored { .. } | JournalEntry::BranchCreated { .. } + )) { + writeln!(md).unwrap(); + render_exploration_log(&mut md, journal); + } + + md +} + +pub(crate) fn render_findings_block(md: &mut String, findings: &[Finding]) { + if findings.is_empty() { + writeln!(md, "## Findings\n\nNo findings recorded.").unwrap(); + return; + } + writeln!(md, "## Findings\n").unwrap(); + writeln!(md, "| ID | Severity | Title | Function |").unwrap(); + writeln!(md, "|----|----------|-------|----------|").unwrap(); + for f in findings { + writeln!(md, "| {} | {} | {} | {} |", f.id, f.severity, f.title, f.affected_function).unwrap(); + } + for f in findings { + writeln!(md).unwrap(); + render_finding_detail(md, f, None); + } +} + +pub(crate) fn render_finding_detail(md: &mut String, f: &Finding, scenario: Option<&str>) { + writeln!(md, "### {}: {}\n", f.id, f.title).unwrap(); + let mut header = format!( + "**Severity**: {} | **Function**: {}", + f.severity, f.affected_function, + ); + if let Some(idx) = f.affected_step_index { + header.push_str(&format!(" | **Step**: #{idx}")); + } + if let Some(s) = scenario { + header.push_str(&format!(" | **Scenario**: `{s}`")); + } + writeln!(md, "{header}").unwrap(); + if let Some(seq) = &f.affected_sequence { + writeln!(md, "**Sequence**: {}", seq.join(" → ")).unwrap(); + } + writeln!(md, "\n{}", f.description).unwrap(); + if let Some(rec) = &f.recommendation { + writeln!(md, "\n**Recommendation**\n\n{rec}").unwrap(); + } + for note in &f.notes { + writeln!(md, "\n> {note}").unwrap(); + } +} + +pub(crate) fn render_coverage(md: &mut String, journal: &AuditJournal) { + writeln!(md, "## Coverage\n").unwrap(); + if journal.function_status.is_empty() { + writeln!(md, "No functions reviewed yet.").unwrap(); + return; + } + let mut funcs: Vec<_> = journal.function_status.iter().collect(); + funcs.sort_by_key(|(name, _)| (*name).clone()); + for (name, status) in funcs { + writeln!(md, "- {} {}", status.badge(), name).unwrap(); + } +} + +pub(crate) fn render_exploration_log(md: &mut String, journal: &AuditJournal) { + writeln!(md, "## Exploration Log\n").unwrap(); + for entry in &journal.entries { + match entry { + JournalEntry::SequenceExplored { steps, timestamp, .. } => { + writeln!(md, "- **{}** Explored: {}", + ×tamp[..std::cmp::min(16, timestamp.len())], steps.join(" → ")).unwrap(); + } + JournalEntry::BranchCreated { from_function, branch_function, timestamp } => { + writeln!(md, "- **{}** Branch: {} → {}", + ×tamp[..std::cmp::min(16, timestamp.len())], from_function, branch_function).unwrap(); + } + _ => {} + } + } +} + +pub(crate) fn render_metadata_header(md: &mut String, m: &AuditMetadata) { + let mut parts: Vec<String> = Vec::new(); + if let Some(a) = &m.auditor { parts.push(format!("**Auditor**: {a}")); } + if let Some(v) = &m.project_version { parts.push(format!("**Version**: {v}")); } + if let Some(d) = &m.audit_date { parts.push(format!("**Date**: {d}")); } + if !parts.is_empty() { + writeln!(md, "{}\n", parts.join(" | ")).unwrap(); + } +} + +pub(crate) fn render_severity_matrix(md: &mut String, journals: &[&AuditJournal]) { + let mut counts = [0usize; 5]; // Critical, High, Medium, Low, Informational + for j in journals { + for f in &j.findings { + let i = match f.severity { + Severity::Critical => 0, + Severity::High => 1, + Severity::Medium => 2, + Severity::Low => 3, + Severity::Informational => 4, + }; + counts[i] += 1; + } + } + writeln!(md, "## Severity Matrix\n").unwrap(); + writeln!(md, "| Severity | Count |").unwrap(); + writeln!(md, "|----------|-------|").unwrap(); + let labels = ["Critical", "High", "Medium", "Low", "Informational"]; + for (i, label) in labels.iter().enumerate() { + writeln!(md, "| {label} | {} |", counts[i]).unwrap(); + } + let total: usize = counts.iter().sum(); + writeln!(md, "| **Total** | **{total}** |").unwrap(); +} + +pub(crate) fn render_methodology(md: &mut String, program_name: &str) { + writeln!(md, "## Methodology\n").unwrap(); + writeln!(md, "This deliverable was produced with Ilold, an interactive REPL for \ +auditing smart contracts. The Solana backend executes the program (`{program_name}`) \ +inside LiteSVM with the real BPF binary, so every step in the timeline corresponds to \ +an actual transaction; compute-unit usage, account diffs and program logs are recorded \ +verbatim. Findings are scenario-anchored — each entry references the scenario in which \ +the auditor observed the issue, and every scenario carries an independent VM with its \ +own state snapshot stack so attacks explored in a branch never leak into the main \ +session.\n").unwrap(); +} + +pub fn export_markdown_multi( + scenarios: &[(&str, &AuditJournal)], + program: Option<&ProgramSection>, + metadata: Option<&AuditMetadata>, + instructions_count: usize, +) -> String { + let _ = instructions_count; // reserved for future per-instruction coverage + let mut md = String::new(); + let header_name = program.map(|p| p.name.as_str()) + .or_else(|| scenarios.first().map(|(_, j)| j.contract.as_str())) + .unwrap_or("(unknown)"); + writeln!(md, "# Audit report — {header_name}\n").unwrap(); + if let Some(m) = metadata { + render_metadata_header(&mut md, m); + } + let total_steps: usize = 0; // step counts live on ExplorationSession, + let total_findings: usize = scenarios.iter().map(|(_, j)| j.findings.len()).sum(); + writeln!(md, "**Scenarios**: {} · **Total findings**: {}\n", + scenarios.len(), total_findings).unwrap(); + let _ = total_steps; + + if let Some(p) = program { + writeln!(md, "## Program\n").unwrap(); + writeln!(md, "- Program ID: `{}`", p.program_id).unwrap(); + writeln!(md, "- Instructions: {}", p.instructions).unwrap(); + writeln!(md, "- Account types: {}\n", p.account_types).unwrap(); + } + + render_methodology(&mut md, header_name); + + let journals: Vec<&AuditJournal> = scenarios.iter().map(|(_, j)| *j).collect(); + render_severity_matrix(&mut md, &journals); + writeln!(md).unwrap(); + + writeln!(md, "## Findings (all scenarios)\n").unwrap(); + let mut any = false; + for (scn_name, journal) in scenarios { + for f in &journal.findings { + any = true; + render_finding_detail(&mut md, f, Some(scn_name)); + writeln!(md).unwrap(); + } + } + if !any { + writeln!(md, "_(no findings recorded)_\n").unwrap(); + } + + md +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn empty_journal_export() { + let j = AuditJournal::new("myproject", "Staking", "2026-03-31T10:00:00Z"); + let md = export_markdown(&j, 8); + assert!(md.contains("# Audit: Staking")); + assert!(md.contains("0/8 functions (0%)")); + assert!(md.contains("No findings recorded.")); + assert!(md.contains("No functions reviewed yet.")); + } + + #[test] + fn export_with_findings_and_status() { + let mut j = AuditJournal::new("myproject", "Staking", "2026-03-31T10:00:00Z"); + + j.add_finding(Finding { + id: String::new(), + severity: Severity::High, + title: "Reentrancy in withdraw".into(), + affected_function: "withdraw".into(), + affected_sequence: Some(vec!["deposit".into(), "withdraw".into()]), + description: "External call before state update".into(), + notes: vec![], + created_at: String::new(), + affected_step_index: None, + recommendation: None, + }, "2026-03-31T14:30:00Z"); + + j.record(JournalEntry::StatusChanged { + function: "deposit".into(), + status: ReviewStatus::Clean, + timestamp: "2026-03-31T14:00:00Z".into(), + }); + j.record(JournalEntry::StatusChanged { + function: "withdraw".into(), + status: ReviewStatus::Vulnerable, + timestamp: "2026-03-31T14:30:00Z".into(), + }); + + let md = export_markdown(&j, 8); + assert!(md.contains("2/8 functions (25%)")); + assert!(md.contains("| F-01 | High | Reentrancy in withdraw | withdraw |")); + assert!(md.contains("[ok] deposit")); + assert!(md.contains("[!] withdraw")); + assert!(md.contains("deposit → withdraw")); + } + + #[test] + fn export_with_exploration_log() { + let mut j = AuditJournal::new("p", "c", "t"); + j.record(JournalEntry::SequenceExplored { + steps: vec!["deposit".into(), "withdraw".into()], + timestamp: "2026-03-31T14:32:00Z".into(), + }); + j.record(JournalEntry::BranchCreated { + from_function: "deposit".into(), + branch_function: "claimRewards".into(), + timestamp: "2026-03-31T14:45:00Z".into(), + }); + + let md = export_markdown(&j, 5); + assert!(md.contains("## Exploration Log")); + assert!(md.contains("Explored: deposit → withdraw")); + assert!(md.contains("Branch: deposit → claimRewards")); + } + + #[test] + fn timestamp_truncation_handles_short_strings() { + let mut j = AuditJournal::new("p", "c", "t"); + j.record(JournalEntry::SequenceExplored { + steps: vec!["x".into()], + timestamp: "short".into(), + }); + let md = export_markdown(&j, 1); + assert!(md.contains("**short**")); + } +} diff --git a/crates/ilold-core/src/journal/mod.rs b/crates/ilold-session-core/src/journal/mod.rs similarity index 100% rename from crates/ilold-core/src/journal/mod.rs rename to crates/ilold-session-core/src/journal/mod.rs diff --git a/crates/ilold-core/src/journal/types.rs b/crates/ilold-session-core/src/journal/types.rs similarity index 96% rename from crates/ilold-core/src/journal/types.rs rename to crates/ilold-session-core/src/journal/types.rs index b4065a7..0ec02d7 100644 --- a/crates/ilold-core/src/journal/types.rs +++ b/crates/ilold-session-core/src/journal/types.rs @@ -34,9 +34,13 @@ pub struct Finding { pub description: String, pub notes: Vec<String>, pub created_at: String, + #[serde(default)] + pub affected_step_index: Option<usize>, + #[serde(default)] + pub recommendation: Option<String>, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] pub enum Severity { Critical, High, @@ -57,7 +61,7 @@ impl fmt::Display for Severity { } } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] pub enum ReviewStatus { NotReviewed, InProgress, @@ -170,12 +174,13 @@ mod tests { description: "CEI violation".into(), notes: vec![], created_at: String::new(), + affected_step_index: None, + recommendation: None, }, "2026-03-31T10:00:00Z"); assert_eq!(j.findings.len(), 1); assert_eq!(j.findings[0].id, "F-01"); assert_eq!(j.findings[0].created_at, "2026-03-31T10:00:00Z"); - // add_finding also records a FindingRecorded entry assert_eq!(j.entries.len(), 1); } @@ -215,7 +220,7 @@ mod tests { let f = Finding { id: String::new(), severity: Severity::High, title: "A".into(), affected_function: "x".into(), affected_sequence: None, - description: "d".into(), notes: vec![], created_at: String::new(), + description: "d".into(), notes: vec![], created_at: String::new(), affected_step_index: None, recommendation: None, }; j.add_finding(f.clone(), "t1"); j.add_finding(f.clone(), "t2"); diff --git a/crates/ilold-session-core/src/lib.rs b/crates/ilold-session-core/src/lib.rs new file mode 100644 index 0000000..e3a619f --- /dev/null +++ b/crates/ilold-session-core/src/lib.rs @@ -0,0 +1,3 @@ +pub mod exploration; +pub mod journal; +pub mod runtime_trace; diff --git a/crates/ilold-session-core/src/runtime_trace.rs b/crates/ilold-session-core/src/runtime_trace.rs new file mode 100644 index 0000000..7e28259 --- /dev/null +++ b/crates/ilold-session-core/src/runtime_trace.rs @@ -0,0 +1,43 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct RuntimeTrace { + pub logs: Vec<String>, + pub compute_units: u64, + pub inner_instructions: Vec<InnerInstruction>, + pub account_diffs: Vec<AccountDiff>, + #[serde(default)] + pub return_data: Option<Vec<u8>>, + #[serde(default)] + pub error: Option<String>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InnerInstruction { + pub program: String, + pub instruction: String, + pub depth: u32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AccountDiff { + pub address: String, + #[serde(default)] + pub name: Option<String>, + #[serde(default)] + pub before: Option<Vec<u8>>, + #[serde(default)] + pub after: Option<Vec<u8>>, + pub lamports_delta: i128, + pub owner_changed: bool, + #[serde(default)] + pub decoded_before: Option<DecodedAccount>, + #[serde(default)] + pub decoded_after: Option<DecodedAccount>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DecodedAccount { + pub type_name: String, + pub value: serde_json::Value, +} diff --git a/crates/ilold-solana-core/Cargo.toml b/crates/ilold-solana-core/Cargo.toml new file mode 100644 index 0000000..f063702 --- /dev/null +++ b/crates/ilold-solana-core/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "ilold-solana-core" +version.workspace = true +edition.workspace = true +description = "Anchor IDL parser and (later) Solana execution backend for Ilold." + +[dependencies] +ilold-session-core = { path = "../ilold-session-core" } +anchor-lang-idl = { git = "https://github.com/solana-foundation/anchor", tag = "v1.0.2", features = ["build", "convert"] } +solana-account = "3" +solana-address = { version = "2.0", features = ["serde", "decode", "curve25519"] } +solana-clock = "3.0" +solana-hash = "3.1" +solana-instruction = "3.0" +solana-keypair = "3.1" +solana-message = "3.0" +solana-sdk-ids = "3" +solana-signer = "3.0" +solana-transaction = "3.0" +litesvm = "0.11" +serde = { workspace = true } +serde_json = { workspace = true } +schemars = { workspace = true } +thiserror = { workspace = true } +anyhow = { workspace = true } +borsh = "1" +bs58 = "0.5" + +[dev-dependencies] +tempfile = "3" diff --git a/crates/ilold-solana-core/src/decode/account.rs b/crates/ilold-solana-core/src/decode/account.rs new file mode 100644 index 0000000..e36d4be --- /dev/null +++ b/crates/ilold-solana-core/src/decode/account.rs @@ -0,0 +1,50 @@ +use anchor_lang_idl::types::{Idl, IdlTypeDefTy}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::decode::borsh::decode_defined_fields; +use crate::error::SolanaError; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DecodedAccount { + pub type_name: String, + pub value: Value, +} + +pub fn decode_account(data: &[u8], idl: &Idl) -> Result<DecodedAccount, SolanaError> { + if data.len() < 8 { + return Err(SolanaError::DecodeFailed(format!( + "account data is {} bytes, need at least 8 for discriminator", + data.len() + ))); + } + let (disc, rest) = data.split_at(8); + let account = idl + .accounts + .iter() + .find(|a| a.discriminator == disc) + .ok_or_else(|| SolanaError::UnknownDiscriminator { + hex: disc.iter().map(|b| format!("{:02x}", b)).collect(), + })?; + let type_def = idl + .types + .iter() + .find(|t| t.name == account.name) + .ok_or_else(|| SolanaError::UnknownType(account.name.clone()))?; + let mut cursor = rest; + let value = match &type_def.ty { + IdlTypeDefTy::Struct { fields } => { + decode_defined_fields(&mut cursor, fields.as_ref(), &idl.types)? + } + _ => { + return Err(SolanaError::DecodeFailed(format!( + "account '{}' is not a struct typedef", + account.name + ))); + } + }; + Ok(DecodedAccount { + type_name: account.name.clone(), + value, + }) +} diff --git a/crates/ilold-solana-core/src/decode/borsh.rs b/crates/ilold-solana-core/src/decode/borsh.rs new file mode 100644 index 0000000..20712ac --- /dev/null +++ b/crates/ilold-solana-core/src/decode/borsh.rs @@ -0,0 +1,169 @@ +use std::io::Read; + +use anchor_lang_idl::types::{ + IdlArrayLen, IdlDefinedFields, IdlEnumVariant, IdlType, IdlTypeDef, IdlTypeDefTy, +}; +use borsh::BorshDeserialize; +use serde_json::{Map, Value}; + +use crate::error::SolanaError; + +pub fn decode_value( + reader: &mut &[u8], + ty: &IdlType, + types: &[IdlTypeDef], +) -> Result<Value, SolanaError> { + Ok(match ty { + IdlType::Bool => Value::Bool(read::<bool>(reader)?), + IdlType::U8 => Value::Number(read::<u8>(reader)?.into()), + IdlType::I8 => Value::Number(read::<i8>(reader)?.into()), + IdlType::U16 => Value::Number(read::<u16>(reader)?.into()), + IdlType::I16 => Value::Number(read::<i16>(reader)?.into()), + IdlType::U32 => Value::Number(read::<u32>(reader)?.into()), + IdlType::I32 => Value::Number(read::<i32>(reader)?.into()), + IdlType::F32 => json_number_or_string(read::<f32>(reader)? as f64), + IdlType::U64 => Value::Number(read::<u64>(reader)?.into()), + IdlType::I64 => Value::Number(read::<i64>(reader)?.into()), + IdlType::F64 => json_number_or_string(read::<f64>(reader)?), + IdlType::U128 => Value::String(read::<u128>(reader)?.to_string()), + IdlType::I128 => Value::String(read::<i128>(reader)?.to_string()), + IdlType::U256 | IdlType::I256 => { + let mut buf = [0u8; 32]; + read_exact(reader, &mut buf)?; + Value::String(format!("0x{}", hex_le(&buf))) + } + IdlType::String => Value::String(read::<String>(reader)?), + IdlType::Bytes => Value::String(hex_le(&read::<Vec<u8>>(reader)?)), + IdlType::Pubkey => { + let mut buf = [0u8; 32]; + read_exact(reader, &mut buf)?; + Value::String(bs58::encode(buf).into_string()) + } + IdlType::Option(inner) => match read::<u8>(reader)? { + 0 => Value::Null, + 1 => decode_value(reader, inner, types)?, + tag => { + return Err(SolanaError::DecodeFailed(format!( + "invalid Option tag {tag}" + ))); + } + }, + IdlType::Vec(inner) => { + let len = read::<u32>(reader)? as usize; + let mut arr = Vec::with_capacity(len); + for _ in 0..len { + arr.push(decode_value(reader, inner, types)?); + } + Value::Array(arr) + } + IdlType::Array(inner, IdlArrayLen::Value(n)) => { + let mut arr = Vec::with_capacity(*n); + for _ in 0..*n { + arr.push(decode_value(reader, inner, types)?); + } + Value::Array(arr) + } + IdlType::Array(_, IdlArrayLen::Generic(name)) => { + return Err(SolanaError::UnsupportedGeneric(format!( + "array length generic '{name}'" + ))); + } + IdlType::Defined { name, .. } => { + let def = types + .iter() + .find(|t| &t.name == name) + .ok_or_else(|| SolanaError::UnknownType(name.clone()))?; + decode_typedef(reader, def, types)? + } + IdlType::Generic(name) => { + return Err(SolanaError::UnsupportedGeneric(name.clone())); + } + _ => return Err(SolanaError::DecodeFailed("unsupported IdlType variant".into())), + }) +} + +pub(crate) fn decode_typedef( + reader: &mut &[u8], + def: &IdlTypeDef, + types: &[IdlTypeDef], +) -> Result<Value, SolanaError> { + match &def.ty { + IdlTypeDefTy::Struct { fields } => decode_defined_fields(reader, fields.as_ref(), types), + IdlTypeDefTy::Enum { variants } => decode_enum(reader, variants, types), + IdlTypeDefTy::Type { alias } => decode_value(reader, alias, types), + } +} + +pub(crate) fn decode_defined_fields( + reader: &mut &[u8], + fields: Option<&IdlDefinedFields>, + types: &[IdlTypeDef], +) -> Result<Value, SolanaError> { + match fields { + None => Ok(Value::Object(Map::new())), + Some(IdlDefinedFields::Named(items)) => { + let mut obj = Map::with_capacity(items.len()); + for f in items { + obj.insert(f.name.clone(), decode_value(reader, &f.ty, types)?); + } + Ok(Value::Object(obj)) + } + Some(IdlDefinedFields::Tuple(items)) => { + let mut arr = Vec::with_capacity(items.len()); + for ty in items { + arr.push(decode_value(reader, ty, types)?); + } + Ok(Value::Array(arr)) + } + } +} + +fn decode_enum( + reader: &mut &[u8], + variants: &[IdlEnumVariant], + types: &[IdlTypeDef], +) -> Result<Value, SolanaError> { + let tag = read::<u8>(reader)? as usize; + let variant = variants.get(tag).ok_or_else(|| { + SolanaError::DecodeFailed(format!( + "enum tag {tag} out of range (have {} variants)", + variants.len() + )) + })?; + let payload = decode_defined_fields(reader, variant.fields.as_ref(), types)?; + let payload_empty = payload + .as_object() + .map(|o| o.is_empty()) + .or_else(|| payload.as_array().map(|a| a.is_empty())) + .unwrap_or(false); + let mut obj = Map::new(); + obj.insert("kind".into(), Value::String(variant.name.clone())); + if !payload_empty { + obj.insert("value".into(), payload); + } + Ok(Value::Object(obj)) +} + +fn read<T: BorshDeserialize>(reader: &mut &[u8]) -> Result<T, SolanaError> { + T::deserialize_reader(reader).map_err(|e| SolanaError::DecodeFailed(e.to_string())) +} + +fn read_exact(reader: &mut &[u8], buf: &mut [u8]) -> Result<(), SolanaError> { + reader + .read_exact(buf) + .map_err(|e| SolanaError::DecodeFailed(e.to_string())) +} + +fn hex_le(bytes: &[u8]) -> String { + let mut out = String::with_capacity(bytes.len() * 2); + for b in bytes { + out.push_str(&format!("{:02x}", b)); + } + out +} + +fn json_number_or_string(f: f64) -> Value { + serde_json::Number::from_f64(f) + .map(Value::Number) + .unwrap_or_else(|| Value::String(f.to_string())) +} diff --git a/crates/ilold-solana-core/src/decode/instruction.rs b/crates/ilold-solana-core/src/decode/instruction.rs new file mode 100644 index 0000000..c4f333a --- /dev/null +++ b/crates/ilold-solana-core/src/decode/instruction.rs @@ -0,0 +1,31 @@ +use anchor_lang_idl::types::IdlTypeDef; +use serde_json::{Map, Value}; + +use crate::decode::borsh::decode_value; +use crate::error::SolanaError; +use crate::model::InstructionDef; + +pub fn decode_ix_data( + data: &[u8], + ix: &InstructionDef, + types: &[IdlTypeDef], +) -> Result<Value, SolanaError> { + if data.len() < 8 { + return Err(SolanaError::DecodeFailed(format!( + "instruction data is {} bytes, need at least 8 for discriminator", + data.len() + ))); + } + let (disc, mut rest) = data.split_at(8); + if disc != ix.discriminator { + return Err(SolanaError::DecodeFailed(format!( + "instruction discriminator does not match '{}'", + ix.name + ))); + } + let mut obj = Map::with_capacity(ix.args.len()); + for arg in &ix.args { + obj.insert(arg.name.clone(), decode_value(&mut rest, &arg.ty, types)?); + } + Ok(Value::Object(obj)) +} diff --git a/crates/ilold-solana-core/src/decode/mod.rs b/crates/ilold-solana-core/src/decode/mod.rs new file mode 100644 index 0000000..eb13695 --- /dev/null +++ b/crates/ilold-solana-core/src/decode/mod.rs @@ -0,0 +1,7 @@ +pub mod account; +pub mod borsh; +pub mod instruction; + +pub use account::{decode_account, DecodedAccount}; +pub use borsh::decode_value; +pub use instruction::decode_ix_data; diff --git a/crates/ilold-solana-core/src/encode/borsh.rs b/crates/ilold-solana-core/src/encode/borsh.rs new file mode 100644 index 0000000..47a6406 --- /dev/null +++ b/crates/ilold-solana-core/src/encode/borsh.rs @@ -0,0 +1,280 @@ +use anchor_lang_idl::types::{ + IdlArrayLen, IdlDefinedFields, IdlEnumVariant, IdlType, IdlTypeDef, IdlTypeDefTy, +}; +use borsh::BorshSerialize; +use serde_json::Value; + +use crate::error::SolanaError; +use crate::model::InstructionDef; + +pub fn encode_value( + value: &Value, + ty: &IdlType, + types: &[IdlTypeDef], +) -> Result<Vec<u8>, SolanaError> { + let mut buf = Vec::new(); + write_value(&mut buf, value, ty, types)?; + Ok(buf) +} + +pub fn encode_ix_data( + ix: &InstructionDef, + args: &Value, + types: &[IdlTypeDef], +) -> Result<Vec<u8>, SolanaError> { + let mut buf = Vec::with_capacity(8); + buf.extend_from_slice(&ix.discriminator); + let obj = args + .as_object() + .ok_or_else(|| SolanaError::EncodeTypeMismatch { + expected: "object with named args".into(), + got: format!("{args}"), + })?; + for arg in &ix.args { + let v = obj.get(&arg.name).ok_or_else(|| SolanaError::EncodeFailed(format!( + "missing instruction arg '{}'", + arg.name + )))?; + write_value(&mut buf, v, &arg.ty, types)?; + } + Ok(buf) +} + +fn write_value( + buf: &mut Vec<u8>, + value: &Value, + ty: &IdlType, + types: &[IdlTypeDef], +) -> Result<(), SolanaError> { + match ty { + IdlType::Bool => write_primitive::<bool>(buf, value.as_bool().ok_or_else(|| mismatch("bool", value))?), + IdlType::U8 => write_primitive::<u8>( + buf, + u8::try_from(value.as_u64().ok_or_else(|| mismatch("u8", value))?) + .map_err(|_| mismatch("u8", value))?, + ), + IdlType::I8 => write_primitive::<i8>( + buf, + i8::try_from(value.as_i64().ok_or_else(|| mismatch("i8", value))?) + .map_err(|_| mismatch("i8", value))?, + ), + IdlType::U16 => write_primitive::<u16>( + buf, + u16::try_from(value.as_u64().ok_or_else(|| mismatch("u16", value))?) + .map_err(|_| mismatch("u16", value))?, + ), + IdlType::I16 => write_primitive::<i16>( + buf, + i16::try_from(value.as_i64().ok_or_else(|| mismatch("i16", value))?) + .map_err(|_| mismatch("i16", value))?, + ), + IdlType::U32 => write_primitive::<u32>( + buf, + u32::try_from(value.as_u64().ok_or_else(|| mismatch("u32", value))?) + .map_err(|_| mismatch("u32", value))?, + ), + IdlType::I32 => write_primitive::<i32>( + buf, + i32::try_from(value.as_i64().ok_or_else(|| mismatch("i32", value))?) + .map_err(|_| mismatch("i32", value))?, + ), + IdlType::F32 => write_primitive::<f32>(buf, value.as_f64().ok_or_else(|| mismatch("f32", value))? as f32), + IdlType::U64 => write_primitive::<u64>(buf, value.as_u64().ok_or_else(|| mismatch("u64", value))?), + IdlType::I64 => write_primitive::<i64>(buf, value.as_i64().ok_or_else(|| mismatch("i64", value))?), + IdlType::F64 => write_primitive::<f64>(buf, value.as_f64().ok_or_else(|| mismatch("f64", value))?), + IdlType::U128 => { + let s = value.as_str().ok_or_else(|| mismatch("u128 decimal string", value))?; + let v: u128 = s.parse().map_err(|_| mismatch("u128 decimal string", value))?; + write_primitive::<u128>(buf, v) + } + IdlType::I128 => { + let s = value.as_str().ok_or_else(|| mismatch("i128 decimal string", value))?; + let v: i128 = s.parse().map_err(|_| mismatch("i128 decimal string", value))?; + write_primitive::<i128>(buf, v) + } + IdlType::U256 | IdlType::I256 => { + let s = value.as_str().ok_or_else(|| mismatch("u/i256 hex string", value))?; + let stripped = s.strip_prefix("0x").unwrap_or(s); + let bytes = decode_hex(stripped).ok_or_else(|| mismatch("u/i256 hex string", value))?; + if bytes.len() != 32 { + return Err(mismatch("u/i256 32-byte hex", value)); + } + buf.extend_from_slice(&bytes); + Ok(()) + } + IdlType::String => { + let s = value.as_str().ok_or_else(|| mismatch("string", value))?; + write_primitive::<String>(buf, s.to_string()) + } + IdlType::Bytes => { + let s = value.as_str().ok_or_else(|| mismatch("bytes hex string", value))?; + let bytes = decode_hex(s).ok_or_else(|| mismatch("bytes hex string", value))?; + write_primitive::<Vec<u8>>(buf, bytes) + } + IdlType::Pubkey => { + let s = value.as_str().ok_or_else(|| mismatch("pubkey base58", value))?; + let bytes = bs58::decode(s) + .into_vec() + .map_err(|_| mismatch("pubkey base58", value))?; + if bytes.len() != 32 { + return Err(mismatch("pubkey 32 bytes", value)); + } + buf.extend_from_slice(&bytes); + Ok(()) + } + IdlType::Option(inner) => { + if value.is_null() { + buf.push(0); + Ok(()) + } else { + buf.push(1); + write_value(buf, value, inner, types) + } + } + IdlType::Vec(inner) => { + let arr = value.as_array().ok_or_else(|| mismatch("array", value))?; + let len = u32::try_from(arr.len()) + .map_err(|_| SolanaError::EncodeFailed("vec length exceeds u32".into()))?; + write_primitive::<u32>(buf, len)?; + for item in arr { + write_value(buf, item, inner, types)?; + } + Ok(()) + } + IdlType::Array(inner, IdlArrayLen::Value(n)) => { + let arr = value.as_array().ok_or_else(|| mismatch("array", value))?; + if arr.len() != *n { + return Err(SolanaError::EncodeFailed(format!( + "expected array of {} elements, got {}", + n, + arr.len() + ))); + } + for item in arr { + write_value(buf, item, inner, types)?; + } + Ok(()) + } + IdlType::Array(_, IdlArrayLen::Generic(name)) => { + Err(SolanaError::UnsupportedGeneric(format!("array length generic '{name}'"))) + } + IdlType::Defined { name, .. } => { + let def = types + .iter() + .find(|t| &t.name == name) + .ok_or_else(|| SolanaError::UnknownType(name.clone()))?; + write_typedef(buf, value, def, types) + } + IdlType::Generic(name) => Err(SolanaError::UnsupportedGeneric(name.clone())), + _ => Err(SolanaError::EncodeFailed("unsupported IdlType variant".into())), + } +} + +fn write_typedef( + buf: &mut Vec<u8>, + value: &Value, + def: &IdlTypeDef, + types: &[IdlTypeDef], +) -> Result<(), SolanaError> { + match &def.ty { + IdlTypeDefTy::Struct { fields } => write_defined_fields(buf, value, fields.as_ref(), types), + IdlTypeDefTy::Enum { variants } => write_enum(buf, value, variants, types), + IdlTypeDefTy::Type { alias } => write_value(buf, value, alias, types), + } +} + +fn write_defined_fields( + buf: &mut Vec<u8>, + value: &Value, + fields: Option<&IdlDefinedFields>, + types: &[IdlTypeDef], +) -> Result<(), SolanaError> { + match fields { + None => Ok(()), + Some(IdlDefinedFields::Named(items)) => { + let obj = value + .as_object() + .ok_or_else(|| mismatch("object with named fields", value))?; + for f in items { + let v = obj.get(&f.name).ok_or_else(|| { + SolanaError::EncodeFailed(format!("missing struct field '{}'", f.name)) + })?; + write_value(buf, v, &f.ty, types)?; + } + Ok(()) + } + Some(IdlDefinedFields::Tuple(items)) => { + let arr = value + .as_array() + .ok_or_else(|| mismatch("array for tuple struct", value))?; + if arr.len() != items.len() { + return Err(SolanaError::EncodeFailed(format!( + "tuple expected {} elements, got {}", + items.len(), + arr.len() + ))); + } + for (v, ty) in arr.iter().zip(items.iter()) { + write_value(buf, v, ty, types)?; + } + Ok(()) + } + } +} + +fn write_enum( + buf: &mut Vec<u8>, + value: &Value, + variants: &[IdlEnumVariant], + types: &[IdlTypeDef], +) -> Result<(), SolanaError> { + let obj = value + .as_object() + .ok_or_else(|| mismatch("enum object {kind, value?}", value))?; + let kind = obj + .get("kind") + .and_then(|v| v.as_str()) + .ok_or_else(|| mismatch("enum kind string", value))?; + let (idx, variant) = variants + .iter() + .enumerate() + .find(|(_, v)| v.name == kind) + .ok_or_else(|| SolanaError::EncodeFailed(format!("unknown enum variant '{kind}'")))?; + let tag = u8::try_from(idx).map_err(|_| SolanaError::EncodeFailed("enum tag > 255".into()))?; + buf.push(tag); + + match (&variant.fields, obj.get("value")) { + (None, _) => Ok(()), + (Some(fields), Some(payload)) => { + write_defined_fields(buf, payload, Some(fields), types) + } + (Some(_), None) => Err(SolanaError::EncodeFailed(format!( + "enum variant '{kind}' expects payload but got none" + ))), + } +} + +fn write_primitive<T: BorshSerialize>(buf: &mut Vec<u8>, v: T) -> Result<(), SolanaError> { + v.serialize(buf) + .map_err(|e| SolanaError::EncodeFailed(e.to_string())) +} + +fn mismatch(expected: &str, got: &Value) -> SolanaError { + SolanaError::EncodeTypeMismatch { + expected: expected.to_string(), + got: format!("{got}"), + } +} + +fn decode_hex(s: &str) -> Option<Vec<u8>> { + let s = s.strip_prefix("0x").unwrap_or(s); + if s.len() % 2 != 0 { + return None; + } + let mut out = Vec::with_capacity(s.len() / 2); + for i in (0..s.len()).step_by(2) { + out.push(u8::from_str_radix(&s[i..i + 2], 16).ok()?); + } + Some(out) +} + diff --git a/crates/ilold-solana-core/src/encode/mod.rs b/crates/ilold-solana-core/src/encode/mod.rs new file mode 100644 index 0000000..69925f6 --- /dev/null +++ b/crates/ilold-solana-core/src/encode/mod.rs @@ -0,0 +1,3 @@ +pub mod borsh; + +pub use borsh::{encode_ix_data, encode_value}; diff --git a/crates/ilold-solana-core/src/error.rs b/crates/ilold-solana-core/src/error.rs new file mode 100644 index 0000000..fc5b9ef --- /dev/null +++ b/crates/ilold-solana-core/src/error.rs @@ -0,0 +1,70 @@ +use std::path::PathBuf; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum SolanaError { + #[error("failed to read IDL file '{path}': {source}")] + IdlReadFailed { + path: PathBuf, + #[source] + source: std::io::Error, + }, + + #[error("failed to parse or convert IDL JSON: {message}")] + IdlParseFailed { message: String }, + + #[error("IDL spec '{0}' is not a recognized version")] + UnsupportedIdlSpec(String), + + #[error("IDL type '{0}' uses generics, which are not supported")] + UnsupportedGeneric(String), + + #[error("IDL address '{0}' is not a valid base58 pubkey")] + InvalidProgramId(String), + + #[error("expected 8-byte discriminator for '{name}', got {len}")] + InvalidDiscriminatorLength { name: String, len: usize }, + + #[error("PDA seed references arg '{path}' which is not declared on the instruction")] + SeedArgUnresolved { path: String }, + + #[error("project at '{path}' contains both Anchor.toml and Solidity sources")] + MixedProject { path: PathBuf }, + + #[error("project at '{path}' is neither Anchor nor Solidity (no Anchor.toml, foundry.toml or *.sol found)")] + UnknownProjectType { path: PathBuf }, + + #[error("Borsh decode failed: {0}")] + DecodeFailed(String), + + #[error("unknown account discriminator: {hex}")] + UnknownDiscriminator { hex: String }, + + #[error("IDL references unknown type '{0}'")] + UnknownType(String), + + #[error("VM boot failed: {0}")] + VmBootFailed(String), + + #[error("VM operation failed: {0}")] + VmOperationFailed(String), + + #[error("PDA seed type mismatch at '{path}': expected {expected}, got {got}")] + SeedTypeMismatch { + path: String, + expected: String, + got: String, + }, + + #[error("PDA program override is bound to an arg seed '{path}', which is not supported")] + PdaProgramArgUnsupported { path: String }, + + #[error("Borsh encode failed: {0}")] + EncodeFailed(String), + + #[error("Borsh encode type mismatch: expected {expected}, got {got}")] + EncodeTypeMismatch { expected: String, got: String }, + + #[error("instruction account '{path}' is not constant, not a PDA, and not provided")] + AccountNotProvided { path: String }, +} diff --git a/crates/ilold-solana-core/src/execute/builder.rs b/crates/ilold-solana-core/src/execute/builder.rs new file mode 100644 index 0000000..14508dd --- /dev/null +++ b/crates/ilold-solana-core/src/execute/builder.rs @@ -0,0 +1,92 @@ +use std::collections::HashMap; + +use anchor_lang_idl::types::IdlTypeDef; +use serde_json::Value; +use solana_address::Address; +use solana_hash::Hash; +use solana_instruction::{AccountMeta, Instruction}; +use solana_keypair::Keypair; +use solana_message::{Message, VersionedMessage}; +use solana_signer::Signer; +use solana_transaction::versioned::VersionedTransaction; + +use crate::encode::encode_ix_data; +use crate::error::SolanaError; +use crate::execute::pda::derive_pda; +use crate::model::InstructionDef; + +pub fn build_instruction( + program_id: Address, + ix: &InstructionDef, + mut args: Value, + accounts: HashMap<String, Address>, + types: &[IdlTypeDef], +) -> Result<Instruction, SolanaError> { + let mut resolved = accounts; + + for spec in &ix.accounts { + if resolved.contains_key(&spec.path) || resolved.contains_key(&spec.name) { + continue; + } + if let Some(pda_spec) = &spec.pda { + let (pda, bump) = derive_pda(pda_spec, program_id, &args, &resolved)?; + resolved.insert(spec.path.clone(), pda); + if let Some(arg_name) = &pda_spec.bump_arg { + if let Some(obj) = args.as_object_mut() { + obj.insert(arg_name.clone(), Value::from(bump)); + } + } + } else if let Some(addr) = spec.address { + resolved.insert(spec.path.clone(), addr); + } else { + return Err(SolanaError::AccountNotProvided { + path: spec.path.clone(), + }); + } + } + + let metas: Vec<AccountMeta> = ix + .accounts + .iter() + .map(|spec| { + let addr = resolved + .get(&spec.path) + .or_else(|| resolved.get(&spec.name)) + .copied() + .ok_or_else(|| SolanaError::AccountNotProvided { + path: spec.path.clone(), + })?; + Ok(if spec.writable { + AccountMeta::new(addr, spec.signer) + } else { + AccountMeta::new_readonly(addr, spec.signer) + }) + }) + .collect::<Result<Vec<_>, SolanaError>>()?; + + let data = encode_ix_data(ix, &args, types)?; + + Ok(Instruction { + program_id, + accounts: metas, + data, + }) +} + +pub fn build_transaction( + ix: Instruction, + payer: &Keypair, + extra_signers: &[&Keypair], + blockhash: Hash, +) -> Result<VersionedTransaction, SolanaError> { + let msg = Message::new_with_blockhash(&[ix], Some(&payer.pubkey()), &blockhash); + let payer_pk = payer.pubkey(); + let mut signers: Vec<&Keypair> = vec![payer]; + for kp in extra_signers { + if kp.pubkey() != payer_pk && !signers.iter().any(|s| s.pubkey() == kp.pubkey()) { + signers.push(*kp); + } + } + VersionedTransaction::try_new(VersionedMessage::Legacy(msg), &signers) + .map_err(|e| SolanaError::EncodeFailed(format!("transaction sign: {e:?}"))) +} diff --git a/crates/ilold-solana-core/src/execute/fork.rs b/crates/ilold-solana-core/src/execute/fork.rs new file mode 100644 index 0000000..250ccdf --- /dev/null +++ b/crates/ilold-solana-core/src/execute/fork.rs @@ -0,0 +1,137 @@ +use litesvm::LiteSVM; +use solana_account::{AccountSharedData, ReadableAccount}; +use solana_address::Address; +use solana_clock::Clock; +use solana_keypair::Keypair; +use solana_sdk_ids::bpf_loader_upgradeable; +use solana_signer::Signer; + +use crate::error::SolanaError; +use crate::execute::vm::VmHost; + +const PROGRAMDATA_DISCRIMINATOR_BYTE: u8 = 3; + +#[derive(Clone)] +pub struct VmSnapshot { + pub accounts: Vec<(Address, AccountSharedData)>, + pub clock: Clock, + pub payer_bytes: [u8; 64], + pub programs: Vec<(Address, Vec<u8>)>, +} + +impl VmHost { + pub fn snapshot(&self) -> VmSnapshot { + let accounts: Vec<(Address, AccountSharedData)> = self + .svm() + .accounts_db() + .inner + .iter() + .map(|(k, v)| (*k, v.clone())) + .collect(); + VmSnapshot { + accounts, + clock: self.svm().get_sysvar::<Clock>(), + payer_bytes: self.payer().to_bytes(), + programs: self.programs().to_vec(), + } + } + + pub fn restore(snap: VmSnapshot) -> Result<Self, SolanaError> { + let mut svm = LiteSVM::new(); + svm.set_sysvar(&snap.clock); + + for (program_id, bytes) in &snap.programs { + svm.add_program(*program_id, bytes).map_err(|e| { + SolanaError::VmBootFailed(format!("restore add_program {program_id}: {e:?}")) + })?; + } + + let upgradeable = bpf_loader_upgradeable::id(); + let (programdata, other): (Vec<_>, Vec<_>) = + snap.accounts.iter().cloned().partition(|(_, acc)| { + acc.owner() == &upgradeable + && acc.data().first() == Some(&PROGRAMDATA_DISCRIMINATOR_BYTE) + }); + + for (pk, acc) in programdata { + svm.set_account(pk, acc.into()).map_err(|e| { + SolanaError::VmOperationFailed(format!("restore programdata {pk}: {e:?}")) + })?; + } + for (pk, acc) in other { + svm.set_account(pk, acc.into()) + .map_err(|e| SolanaError::VmOperationFailed(format!("restore account {pk}: {e:?}")))?; + } + + let payer = Keypair::try_from(snap.payer_bytes.as_slice()) + .map_err(|_| SolanaError::VmBootFailed("invalid payer bytes in snapshot".into()))?; + + Ok(VmHost::from_parts(svm, payer, snap.programs)) + } +} + +impl VmSnapshot { + pub fn payer_pubkey(&self) -> Result<Address, SolanaError> { + let kp = Keypair::try_from(self.payer_bytes.as_slice()) + .map_err(|_| SolanaError::VmBootFailed("invalid payer bytes in snapshot".into()))?; + Ok(kp.pubkey()) + } +} + +#[derive(Clone)] +pub struct StateSnapshot { + pub accounts: Vec<(Address, AccountSharedData)>, + pub clock: Clock, +} + +impl VmHost { + pub fn snapshot_state(&self) -> StateSnapshot { + let accounts = self + .svm() + .accounts_db() + .inner + .iter() + .map(|(k, v)| (*k, v.clone())) + .collect(); + StateSnapshot { + accounts, + clock: self.svm().get_sysvar::<Clock>(), + } + } + + pub fn restore_state(&mut self, snap: StateSnapshot) -> Result<(), SolanaError> { + let live: std::collections::HashSet<Address> = + self.svm().accounts_db().inner.keys().copied().collect(); + let snap_keys: std::collections::HashSet<Address> = + snap.accounts.iter().map(|(k, _)| *k).collect(); + + let to_drop: Vec<Address> = live.difference(&snap_keys).copied().collect(); + for pk in to_drop { + let empty: solana_account::Account = solana_account::Account::default(); + self.svm_mut().set_account(pk, empty).map_err(|e| { + SolanaError::VmOperationFailed(format!("drop account {pk}: {e:?}")) + })?; + } + + let upgradeable = solana_sdk_ids::bpf_loader_upgradeable::id(); + const PROGRAMDATA_DISCRIMINATOR_BYTE: u8 = 3; + use solana_account::ReadableAccount; + let (programdata, other): (Vec<_>, Vec<_>) = + snap.accounts.iter().cloned().partition(|(_, acc)| { + acc.owner() == &upgradeable + && acc.data().first() == Some(&PROGRAMDATA_DISCRIMINATOR_BYTE) + }); + for (pk, acc) in programdata { + self.svm_mut().set_account(pk, acc.into()).map_err(|e| { + SolanaError::VmOperationFailed(format!("restore programdata {pk}: {e:?}")) + })?; + } + for (pk, acc) in other { + self.svm_mut().set_account(pk, acc.into()).map_err(|e| { + SolanaError::VmOperationFailed(format!("restore account {pk}: {e:?}")) + })?; + } + self.svm_mut().set_sysvar(&snap.clock); + Ok(()) + } +} diff --git a/crates/ilold-solana-core/src/execute/mod.rs b/crates/ilold-solana-core/src/execute/mod.rs new file mode 100644 index 0000000..178b795 --- /dev/null +++ b/crates/ilold-solana-core/src/execute/mod.rs @@ -0,0 +1,9 @@ +pub mod builder; +pub mod fork; +pub mod pda; +pub mod vm; + +pub use builder::{build_instruction, build_transaction}; +pub use fork::{StateSnapshot, VmSnapshot}; +pub use pda::derive_pda; +pub use vm::VmHost; diff --git a/crates/ilold-solana-core/src/execute/pda.rs b/crates/ilold-solana-core/src/execute/pda.rs new file mode 100644 index 0000000..695edd6 --- /dev/null +++ b/crates/ilold-solana-core/src/execute/pda.rs @@ -0,0 +1,128 @@ +use std::collections::HashMap; + +use anchor_lang_idl::types::IdlType; +use serde_json::Value; +use solana_address::Address; + +use crate::error::SolanaError; +use crate::model::{PdaSpec, SeedSpec}; + +pub fn derive_pda( + spec: &PdaSpec, + program: Address, + args: &Value, + accounts: &HashMap<String, Address>, +) -> Result<(Address, u8), SolanaError> { + let mut seed_bytes: Vec<Vec<u8>> = Vec::with_capacity(spec.seeds.len()); + for seed in &spec.seeds { + seed_bytes.push(resolve_seed(seed, args, accounts)?); + } + + let program_id = match &spec.program { + None => program, + Some(SeedSpec::Const { value }) => Address::try_from(value.as_slice()) + .map_err(|_| SolanaError::InvalidProgramId(format!("{:02x?}", value)))?, + Some(SeedSpec::Account { path }) => *accounts + .get(path) + .ok_or_else(|| SolanaError::SeedArgUnresolved { path: path.clone() })?, + Some(SeedSpec::Arg { path, .. }) => { + return Err(SolanaError::PdaProgramArgUnsupported { path: path.clone() }); + } + }; + + let refs: Vec<&[u8]> = seed_bytes.iter().map(Vec::as_slice).collect(); + let (pda, bump) = Address::find_program_address(&refs, &program_id); + Ok((pda, bump)) +} + +fn resolve_seed( + seed: &SeedSpec, + args: &Value, + accounts: &HashMap<String, Address>, +) -> Result<Vec<u8>, SolanaError> { + match seed { + SeedSpec::Const { value } => Ok(value.clone()), + SeedSpec::Account { path } => accounts + .get(path) + .map(|pk| pk.to_bytes().to_vec()) + .ok_or_else(|| SolanaError::SeedArgUnresolved { path: path.clone() }), + SeedSpec::Arg { path, ty } => { + let value = args + .pointer(&format!("/{}", path.replace('.', "/"))) + .ok_or_else(|| SolanaError::SeedArgUnresolved { path: path.clone() })?; + encode_arg_seed(value, ty, path) + } + } +} + +fn encode_arg_seed(value: &Value, ty: &IdlType, path: &str) -> Result<Vec<u8>, SolanaError> { + let mismatch = |expected: &str| SolanaError::SeedTypeMismatch { + path: path.to_string(), + expected: expected.to_string(), + got: format!("{value}"), + }; + match (ty, value) { + (IdlType::String, Value::String(s)) => Ok(s.as_bytes().to_vec()), + (IdlType::Bytes, Value::String(hex)) => decode_hex(hex).ok_or_else(|| mismatch("bytes hex")), + (IdlType::Pubkey, Value::String(b58)) => bs58::decode(b58) + .into_vec() + .map_err(|_| mismatch("pubkey base58")), + (IdlType::U8, Value::Number(n)) => { + let v = u8::try_from(n.as_u64().ok_or_else(|| mismatch("u8"))?) + .map_err(|_| mismatch("u8"))?; + Ok(v.to_le_bytes().to_vec()) + } + (IdlType::U16, Value::Number(n)) => { + let v = u16::try_from(n.as_u64().ok_or_else(|| mismatch("u16"))?) + .map_err(|_| mismatch("u16"))?; + Ok(v.to_le_bytes().to_vec()) + } + (IdlType::U32, Value::Number(n)) => { + let v = u32::try_from(n.as_u64().ok_or_else(|| mismatch("u32"))?) + .map_err(|_| mismatch("u32"))?; + Ok(v.to_le_bytes().to_vec()) + } + (IdlType::U64, Value::Number(n)) => Ok(n + .as_u64() + .ok_or_else(|| mismatch("u64"))? + .to_le_bytes() + .to_vec()), + (IdlType::I8, Value::Number(n)) => { + let v = i8::try_from(n.as_i64().ok_or_else(|| mismatch("i8"))?) + .map_err(|_| mismatch("i8"))?; + Ok(v.to_le_bytes().to_vec()) + } + (IdlType::I16, Value::Number(n)) => { + let v = i16::try_from(n.as_i64().ok_or_else(|| mismatch("i16"))?) + .map_err(|_| mismatch("i16"))?; + Ok(v.to_le_bytes().to_vec()) + } + (IdlType::I32, Value::Number(n)) => { + let v = i32::try_from(n.as_i64().ok_or_else(|| mismatch("i32"))?) + .map_err(|_| mismatch("i32"))?; + Ok(v.to_le_bytes().to_vec()) + } + (IdlType::I64, Value::Number(n)) => Ok(n + .as_i64() + .ok_or_else(|| mismatch("i64"))? + .to_le_bytes() + .to_vec()), + _ => Err(SolanaError::SeedTypeMismatch { + path: path.to_string(), + expected: format!("{ty:?}"), + got: format!("{value}"), + }), + } +} + +fn decode_hex(s: &str) -> Option<Vec<u8>> { + let s = s.strip_prefix("0x").unwrap_or(s); + if s.len() % 2 != 0 { + return None; + } + let mut out = Vec::with_capacity(s.len() / 2); + for i in (0..s.len()).step_by(2) { + out.push(u8::from_str_radix(&s[i..i + 2], 16).ok()?); + } + Some(out) +} diff --git a/crates/ilold-solana-core/src/execute/vm.rs b/crates/ilold-solana-core/src/execute/vm.rs new file mode 100644 index 0000000..fc1453f --- /dev/null +++ b/crates/ilold-solana-core/src/execute/vm.rs @@ -0,0 +1,87 @@ +use litesvm::LiteSVM; +use solana_address::Address; +use solana_clock::Clock; +use solana_keypair::Keypair; +use solana_signer::Signer; + +use crate::error::SolanaError; + +pub const DEFAULT_PAYER_LAMPORTS: u64 = 1_000_000_000_000; + +pub struct VmHost { + svm: LiteSVM, + payer: Keypair, + programs: Vec<(Address, Vec<u8>)>, +} + +impl VmHost { + pub fn boot(programs: Vec<(Address, Vec<u8>)>) -> Result<Self, SolanaError> { + let mut svm = LiteSVM::new(); + svm.set_sysvar(&Clock::default()); + + let payer = Keypair::new(); + svm.airdrop(&payer.pubkey(), DEFAULT_PAYER_LAMPORTS) + .map_err(|meta| { + SolanaError::VmBootFailed(format!("airdrop payer: {:?}", meta.err)) + })?; + + for (program_id, bytes) in &programs { + svm.add_program(*program_id, bytes).map_err(|e| { + SolanaError::VmBootFailed(format!( + "add_program {program_id}: {e:?}" + )) + })?; + } + + Ok(Self { svm, payer, programs }) + } + + pub(crate) fn programs(&self) -> &[(Address, Vec<u8>)] { + &self.programs + } + + pub(crate) fn from_parts( + svm: LiteSVM, + payer: Keypair, + programs: Vec<(Address, Vec<u8>)>, + ) -> Self { + Self { svm, payer, programs } + } + + pub fn payer(&self) -> &Keypair { + &self.payer + } + + pub fn payer_pubkey(&self) -> Address { + self.payer.pubkey() + } + + pub fn svm(&self) -> &LiteSVM { + &self.svm + } + + pub fn svm_mut(&mut self) -> &mut LiteSVM { + &mut self.svm + } + + pub fn airdrop(&mut self, address: Address, lamports: u64) -> Result<(), SolanaError> { + self.svm.airdrop(&address, lamports).map(|_| ()).map_err(|meta| { + SolanaError::VmOperationFailed(format!("airdrop: {:?}", meta.err)) + }) + } + + pub fn balance(&self, address: &Address) -> u64 { + self.svm.get_balance(address).unwrap_or(0) + } + + pub fn warp_clock(&mut self, slot: u64, unix_timestamp: i64) { + let mut clock = self.svm.get_sysvar::<Clock>(); + clock.slot = slot; + clock.unix_timestamp = unix_timestamp; + self.svm.set_sysvar(&clock); + } + + pub fn clock(&self) -> Clock { + self.svm.get_sysvar::<Clock>() + } +} diff --git a/crates/ilold-solana-core/src/exploration/add_step.rs b/crates/ilold-solana-core/src/exploration/add_step.rs new file mode 100644 index 0000000..85699dc --- /dev/null +++ b/crates/ilold-solana-core/src/exploration/add_step.rs @@ -0,0 +1,331 @@ +use std::collections::HashMap; + +use ilold_session_core::exploration::assign_operator::AssignOperator; +use ilold_session_core::exploration::session::{ + ExplorationSession, ExplorationStep, MutationScope, StateMutation, TraceConfig, +}; +use ilold_session_core::journal::types::JournalEntry; +use ilold_session_core::runtime_trace::{ + AccountDiff, InnerInstruction as TraceInnerInstruction, RuntimeTrace, +}; +use litesvm::types::TransactionMetadata; +use serde_json::Value; +use solana_account::{Account, ReadableAccount}; +use solana_address::Address; +use solana_keypair::Keypair; + +use crate::error::SolanaError; +use crate::execute::{build_instruction, build_transaction, VmHost}; +use crate::model::{InstructionDef, ProgramDef}; + +pub struct StepOutcome { + pub step_index: Option<usize>, + pub trace: RuntimeTrace, +} + +#[allow(clippy::too_many_arguments)] +pub fn add_solana_step( + session: &mut ExplorationSession, + program: &ProgramDef, + ix: &InstructionDef, + vm: &mut VmHost, + args: Value, + accounts: HashMap<String, Address>, + extra_signers: &[&Keypair], + timestamp: &str, + call_payload: Option<Value>, +) -> Result<StepOutcome, SolanaError> { + let step_index = session.steps.len(); + let types = &program.types; + + let instruction = + build_instruction(program.program_id, ix, args, accounts, types)?; + + let pre_state: Vec<(Address, Option<Account>)> = instruction + .accounts + .iter() + .map(|m| (m.pubkey, vm.svm().get_account(&m.pubkey))) + .collect(); + + let blockhash = vm.svm().latest_blockhash(); + let tx = build_transaction(instruction.clone(), vm.payer(), extra_signers, blockhash)?; + let account_keys: Vec<Address> = tx.message.static_account_keys().to_vec(); + let result = vm.svm_mut().send_transaction(tx); + // LiteSVM does not rotate the blockhash automatically; force expiry. + vm.svm_mut().expire_blockhash(); + + let (runtime_trace, mutations) = match result { + Ok(meta) => { + let post_state: Vec<(Address, Option<Account>)> = pre_state + .iter() + .map(|(addr, _)| (*addr, vm.svm().get_account(addr))) + .collect(); + + let diffs = compute_diffs(&pre_state, &post_state, ix); + let mutations = diffs_to_mutations(&diffs, step_index); + let inner = project_inner_instructions(&meta, &account_keys); + let return_data = if meta.return_data.data.is_empty() { + None + } else { + Some(meta.return_data.data) + }; + ( + RuntimeTrace { + logs: meta.logs, + compute_units: meta.compute_units_consumed, + inner_instructions: inner, + account_diffs: diffs, + return_data, + error: None, + }, + mutations, + ) + } + Err(failed) => { + let inner = project_inner_instructions(&failed.meta, &account_keys); + ( + RuntimeTrace { + logs: failed.meta.logs, + compute_units: failed.meta.compute_units_consumed, + inner_instructions: inner, + account_diffs: vec![], + return_data: None, + error: Some(format!("{:?}", failed.err)), + }, + vec![], + ) + } + }; + + if runtime_trace.error.is_some() { + return Ok(StepOutcome { step_index: None, trace: runtime_trace }); + } + + let trace_value = serde_json::to_value(&runtime_trace).ok(); + + session.steps.push(ExplorationStep { + function: ix.name.clone(), + mutations, + flow_tree: None, + trace_config: TraceConfig::default(), + runtime_trace: trace_value, + call_payload, + }); + + session + .journal + .record(JournalEntry::SequenceExplored { + steps: session.steps.iter().map(|s| s.function.clone()).collect(), + timestamp: timestamp.into(), + }); + + Ok(StepOutcome { + step_index: Some(step_index), + trace: runtime_trace, + }) +} + + +fn project_inner_instructions( + meta: &TransactionMetadata, + account_keys: &[Address], +) -> Vec<TraceInnerInstruction> { + let mut out = Vec::new(); + for level in &meta.inner_instructions { + for ii in level { + let program = account_keys + .get(ii.instruction.program_id_index as usize) + .map(|k| k.to_string()) + .unwrap_or_else(|| format!("idx:{}", ii.instruction.program_id_index)); + let instruction = if ii.instruction.data.is_empty() { + "ix".to_string() + } else { + let take = ii.instruction.data.len().min(8); + bs58::encode(&ii.instruction.data[..take]).into_string() + }; + out.push(TraceInnerInstruction { + program, + instruction, + depth: ii.stack_height as u32, + }); + } + } + out +} + +fn compute_diffs( + pre: &[(Address, Option<Account>)], + post: &[(Address, Option<Account>)], + ix: &InstructionDef, +) -> Vec<AccountDiff> { + pre.iter() + .zip(post.iter()) + .enumerate() + .filter_map(|(idx, ((addr, before), (_, after)))| { + let lamports_before = before.as_ref().map(|a| a.lamports()).unwrap_or(0); + let lamports_after = after.as_ref().map(|a| a.lamports()).unwrap_or(0); + let data_before = before.as_ref().map(|a| a.data().to_vec()); + let data_after = after.as_ref().map(|a| a.data().to_vec()); + let owner_before = before.as_ref().map(|a| a.owner); + let owner_after = after.as_ref().map(|a| a.owner); + + let lamports_changed = lamports_before != lamports_after; + let data_changed = data_before != data_after; + let owner_changed = owner_before != owner_after; + + if !lamports_changed && !data_changed && !owner_changed { + return None; + } + + Some(AccountDiff { + address: addr.to_string(), + name: ix.accounts.get(idx).map(|s| s.name.clone()), + before: data_before, + after: data_after, + lamports_delta: (lamports_after as i128) - (lamports_before as i128), + owner_changed, + decoded_before: None, + decoded_after: None, + }) + }) + .collect() +} + +fn diffs_to_mutations(diffs: &[AccountDiff], step_index: usize) -> Vec<StateMutation> { + let mut out = Vec::new(); + for d in diffs { + let label = d.name.clone().unwrap_or_else(|| d.address.clone()); + if d.lamports_delta != 0 { + out.push(StateMutation { + variable: format!("{label}.lamports"), + operator: AssignOperator::Assign, + value_expr: d.lamports_delta.to_string(), + step_index, + via: None, + flow_step_id: None, + scope: MutationScope::State, + }); + } + if d.before != d.after { + out.push(StateMutation { + variable: format!("{label}.data"), + operator: AssignOperator::Assign, + value_expr: d + .after + .as_ref() + .map(|b| format!("{} bytes", b.len())) + .unwrap_or_else(|| "<closed>".into()), + step_index, + via: None, + flow_step_id: None, + scope: MutationScope::State, + }); + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::AccountSpec; + use solana_keypair::Keypair; + use solana_signer::Signer; + + fn ix_with_accounts(names: &[&str]) -> InstructionDef { + InstructionDef { + name: "test".into(), + discriminator: [0u8; 8], + args: vec![], + accounts: names + .iter() + .map(|n| AccountSpec { + path: n.to_string(), + name: n.to_string(), + writable: true, + signer: false, + optional: false, + address: None, + pda: None, + relations: vec![], + }) + .collect(), + returns: None, + } + } + + fn account_with(lamports: u64, data: Vec<u8>) -> Account { + Account { + lamports, + data, + owner: Address::default(), + executable: false, + rent_epoch: 0, + } + } + + #[test] + fn diff_skips_unchanged_accounts() { + let addr = Keypair::new().pubkey(); + let acc = account_with(100, vec![1, 2, 3]); + let pre = vec![(addr, Some(acc.clone()))]; + let post = vec![(addr, Some(acc))]; + let ix = ix_with_accounts(&["a"]); + + let diffs = compute_diffs(&pre, &post, &ix); + assert!(diffs.is_empty()); + } + + #[test] + fn diff_detects_lamports_and_data_changes() { + let addr = Keypair::new().pubkey(); + let pre = vec![(addr, Some(account_with(100, vec![1])))]; + let post = vec![(addr, Some(account_with(150, vec![1, 2, 3])))]; + let ix = ix_with_accounts(&["counter"]); + + let diffs = compute_diffs(&pre, &post, &ix); + assert_eq!(diffs.len(), 1); + let d = &diffs[0]; + assert_eq!(d.lamports_delta, 50); + assert_eq!(d.before, Some(vec![1])); + assert_eq!(d.after, Some(vec![1, 2, 3])); + assert_eq!(d.name.as_deref(), Some("counter")); + } + + #[test] + fn diff_handles_account_creation() { + let addr = Keypair::new().pubkey(); + let pre = vec![(addr, None)]; + let post = vec![(addr, Some(account_with(890, vec![0; 8])))]; + let ix = ix_with_accounts(&["new_acc"]); + + let diffs = compute_diffs(&pre, &post, &ix); + assert_eq!(diffs.len(), 1); + let d = &diffs[0]; + assert_eq!(d.lamports_delta, 890); + assert_eq!(d.before, None); + assert_eq!(d.after, Some(vec![0; 8])); + } + + #[test] + fn mutations_emit_lamports_and_data_entries() { + let diffs = vec![AccountDiff { + address: "abc".into(), + name: Some("counter".into()), + before: Some(vec![0]), + after: Some(vec![42]), + lamports_delta: 1_000, + owner_changed: false, + decoded_before: None, + decoded_after: None, + }]; + + let muts = diffs_to_mutations(&diffs, 3); + assert_eq!(muts.len(), 2); + assert_eq!(muts[0].variable, "counter.lamports"); + assert_eq!(muts[0].value_expr, "1000"); + assert_eq!(muts[0].step_index, 3); + assert_eq!(muts[1].variable, "counter.data"); + assert_eq!(muts[1].value_expr, "1 bytes"); + } +} diff --git a/crates/ilold-solana-core/src/exploration/commands.rs b/crates/ilold-solana-core/src/exploration/commands.rs new file mode 100644 index 0000000..65994c2 --- /dev/null +++ b/crates/ilold-solana-core/src/exploration/commands.rs @@ -0,0 +1,440 @@ +use std::collections::HashMap; + +use ilold_session_core::exploration::access::AccessLevel; +use ilold_session_core::exploration::canvas::CanvasPatch; +use ilold_session_core::exploration::scenario::{ScenarioAction, ScenarioEvent, ScenarioInfo}; +use ilold_session_core::journal::types::{ReviewStatus, Severity}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::overlay::RuntimeOverlay; +use crate::view::{AccountView, ArgView, CouplingPair, FieldView, IxView}; + +#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)] +pub enum SolanaCommand { + Call { + #[schemars(description = "Instruction name (e.g. stake, initialize_pool).")] + ix: String, + #[serde(default)] + #[schemars( + description = "Anchor instruction args as a JSON object (e.g. {\"amount\": 1000}). Required keys depend on the instruction — call `ilold_info <ix>` first to discover them. Defaults to {}." + )] + args: Value, + #[serde(default)] + #[schemars( + description = "Map of account-name (from the IDL) to user handle or pubkey (e.g. {\"pool\":\"pool\",\"user\":\"alice\"})." + )] + accounts: HashMap<String, String>, + #[serde(default)] + #[schemars( + description = "Override the default signer set. Empty list means use the IDL defaults." + )] + signers: Vec<String>, + }, + Back, + Clear, + Funcs, + State, + Session, + Users, + UsersNew { + name: String, + #[serde(default = "default_initial_lamports")] + lamports: u64, + }, + Airdrop { + user: String, + lamports: u64, + }, + TimeWarp { + delta_seconds: i64, + }, + Pda { + instruction: String, + }, + Inspect { + pubkey: String, + }, + Finding { + severity: Severity, + title: String, + description: String, + #[serde(default)] + recommendation: Option<String>, + }, + Note { + text: String, + }, + Status { + ix: String, + status: ReviewStatus, + }, + #[serde(alias = "SaveSession")] + SaveSession { + #[serde(default)] + with_keypairs: bool, + }, + LoadSession { + json: String, + }, + Scenario { + sub: ScenarioAction, + }, + Step { + index: usize, + }, + Findings, + Export { + #[serde(default)] + metadata: Option<ilold_session_core::journal::export::AuditMetadata>, + }, + Who { + account_type: String, + }, + Timeline { + pubkey: String, + }, + Info { + ix: String, + }, + Coupling, + Vars, + Coverage, +} + +fn default_initial_lamports() -> u64 { + 10_000_000_000 +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InstructionEntry { + pub name: String, + pub args_count: usize, + pub accounts_count: usize, + pub has_pdas: bool, + pub signers: Vec<String>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AccountSummary { + pub label: String, + pub pubkey: String, + pub owner: String, + pub lamports: u64, + pub decoded: Option<Value>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UserEntry { + pub name: String, + pub pubkey: String, + pub lamports: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PdaEntry { + pub account_name: String, + pub seeds: Vec<String>, + pub program: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum SolanaCommandResult { + StepAdded { + step_index: usize, + instruction: String, + logs_excerpt: Vec<String>, + account_diffs_count: usize, + compute_units: u64, + #[serde(default)] + error: Option<String>, + }, + CallFailed { + instruction: String, + logs_excerpt: Vec<String>, + compute_units: u64, + error: String, + }, + StepRemoved { + remaining: usize, + }, + Cleared, + InstructionList { + items: Vec<InstructionEntry>, + }, + StateView { + accounts: Vec<AccountSummary>, + }, + SessionView { + program: String, + scenario: String, + steps: Vec<String>, + findings_count: usize, + }, + UserList { + users: Vec<UserEntry>, + }, + UserCreated { + name: String, + pubkey: String, + lamports: u64, + }, + Airdropped { + name: String, + pubkey: String, + total_lamports: u64, + }, + TimeWarped { + unix_timestamp: i64, + slot: u64, + }, + PdaList { + instruction: String, + pdas: Vec<PdaEntry>, + }, + AccountInspected { + pubkey: String, + owner: String, + lamports: u64, + data_len: usize, + decoded: Option<Value>, + }, + FindingAdded { + id: String, + }, + NoteAdded, + StatusUpdated, + SessionSaved { + json: String, + }, + SessionLoaded { + program: String, + steps: Vec<String>, + }, + ScenarioList { + items: Vec<ScenarioInfo>, + }, + ScenarioCreated { + name: String, + }, + ScenarioSwitched { + from: String, + to: String, + }, + ScenarioForked { + from: String, + to: String, + at_step: usize, + }, + ScenarioDeleted { + name: String, + }, + StepDetail { + step_index: usize, + instruction: String, + runtime_trace: Option<Value>, + diff_summary: Vec<StepDiffSummary>, + }, + FindingsList { + items: Vec<FindingSummary>, + }, + Exported { + markdown: String, + bytes: usize, + }, + WhoList { + account_type: String, + instructions: Vec<WhoEntry>, + #[serde(default)] + query_kind: WhoQueryKind, + #[serde(default, skip_serializing_if = "Option::is_none")] + field_owner: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + field_type: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + owner_fields: Option<Vec<FieldView>>, + #[serde(default, skip_serializing_if = "Option::is_none")] + ix_args: Option<Vec<ArgView>>, + #[serde(default, skip_serializing_if = "Option::is_none")] + ix_discriminator_hex: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + ix_accounts: Option<Vec<WhoIxAccount>>, + }, + TimelineView { + pubkey: String, + label: Option<String>, + entries: Vec<TimelineEntry>, + }, + IxInfo { + ix: IxView, + admin_gated: bool, + }, + CouplingList { + pairs: Vec<CouplingPair>, + }, + AccountTypes { + accounts: Vec<AccountView>, + }, + Coverage { + overlay: RuntimeOverlay, + }, + Error { + message: String, + }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StepDiffSummary { + pub address: String, + pub name: Option<String>, + pub lamports_delta: i128, + pub data_changed: bool, + #[serde(default)] + pub decoded_before: Option<Value>, + #[serde(default)] + pub decoded_after: Option<Value>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FindingSummary { + pub id: String, + pub severity: String, + pub title: String, + pub description: String, + pub created_at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WhoEntry { + pub instruction: String, + pub account_field: String, + pub writable: bool, + pub signer: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub account_type: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ix_args: Option<Vec<ArgView>>, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)] +pub enum WhoQueryKind { + #[default] + AccountType, + Field, + Instruction, + NotFound, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WhoIxAccount { + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub account_type: Option<String>, + pub writable: bool, + pub signer: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub fields: Option<Vec<FieldView>>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TimelineEntry { + pub step_index: usize, + pub instruction: String, + pub scenario: String, + pub lamports_delta: i128, + pub data_changed: bool, + pub before_decoded: Option<Value>, + pub after_decoded: Option<Value>, +} + +pub fn canvas_patches_from_solana( + result: &SolanaCommandResult, + active_scenario: &str, + cpi_targets: &[String], +) -> Vec<CanvasPatch> { + use ilold_session_core::exploration::canvas::RuntimeMeta; + match result { + SolanaCommandResult::StepAdded { + instruction, + step_index, + logs_excerpt, + account_diffs_count, + compute_units, + error, + } => vec![ + CanvasPatch::AddNode { + scenario: active_scenario.to_string(), + function: instruction.clone(), + access: AccessLevel::Public, + step_index: *step_index, + runtime: Some(RuntimeMeta { + compute_units: *compute_units, + diffs_count: *account_diffs_count, + logs_excerpt: logs_excerpt.clone(), + error: error.clone(), + trace: None, + }), + }, + CanvasPatch::OverlayUpdate { + scenario: active_scenario.to_string(), + ix_name: instruction.clone(), + calls_added: 1, + failed_added: 0, + cu: Some(*compute_units), + cpi_targets_added: cpi_targets.to_vec(), + }, + ], + SolanaCommandResult::CallFailed { + instruction, + compute_units, + .. + } => vec![CanvasPatch::OverlayUpdate { + scenario: active_scenario.to_string(), + ix_name: instruction.clone(), + calls_added: 0, + failed_added: 1, + cu: Some(*compute_units), + cpi_targets_added: Vec::new(), + }], + SolanaCommandResult::StepRemoved { .. } => vec![CanvasPatch::RemoveLastNode { + scenario: active_scenario.to_string(), + }], + SolanaCommandResult::Cleared => vec![CanvasPatch::ClearAll { + scenario: active_scenario.to_string(), + }], + SolanaCommandResult::ScenarioCreated { name } => vec![CanvasPatch::ScenarioEvent( + ScenarioEvent::Created { name: name.clone() }, + )], + SolanaCommandResult::ScenarioSwitched { from, to } => { + if from == to { + Vec::new() + } else { + vec![CanvasPatch::ScenarioEvent(ScenarioEvent::Switched { + from: from.clone(), + to: to.clone(), + })] + } + } + SolanaCommandResult::ScenarioDeleted { name } => vec![CanvasPatch::ScenarioEvent( + ScenarioEvent::Deleted { name: name.clone() }, + )], + SolanaCommandResult::ScenarioForked { from, to, at_step } => { + vec![CanvasPatch::ScenarioEvent(ScenarioEvent::Forked { + from: from.clone(), + to: to.clone(), + at_step: *at_step, + })] + } + SolanaCommandResult::SessionLoaded { .. } => vec![CanvasPatch::ScenarioEvent( + ScenarioEvent::Reloaded { + active: active_scenario.to_string(), + }, + )], + SolanaCommandResult::UserCreated { .. } | SolanaCommandResult::Airdropped { .. } => { + vec![CanvasPatch::SolanaUsersChanged { + scenario: active_scenario.to_string(), + }] + } + _ => Vec::new(), + } +} diff --git a/crates/ilold-solana-core/src/exploration/execute.rs b/crates/ilold-solana-core/src/exploration/execute.rs new file mode 100644 index 0000000..208d3d1 --- /dev/null +++ b/crates/ilold-solana-core/src/exploration/execute.rs @@ -0,0 +1,1098 @@ +use std::collections::HashMap; + +use anchor_lang_idl::types::IdlTypeDefTy; +use ilold_session_core::exploration::session::ExplorationSession; +use ilold_session_core::journal::types::{Finding, JournalEntry, ReviewStatus, Severity}; +use serde_json::Value; +use solana_address::Address; +use solana_keypair::Keypair; +use solana_signer::Signer; + +use crate::decode::borsh::decode_defined_fields; +use crate::execute::VmHost; +use crate::model::{AccountTypeDef, ProgramDef}; +use crate::view::describe_seed_view; + +use super::add_step::add_solana_step; +use super::commands::{ + AccountSummary, FindingSummary, InstructionEntry, PdaEntry, SolanaCommandResult, + StepDiffSummary, TimelineEntry, UserEntry, WhoEntry, WhoIxAccount, WhoQueryKind, +}; + +const DEFAULT_USER_LAMPORTS: u64 = 10_000_000_000; + +pub fn execute_funcs(program: &ProgramDef) -> SolanaCommandResult { + let view = program.compute_view(); + let items = view + .instructions + .iter() + .map(|ix| InstructionEntry { + name: ix.name.clone(), + args_count: ix.args.len(), + accounts_count: ix.accounts.len(), + has_pdas: ix.accounts.iter().any(|a| a.pda.is_some()), + signers: ix + .accounts + .iter() + .filter(|a| a.signer) + .map(|a| a.name.clone()) + .collect(), + }) + .collect(); + SolanaCommandResult::InstructionList { items } +} + +pub fn execute_info(program: &ProgramDef, ix_name: &str) -> SolanaCommandResult { + let view = program.compute_view(); + let ix = match view.instructions.iter().find(|i| i.name == ix_name) { + Some(i) => i.clone(), + None => { + return SolanaCommandResult::Error { + message: format!("instruction '{ix_name}' not found"), + }; + } + }; + let admin_gated = view + .admin_gated + .as_ref() + .map(|set| set.contains(ix_name)) + .unwrap_or(false); + SolanaCommandResult::IxInfo { ix, admin_gated } +} + +pub fn execute_coupling(program: &ProgramDef) -> SolanaCommandResult { + let view = program.compute_view(); + SolanaCommandResult::CouplingList { + pairs: view.state_coupling.unwrap_or_default(), + } +} + +pub fn execute_vars(program: &ProgramDef) -> SolanaCommandResult { + let view = program.compute_view(); + SolanaCommandResult::AccountTypes { + accounts: view.accounts, + } +} + +pub fn execute_coverage( + program: &ProgramDef, + session: &ExplorationSession, + scenario: &str, +) -> SolanaCommandResult { + let mut overlay = crate::overlay::RuntimeOverlay::from_session(session); + if overlay.program.is_empty() { + overlay.program = program.name.clone(); + } + overlay.scenario = scenario.to_string(); + SolanaCommandResult::Coverage { overlay } +} + +pub fn execute_users(users: &HashMap<String, Keypair>, vm: &VmHost) -> SolanaCommandResult { + let mut entries: Vec<UserEntry> = users + .iter() + .map(|(name, kp)| { + let pk = kp.pubkey(); + UserEntry { + name: name.clone(), + pubkey: pk.to_string(), + lamports: vm.balance(&pk), + } + }) + .collect(); + entries.sort_by(|a, b| a.name.cmp(&b.name)); + SolanaCommandResult::UserList { users: entries } +} + +pub fn execute_state( + session: &ExplorationSession, + program: &ProgramDef, + vm: &VmHost, +) -> SolanaCommandResult { + let mut seen: std::collections::BTreeMap<String, AccountSummary> = + std::collections::BTreeMap::new(); + + for (idx, step) in session.steps.iter().enumerate() { + let trace = match step.runtime_trace.as_ref() { + Some(t) => t, + None => continue, + }; + let diffs = trace.get("account_diffs").and_then(|v| v.as_array()); + let diffs = match diffs { + Some(d) => d, + None => continue, + }; + for diff in diffs { + let address = diff.get("address").and_then(|v| v.as_str()).unwrap_or(""); + if address.is_empty() { + continue; + } + let label = diff + .get("name") + .and_then(|v| v.as_str()) + .map(|n| format!("{n}#{idx}")) + .unwrap_or_else(|| format!("acc#{idx}")); + let pk: Address = match address.parse() { + Ok(p) => p, + Err(_) => continue, + }; + let acc = match vm.svm().get_account(&pk) { + Some(a) => a, + None => continue, + }; + let decoded = decode_account_bytes(&acc.data, &program.account_types, &program.types); + seen.insert( + address.to_string(), + AccountSummary { + label, + pubkey: address.to_string(), + owner: acc.owner.to_string(), + lamports: acc.lamports, + decoded, + }, + ); + } + } + + SolanaCommandResult::StateView { + accounts: seen.into_values().collect(), + } +} + +pub fn execute_session( + session: &ExplorationSession, + program: &ProgramDef, + active_scenario: &str, +) -> SolanaCommandResult { + SolanaCommandResult::SessionView { + program: program.name.clone(), + scenario: active_scenario.to_string(), + steps: session.steps.iter().map(|s| s.function.clone()).collect(), + findings_count: session.journal.findings.len(), + } +} + +pub fn execute_pda(program: &ProgramDef, instruction: &str) -> SolanaCommandResult { + let view = program.compute_view(); + let ix = match view.instructions.iter().find(|i| i.name == instruction) { + Some(i) => i, + None => { + return SolanaCommandResult::Error { + message: format!("instruction '{instruction}' not found"), + }; + } + }; + + let self_program_id = view.program_id.clone(); + let pdas: Vec<PdaEntry> = ix + .accounts + .iter() + .filter_map(|acc| { + let pda = acc.pda.as_ref()?; + let seeds = pda.seeds.iter().map(describe_seed_view).collect(); + let prog = pda.program.clone().unwrap_or_else(|| self_program_id.clone()); + Some(PdaEntry { + account_name: acc.name.clone(), + seeds, + program: prog, + }) + }) + .collect(); + + SolanaCommandResult::PdaList { + instruction: instruction.to_string(), + pdas, + } +} + +pub fn execute_inspect( + program: &ProgramDef, + vm: &VmHost, + pubkey: &str, +) -> SolanaCommandResult { + let pk: Address = match pubkey.parse() { + Ok(p) => p, + Err(_) => { + return SolanaCommandResult::Error { + message: format!("invalid pubkey '{pubkey}'"), + }; + } + }; + let acc = match vm.svm().get_account(&pk) { + Some(a) => a, + None => { + return SolanaCommandResult::Error { + message: format!("account '{pubkey}' not found in VM"), + }; + } + }; + let decoded = decode_account_bytes(&acc.data, &program.account_types, &program.types); + SolanaCommandResult::AccountInspected { + pubkey: pubkey.to_string(), + owner: acc.owner.to_string(), + lamports: acc.lamports, + data_len: acc.data.len(), + decoded, + } +} + +#[allow(clippy::too_many_arguments)] +pub fn execute_call( + program: &ProgramDef, + ix_name: &str, + args: Value, + accounts_input: HashMap<String, String>, + signer_names: Vec<String>, + users: &HashMap<String, Keypair>, + session: &mut ExplorationSession, + vm: &mut VmHost, + timestamp: &str, +) -> SolanaCommandResult { + let ix = match program.instructions.iter().find(|i| i.name == ix_name) { + Some(i) => i.clone(), + None => { + return SolanaCommandResult::Error { + message: format!("instruction '{ix_name}' not found"), + }; + } + }; + + let call_payload = serde_json::json!({ + "ix": ix_name, + "args": args.clone(), + "accounts": accounts_input.clone(), + "signers": signer_names.clone(), + }); + + let mut accounts: HashMap<String, Address> = HashMap::new(); + for (key, raw) in accounts_input { + if let Some(kp) = users.get(&raw) { + accounts.insert(key, kp.pubkey()); + continue; + } + match raw.parse::<Address>() { + Ok(addr) => { + accounts.insert(key, addr); + } + Err(_) => { + return SolanaCommandResult::Error { + message: format!( + "account '{key}': '{raw}' is neither a known user nor a valid pubkey" + ), + }; + } + } + } + + let mut extra_signers: Vec<&Keypair> = Vec::new(); + for name in &signer_names { + match users.get(name) { + Some(kp) => extra_signers.push(kp), + None => { + return SolanaCommandResult::Error { + message: format!("signer '{name}' not found in users registry"), + }; + } + } + } + + let payer_pk = vm.payer_pubkey(); + for spec in &ix.accounts { + if !spec.signer { + continue; + } + let resolved_pk = accounts + .get(&spec.path) + .or_else(|| accounts.get(&spec.name)) + .copied(); + let pk = match resolved_pk { + Some(p) => p, + None => continue, + }; + let in_signers = extra_signers.iter().any(|kp| kp.pubkey() == pk); + if pk != payer_pk && !in_signers { + return SolanaCommandResult::Error { + message: format!( + "account '{}' is marked signer but no matching keypair was provided", + spec.name + ), + }; + } + } + + let outcome = match add_solana_step( + session, + program, + &ix, + vm, + args, + accounts, + &extra_signers, + timestamp, + Some(call_payload), + ) { + Ok(o) => o, + Err(e) => { + return SolanaCommandResult::Error { + message: format!("{e:?}"), + } + } + }; + + let trace = &outcome.trace; + let logs_excerpt: Vec<String> = trace.logs.iter().take(10).cloned().collect(); + let compute_units = trace.compute_units; + + match outcome.step_index { + Some(idx) => SolanaCommandResult::StepAdded { + step_index: idx, + instruction: ix.name.clone(), + logs_excerpt, + account_diffs_count: trace.account_diffs.len(), + compute_units, + error: None, + }, + None => { + session.record_failed_call(&ix.name); + SolanaCommandResult::CallFailed { + instruction: ix.name.clone(), + logs_excerpt, + compute_units, + error: trace + .error + .clone() + .unwrap_or_else(|| "unknown VM error".to_string()), + } + } + } +} + +pub fn execute_back(session: &mut ExplorationSession) -> SolanaCommandResult { + if session.remove_last_step() { + SolanaCommandResult::StepRemoved { + remaining: session.steps.len(), + } + } else { + SolanaCommandResult::Error { + message: "no steps to undo".into(), + } + } +} + +pub fn execute_clear(session: &mut ExplorationSession) -> SolanaCommandResult { + session.clear(); + SolanaCommandResult::Cleared +} + +pub fn execute_users_new( + name: String, + lamports: u64, + users: &mut HashMap<String, Keypair>, + vm: &mut VmHost, +) -> SolanaCommandResult { + if users.contains_key(&name) { + return SolanaCommandResult::Error { + message: format!("user '{name}' already exists"), + }; + } + let kp = Keypair::new(); + let pk = kp.pubkey(); + let funded = if lamports == 0 { + DEFAULT_USER_LAMPORTS + } else { + lamports + }; + if let Err(e) = vm.airdrop(pk, funded) { + return SolanaCommandResult::Error { + message: format!("airdrop failed: {e:?}"), + }; + } + users.insert(name.clone(), kp); + SolanaCommandResult::UserCreated { + name, + pubkey: pk.to_string(), + lamports: funded, + } +} + +pub fn execute_airdrop( + user: &str, + lamports: u64, + users: &HashMap<String, Keypair>, + vm: &mut VmHost, +) -> SolanaCommandResult { + let kp = match users.get(user) { + Some(k) => k, + None => { + return SolanaCommandResult::Error { + message: format!("user '{user}' not found"), + }; + } + }; + let pk = kp.pubkey(); + if let Err(e) = vm.airdrop(pk, lamports) { + return SolanaCommandResult::Error { + message: format!("airdrop failed: {e:?}"), + }; + } + SolanaCommandResult::Airdropped { + name: user.to_string(), + pubkey: pk.to_string(), + total_lamports: vm.balance(&pk), + } +} + +pub fn execute_time_warp(delta_seconds: i64, vm: &mut VmHost) -> SolanaCommandResult { + let clock = vm.clock(); + let new_ts = clock.unix_timestamp.saturating_add(delta_seconds); + let slot_advance = delta_seconds.max(0) as u64; + let new_slot = clock.slot.saturating_add(slot_advance); + vm.warp_clock(new_slot, new_ts); + SolanaCommandResult::TimeWarped { + unix_timestamp: new_ts, + slot: new_slot, + } +} + +pub fn execute_finding( + session: &mut ExplorationSession, + severity: Severity, + title: String, + description: String, + recommendation: Option<String>, + timestamp: &str, +) -> SolanaCommandResult { + let affected_sequence = if session.steps.is_empty() { + None + } else { + Some( + session + .current_sequence() + .into_iter() + .map(|s| s.to_string()) + .collect(), + ) + }; + let affected_step_index = if session.steps.is_empty() { + None + } else { + Some(session.steps.len() - 1) + }; + 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(), + affected_step_index, + recommendation, + }; + session.journal.add_finding(finding, timestamp); + let id = session + .journal + .findings + .last() + .map(|f| f.id.clone()) + .unwrap_or_default(); + SolanaCommandResult::FindingAdded { id } +} + +pub fn execute_note( + session: &mut ExplorationSession, + text: &str, + timestamp: &str, +) -> SolanaCommandResult { + let anchor = session.current_sequence().join(" → "); + session.journal.record(JournalEntry::NoteAdded { + anchor, + content: text.into(), + timestamp: timestamp.into(), + }); + SolanaCommandResult::NoteAdded +} + +pub fn execute_status( + session: &mut ExplorationSession, + program: &ProgramDef, + ix_name: &str, + status: ReviewStatus, + timestamp: &str, +) -> SolanaCommandResult { + if !program.instructions.iter().any(|i| i.name == ix_name) { + return SolanaCommandResult::Error { + message: format!("instruction '{ix_name}' not found in program '{}'", program.name), + }; + } + session.journal.record(JournalEntry::StatusChanged { + function: ix_name.into(), + status, + timestamp: timestamp.into(), + }); + SolanaCommandResult::StatusUpdated +} + +fn decode_account_bytes( + data: &[u8], + account_types: &[AccountTypeDef], + types: &[anchor_lang_idl::types::IdlTypeDef], +) -> Option<serde_json::Value> { + if data.len() < 8 { + return None; + } + let (disc, rest) = data.split_at(8); + let acc_def = account_types.iter().find(|a| a.discriminator == disc)?; + let mut cursor = rest; + match &acc_def.layout.ty { + IdlTypeDefTy::Struct { fields } => { + decode_defined_fields(&mut cursor, fields.as_ref(), types).ok() + } + _ => None, + } +} + +pub fn execute_step( + session: &ExplorationSession, + index: usize, + program: &ProgramDef, +) -> SolanaCommandResult { + let step = match session.steps.get(index) { + Some(s) => s, + None => { + return SolanaCommandResult::Error { + message: format!( + "step {index} out of range (session has {} steps)", + session.steps.len() + ), + }; + } + }; + let runtime_trace = step.runtime_trace.clone(); + let diff_summary: Vec<StepDiffSummary> = runtime_trace + .as_ref() + .and_then(|v| v.get("account_diffs").and_then(|d| d.as_array())) + .map(|arr| { + arr.iter() + .map(|d| { + let before_bytes: Option<Vec<u8>> = d.get("before").and_then(|v| v.as_array()) + .map(|a| a.iter().filter_map(|b| b.as_u64().map(|n| n as u8)).collect()); + let after_bytes: Option<Vec<u8>> = d.get("after").and_then(|v| v.as_array()) + .map(|a| a.iter().filter_map(|b| b.as_u64().map(|n| n as u8)).collect()); + let decoded_before = before_bytes.as_ref().and_then(|b| + decode_account_bytes(b, &program.account_types, &program.types)); + let decoded_after = after_bytes.as_ref().and_then(|b| + decode_account_bytes(b, &program.account_types, &program.types)); + StepDiffSummary { + address: d.get("address").and_then(|v| v.as_str()).unwrap_or("").to_string(), + name: d.get("name").and_then(|v| v.as_str()).map(String::from), + lamports_delta: d + .get("lamports_delta") + .and_then(|v| v.as_i64()) + .map(|n| n as i128) + .unwrap_or(0), + data_changed: before_bytes != after_bytes, + decoded_before, + decoded_after, + } + }) + .collect() + }) + .unwrap_or_default(); + SolanaCommandResult::StepDetail { + step_index: index, + instruction: step.function.clone(), + runtime_trace, + diff_summary, + } +} + +pub fn execute_findings_list(session: &ExplorationSession) -> SolanaCommandResult { + let items: Vec<FindingSummary> = session + .journal + .findings + .iter() + .map(|f: &Finding| FindingSummary { + id: f.id.clone(), + severity: format!("{:?}", f.severity), + title: f.title.clone(), + description: f.description.clone(), + created_at: f.created_at.clone(), + }) + .collect(); + SolanaCommandResult::FindingsList { items } +} + +pub fn execute_export<'a, I>( + scenarios: I, + active: &str, + program: &ProgramDef, + metadata: Option<&ilold_session_core::journal::export::AuditMetadata>, +) -> SolanaCommandResult +where + I: IntoIterator<Item = (&'a str, &'a ExplorationSession)>, +{ + use ilold_session_core::journal::export::{ + export_markdown_multi, ProgramSection, + }; + let scenarios: Vec<(&str, &ExplorationSession)> = scenarios.into_iter().collect(); + let prog_section = ProgramSection { + name: program.name.clone(), + program_id: program.program_id.to_string(), + instructions: program.instructions.len(), + account_types: program.account_types.len(), + }; + + let journal_pairs: Vec<(&str, &ilold_session_core::journal::types::AuditJournal)> = + scenarios.iter().map(|(n, s)| (*n, &s.journal)).collect(); + let mut md = export_markdown_multi( + &journal_pairs, + Some(&prog_section), + metadata, + program.instructions.len(), + ); + + use std::fmt::Write; + writeln!(md, "## Scenarios\n").unwrap(); + writeln!(md, "**Active**: `{active}`\n").unwrap(); + for (scn_name, session) in &scenarios { + writeln!(md, "### `{scn_name}` — {} steps\n", session.steps.len()).unwrap(); + if session.steps.is_empty() { + writeln!(md, "_(no steps)_\n").unwrap(); + continue; + } + for (i, s) in session.steps.iter().enumerate() { + let cu = s.runtime_trace.as_ref() + .and_then(|v| v.get("compute_units")) + .and_then(|v| v.as_u64()) + .unwrap_or(0); + let err = s.runtime_trace.as_ref() + .and_then(|v| v.get("error")) + .and_then(|v| v.as_str()); + let mark = if err.is_some() { "FAIL" } else { "OK" }; + writeln!(md, "- **#{i}** `{}` — {} ({} CU)", s.function, mark, cu).unwrap(); + if let Some(e) = err { + writeln!(md, " - error: `{e}`").unwrap(); + } + } + writeln!(md).unwrap(); + } + + let bytes = md.len(); + SolanaCommandResult::Exported { markdown: md, bytes } +} + +pub fn execute_who( + program: &ProgramDef, + query: &str, +) -> SolanaCommandResult { + let view = program.compute_view(); + let raw = query.trim(); + + let resolved_account_type = view + .accounts + .iter() + .find(|a| a.name == raw) + .map(|a| a.name.clone()) + .or_else(|| { + let pascal = crate::view::snake_to_pascal(raw); + view.accounts + .iter() + .find(|a| a.name == pascal) + .map(|a| a.name.clone()) + }); + + if let Some(name) = resolved_account_type { + return who_for_account_type(&view, &name); + } + + if let Some(ix) = view.instructions.iter().find(|i| i.name == raw) { + return who_for_instruction(&view, ix); + } + + if let Some((owner_name, field)) = find_field_owner(&view, raw) { + return who_for_field(&view, raw, &owner_name, &field); + } + + SolanaCommandResult::WhoList { + account_type: raw.to_string(), + instructions: Vec::new(), + query_kind: WhoQueryKind::NotFound, + field_owner: None, + field_type: None, + owner_fields: None, + ix_args: None, + ix_discriminator_hex: None, + ix_accounts: None, + } +} + +fn who_for_account_type( + view: &crate::view::ProgramView, + type_name: &str, +) -> SolanaCommandResult { + let mut hits: Vec<WhoEntry> = Vec::new(); + for ix in &view.instructions { + for acc in &ix.accounts { + let resolved = resolve_account_type(view, &acc.name); + if resolved.as_deref() == Some(type_name) { + hits.push(WhoEntry { + instruction: ix.name.clone(), + account_field: acc.name.clone(), + writable: acc.writable, + signer: acc.signer, + account_type: resolved, + ix_args: Some(ix.args.clone()), + }); + } + } + } + hits.sort_by(|a, b| a.instruction.cmp(&b.instruction)); + let owner_fields = view + .accounts + .iter() + .find(|a| a.name == type_name) + .map(|a| a.fields.clone()); + SolanaCommandResult::WhoList { + account_type: type_name.to_string(), + instructions: hits, + query_kind: WhoQueryKind::AccountType, + field_owner: None, + field_type: None, + owner_fields, + ix_args: None, + ix_discriminator_hex: None, + ix_accounts: None, + } +} + +fn who_for_instruction( + view: &crate::view::ProgramView, + ix: &crate::view::IxView, +) -> SolanaCommandResult { + let accounts: Vec<WhoIxAccount> = ix + .accounts + .iter() + .map(|acc| { + let resolved = resolve_account_type(view, &acc.name); + let fields = resolved.as_ref().and_then(|t| { + view.accounts + .iter() + .find(|a| &a.name == t) + .map(|a| a.fields.clone()) + }); + WhoIxAccount { + name: acc.name.clone(), + account_type: resolved, + writable: acc.writable, + signer: acc.signer, + fields, + } + }) + .collect(); + SolanaCommandResult::WhoList { + account_type: ix.name.clone(), + instructions: Vec::new(), + query_kind: WhoQueryKind::Instruction, + field_owner: None, + field_type: None, + owner_fields: None, + ix_args: Some(ix.args.clone()), + ix_discriminator_hex: Some(ix.discriminator_hex.clone()), + ix_accounts: Some(accounts), + } +} + +fn who_for_field( + view: &crate::view::ProgramView, + field_name: &str, + owner: &str, + field: &crate::view::FieldView, +) -> SolanaCommandResult { + let mut hits: Vec<WhoEntry> = Vec::new(); + for ix in &view.instructions { + for acc in &ix.accounts { + let resolved = resolve_account_type(view, &acc.name); + if resolved.as_deref() == Some(owner) && acc.writable { + hits.push(WhoEntry { + instruction: ix.name.clone(), + account_field: acc.name.clone(), + writable: acc.writable, + signer: acc.signer, + account_type: resolved, + ix_args: Some(ix.args.clone()), + }); + break; + } + } + } + hits.sort_by(|a, b| a.instruction.cmp(&b.instruction)); + let owner_fields = view + .accounts + .iter() + .find(|a| a.name == owner) + .map(|a| a.fields.clone()); + SolanaCommandResult::WhoList { + account_type: field_name.to_string(), + instructions: hits, + query_kind: WhoQueryKind::Field, + field_owner: Some(owner.to_string()), + field_type: Some(field.ty.clone()), + owner_fields, + ix_args: None, + ix_discriminator_hex: None, + ix_accounts: None, + } +} + +fn find_field_owner( + view: &crate::view::ProgramView, + field_name: &str, +) -> Option<(String, crate::view::FieldView)> { + for acc in &view.accounts { + if let Some(f) = acc.fields.iter().find(|f| f.name == field_name) { + return Some((acc.name.clone(), f.clone())); + } + } + None +} + +fn resolve_account_type( + view: &crate::view::ProgramView, + account_name: &str, +) -> Option<String> { + let pascal = crate::view::snake_to_pascal(account_name); + if view.accounts.iter().any(|a| a.name == pascal) { + return Some(pascal); + } + if view.accounts.iter().any(|a| a.name == account_name) { + return Some(account_name.to_string()); + } + None +} + +pub fn execute_timeline( + session: &ExplorationSession, + program: &ProgramDef, + raw_target: &str, + active_scenario: &str, + users: &HashMap<String, Keypair>, +) -> SolanaCommandResult { + let resolved_label = users.get(raw_target).map(|_| raw_target.to_string()); + let pubkey = match users.get(raw_target) { + Some(kp) => kp.pubkey().to_string(), + None => raw_target.to_string(), + }; + let pubkey = pubkey.as_str(); + let mut entries: Vec<TimelineEntry> = Vec::new(); + let mut label: Option<String> = resolved_label; + for (idx, step) in session.steps.iter().enumerate() { + let trace = match &step.runtime_trace { + Some(t) => t, + None => continue, + }; + let diffs = match trace.get("account_diffs").and_then(|v| v.as_array()) { + Some(a) => a, + None => continue, + }; + for d in diffs { + let addr = d.get("address").and_then(|v| v.as_str()).unwrap_or(""); + if addr != pubkey { + continue; + } + if label.is_none() { + label = d.get("name").and_then(|v| v.as_str()).map(String::from); + } + let lamports_delta = d + .get("lamports_delta") + .and_then(|v| v.as_i64()) + .map(|n| n as i128) + .unwrap_or(0); + let data_changed = d + .get("before") + .and_then(|v| v.as_array()) + .zip(d.get("after").and_then(|v| v.as_array())) + .map(|(b, a)| b != a) + .unwrap_or(false); + let decode = |bytes_v: Option<&Value>| -> Option<Value> { + let arr = bytes_v.and_then(|v| v.as_array())?; + let bytes: Vec<u8> = arr.iter().filter_map(|b| b.as_u64().map(|n| n as u8)).collect(); + decode_account_bytes(&bytes, &program.account_types, &program.types) + }; + entries.push(TimelineEntry { + step_index: idx, + instruction: step.function.clone(), + scenario: active_scenario.to_string(), + lamports_delta, + data_changed, + before_decoded: decode(d.get("before")), + after_decoded: decode(d.get("after")), + }); + } + } + SolanaCommandResult::TimelineView { + pubkey: pubkey.to_string(), + label, + entries, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::idl::parse_idl; + + const STAKING_JSON: &str = include_str!( + "../../../../tests/fixtures/solana/staking/idls/staking.json" + ); + const LEVER_JSON: &str = include_str!("../../tests/fixtures/lever.json"); + + fn staking() -> ProgramDef { + ProgramDef::from_idl(parse_idl(STAKING_JSON).expect("parse staking")) + .expect("build staking ProgramDef") + } + + fn lever() -> ProgramDef { + ProgramDef::from_idl(parse_idl(LEVER_JSON).expect("parse lever")) + .expect("build lever ProgramDef") + } + + fn unwrap_who( + result: SolanaCommandResult, + ) -> ( + String, + Vec<WhoEntry>, + WhoQueryKind, + Option<String>, + Option<Vec<crate::view::FieldView>>, + Option<Vec<crate::exploration::commands::WhoIxAccount>>, + Option<Vec<crate::view::ArgView>>, + ) { + match result { + SolanaCommandResult::WhoList { + account_type, + instructions, + query_kind, + field_owner, + owner_fields, + ix_accounts, + ix_args, + .. + } => ( + account_type, + instructions, + query_kind, + field_owner, + owner_fields, + ix_accounts, + ix_args, + ), + other => panic!("expected WhoList, got {other:?}"), + } + } + + #[test] + fn who_resolves_account_type_pool() { + let (target, hits, kind, owner, fields, accounts, _) = + unwrap_who(execute_who(&staking(), "Pool")); + assert_eq!(target, "Pool"); + assert_eq!(kind, WhoQueryKind::AccountType); + assert!(owner.is_none()); + assert_eq!(hits.len(), 5); + let names: Vec<_> = hits.iter().map(|w| w.instruction.as_str()).collect(); + assert_eq!( + names, + vec!["add_rewards", "claim_rewards", "initialize_pool", "stake", "unstake"] + ); + assert!(hits.iter().all(|w| w.account_type.as_deref() == Some("Pool"))); + assert!(hits.iter().all(|w| w.ix_args.is_some())); + let pool_fields = fields.expect("Pool fields populated"); + assert!(pool_fields.iter().any(|f| f.name == "total_staked")); + assert!(accounts.is_none()); + } + + #[test] + fn who_resolves_lowercase_account_type() { + let (target, hits, kind, ..) = unwrap_who(execute_who(&staking(), "pool")); + assert_eq!(target, "Pool"); + assert_eq!(kind, WhoQueryKind::AccountType); + assert_eq!(hits.len(), 5); + } + + #[test] + fn who_resolves_instruction_claim_rewards() { + let (target, hits, kind, owner, fields, accounts, args) = + unwrap_who(execute_who(&staking(), "claim_rewards")); + assert_eq!(target, "claim_rewards"); + assert_eq!(kind, WhoQueryKind::Instruction); + assert!(hits.is_empty()); + assert!(owner.is_none()); + assert!(fields.is_none()); + let accs = accounts.expect("ix_accounts populated"); + assert!(accs.iter().any(|a| a.name == "pool" && a.account_type.as_deref() == Some("Pool"))); + assert!(accs + .iter() + .any(|a| a.name == "user_stake" && a.account_type.as_deref() == Some("UserStake"))); + assert!(accs + .iter() + .any(|a| a.name == "user" && a.account_type.is_none() && a.signer)); + assert!(args.is_some()); + } + + #[test] + fn who_resolves_field_total_staked() { + let (target, hits, kind, owner, fields, accounts, _) = + unwrap_who(execute_who(&staking(), "total_staked")); + assert_eq!(target, "total_staked"); + assert_eq!(kind, WhoQueryKind::Field); + assert_eq!(owner.as_deref(), Some("Pool")); + assert!(accounts.is_none()); + let pool_fields = fields.expect("owner_fields present"); + assert!(pool_fields.iter().any(|f| f.name == "total_staked")); + let names: Vec<_> = hits.iter().map(|w| w.instruction.as_str()).collect(); + assert_eq!( + names, + vec!["add_rewards", "claim_rewards", "initialize_pool", "stake", "unstake"] + ); + } + + #[test] + fn who_returns_not_found_for_unknown_query() { + let (target, hits, kind, owner, ..) = + unwrap_who(execute_who(&staking(), "nonexistent")); + assert_eq!(target, "nonexistent"); + assert_eq!(kind, WhoQueryKind::NotFound); + assert!(hits.is_empty()); + assert!(owner.is_none()); + } + + #[test] + fn who_field_returns_no_writers_when_account_name_does_not_map() { + let (target, hits, kind, owner, fields, ..) = + unwrap_who(execute_who(&lever(), "is_on")); + assert_eq!(target, "is_on"); + assert_eq!(kind, WhoQueryKind::Field); + assert_eq!(owner.as_deref(), Some("PowerStatus")); + assert!(hits.is_empty(), "no field-name-to-type bridge available"); + assert!(fields.is_some()); + } + + #[test] + fn who_instruction_handles_system_program_account_kind() { + let (_, _, kind, _, _, accounts, _) = + unwrap_who(execute_who(&lever(), "initialize")); + assert_eq!(kind, WhoQueryKind::Instruction); + let accs = accounts.expect("ix_accounts populated"); + let sys = accs + .iter() + .find(|a| a.name == "system_program") + .expect("system_program present"); + assert!(sys.account_type.is_none()); + assert!(!sys.signer && !sys.writable); + assert!(sys.fields.is_none()); + } +} diff --git a/crates/ilold-solana-core/src/exploration/mod.rs b/crates/ilold-solana-core/src/exploration/mod.rs new file mode 100644 index 0000000..13434df --- /dev/null +++ b/crates/ilold-solana-core/src/exploration/mod.rs @@ -0,0 +1,16 @@ +pub mod add_step; +pub mod commands; +pub mod execute; + +pub use add_step::add_solana_step; +pub use commands::{ + canvas_patches_from_solana, AccountSummary, InstructionEntry, PdaEntry, SolanaCommand, + SolanaCommandResult, UserEntry, WhoEntry, WhoIxAccount, WhoQueryKind, +}; +pub use execute::{ + execute_airdrop, execute_back, execute_call, execute_clear, execute_coupling, + execute_coverage, execute_export, execute_finding, execute_findings_list, 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, +}; diff --git a/crates/ilold-solana-core/src/idl/mod.rs b/crates/ilold-solana-core/src/idl/mod.rs new file mode 100644 index 0000000..edeb16e --- /dev/null +++ b/crates/ilold-solana-core/src/idl/mod.rs @@ -0,0 +1,5 @@ +pub mod parse; +pub mod types; + +pub use parse::{parse_idl, parse_idl_dir}; +pub use types::*; diff --git a/crates/ilold-solana-core/src/idl/parse.rs b/crates/ilold-solana-core/src/idl/parse.rs new file mode 100644 index 0000000..67e1bf7 --- /dev/null +++ b/crates/ilold-solana-core/src/idl/parse.rs @@ -0,0 +1,37 @@ +use std::path::{Path, PathBuf}; + +use anchor_lang_idl::convert::convert_idl; +use anchor_lang_idl::types::Idl; + +use crate::error::SolanaError; + +pub fn parse_idl(json: &str) -> Result<Idl, SolanaError> { + convert_idl(json.as_bytes()).map_err(|e| SolanaError::IdlParseFailed { + message: e.to_string(), + }) +} + +pub fn parse_idl_dir(dir: &Path) -> Result<Vec<(PathBuf, Idl)>, SolanaError> { + let mut idls = Vec::new(); + let entries = std::fs::read_dir(dir).map_err(|e| SolanaError::IdlReadFailed { + path: dir.to_path_buf(), + source: e, + })?; + for entry in entries { + let entry = entry.map_err(|e| SolanaError::IdlReadFailed { + path: dir.to_path_buf(), + source: e, + })?; + let path = entry.path(); + if path.extension().is_some_and(|ext| ext == "json") { + let json = std::fs::read_to_string(&path).map_err(|e| SolanaError::IdlReadFailed { + path: path.clone(), + source: e, + })?; + let idl = parse_idl(&json)?; + idls.push((path, idl)); + } + } + idls.sort_by(|a, b| a.0.cmp(&b.0)); + Ok(idls) +} diff --git a/crates/ilold-solana-core/src/idl/types.rs b/crates/ilold-solana-core/src/idl/types.rs new file mode 100644 index 0000000..d512170 --- /dev/null +++ b/crates/ilold-solana-core/src/idl/types.rs @@ -0,0 +1 @@ +pub use anchor_lang_idl::types::*; diff --git a/crates/ilold-solana-core/src/ingest.rs b/crates/ilold-solana-core/src/ingest.rs new file mode 100644 index 0000000..a46bf98 --- /dev/null +++ b/crates/ilold-solana-core/src/ingest.rs @@ -0,0 +1,147 @@ +use std::path::{Path, PathBuf}; + +use crate::error::SolanaError; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProjectKind { + Solana, + Solidity, +} + +#[derive(Debug, Clone)] +pub struct DetectedProject { + pub kind: ProjectKind, + pub root: PathBuf, + pub idl_paths: Vec<PathBuf>, + pub so_paths: Vec<PathBuf>, +} + +pub fn detect(path: &Path) -> Result<DetectedProject, SolanaError> { + let anchor_root = find_anchor_root(path); + let solidity_marker = has_solidity_marker(path); + + match (anchor_root, solidity_marker) { + (Some(_), true) => Err(SolanaError::MixedProject { + path: path.to_path_buf(), + }), + (Some(root), false) => Ok(DetectedProject { + kind: ProjectKind::Solana, + idl_paths: find_idls(&root), + so_paths: find_so(&root), + root, + }), + (None, true) => Ok(DetectedProject { + kind: ProjectKind::Solidity, + root: path.to_path_buf(), + idl_paths: vec![], + so_paths: vec![], + }), + (None, false) => Err(SolanaError::UnknownProjectType { + path: path.to_path_buf(), + }), + } +} + +fn find_anchor_root(path: &Path) -> Option<PathBuf> { + let mut current = if path.is_file() { + path.parent()?.to_path_buf() + } else { + path.to_path_buf() + }; + loop { + if current.join("Anchor.toml").is_file() { + return Some(current); + } + match current.parent() { + Some(parent) => current = parent.to_path_buf(), + None => return None, + } + } +} + +fn has_solidity_marker(path: &Path) -> bool { + let dir = if path.is_file() { + match path.parent() { + Some(p) => p, + None => return false, + } + } else { + path + }; + if dir.join("foundry.toml").is_file() || dir.join("hardhat.config.ts").is_file() + || dir.join("hardhat.config.js").is_file() + { + return true; + } + has_sol_anywhere(dir, 6) +} + +fn has_sol_anywhere(dir: &Path, depth_remaining: usize) -> bool { + if depth_remaining == 0 { + return false; + } + let entries = match std::fs::read_dir(dir) { + Ok(e) => e, + Err(_) => return false, + }; + for entry in entries.flatten() { + let p = entry.path(); + if p.is_file() && p.extension().is_some_and(|e| e == "sol") { + return true; + } + if p.is_dir() { + let name = p.file_name().and_then(|n| n.to_str()).unwrap_or(""); + if name.starts_with('.') || matches!(name, "node_modules" | "target" | "out" | "cache" | "lib") { + continue; + } + if has_sol_anywhere(&p, depth_remaining - 1) { + return true; + } + } + } + false +} + +pub fn find_idls(anchor_root: &Path) -> Vec<PathBuf> { + let preferred = anchor_root.join("target").join("idl"); + let fallback = anchor_root.join("idls"); + let mut found = collect_jsons(&preferred); + if found.is_empty() { + found = collect_jsons(&fallback); + } + found.sort(); + found +} + +pub fn find_so(anchor_root: &Path) -> Vec<PathBuf> { + let preferred = anchor_root.join("target").join("deploy"); + let fallback = anchor_root.join("bin"); + let mut found = collect_so(&preferred); + if found.is_empty() { + found = collect_so(&fallback); + } + found.sort(); + found +} + +fn collect_so(dir: &Path) -> Vec<PathBuf> { + match std::fs::read_dir(dir) { + Ok(entries) => entries + .flatten() + .map(|e| e.path()) + .filter(|p| p.extension().is_some_and(|e| e == "so")) + .collect(), + Err(_) => Vec::new(), + } +} + +fn collect_jsons(dir: &Path) -> Vec<PathBuf> { + match std::fs::read_dir(dir) { + Ok(entries) => entries + .flatten() + .map(|e| e.path()) + .filter(|p| p.extension().is_some_and(|e| e == "json")) + .collect(), + Err(_) => Vec::new(), + } +} diff --git a/crates/ilold-solana-core/src/lib.rs b/crates/ilold-solana-core/src/lib.rs new file mode 100644 index 0000000..75ce073 --- /dev/null +++ b/crates/ilold-solana-core/src/lib.rs @@ -0,0 +1,13 @@ +pub mod decode; +pub mod encode; +pub mod error; +pub mod execute; +pub mod exploration; +pub mod idl; +pub mod ingest; +pub mod model; +pub mod overlay; +pub mod view; + +pub use overlay::{CpiEdge, CuStats, RuntimeOverlay}; +pub use view::ProgramView; diff --git a/crates/ilold-solana-core/src/model/account.rs b/crates/ilold-solana-core/src/model/account.rs new file mode 100644 index 0000000..ddc6f4e --- /dev/null +++ b/crates/ilold-solana-core/src/model/account.rs @@ -0,0 +1,10 @@ +use anchor_lang_idl::types::IdlTypeDef; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AccountTypeDef { + pub name: String, + #[serde(with = "super::instruction::discriminator_serde")] + pub discriminator: [u8; 8], + pub layout: IdlTypeDef, +} diff --git a/crates/ilold-solana-core/src/model/instruction.rs b/crates/ilold-solana-core/src/model/instruction.rs new file mode 100644 index 0000000..c6e03c5 --- /dev/null +++ b/crates/ilold-solana-core/src/model/instruction.rs @@ -0,0 +1,61 @@ +use anchor_lang_idl::types::{IdlField, IdlType}; +use serde::{Deserialize, Serialize}; +use solana_address::Address; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InstructionDef { + pub name: String, + #[serde(with = "discriminator_serde")] + pub discriminator: [u8; 8], + pub args: Vec<IdlField>, + pub accounts: Vec<AccountSpec>, + #[serde(default)] + pub returns: Option<IdlType>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AccountSpec { + pub path: String, + pub name: String, + pub writable: bool, + pub signer: bool, + pub optional: bool, + #[serde(default)] + pub address: Option<Address>, + #[serde(default)] + pub pda: Option<PdaSpec>, + #[serde(default)] + pub relations: Vec<String>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PdaSpec { + pub seeds: Vec<SeedSpec>, + #[serde(default)] + pub program: Option<SeedSpec>, + #[serde(default)] + pub bump_arg: Option<String>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "lowercase")] +pub enum SeedSpec { + Const { value: Vec<u8> }, + Arg { path: String, ty: IdlType }, + Account { path: String }, +} + +pub(crate) mod discriminator_serde { + use serde::{Deserialize, Deserializer, Serialize, Serializer}; + + pub fn serialize<S: Serializer>(bytes: &[u8; 8], s: S) -> Result<S::Ok, S::Error> { + bytes.as_slice().serialize(s) + } + + pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<[u8; 8], D::Error> { + let v = Vec::<u8>::deserialize(d)?; + v.try_into().map_err(|v: Vec<u8>| { + serde::de::Error::custom(format!("expected 8-byte discriminator, got {}", v.len())) + }) + } +} diff --git a/crates/ilold-solana-core/src/model/mod.rs b/crates/ilold-solana-core/src/model/mod.rs new file mode 100644 index 0000000..129e2e2 --- /dev/null +++ b/crates/ilold-solana-core/src/model/mod.rs @@ -0,0 +1,9 @@ +pub mod account; +pub mod instruction; +pub mod program; +pub mod project; + +pub use account::AccountTypeDef; +pub use instruction::{AccountSpec, InstructionDef, PdaSpec, SeedSpec}; +pub use program::ProgramDef; +pub use project::SolanaProject; diff --git a/crates/ilold-solana-core/src/model/program.rs b/crates/ilold-solana-core/src/model/program.rs new file mode 100644 index 0000000..92a05a9 --- /dev/null +++ b/crates/ilold-solana-core/src/model/program.rs @@ -0,0 +1,219 @@ +use anchor_lang_idl::types::{ + Idl, IdlField, IdlInstruction, IdlInstructionAccount, IdlInstructionAccountItem, IdlPda, + IdlSeed, IdlType, IdlTypeDef, +}; +use serde::{Deserialize, Serialize}; +use solana_address::Address; + +use crate::error::SolanaError; + +use super::{ + AccountSpec, AccountTypeDef, InstructionDef, PdaSpec, SeedSpec, +}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProgramDef { + pub name: String, + pub program_id: Address, + pub instructions: Vec<InstructionDef>, + pub account_types: Vec<AccountTypeDef>, + pub types: Vec<IdlTypeDef>, +} + +impl ProgramDef { + pub fn from_idl(idl: Idl) -> Result<Self, SolanaError> { + let program_id = idl + .address + .parse::<Address>() + .map_err(|_| SolanaError::InvalidProgramId(idl.address.clone()))?; + + let instructions = idl + .instructions + .iter() + .map(build_instruction) + .collect::<Result<Vec<_>, _>>()?; + + let account_types = idl + .accounts + .iter() + .map(|a| build_account_type(a, &idl.types)) + .collect::<Result<Vec<_>, _>>()?; + + Ok(Self { + name: idl.metadata.name.clone(), + program_id, + instructions, + account_types, + types: idl.types, + }) + } +} + +fn build_instruction(ix: &IdlInstruction) -> Result<InstructionDef, SolanaError> { + let discriminator: [u8; 8] = ix.discriminator.as_slice().try_into().map_err(|_| { + SolanaError::InvalidDiscriminatorLength { + name: ix.name.clone(), + len: ix.discriminator.len(), + } + })?; + + let mut accounts = Vec::new(); + for item in &ix.accounts { + flatten_account_item(item, "", &ix.args, &mut accounts)?; + } + + let bump_arg = detect_bump_arg(&ix.args); + if let Some(arg) = bump_arg.as_deref() { + for spec in accounts.iter_mut() { + if let Some(pda) = spec.pda.as_mut() { + if pda.bump_arg.is_none() { + pda.bump_arg = Some(arg.to_string()); + } + } + } + } + + Ok(InstructionDef { + name: ix.name.clone(), + discriminator, + args: ix.args.clone(), + accounts, + returns: ix.returns.clone(), + }) +} + +fn flatten_account_item( + item: &IdlInstructionAccountItem, + prefix: &str, + ix_args: &[IdlField], + out: &mut Vec<AccountSpec>, +) -> Result<(), SolanaError> { + match item { + IdlInstructionAccountItem::Single(single) => { + out.push(build_account_spec(single, prefix, ix_args)?); + Ok(()) + } + IdlInstructionAccountItem::Composite(group) => { + let new_prefix = join_path(prefix, &group.name); + for sub in &group.accounts { + flatten_account_item(sub, &new_prefix, ix_args, out)?; + } + Ok(()) + } + } +} + +fn build_account_spec( + src: &IdlInstructionAccount, + prefix: &str, + ix_args: &[IdlField], +) -> Result<AccountSpec, SolanaError> { + let address = match &src.address { + Some(s) => Some( + s.parse::<Address>() + .map_err(|_| SolanaError::InvalidProgramId(s.clone()))?, + ), + None => None, + }; + + let pda = match &src.pda { + Some(spec) => Some(map_pda(spec, ix_args)?), + None => None, + }; + + Ok(AccountSpec { + path: join_path(prefix, &src.name), + name: src.name.clone(), + writable: src.writable, + signer: src.signer, + optional: src.optional, + address, + pda, + relations: src.relations.clone(), + }) +} + +fn map_pda(spec: &IdlPda, ix_args: &[IdlField]) -> Result<PdaSpec, SolanaError> { + let seeds = spec + .seeds + .iter() + .map(|s| map_seed(s, ix_args)) + .collect::<Result<Vec<_>, _>>()?; + let program = match &spec.program { + Some(s) => Some(map_seed(s, ix_args)?), + None => None, + }; + Ok(PdaSpec { + seeds, + program, + bump_arg: None, + }) +} + +fn map_seed(seed: &IdlSeed, ix_args: &[IdlField]) -> Result<SeedSpec, SolanaError> { + Ok(match seed { + IdlSeed::Const(c) => SeedSpec::Const { value: c.value.clone() }, + IdlSeed::Arg(a) => { + let ty = ix_args + .iter() + .find(|f| f.name == a.path) + .map(|f| f.ty.clone()) + .ok_or_else(|| SolanaError::SeedArgUnresolved { + path: a.path.clone(), + })?; + SeedSpec::Arg { + path: a.path.clone(), + ty, + } + } + IdlSeed::Account(a) => SeedSpec::Account { path: a.path.clone() }, + }) +} + +fn detect_bump_arg(args: &[IdlField]) -> Option<String> { + args.iter() + .find(|f| (f.name == "bump" || f.name.ends_with("_bump")) && matches!(f.ty, IdlType::U8)) + .map(|f| f.name.clone()) +} + +fn build_account_type( + src: &anchor_lang_idl::types::IdlAccount, + types: &[IdlTypeDef], +) -> Result<AccountTypeDef, SolanaError> { + let discriminator: [u8; 8] = src.discriminator.as_slice().try_into().map_err(|_| { + SolanaError::InvalidDiscriminatorLength { + name: src.name.clone(), + len: src.discriminator.len(), + } + })?; + let layout = types + .iter() + .find(|t| t.name == src.name) + .cloned() + .unwrap_or_else(|| placeholder_typedef(&src.name)); + Ok(AccountTypeDef { + name: src.name.clone(), + discriminator, + layout, + }) +} + +fn placeholder_typedef(name: &str) -> IdlTypeDef { + use anchor_lang_idl::types::{IdlSerialization, IdlTypeDefTy}; + IdlTypeDef { + name: name.to_string(), + docs: vec![], + serialization: IdlSerialization::Borsh, + repr: None, + generics: vec![], + ty: IdlTypeDefTy::Struct { fields: None }, + } +} + +fn join_path(prefix: &str, name: &str) -> String { + if prefix.is_empty() { + name.to_string() + } else { + format!("{prefix}.{name}") + } +} diff --git a/crates/ilold-solana-core/src/model/project.rs b/crates/ilold-solana-core/src/model/project.rs new file mode 100644 index 0000000..61a2de2 --- /dev/null +++ b/crates/ilold-solana-core/src/model/project.rs @@ -0,0 +1,34 @@ +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; + +use super::ProgramDef; + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct SolanaProject { + pub programs: Vec<ProgramDef>, + #[serde(skip)] + pub program_index: HashMap<String, usize>, +} + +impl SolanaProject { + pub fn new(programs: Vec<ProgramDef>) -> Self { + let mut me = Self { + programs, + program_index: HashMap::new(), + }; + me.rebuild_index(); + me + } + + pub fn rebuild_index(&mut self) { + self.program_index.clear(); + for (idx, program) in self.programs.iter().enumerate() { + self.program_index.insert(program.name.clone(), idx); + } + } + + pub fn find_program(&self, name: &str) -> Option<&ProgramDef> { + self.program_index.get(name).and_then(|i| self.programs.get(*i)) + } +} diff --git a/crates/ilold-solana-core/src/overlay.rs b/crates/ilold-solana-core/src/overlay.rs new file mode 100644 index 0000000..e7dc78d --- /dev/null +++ b/crates/ilold-solana-core/src/overlay.rs @@ -0,0 +1,274 @@ +use std::collections::BTreeMap; + +use ilold_session_core::exploration::session::ExplorationSession; +use ilold_session_core::runtime_trace::RuntimeTrace; +use serde::{Deserialize, Serialize}; + +pub fn extract_cpi_programs(trace: &RuntimeTrace) -> Vec<String> { + let mut seen = std::collections::HashSet::new(); + let mut out = Vec::new(); + for ii in &trace.inner_instructions { + if seen.insert(ii.program.clone()) { + out.push(ii.program.clone()); + } + } + out +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] +pub struct RuntimeOverlay { + pub program: String, + pub scenario: String, + pub calls_per_ix: BTreeMap<String, u32>, + pub failed_per_ix: BTreeMap<String, u32>, + pub cu_stats_per_ix: BTreeMap<String, CuStats>, + pub cpi_edges: Vec<CpiEdge>, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct CuStats { + pub min: u64, + pub max: u64, + pub avg: u64, + pub samples: u32, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct CpiEdge { + pub from_ix: String, + pub to_program: String, + pub depth: u32, + pub samples: u32, +} + +impl RuntimeOverlay { + pub fn from_session(session: &ExplorationSession) -> Self { + let mut overlay = RuntimeOverlay { + program: session.contract.clone(), + scenario: String::new(), + calls_per_ix: BTreeMap::new(), + failed_per_ix: BTreeMap::new(), + cu_stats_per_ix: BTreeMap::new(), + cpi_edges: Vec::new(), + }; + + let mut cu_samples: BTreeMap<String, Vec<u64>> = BTreeMap::new(); + let mut cpi_counts: BTreeMap<(String, String, u32), u32> = BTreeMap::new(); + + for step in &session.steps { + *overlay.calls_per_ix.entry(step.function.clone()).or_insert(0) += 1; + + let trace: Option<RuntimeTrace> = step + .runtime_trace + .as_ref() + .and_then(|v| serde_json::from_value(v.clone()).ok()); + + if let Some(t) = trace.as_ref() { + cu_samples + .entry(step.function.clone()) + .or_default() + .push(t.compute_units); + + for inner in &t.inner_instructions { + let key = ( + step.function.clone(), + inner.program.clone(), + inner.depth, + ); + *cpi_counts.entry(key).or_insert(0) += 1; + } + } + } + + for (ix, count) in &session.failed_calls_per_ix { + overlay.failed_per_ix.insert(ix.clone(), *count); + } + + for (ix, samples) in cu_samples { + let count = samples.len() as u32; + if count == 0 { + continue; + } + let min = samples.iter().copied().min().unwrap_or(0); + let max = samples.iter().copied().max().unwrap_or(0); + let sum: u128 = samples.iter().map(|v| *v as u128).sum(); + let avg = (sum / count as u128) as u64; + overlay + .cu_stats_per_ix + .insert(ix, CuStats { min, max, avg, samples: count }); + } + + let mut edges: Vec<CpiEdge> = cpi_counts + .into_iter() + .map(|((from_ix, to_program, depth), samples)| CpiEdge { + from_ix, + to_program, + depth, + samples, + }) + .collect(); + edges.sort_by(|a, b| { + a.from_ix + .cmp(&b.from_ix) + .then_with(|| a.to_program.cmp(&b.to_program)) + .then_with(|| a.depth.cmp(&b.depth)) + }); + overlay.cpi_edges = edges; + + overlay + } +} + +#[cfg(test)] +mod tests { + use super::*; + use ilold_session_core::exploration::session::{ + ExplorationSession, ExplorationStep, TraceConfig, + }; + use ilold_session_core::runtime_trace::{InnerInstruction, RuntimeTrace}; + + fn empty_session() -> ExplorationSession { + ExplorationSession::new("staking", "ilold") + } + + fn step_with_trace(name: &str, trace: RuntimeTrace) -> ExplorationStep { + ExplorationStep { + function: name.to_string(), + mutations: vec![], + flow_tree: None, + trace_config: TraceConfig::default(), + runtime_trace: Some(serde_json::to_value(&trace).unwrap()), + call_payload: None, + } + } + + fn ok_trace(cu: u64) -> RuntimeTrace { + RuntimeTrace { + logs: vec![], + compute_units: cu, + inner_instructions: vec![], + account_diffs: vec![], + return_data: None, + error: None, + } + } + + #[test] + fn from_session_empty_returns_empty() { + let session = empty_session(); + let overlay = RuntimeOverlay::from_session(&session); + assert_eq!(overlay.program, "staking"); + assert!(overlay.calls_per_ix.is_empty()); + assert!(overlay.failed_per_ix.is_empty()); + assert!(overlay.cu_stats_per_ix.is_empty()); + assert!(overlay.cpi_edges.is_empty()); + } + + #[test] + fn from_session_aggregates_calls_per_ix() { + let mut session = empty_session(); + session.steps.push(step_with_trace("stake", ok_trace(10_000))); + session.steps.push(step_with_trace("stake", ok_trace(14_000))); + session.steps.push(step_with_trace("unstake", ok_trace(12_000))); + + let overlay = RuntimeOverlay::from_session(&session); + assert_eq!(overlay.calls_per_ix.get("stake").copied(), Some(2)); + assert_eq!(overlay.calls_per_ix.get("unstake").copied(), Some(1)); + assert!(overlay.failed_per_ix.is_empty()); + + let stake = overlay.cu_stats_per_ix.get("stake").expect("stake stats"); + assert_eq!(stake.samples, 2); + assert_eq!(stake.min, 10_000); + assert_eq!(stake.max, 14_000); + assert_eq!(stake.avg, 12_000); + } + + #[test] + fn from_session_reads_failed_calls_counter() { + let mut session = empty_session(); + session.steps.push(step_with_trace("stake", ok_trace(11_000))); + session.record_failed_call("stake"); + session.record_failed_call("unstake"); + + let overlay = RuntimeOverlay::from_session(&session); + assert_eq!(overlay.calls_per_ix.get("stake").copied(), Some(1)); + assert_eq!(overlay.failed_per_ix.get("stake").copied(), Some(1)); + assert_eq!(overlay.failed_per_ix.get("unstake").copied(), Some(1)); + assert_eq!(overlay.calls_per_ix.get("unstake"), None); + } + + #[test] + fn extract_cpi_programs_dedups_in_insertion_order() { + let trace = RuntimeTrace { + logs: vec![], + compute_units: 0, + inner_instructions: vec![ + InnerInstruction { program: "B".into(), instruction: "x".into(), depth: 1 }, + InnerInstruction { program: "A".into(), instruction: "x".into(), depth: 1 }, + InnerInstruction { program: "B".into(), instruction: "y".into(), depth: 2 }, + InnerInstruction { program: "A".into(), instruction: "z".into(), depth: 1 }, + ], + account_diffs: vec![], + return_data: None, + error: None, + }; + let programs = extract_cpi_programs(&trace); + assert_eq!(programs, vec!["B".to_string(), "A".to_string()]); + } + + #[test] + fn extract_cpi_programs_empty_trace_is_empty() { + let trace = ok_trace(1_000); + assert!(extract_cpi_programs(&trace).is_empty()); + } + + #[test] + fn from_session_collects_cpi_edges() { + let mut session = empty_session(); + let mut trace_a = ok_trace(15_000); + trace_a.inner_instructions = vec![ + InnerInstruction { + program: "11111111111111111111111111111111".into(), + instruction: "abc".into(), + depth: 1, + }, + InnerInstruction { + program: "11111111111111111111111111111111".into(), + instruction: "abc".into(), + depth: 1, + }, + InnerInstruction { + program: "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA".into(), + instruction: "xyz".into(), + depth: 2, + }, + ]; + let mut trace_b = ok_trace(13_000); + trace_b.inner_instructions = vec![InnerInstruction { + program: "11111111111111111111111111111111".into(), + instruction: "abc".into(), + depth: 1, + }]; + session.steps.push(step_with_trace("stake", trace_a)); + session.steps.push(step_with_trace("unstake", trace_b)); + + let overlay = RuntimeOverlay::from_session(&session); + assert_eq!(overlay.cpi_edges.len(), 3); + let stake_sys = &overlay.cpi_edges[0]; + assert_eq!(stake_sys.from_ix, "stake"); + assert_eq!(stake_sys.to_program, "11111111111111111111111111111111"); + assert_eq!(stake_sys.depth, 1); + assert_eq!(stake_sys.samples, 2); + let stake_token = &overlay.cpi_edges[1]; + assert_eq!(stake_token.from_ix, "stake"); + assert_eq!( + stake_token.to_program, + "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" + ); + assert_eq!(stake_token.depth, 2); + assert_eq!(stake_token.samples, 1); + let unstake_sys = &overlay.cpi_edges[2]; + assert_eq!(unstake_sys.from_ix, "unstake"); + assert_eq!(unstake_sys.samples, 1); + } +} diff --git a/crates/ilold-solana-core/src/view.rs b/crates/ilold-solana-core/src/view.rs new file mode 100644 index 0000000..6da891e --- /dev/null +++ b/crates/ilold-solana-core/src/view.rs @@ -0,0 +1,729 @@ +use std::collections::{BTreeSet, HashSet}; + +use anchor_lang_idl::types::{ + IdlArrayLen, IdlDefinedFields, IdlField, IdlGenericArg, IdlType, IdlTypeDefTy, +}; +use serde::{Deserialize, Serialize}; + +use crate::model::{ + AccountSpec, AccountTypeDef, InstructionDef, PdaSpec, ProgramDef, SeedSpec, +}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProgramView { + pub name: String, + pub program_id: String, + pub instructions: Vec<IxView>, + pub accounts: Vec<AccountView>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub state_coupling: Option<Vec<CouplingPair>>, + #[serde( + default, + skip_serializing_if = "Option::is_none", + serialize_with = "serialize_sorted_opt_string_set", + deserialize_with = "deserialize_opt_string_set" + )] + pub admin_gated: Option<HashSet<String>>, + #[serde( + default, + skip_serializing_if = "Option::is_none", + serialize_with = "serialize_sorted_opt_string_set", + deserialize_with = "deserialize_opt_string_set" + )] + pub system_accounts: Option<HashSet<String>>, +} + +fn serialize_sorted_opt_string_set<S: serde::Serializer>( + value: &Option<HashSet<String>>, + serializer: S, +) -> Result<S::Ok, S::Error> { + match value { + None => serializer.serialize_none(), + Some(set) => { + let mut sorted: Vec<&String> = set.iter().collect(); + sorted.sort(); + serializer.collect_seq(sorted) + } + } +} + +fn deserialize_opt_string_set<'de, D: serde::Deserializer<'de>>( + deserializer: D, +) -> Result<Option<HashSet<String>>, D::Error> { + let opt: Option<Vec<String>> = Option::deserialize(deserializer)?; + Ok(opt.map(|v| v.into_iter().collect())) +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct IxView { + pub name: String, + pub discriminator_hex: String, + pub args: Vec<ArgView>, + pub accounts: Vec<IxAccountView>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub returns: Option<String>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ArgView { + pub name: String, + pub ty: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct IxAccountView { + pub path: String, + pub name: String, + pub kind: AccountKind, + pub writable: bool, + pub signer: bool, + pub optional: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub address: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pda: Option<PdaView>, + #[serde(default)] + pub relations: Vec<String>, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum AccountKind { + Program, + System, + Sysvar, + Pda, + Other, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PdaView { + pub seeds: Vec<SeedView>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub program: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub bump_arg: Option<String>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "lowercase")] +pub enum SeedView { + Const { + value_hex: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + value_utf8: Option<String>, + }, + Arg { + name: String, + ty: String, + }, + Account { + path: String, + }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AccountView { + pub name: String, + pub discriminator_hex: String, + pub fields: Vec<FieldView>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FieldView { + pub name: String, + pub ty: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CouplingPair { + pub a: String, + pub b: String, + pub shared_writable: Vec<String>, +} + +const SYSTEM_PROGRAM_NAMES: &[&str] = &[ + "system_program", + "token_program", + "associated_token_program", + "token_program_2022", +]; + +const SYSVAR_NAMES: &[&str] = &[ + "rent", + "clock", + "instructions", + "recent_blockhashes", + "slot_history", + "stake_history", + "epoch_schedule", + "fees", +]; + +impl ProgramDef { + pub fn compute_view(&self) -> ProgramView { + let instructions: Vec<IxView> = self + .instructions + .iter() + .map(|ix| build_ix_view(ix, &self.account_types)) + .collect(); + let accounts: Vec<AccountView> = self + .account_types + .iter() + .map(build_account_view) + .collect(); + + let state_coupling = Some(compute_coupling(&instructions)); + let admin_gated = Some(compute_admin_gated(self, &accounts)); + let system_accounts = Some(collect_system_accounts(&instructions)); + + ProgramView { + name: self.name.clone(), + program_id: self.program_id.to_string(), + instructions, + accounts, + state_coupling, + admin_gated, + system_accounts, + } + } +} + +fn build_ix_view(ix: &InstructionDef, account_types: &[AccountTypeDef]) -> IxView { + let args: Vec<ArgView> = ix + .args + .iter() + .map(|a| ArgView { + name: a.name.clone(), + ty: format_idl_type(&a.ty), + }) + .collect(); + let accounts = ix + .accounts + .iter() + .map(|spec| build_ix_account_view(spec, &ix.args, account_types)) + .collect(); + let returns = ix.returns.as_ref().map(format_idl_type); + IxView { + name: ix.name.clone(), + discriminator_hex: format_discriminator(&ix.discriminator), + args, + accounts, + returns, + } +} + +fn build_ix_account_view( + spec: &AccountSpec, + ix_args: &[IdlField], + account_types: &[AccountTypeDef], +) -> IxAccountView { + let pda = spec.pda.as_ref().map(|p| build_pda_view(p, ix_args)); + let kind = classify_account_kind(&spec.name, &pda, account_types); + IxAccountView { + path: spec.path.clone(), + name: spec.name.clone(), + kind, + writable: spec.writable, + signer: spec.signer, + optional: spec.optional, + address: spec.address.as_ref().map(|a| a.to_string()), + pda, + relations: spec.relations.clone(), + } +} + +fn build_pda_view(pda: &PdaSpec, ix_args: &[IdlField]) -> PdaView { + let seeds = pda.seeds.iter().map(|s| seed_to_view(s, ix_args)).collect(); + let program = pda.program.as_ref().map(seed_program_to_string); + PdaView { + seeds, + program, + bump_arg: pda.bump_arg.clone(), + } +} + +fn seed_to_view(seed: &SeedSpec, ix_args: &[IdlField]) -> SeedView { + match seed { + SeedSpec::Const { value } => { + let value_hex = bytes_to_hex(value); + let value_utf8 = bytes_to_ascii_graphic(value); + SeedView::Const { + value_hex, + value_utf8, + } + } + SeedSpec::Arg { path, ty } => { + let resolved = ix_args + .iter() + .find(|f| f.name == *path) + .map(|f| format_idl_type(&f.ty)) + .unwrap_or_else(|| format_idl_type(ty)); + SeedView::Arg { + name: path.clone(), + ty: resolved, + } + } + SeedSpec::Account { path } => SeedView::Account { path: path.clone() }, + } +} + +fn seed_program_to_string(seed: &SeedSpec) -> String { + match seed { + SeedSpec::Const { value } => match solana_address::Address::try_from(value.as_slice()) { + Ok(a) => a.to_string(), + Err(_) => format!("const:{:02x?}", value), + }, + SeedSpec::Account { path } => format!("account:{path}"), + SeedSpec::Arg { path, .. } => format!("arg:{path}"), + } +} + +fn build_account_view(account: &AccountTypeDef) -> AccountView { + let fields = match &account.layout.ty { + IdlTypeDefTy::Struct { fields: Some(IdlDefinedFields::Named(named)) } => named + .iter() + .map(|f| FieldView { + name: f.name.clone(), + ty: format_idl_type(&f.ty), + }) + .collect(), + IdlTypeDefTy::Struct { fields: Some(IdlDefinedFields::Tuple(items)) } => items + .iter() + .enumerate() + .map(|(idx, ty)| FieldView { + name: idx.to_string(), + ty: format_idl_type(ty), + }) + .collect(), + _ => Vec::new(), + }; + AccountView { + name: account.name.clone(), + discriminator_hex: format_discriminator(&account.discriminator), + fields, + } +} + +pub(crate) fn snake_to_pascal(s: &str) -> String { + s.split('_') + .filter(|p| !p.is_empty()) + .map(|p| { + let mut c = p.chars(); + match c.next() { + None => String::new(), + Some(f) => f.to_uppercase().collect::<String>() + c.as_str(), + } + }) + .collect() +} + +fn classify_account_kind( + name: &str, + pda: &Option<PdaView>, + account_types: &[AccountTypeDef], +) -> AccountKind { + if pda.is_some() { + return AccountKind::Pda; + } + if SYSTEM_PROGRAM_NAMES.contains(&name) { + return AccountKind::System; + } + if SYSVAR_NAMES.contains(&name) || name.starts_with("sysvar_") { + return AccountKind::Sysvar; + } + let pascal = snake_to_pascal(name); + if account_types.iter().any(|a| a.name == pascal) { + return AccountKind::Program; + } + AccountKind::Other +} + +fn bytes_to_hex(bytes: &[u8]) -> String { + let mut s = String::with_capacity(2 + bytes.len() * 2); + s.push_str("0x"); + for b in bytes { + s.push_str(&format!("{:02x}", b)); + } + s +} + +fn bytes_to_ascii_graphic(bytes: &[u8]) -> Option<String> { + let s = std::str::from_utf8(bytes).ok()?; + if s.chars().all(|c| c.is_ascii_graphic() || c == ' ') { + Some(s.to_string()) + } else { + None + } +} + +fn format_discriminator(d: &[u8; 8]) -> String { + let mut s = String::with_capacity(2 + d.len() * 2); + s.push_str("0x"); + for b in d { + s.push_str(&format!("{:02x}", b)); + } + s +} + +fn format_idl_type(ty: &IdlType) -> String { + match ty { + IdlType::Bool => "bool".into(), + IdlType::U8 => "u8".into(), + IdlType::I8 => "i8".into(), + IdlType::U16 => "u16".into(), + IdlType::I16 => "i16".into(), + IdlType::U32 => "u32".into(), + IdlType::I32 => "i32".into(), + IdlType::F32 => "f32".into(), + IdlType::U64 => "u64".into(), + IdlType::I64 => "i64".into(), + IdlType::F64 => "f64".into(), + IdlType::U128 => "u128".into(), + IdlType::I128 => "i128".into(), + IdlType::U256 => "u256".into(), + IdlType::I256 => "i256".into(), + IdlType::Bytes => "bytes".into(), + IdlType::String => "string".into(), + IdlType::Pubkey => "Pubkey".into(), + IdlType::Option(inner) => format!("Option<{}>", format_idl_type(inner)), + IdlType::Vec(inner) => format!("Vec<{}>", format_idl_type(inner)), + IdlType::Array(inner, len) => { + let len_str = match len { + IdlArrayLen::Value(v) => v.to_string(), + IdlArrayLen::Generic(name) => name.clone(), + }; + format!("[{}; {}]", format_idl_type(inner), len_str) + } + IdlType::Defined { name, generics } => { + if generics.is_empty() { + name.clone() + } else { + let parts: Vec<String> = generics.iter().map(format_generic_arg).collect(); + format!("{name}<{}>", parts.join(", ")) + } + } + IdlType::Generic(name) => name.clone(), + other => format!("{other:?}"), + } +} + +fn format_generic_arg(arg: &IdlGenericArg) -> String { + match arg { + IdlGenericArg::Type { ty } => format_idl_type(ty), + IdlGenericArg::Const { value } => value.clone(), + } +} + +fn compute_coupling(ixs: &[IxView]) -> Vec<CouplingPair> { + let mut out: Vec<CouplingPair> = Vec::new(); + for i in 0..ixs.len() { + for j in (i + 1)..ixs.len() { + let writable_i: HashSet<&str> = ixs[i] + .accounts + .iter() + .filter(|a| a.writable) + .map(|a| a.name.as_str()) + .collect(); + let writable_j: HashSet<&str> = ixs[j] + .accounts + .iter() + .filter(|a| a.writable) + .map(|a| a.name.as_str()) + .collect(); + let mut shared: Vec<String> = writable_i + .intersection(&writable_j) + .map(|s| s.to_string()) + .collect(); + if shared.is_empty() { + continue; + } + shared.sort(); + let (a, b) = if ixs[i].name <= ixs[j].name { + (ixs[i].name.clone(), ixs[j].name.clone()) + } else { + (ixs[j].name.clone(), ixs[i].name.clone()) + }; + out.push(CouplingPair { + a, + b, + shared_writable: shared, + }); + } + } + out.sort_by(|x, y| { + y.shared_writable + .len() + .cmp(&x.shared_writable.len()) + .then_with(|| x.a.cmp(&y.a)) + .then_with(|| x.b.cmp(&y.b)) + }); + out +} + +fn compute_admin_gated(program: &ProgramDef, accounts: &[AccountView]) -> HashSet<String> { + let admin_account_exists = accounts + .iter() + .any(|a| a.fields.iter().any(|f| is_admin_field(&f.name) && f.ty == "Pubkey")); + if !admin_account_exists { + return HashSet::new(); + } + program + .instructions + .iter() + .filter(|ix| { + ix.accounts + .iter() + .any(|acc| is_admin_field(&acc.name) && acc.signer) + }) + .map(|ix| ix.name.clone()) + .collect() +} + +fn is_admin_field(name: &str) -> bool { + name == "admin" || name == "authority" +} + +fn collect_system_accounts(ixs: &[IxView]) -> HashSet<String> { + let mut set: BTreeSet<String> = BTreeSet::new(); + for ix in ixs { + for acc in &ix.accounts { + if matches!(acc.kind, AccountKind::System | AccountKind::Sysvar) { + set.insert(acc.name.clone()); + } + } + } + set.into_iter().collect() +} + +pub fn describe_seed_view(seed: &SeedView) -> String { + match seed { + SeedView::Const { value_hex, value_utf8 } => match value_utf8 { + Some(s) => format!("const:'{s}'"), + None => { + let bytes = hex_to_bytes(value_hex); + format!("const:{:02x?}", bytes) + } + }, + SeedView::Account { path } => format!("account:{path}"), + SeedView::Arg { name, .. } => format!("arg:{name}"), + } +} + +pub fn hex_to_bytes(value_hex: &str) -> Vec<u8> { + let stripped = value_hex.strip_prefix("0x").unwrap_or(value_hex); + let mut out = Vec::with_capacity(stripped.len() / 2); + let bytes = stripped.as_bytes(); + let mut i = 0; + while i + 1 < bytes.len() { + let hi = (bytes[i] as char).to_digit(16).unwrap_or(0) as u8; + let lo = (bytes[i + 1] as char).to_digit(16).unwrap_or(0) as u8; + out.push((hi << 4) | lo); + i += 2; + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::idl::parse_idl; + + const LEVER_JSON: &str = include_str!("../tests/fixtures/lever.json"); + const RELATIONS_JSON: &str = include_str!("../tests/fixtures/relations.json"); + + fn lever_program() -> ProgramDef { + ProgramDef::from_idl(parse_idl(LEVER_JSON).expect("parse lever")) + .expect("build lever ProgramDef") + } + + fn relations_program() -> ProgramDef { + ProgramDef::from_idl(parse_idl(RELATIONS_JSON).expect("parse relations")) + .expect("build relations ProgramDef") + } + + #[test] + fn compute_view_has_all_instructions() { + let view = lever_program().compute_view(); + assert_eq!(view.instructions.len(), 2); + let names: Vec<_> = view.instructions.iter().map(|i| i.name.as_str()).collect(); + assert!(names.contains(&"initialize")); + assert!(names.contains(&"switch_power")); + } + + #[test] + fn compute_view_account_fields_and_discriminators_populated() { + let view = lever_program().compute_view(); + let power = view + .accounts + .iter() + .find(|a| a.name == "PowerStatus") + .expect("PowerStatus account-type present"); + assert_eq!(power.fields.len(), 1); + assert_eq!(power.fields[0].name, "is_on"); + assert_eq!(power.fields[0].ty, "bool"); + assert_eq!(power.discriminator_hex, "0x9193c623fd65e71a"); + } + + #[test] + fn compute_view_ix_args_have_string_types() { + let view = lever_program().compute_view(); + let switch = view + .instructions + .iter() + .find(|i| i.name == "switch_power") + .expect("switch_power present"); + assert_eq!(switch.discriminator_hex, "0xe2ee38acbf2d7a57"); + assert_eq!(switch.args.len(), 1); + assert_eq!(switch.args[0].name, "name"); + assert_eq!(switch.args[0].ty, "string"); + } + + #[test] + fn account_kind_classification() { + let view = lever_program().compute_view(); + let initialize = view.instructions.iter().find(|i| i.name == "initialize").unwrap(); + let system = initialize.accounts.iter().find(|a| a.name == "system_program").unwrap(); + assert_eq!(system.kind, AccountKind::System); + let power = initialize.accounts.iter().find(|a| a.name == "power").unwrap(); + assert_eq!(power.kind, AccountKind::Other); + + let relations = relations_program().compute_view(); + let init_base = relations.instructions.iter().find(|i| i.name == "init_base").unwrap(); + let pda_acc = init_base.accounts.iter().find(|a| a.name == "account").unwrap(); + assert_eq!(pda_acc.kind, AccountKind::Pda); + let test_relation = relations.instructions.iter().find(|i| i.name == "test_relation").unwrap(); + let typed_acc = test_relation.accounts.iter().find(|a| a.name == "my_account").unwrap(); + assert_eq!(typed_acc.kind, AccountKind::Program); + } + + #[test] + fn seed_const_value_utf8_only_when_ascii_graphic() { + let printable = SeedSpec::Const { + value: b"stake".to_vec(), + }; + match seed_to_view(&printable, &[]) { + SeedView::Const { value_hex, value_utf8 } => { + assert_eq!(value_hex, "0x7374616b65"); + assert_eq!(value_utf8.as_deref(), Some("stake")); + } + other => panic!("expected Const, got {other:?}"), + } + + let non_graphic = SeedSpec::Const { + value: vec![0x09, 0x0a], + }; + match seed_to_view(&non_graphic, &[]) { + SeedView::Const { value_hex, value_utf8 } => { + assert_eq!(value_hex, "0x090a"); + assert!(value_utf8.is_none()); + } + other => panic!("expected Const, got {other:?}"), + } + } + + #[test] + fn snake_to_pascal_handles_basic_cases() { + assert_eq!(snake_to_pascal("pool"), "Pool"); + assert_eq!(snake_to_pascal("user_stake"), "UserStake"); + assert_eq!(snake_to_pascal(""), ""); + assert_eq!(snake_to_pascal("__double"), "Double"); + } + + #[test] + fn format_idl_type_covers_primitives_and_compounds() { + assert_eq!(format_idl_type(&IdlType::U8), "u8"); + assert_eq!(format_idl_type(&IdlType::U64), "u64"); + assert_eq!(format_idl_type(&IdlType::I128), "i128"); + assert_eq!(format_idl_type(&IdlType::Bool), "bool"); + assert_eq!(format_idl_type(&IdlType::String), "string"); + assert_eq!(format_idl_type(&IdlType::Bytes), "bytes"); + assert_eq!(format_idl_type(&IdlType::Pubkey), "Pubkey"); + + let opt = IdlType::Option(Box::new(IdlType::U64)); + assert_eq!(format_idl_type(&opt), "Option<u64>"); + + let vec_pk = IdlType::Vec(Box::new(IdlType::Pubkey)); + assert_eq!(format_idl_type(&vec_pk), "Vec<Pubkey>"); + + let arr = IdlType::Array(Box::new(IdlType::U8), IdlArrayLen::Value(32)); + assert_eq!(format_idl_type(&arr), "[u8; 32]"); + + let arr_generic = IdlType::Array(Box::new(IdlType::U8), IdlArrayLen::Generic("N".into())); + assert_eq!(format_idl_type(&arr_generic), "[u8; N]"); + + let defined = IdlType::Defined { + name: "Pool".into(), + generics: vec![], + }; + assert_eq!(format_idl_type(&defined), "Pool"); + + let generic_arg = IdlType::Defined { + name: "Box".into(), + generics: vec![IdlGenericArg::Type { + ty: IdlType::U64, + }], + }; + assert_eq!(format_idl_type(&generic_arg), "Box<u64>"); + + let nested = IdlType::Vec(Box::new(IdlType::Option(Box::new(IdlType::U32)))); + assert_eq!(format_idl_type(&nested), "Vec<Option<u32>>"); + + assert_eq!(format_idl_type(&IdlType::Generic("T".into())), "T"); + } + + #[test] + fn compute_view_state_coupling_empty_when_no_writable_overlap() { + let view = lever_program().compute_view(); + let coupling = view.state_coupling.expect("state_coupling computed"); + assert_eq!(coupling.len(), 1); + assert_eq!(coupling[0].a, "initialize"); + assert_eq!(coupling[0].b, "switch_power"); + assert_eq!(coupling[0].shared_writable, vec!["power"]); + } + + #[test] + fn compute_view_admin_gated_requires_both_signer_and_field() { + let view = lever_program().compute_view(); + let gated = view.admin_gated.expect("admin_gated computed"); + assert!(gated.is_empty(), "lever should not gate any ix"); + } + + #[test] + fn compute_view_system_accounts_collects_kinds() { + let view = lever_program().compute_view(); + let sys = view.system_accounts.expect("system_accounts computed"); + assert!(sys.contains("system_program")); + } + + const STAKING_JSON: &str = include_str!( + "../../../tests/fixtures/solana/staking/idls/staking.json" + ); + + fn staking_program() -> ProgramDef { + ProgramDef::from_idl(parse_idl(STAKING_JSON).expect("parse staking")) + .expect("build staking ProgramDef") + } + + #[test] + fn compute_view_coupling_includes_stake_unstake_on_staking() { + let view = staking_program().compute_view(); + let coupling = view.state_coupling.expect("state_coupling computed"); + let pair = coupling + .iter() + .find(|p| p.a == "stake" && p.b == "unstake") + .expect("stake↔unstake pair present"); + assert!(pair.shared_writable.iter().any(|n| n == "pool")); + assert!(pair.shared_writable.iter().any(|n| n == "user_stake")); + } + + #[test] + fn compute_view_admin_gated_marks_initialize_pool_on_staking() { + let view = staking_program().compute_view(); + let gated = view.admin_gated.expect("admin_gated computed"); + assert!(gated.contains("initialize_pool")); + assert!(gated.contains("add_rewards")); + assert!(!gated.contains("stake")); + assert!(!gated.contains("unstake")); + } +} diff --git a/crates/ilold-solana-core/tests/decode_borsh.rs b/crates/ilold-solana-core/tests/decode_borsh.rs new file mode 100644 index 0000000..aedc78b --- /dev/null +++ b/crates/ilold-solana-core/tests/decode_borsh.rs @@ -0,0 +1,262 @@ +use anchor_lang_idl::types::{ + Idl, IdlAccount, IdlArrayLen, IdlDefinedFields, IdlEnumVariant, IdlField, IdlMetadata, + IdlSerialization, IdlType, IdlTypeDef, IdlTypeDefTy, +}; +use ilold_solana_core::decode::{decode_account, decode_value}; +use ilold_solana_core::error::SolanaError; +use serde_json::json; + +fn types() -> Vec<IdlTypeDef> { + Vec::new() +} + +#[test] +fn primitives_decode_correctly() { + let mut bytes: &[u8] = &[1u8]; + assert_eq!(decode_value(&mut bytes, &IdlType::Bool, &types()).unwrap(), json!(true)); + + let mut bytes: &[u8] = &[42u8]; + assert_eq!(decode_value(&mut bytes, &IdlType::U8, &types()).unwrap(), json!(42)); + + let mut bytes: &[u8] = &42u64.to_le_bytes(); + assert_eq!(decode_value(&mut bytes, &IdlType::U64, &types()).unwrap(), json!(42)); + + let mut bytes: &[u8] = &(-7i32).to_le_bytes(); + assert_eq!(decode_value(&mut bytes, &IdlType::I32, &types()).unwrap(), json!(-7)); +} + +#[test] +fn u128_renders_as_decimal_string() { + let value: u128 = 340_282_366_920_938_463_463_374_607_431_768_211_455; + let mut bytes: &[u8] = &value.to_le_bytes(); + assert_eq!( + decode_value(&mut bytes, &IdlType::U128, &types()).unwrap(), + json!(value.to_string()) + ); +} + +#[test] +fn string_decodes_with_length_prefix() { + let mut buf = Vec::new(); + let s = "hola"; + buf.extend(&(s.len() as u32).to_le_bytes()); + buf.extend(s.as_bytes()); + let mut bytes: &[u8] = &buf; + assert_eq!( + decode_value(&mut bytes, &IdlType::String, &types()).unwrap(), + json!("hola") + ); +} + +#[test] +fn pubkey_renders_base58() { + let bytes = [0u8; 32]; + let mut slice: &[u8] = &bytes; + let v = decode_value(&mut slice, &IdlType::Pubkey, &types()).unwrap(); + assert_eq!(v, json!("11111111111111111111111111111111")); +} + +#[test] +fn option_some_and_none() { + let mut buf: Vec<u8> = vec![0]; + let mut bytes: &[u8] = &buf; + let ty = IdlType::Option(Box::new(IdlType::U32)); + assert_eq!(decode_value(&mut bytes, &ty, &types()).unwrap(), json!(null)); + + buf = vec![1]; + buf.extend(&7u32.to_le_bytes()); + let mut bytes: &[u8] = &buf; + assert_eq!(decode_value(&mut bytes, &ty, &types()).unwrap(), json!(7)); +} + +#[test] +fn vec_u32_with_length_prefix() { + let mut buf = (3u32).to_le_bytes().to_vec(); + for v in [10u32, 20, 30] { + buf.extend(&v.to_le_bytes()); + } + let ty = IdlType::Vec(Box::new(IdlType::U32)); + let mut bytes: &[u8] = &buf; + assert_eq!( + decode_value(&mut bytes, &ty, &types()).unwrap(), + json!([10, 20, 30]) + ); +} + +#[test] +fn array_fixed_length() { + let mut buf = Vec::new(); + for v in [1u32, 2, 3] { + buf.extend(&v.to_le_bytes()); + } + let ty = IdlType::Array(Box::new(IdlType::U32), IdlArrayLen::Value(3)); + let mut bytes: &[u8] = &buf; + assert_eq!(decode_value(&mut bytes, &ty, &types()).unwrap(), json!([1, 2, 3])); +} + +#[test] +fn defined_struct_decodes_named_fields() { + let counter_typedef = IdlTypeDef { + name: "Counter".into(), + docs: vec![], + serialization: IdlSerialization::default(), + repr: None, + generics: vec![], + ty: IdlTypeDefTy::Struct { + fields: Some(IdlDefinedFields::Named(vec![ + IdlField { + name: "count".into(), + docs: vec![], + ty: IdlType::U64, + }, + IdlField { + name: "active".into(), + docs: vec![], + ty: IdlType::Bool, + }, + ])), + }, + }; + let types = vec![counter_typedef]; + let ty = IdlType::Defined { + name: "Counter".into(), + generics: vec![], + }; + + let mut buf = 99u64.to_le_bytes().to_vec(); + buf.push(1u8); + let mut bytes: &[u8] = &buf; + assert_eq!( + decode_value(&mut bytes, &ty, &types).unwrap(), + json!({"count": 99, "active": true}) + ); +} + +#[test] +fn enum_with_unit_and_payload_variants() { + let status_typedef = IdlTypeDef { + name: "Status".into(), + docs: vec![], + serialization: IdlSerialization::default(), + repr: None, + generics: vec![], + ty: IdlTypeDefTy::Enum { + variants: vec![ + IdlEnumVariant { + name: "Off".into(), + fields: None, + }, + IdlEnumVariant { + name: "On".into(), + fields: Some(IdlDefinedFields::Named(vec![IdlField { + name: "level".into(), + docs: vec![], + ty: IdlType::U8, + }])), + }, + ], + }, + }; + let types = vec![status_typedef]; + let ty = IdlType::Defined { + name: "Status".into(), + generics: vec![], + }; + + let mut bytes: &[u8] = &[0u8]; + assert_eq!( + decode_value(&mut bytes, &ty, &types).unwrap(), + json!({"kind": "Off"}) + ); + + let buf = vec![1u8, 200u8]; + let mut bytes: &[u8] = &buf; + assert_eq!( + decode_value(&mut bytes, &ty, &types).unwrap(), + json!({"kind": "On", "value": {"level": 200}}) + ); +} + +#[test] +fn decode_account_uses_discriminator_lookup() { + let counter_typedef = IdlTypeDef { + name: "Counter".into(), + docs: vec![], + serialization: IdlSerialization::default(), + repr: None, + generics: vec![], + ty: IdlTypeDefTy::Struct { + fields: Some(IdlDefinedFields::Named(vec![IdlField { + name: "count".into(), + docs: vec![], + ty: IdlType::U64, + }])), + }, + }; + let disc = vec![1u8, 2, 3, 4, 5, 6, 7, 8]; + let idl = Idl { + address: "11111111111111111111111111111111".into(), + metadata: IdlMetadata { + name: "test".into(), + version: "0.1.0".into(), + spec: "0.1.0".into(), + description: None, + repository: None, + dependencies: vec![], + contact: None, + deployments: None, + }, + docs: vec![], + instructions: vec![], + accounts: vec![IdlAccount { + name: "Counter".into(), + discriminator: disc.clone(), + }], + events: vec![], + errors: vec![], + types: vec![counter_typedef], + constants: vec![], + }; + + let mut data = disc.clone(); + data.extend(&123u64.to_le_bytes()); + let decoded = decode_account(&data, &idl).unwrap(); + assert_eq!(decoded.type_name, "Counter"); + assert_eq!(decoded.value, json!({"count": 123})); +} + +#[test] +fn decode_account_unknown_discriminator_errors() { + let idl = Idl { + address: "11111111111111111111111111111111".into(), + metadata: IdlMetadata { + name: "test".into(), + version: "0.1.0".into(), + spec: "0.1.0".into(), + description: None, + repository: None, + dependencies: vec![], + contact: None, + deployments: None, + }, + docs: vec![], + instructions: vec![], + accounts: vec![], + events: vec![], + errors: vec![], + types: vec![], + constants: vec![], + }; + + let data = [9u8; 16]; + let err = decode_account(&data, &idl).unwrap_err(); + assert!(matches!(err, SolanaError::UnknownDiscriminator { .. })); +} + +#[test] +fn generics_rejected() { + let ty = IdlType::Generic("T".into()); + let mut bytes: &[u8] = &[0u8]; + let err = decode_value(&mut bytes, &ty, &types()).unwrap_err(); + assert!(matches!(err, SolanaError::UnsupportedGeneric(_))); +} diff --git a/crates/ilold-solana-core/tests/e2e_lever.rs b/crates/ilold-solana-core/tests/e2e_lever.rs new file mode 100644 index 0000000..3aad688 --- /dev/null +++ b/crates/ilold-solana-core/tests/e2e_lever.rs @@ -0,0 +1,156 @@ +use std::collections::HashMap; +use std::path::PathBuf; + +use ilold_session_core::exploration::session::ExplorationSession; +use ilold_solana_core::execute::VmHost; +use ilold_solana_core::exploration::add_solana_step; +use ilold_solana_core::idl::parse_idl; +use ilold_solana_core::model::ProgramDef; +use solana_account::Account; +use solana_address::Address; +use solana_keypair::Keypair; +use solana_signer::Signer; + +const LEVER_JSON: &str = include_str!("fixtures/lever.json"); + +fn lever_so_path() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/programs/lever.so") +} + +fn read_lever_so() -> Vec<u8> { + std::fs::read(lever_so_path()).expect( + "lever.so missing — run `cd tests/fixtures/solana/cpi && anchor build` and copy \ + target/deploy/lever.so to crates/ilold-solana-core/tests/programs/lever.so", + ) +} + +#[test] +fn add_solana_step_initialize_captures_cpi_inner_instructions() { + // Anchor's `init` constraint CPIs into system_program::create_account. + // The patch in add_step.rs must surface those inner ixs in the trace. + let idl = parse_idl(LEVER_JSON).expect("parse lever idl"); + let program = ProgramDef::from_idl(idl).expect("build ProgramDef"); + let so_bytes = read_lever_so(); + let mut vm = VmHost::boot(vec![(program.program_id, so_bytes)]).expect("boot vm"); + + let admin = Keypair::new(); + vm.svm_mut() + .airdrop(&admin.pubkey(), 5_000_000_000) + .expect("airdrop admin"); + let power = Keypair::new(); + + let mut accounts: HashMap<String, Address> = HashMap::new(); + accounts.insert("power".into(), power.pubkey()); + accounts.insert("user".into(), admin.pubkey()); + + let init_ix = program + .instructions + .iter() + .find(|i| i.name == "initialize") + .expect("initialize ix"); + + let mut session = ExplorationSession::new("lever", "ilold"); + let outcome = add_solana_step( + &mut session, + &program, + init_ix, + &mut vm, + serde_json::json!({}), + accounts, + &[&admin, &power], + "2026-05-09T00:00:00Z", + None, + ) + .expect("add_solana_step initialize"); + + assert!( + outcome.trace.error.is_none(), + "initialize errored: {:?}", + outcome.trace.error + ); + assert!( + !outcome.trace.inner_instructions.is_empty(), + "expected inner_instructions to be populated by Anchor `init` CPI; got empty" + ); + let sys = outcome + .trace + .inner_instructions + .iter() + .find(|i| i.program == "11111111111111111111111111111111"); + assert!( + sys.is_some(), + "expected at least one inner ix targeting system_program; got {:?}", + outcome.trace.inner_instructions, + ); +} + +#[test] +fn add_solana_step_runs_switch_power_against_real_program() { + let idl = parse_idl(LEVER_JSON).expect("parse lever idl"); + let program = ProgramDef::from_idl(idl).expect("build ProgramDef"); + let so_bytes = read_lever_so(); + + let mut vm = VmHost::boot(vec![(program.program_id, so_bytes)]).expect("boot vm"); + + let power_status_def = program + .account_types + .iter() + .find(|a| a.name == "PowerStatus") + .expect("PowerStatus account in IDL"); + + let power_kp = Keypair::new(); + let power_pk = power_kp.pubkey(); + let mut data = Vec::with_capacity(9); + data.extend_from_slice(&power_status_def.discriminator); + data.push(0u8); + let lamports = vm.svm().minimum_balance_for_rent_exemption(data.len()); + let acc = Account { + lamports, + data, + owner: program.program_id, + executable: false, + rent_epoch: 0, + }; + vm.svm_mut().set_account(power_pk, acc).expect("seed power account"); + + let mut accounts: HashMap<String, Address> = HashMap::new(); + accounts.insert("power".into(), power_pk); + + let switch = program + .instructions + .iter() + .find(|ix| ix.name == "switch_power") + .expect("switch_power ix"); + + let mut session = ExplorationSession::new("lever", "ilold"); + let outcome = add_solana_step( + &mut session, + &program, + switch, + &mut vm, + serde_json::json!({"name": "claude"}), + accounts, + &[], + "2026-05-06T00:00:00Z", + None, + ) + .expect("add_solana_step"); + + let step_index = outcome + .step_index + .expect("Call should succeed and produce a session step"); + assert_eq!(session.steps[step_index].function, "switch_power"); + assert!( + outcome.trace.error.is_none(), + "transaction errored: {:?}", + outcome.trace.error, + ); + let joined = outcome.trace.logs.join("\n"); + assert!( + joined.contains("pulling the power switch"), + "expected lever log line, got:\n{joined}" + ); + + let after = vm.svm().get_account(&power_pk).expect("power account after"); + assert_eq!(after.data[8], 1, "is_on flag should flip to true"); +} diff --git a/crates/ilold-solana-core/tests/encode_roundtrip.rs b/crates/ilold-solana-core/tests/encode_roundtrip.rs new file mode 100644 index 0000000..9b960f2 --- /dev/null +++ b/crates/ilold-solana-core/tests/encode_roundtrip.rs @@ -0,0 +1,134 @@ +use anchor_lang_idl::types::{ + IdlArrayLen, IdlDefinedFields, IdlEnumVariant, IdlField, IdlSerialization, IdlType, + IdlTypeDef, IdlTypeDefTy, +}; +use ilold_solana_core::decode::decode_value; +use ilold_solana_core::encode::encode_value; +use serde_json::{json, Value}; + +fn types() -> Vec<IdlTypeDef> { + Vec::new() +} + +fn roundtrip(value: Value, ty: &IdlType, types: &[IdlTypeDef]) -> Value { + let bytes = encode_value(&value, ty, types).expect("encode"); + let mut cursor: &[u8] = &bytes; + decode_value(&mut cursor, ty, types).expect("decode") +} + +#[test] +fn primitives_roundtrip() { + assert_eq!(roundtrip(json!(true), &IdlType::Bool, &types()), json!(true)); + assert_eq!(roundtrip(json!(42), &IdlType::U8, &types()), json!(42)); + assert_eq!(roundtrip(json!(-7), &IdlType::I32, &types()), json!(-7)); + assert_eq!(roundtrip(json!(1_234_567_890_u64), &IdlType::U64, &types()), json!(1_234_567_890_u64)); +} + +#[test] +fn u128_roundtrip_as_decimal_string() { + let value = json!("340282366920938463463374607431768211455"); + let result = roundtrip(value.clone(), &IdlType::U128, &types()); + assert_eq!(result, value); +} + +#[test] +fn string_and_bytes_roundtrip() { + assert_eq!( + roundtrip(json!("hola"), &IdlType::String, &types()), + json!("hola") + ); + let bytes_hex = json!("0102030a"); + assert_eq!(roundtrip(bytes_hex.clone(), &IdlType::Bytes, &types()), bytes_hex); +} + +#[test] +fn pubkey_roundtrip() { + let pk = json!("11111111111111111111111111111111"); + assert_eq!(roundtrip(pk.clone(), &IdlType::Pubkey, &types()), pk); +} + +#[test] +fn option_some_and_none_roundtrip() { + let ty = IdlType::Option(Box::new(IdlType::U32)); + assert_eq!(roundtrip(json!(null), &ty, &types()), json!(null)); + assert_eq!(roundtrip(json!(7), &ty, &types()), json!(7)); +} + +#[test] +fn vec_and_array_roundtrip() { + let vec_ty = IdlType::Vec(Box::new(IdlType::U16)); + assert_eq!(roundtrip(json!([10, 20, 30]), &vec_ty, &types()), json!([10, 20, 30])); + + let arr_ty = IdlType::Array(Box::new(IdlType::U32), IdlArrayLen::Value(3)); + assert_eq!(roundtrip(json!([1, 2, 3]), &arr_ty, &types()), json!([1, 2, 3])); +} + +#[test] +fn defined_struct_roundtrip() { + let counter = IdlTypeDef { + name: "Counter".into(), + docs: vec![], + serialization: IdlSerialization::default(), + repr: None, + generics: vec![], + ty: IdlTypeDefTy::Struct { + fields: Some(IdlDefinedFields::Named(vec![ + IdlField { + name: "count".into(), + docs: vec![], + ty: IdlType::U64, + }, + IdlField { + name: "active".into(), + docs: vec![], + ty: IdlType::Bool, + }, + ])), + }, + }; + let types = vec![counter]; + let ty = IdlType::Defined { + name: "Counter".into(), + generics: vec![], + }; + let value = json!({"count": 99, "active": true}); + assert_eq!(roundtrip(value.clone(), &ty, &types), value); +} + +#[test] +fn enum_with_unit_and_payload_roundtrip() { + let status = IdlTypeDef { + name: "Status".into(), + docs: vec![], + serialization: IdlSerialization::default(), + repr: None, + generics: vec![], + ty: IdlTypeDefTy::Enum { + variants: vec![ + IdlEnumVariant { + name: "Off".into(), + fields: None, + }, + IdlEnumVariant { + name: "On".into(), + fields: Some(IdlDefinedFields::Named(vec![IdlField { + name: "level".into(), + docs: vec![], + ty: IdlType::U8, + }])), + }, + ], + }, + }; + let types = vec![status]; + let ty = IdlType::Defined { + name: "Status".into(), + generics: vec![], + }; + + let off = json!({"kind": "Off"}); + assert_eq!(roundtrip(off.clone(), &ty, &types), off); + + let on = json!({"kind": "On", "value": {"level": 200}}); + assert_eq!(roundtrip(on.clone(), &ty, &types), on); +} diff --git a/crates/ilold-solana-core/tests/execute_call.rs b/crates/ilold-solana-core/tests/execute_call.rs new file mode 100644 index 0000000..8f4f309 --- /dev/null +++ b/crates/ilold-solana-core/tests/execute_call.rs @@ -0,0 +1,174 @@ +use std::collections::HashMap; + +use ilold_session_core::exploration::session::ExplorationSession; +use ilold_solana_core::execute::VmHost; +use ilold_solana_core::exploration::{ + execute_back, execute_call, execute_clear, SolanaCommandResult, +}; +use ilold_solana_core::idl::parse_idl; +use ilold_solana_core::model::ProgramDef; +use solana_keypair::Keypair; +use solana_signer::Signer; + +const LEVER_JSON: &str = include_str!("fixtures/lever.json"); + +fn read_lever_so() -> Vec<u8> { + let path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests/programs/lever.so"); + std::fs::read(&path).expect("lever.so missing") +} + +#[test] +fn execute_call_initialize_then_switch_power() { + let idl = parse_idl(LEVER_JSON).unwrap(); + let program = ProgramDef::from_idl(idl).unwrap(); + let mut vm = VmHost::boot(vec![(program.program_id, read_lever_so())]).unwrap(); + + let admin = Keypair::new(); + let power = Keypair::new(); + vm.svm_mut().airdrop(&admin.pubkey(), 10_000_000_000).unwrap(); + vm.svm_mut().airdrop(&power.pubkey(), 10_000_000_000).unwrap(); + + let mut users = HashMap::new(); + users.insert("admin".into(), admin); + users.insert("power".into(), power); + + let mut session = ExplorationSession::new(&program.name, "ilold"); + + let mut accs = HashMap::new(); + accs.insert("power".into(), "power".to_string()); + accs.insert("user".into(), "admin".to_string()); + accs.insert( + "system_program".into(), + "11111111111111111111111111111111".to_string(), + ); + + let init = execute_call( + &program, + "initialize", + serde_json::json!({}), + accs, + vec!["admin".into(), "power".into()], + &users, + &mut session, + &mut vm, + "2026-05-06T00:00:00Z", + ); + let step_index = match init { + SolanaCommandResult::StepAdded { step_index, instruction, account_diffs_count, .. } => { + assert_eq!(instruction, "initialize"); + assert!(account_diffs_count >= 1, "init should mutate at least power account"); + step_index + } + other => panic!("expected StepAdded for initialize, got {other:?}"), + }; + assert_eq!(step_index, 0); + + let mut accs2 = HashMap::new(); + accs2.insert("power".into(), "power".to_string()); + + let switch = execute_call( + &program, + "switch_power", + serde_json::json!({"name": "claude"}), + accs2, + vec![], + &users, + &mut session, + &mut vm, + "2026-05-06T00:00:01Z", + ); + match switch { + SolanaCommandResult::StepAdded { logs_excerpt, .. } => { + let joined = logs_excerpt.join("\n"); + assert!( + joined.contains("pulling the power switch"), + "expected lever log line, got:\n{joined}" + ); + } + other => panic!("expected StepAdded for switch_power, got {other:?}"), + } + + assert!(matches!( + execute_back(&mut session), + SolanaCommandResult::StepRemoved { remaining: 1 } + )); + assert!(matches!( + execute_back(&mut session), + SolanaCommandResult::StepRemoved { remaining: 0 } + )); + assert!(matches!( + execute_back(&mut session), + SolanaCommandResult::Error { .. } + )); + + assert!(matches!(execute_clear(&mut session), SolanaCommandResult::Cleared)); +} + +#[test] +fn execute_call_rejects_unknown_user_in_account() { + let idl = parse_idl(LEVER_JSON).unwrap(); + let program = ProgramDef::from_idl(idl).unwrap(); + let mut vm = VmHost::boot(vec![(program.program_id, read_lever_so())]).unwrap(); + let users: HashMap<String, Keypair> = HashMap::new(); + let mut session = ExplorationSession::new(&program.name, "ilold"); + + let mut accs = HashMap::new(); + accs.insert("power".into(), "ghost_user".to_string()); + + let result = execute_call( + &program, + "switch_power", + serde_json::json!({"name": "x"}), + accs, + vec![], + &users, + &mut session, + &mut vm, + "2026-05-06T00:00:00Z", + ); + match result { + SolanaCommandResult::Error { message } => { + assert!(message.contains("ghost_user"), "got: {message}"); + } + other => panic!("expected Error, got {other:?}"), + } +} + +#[test] +fn execute_call_rejects_missing_signer_keypair() { + let idl = parse_idl(LEVER_JSON).unwrap(); + let program = ProgramDef::from_idl(idl).unwrap(); + let mut vm = VmHost::boot(vec![(program.program_id, read_lever_so())]).unwrap(); + let users: HashMap<String, Keypair> = HashMap::new(); + let mut session = ExplorationSession::new(&program.name, "ilold"); + + let mut accs = HashMap::new(); + accs.insert("power".into(), Keypair::new().pubkey().to_string()); + accs.insert("user".into(), Keypair::new().pubkey().to_string()); + accs.insert( + "system_program".into(), + "11111111111111111111111111111111".to_string(), + ); + + let result = execute_call( + &program, + "initialize", + serde_json::json!({}), + accs, + vec![], + &users, + &mut session, + &mut vm, + "2026-05-06T00:00:00Z", + ); + match result { + SolanaCommandResult::Error { message } => { + assert!( + message.contains("signer"), + "expected signer error, got: {message}" + ); + } + other => panic!("expected Error, got {other:?}"), + } +} diff --git a/crates/ilold-solana-core/tests/execute_readonly.rs b/crates/ilold-solana-core/tests/execute_readonly.rs new file mode 100644 index 0000000..85c8857 --- /dev/null +++ b/crates/ilold-solana-core/tests/execute_readonly.rs @@ -0,0 +1,99 @@ +use ilold_session_core::exploration::session::ExplorationSession; +use ilold_solana_core::exploration::{ + execute_funcs, execute_pda, execute_session, SolanaCommandResult, +}; +use ilold_solana_core::idl::parse_idl; +use ilold_solana_core::model::ProgramDef; + +const LEVER_JSON: &str = include_str!("fixtures/lever.json"); +const RELATIONS_JSON: &str = include_str!("fixtures/relations.json"); + +fn lever_program() -> ProgramDef { + let idl = parse_idl(LEVER_JSON).expect("parse lever"); + ProgramDef::from_idl(idl).expect("build lever ProgramDef") +} + +fn relations_program() -> ProgramDef { + let idl = parse_idl(RELATIONS_JSON).expect("parse relations"); + ProgramDef::from_idl(idl).expect("build relations ProgramDef") +} + +#[test] +fn funcs_lists_lever_instructions() { + let program = lever_program(); + let result = execute_funcs(&program); + let items = match result { + SolanaCommandResult::InstructionList { items } => items, + other => panic!("expected InstructionList, got {other:?}"), + }; + assert_eq!(items.len(), 2); + let names: Vec<_> = items.iter().map(|i| i.name.as_str()).collect(); + assert!(names.contains(&"initialize")); + assert!(names.contains(&"switch_power")); + + let init = items.iter().find(|i| i.name == "initialize").unwrap(); + assert!(init.signers.iter().any(|s| s == "user")); + assert!(init.signers.iter().any(|s| s == "power")); + assert_eq!(init.has_pdas, false); +} + +#[test] +fn pda_lists_relations_pdas_symbolically() { + let program = relations_program(); + let with_pdas: Vec<_> = program + .instructions + .iter() + .find(|ix| ix.accounts.iter().any(|a| a.pda.is_some())) + .into_iter() + .collect(); + let probe = with_pdas + .first() + .expect("relations IDL should declare at least one ix with PDAs"); + + let result = execute_pda(&program, &probe.name); + let pdas = match result { + SolanaCommandResult::PdaList { pdas, .. } => pdas, + other => panic!("expected PdaList, got {other:?}"), + }; + assert!(!pdas.is_empty(), "expected at least one PDA in {}", probe.name); + let entry = &pdas[0]; + assert!(!entry.account_name.is_empty()); + assert!(!entry.seeds.is_empty(), "PDAs must have seeds"); + assert!( + !entry.program.is_empty(), + "PDA program must resolve to a string" + ); +} + +#[test] +fn pda_unknown_instruction_returns_error() { + let program = lever_program(); + let result = execute_pda(&program, "does_not_exist"); + match result { + SolanaCommandResult::Error { message } => { + assert!(message.contains("does_not_exist")); + } + other => panic!("expected Error, got {other:?}"), + } +} + +#[test] +fn session_view_reports_empty_on_fresh_session() { + let program = lever_program(); + let session = ExplorationSession::new(&program.name, "ilold"); + let result = execute_session(&session, &program, "main"); + match result { + SolanaCommandResult::SessionView { + program: prog, + scenario, + steps, + findings_count, + } => { + assert_eq!(prog, program.name); + assert_eq!(scenario, "main"); + assert!(steps.is_empty()); + assert_eq!(findings_count, 0); + } + other => panic!("expected SessionView, got {other:?}"), + } +} diff --git a/crates/ilold-solana-core/tests/execute_state_ops.rs b/crates/ilold-solana-core/tests/execute_state_ops.rs new file mode 100644 index 0000000..78d6531 --- /dev/null +++ b/crates/ilold-solana-core/tests/execute_state_ops.rs @@ -0,0 +1,150 @@ +use std::collections::HashMap; + +use ilold_session_core::exploration::session::ExplorationSession; +use ilold_session_core::journal::types::{ReviewStatus, Severity}; +use ilold_solana_core::execute::VmHost; +use ilold_solana_core::exploration::{ + execute_airdrop, execute_finding, execute_note, execute_status, execute_time_warp, + execute_users, execute_users_new, SolanaCommandResult, +}; +use ilold_solana_core::idl::parse_idl; +use ilold_solana_core::model::ProgramDef; +use solana_keypair::Keypair; + +const LEVER_JSON: &str = include_str!("fixtures/lever.json"); + +fn empty_vm() -> VmHost { + VmHost::boot(Vec::new()).expect("boot empty vm") +} + +fn lever_program() -> ProgramDef { + let idl = parse_idl(LEVER_JSON).unwrap(); + ProgramDef::from_idl(idl).unwrap() +} + +#[test] +fn users_new_generates_keypair_and_airdrops() { + let mut vm = empty_vm(); + let mut users = HashMap::<String, Keypair>::new(); + + let result = execute_users_new("alice".into(), 5_000_000_000, &mut users, &mut vm); + match result { + SolanaCommandResult::UserCreated { name, pubkey, lamports } => { + assert_eq!(name, "alice"); + assert!(!pubkey.is_empty()); + assert_eq!(lamports, 5_000_000_000); + } + other => panic!("expected UserCreated, got {other:?}"), + } + assert!(users.contains_key("alice")); + + let dup = execute_users_new("alice".into(), 0, &mut users, &mut vm); + assert!(matches!(dup, SolanaCommandResult::Error { .. })); +} + +#[test] +fn users_list_shows_balances() { + let mut vm = empty_vm(); + let mut users = HashMap::<String, Keypair>::new(); + execute_users_new("bob".into(), 2_000_000_000, &mut users, &mut vm); + execute_users_new("admin".into(), 8_000_000_000, &mut users, &mut vm); + + match execute_users(&users, &vm) { + SolanaCommandResult::UserList { users: list } => { + assert_eq!(list.len(), 2); + let admin = list.iter().find(|u| u.name == "admin").unwrap(); + assert_eq!(admin.lamports, 8_000_000_000); + let bob = list.iter().find(|u| u.name == "bob").unwrap(); + assert_eq!(bob.lamports, 2_000_000_000); + } + other => panic!("expected UserList, got {other:?}"), + } +} + +#[test] +fn airdrop_unknown_user_errors() { + let mut vm = empty_vm(); + let users = HashMap::<String, Keypair>::new(); + let result = execute_airdrop("ghost", 1_000, &users, &mut vm); + assert!(matches!(result, SolanaCommandResult::Error { .. })); +} + +#[test] +fn airdrop_known_user_increases_balance() { + let mut vm = empty_vm(); + let mut users = HashMap::<String, Keypair>::new(); + execute_users_new("bob".into(), 1_000_000_000, &mut users, &mut vm); + + match execute_airdrop("bob", 500_000_000, &users, &mut vm) { + SolanaCommandResult::Airdropped { name, total_lamports, .. } => { + assert_eq!(name, "bob"); + assert_eq!(total_lamports, 1_500_000_000); + } + other => panic!("expected Airdropped, got {other:?}"), + } +} + +#[test] +fn time_warp_advances_clock() { + let mut vm = empty_vm(); + let before = vm.clock(); + let result = execute_time_warp(86_400, &mut vm); + match result { + SolanaCommandResult::TimeWarped { unix_timestamp, slot } => { + assert_eq!(unix_timestamp, before.unix_timestamp + 86_400); + assert_eq!(slot, before.slot + 86_400); + } + other => panic!("expected TimeWarped, got {other:?}"), + } + assert_eq!(vm.clock().unix_timestamp, before.unix_timestamp + 86_400); +} + +#[test] +fn finding_and_note_and_status_record_journal() { + let program = lever_program(); + let mut session = ExplorationSession::new(&program.name, "ilold"); + + match execute_finding( + &mut session, + Severity::High, + "missing signer check".into(), + "switch_power should require admin".into(), + None, + "2026-05-06T00:00:00Z", + ) { + SolanaCommandResult::FindingAdded { id } => assert!(!id.is_empty()), + other => panic!("expected FindingAdded, got {other:?}"), + } + assert_eq!(session.journal.findings.len(), 1); + + match execute_note(&mut session, "looking at switch_power", "2026-05-06T00:00:01Z") { + SolanaCommandResult::NoteAdded => {} + other => panic!("expected NoteAdded, got {other:?}"), + } + + match execute_status( + &mut session, + &program, + "switch_power", + ReviewStatus::Reviewed, + "2026-05-06T00:00:02Z", + ) { + SolanaCommandResult::StatusUpdated => {} + other => panic!("expected StatusUpdated, got {other:?}"), + } + assert_eq!( + session.journal.function_status.get("switch_power"), + Some(&ReviewStatus::Reviewed) + ); + + match execute_status( + &mut session, + &program, + "ghost_ix", + ReviewStatus::Reviewed, + "2026-05-06T00:00:03Z", + ) { + SolanaCommandResult::Error { message } => assert!(message.contains("ghost_ix")), + other => panic!("expected Error, got {other:?}"), + } +} diff --git a/crates/ilold-solana-core/tests/fixtures/lever.json b/crates/ilold-solana-core/tests/fixtures/lever.json new file mode 100644 index 0000000..3ec862d --- /dev/null +++ b/crates/ilold-solana-core/tests/fixtures/lever.json @@ -0,0 +1,68 @@ +{ + "address": "E64FVeubGC4NPNF2UBJYX4AkrVowf74fRJD9q6YhwstN", + "metadata": { + "name": "lever", + "version": "0.1.0", + "spec": "0.1.0", + "description": "Created with Anchor" + }, + "instructions": [ + { + "name": "initialize", + "discriminator": [175, 175, 109, 31, 13, 152, 155, 237], + "accounts": [ + { + "name": "power", + "writable": true, + "signer": true + }, + { + "name": "user", + "writable": true, + "signer": true + }, + { + "name": "system_program", + "address": "11111111111111111111111111111111" + } + ], + "args": [] + }, + { + "name": "switch_power", + "discriminator": [226, 238, 56, 172, 191, 45, 122, 87], + "accounts": [ + { + "name": "power", + "writable": true + } + ], + "args": [ + { + "name": "name", + "type": "string" + } + ] + } + ], + "accounts": [ + { + "name": "PowerStatus", + "discriminator": [145, 147, 198, 35, 253, 101, 231, 26] + } + ], + "types": [ + { + "name": "PowerStatus", + "type": { + "kind": "struct", + "fields": [ + { + "name": "is_on", + "type": "bool" + } + ] + } + } + ] +} diff --git a/crates/ilold-solana-core/tests/fixtures/relations.json b/crates/ilold-solana-core/tests/fixtures/relations.json new file mode 100644 index 0000000..6418651 --- /dev/null +++ b/crates/ilold-solana-core/tests/fixtures/relations.json @@ -0,0 +1,151 @@ +{ + "address": "Re1ationsDerivation111111111111111111111111", + "metadata": { + "name": "relations_derivation", + "version": "0.1.0", + "spec": "0.1.0", + "description": "Created with Anchor" + }, + "instructions": [ + { + "name": "init_base", + "discriminator": [ + 85, + 87, + 185, + 141, + 241, + 191, + 213, + 88 + ], + "accounts": [ + { + "name": "my_account", + "writable": true, + "signer": true + }, + { + "name": "account", + "writable": true, + "pda": { + "seeds": [ + { + "kind": "const", + "value": [ + 115, + 101, + 101, + 100 + ] + } + ] + } + }, + { + "name": "system_program", + "address": "11111111111111111111111111111111" + } + ], + "args": [] + }, + { + "name": "test_relation", + "discriminator": [ + 247, + 199, + 255, + 202, + 7, + 0, + 197, + 158 + ], + "accounts": [ + { + "name": "my_account", + "relations": [ + "account" + ] + }, + { + "name": "account", + "pda": { + "seeds": [ + { + "kind": "const", + "value": [ + 115, + 101, + 101, + 100 + ] + } + ] + } + }, + { + "name": "nested", + "accounts": [ + { + "name": "my_account", + "relations": [ + "account" + ] + }, + { + "name": "account", + "pda": { + "seeds": [ + { + "kind": "const", + "value": [ + 115, + 101, + 101, + 100 + ] + } + ] + } + } + ] + } + ], + "args": [] + } + ], + "accounts": [ + { + "name": "MyAccount", + "discriminator": [ + 246, + 28, + 6, + 87, + 251, + 45, + 50, + 42 + ] + } + ], + "types": [ + { + "name": "MyAccount", + "type": { + "kind": "struct", + "fields": [ + { + "name": "my_account", + "type": "pubkey" + }, + { + "name": "bump", + "type": "u8" + } + ] + } + } + ] +} \ No newline at end of file diff --git a/crates/ilold-solana-core/tests/fork.rs b/crates/ilold-solana-core/tests/fork.rs new file mode 100644 index 0000000..db4306b --- /dev/null +++ b/crates/ilold-solana-core/tests/fork.rs @@ -0,0 +1,66 @@ +use ilold_solana_core::execute::VmHost; +use solana_keypair::Keypair; +use solana_signer::Signer; + +#[test] +fn snapshot_then_restore_preserves_payer_and_clock() { + let mut host = VmHost::boot(Vec::new()).unwrap(); + host.warp_clock(123, 1_700_000_000); + + let snap = host.snapshot(); + let restored = VmHost::restore(snap).unwrap(); + + assert_eq!(restored.payer_pubkey(), host.payer_pubkey()); + let clock = restored.clock(); + assert_eq!(clock.slot, 123); + assert_eq!(clock.unix_timestamp, 1_700_000_000); +} + +#[test] +fn fork_branches_diverge_independently() { + let mut main = VmHost::boot(Vec::new()).unwrap(); + + let alice = Keypair::new().pubkey(); + main.airdrop(alice, 1_000_000_000).unwrap(); + + let snap = main.snapshot(); + + let bob = Keypair::new().pubkey(); + main.airdrop(bob, 2_000_000_000).unwrap(); + + let mut branch = VmHost::restore(snap).unwrap(); + let charlie = Keypair::new().pubkey(); + branch.airdrop(charlie, 3_000_000_000).unwrap(); + + println!("\n=== fork showcase ==="); + println!("main branch:"); + println!(" alice = {} lamports", main.balance(&alice)); + println!(" bob = {} lamports", main.balance(&bob)); + println!(" charlie = {} lamports", main.balance(&charlie)); + println!("forked branch:"); + println!(" alice = {} lamports", branch.balance(&alice)); + println!(" bob = {} lamports", branch.balance(&bob)); + println!(" charlie = {} lamports", branch.balance(&charlie)); + + assert_eq!(main.balance(&alice), 1_000_000_000); + assert_eq!(main.balance(&bob), 2_000_000_000); + assert_eq!(main.balance(&charlie), 0); + + assert_eq!(branch.balance(&alice), 1_000_000_000); + assert_eq!(branch.balance(&bob), 0); + assert_eq!(branch.balance(&charlie), 3_000_000_000); +} + +#[test] +fn restore_with_invalid_payer_bytes_fails() { + let mut snap = VmHost::boot(Vec::new()).unwrap().snapshot(); + snap.payer_bytes = [0u8; 64]; + + match VmHost::restore(snap) { + Ok(_) => panic!("expected restore to fail with zeroed payer bytes"), + Err(e) => assert!( + matches!(e, ilold_solana_core::error::SolanaError::VmBootFailed(_)), + "got {e:?}" + ), + } +} diff --git a/crates/ilold-solana-core/tests/idl_parse.rs b/crates/ilold-solana-core/tests/idl_parse.rs new file mode 100644 index 0000000..22facaf --- /dev/null +++ b/crates/ilold-solana-core/tests/idl_parse.rs @@ -0,0 +1,78 @@ +use std::path::Path; + +use anchor_lang_idl::types::{IdlInstructionAccountItem, IdlSeed}; +use ilold_solana_core::error::SolanaError; +use ilold_solana_core::idl::{parse_idl, parse_idl_dir}; + +#[test] +fn parse_lever_basic_shape() { + let json = include_str!("fixtures/lever.json"); + let idl = parse_idl(json).expect("lever IDL should parse"); + + assert_eq!(idl.metadata.name, "lever"); + assert_eq!(idl.address, "E64FVeubGC4NPNF2UBJYX4AkrVowf74fRJD9q6YhwstN"); + assert_eq!(idl.instructions.len(), 2); + assert_eq!(idl.instructions[0].name, "initialize"); + assert_eq!(idl.instructions[1].name, "switch_power"); + assert_eq!(idl.accounts.len(), 1); + assert_eq!(idl.accounts[0].name, "PowerStatus"); + assert_eq!(idl.instructions[0].discriminator.len(), 8); + assert_eq!(idl.accounts[0].discriminator.len(), 8); +} + +#[test] +fn parse_relations_has_pdas_and_composites() { + let json = include_str!("fixtures/relations.json"); + let idl = parse_idl(json).expect("relations IDL should parse"); + + assert_eq!(idl.metadata.name, "relations_derivation"); + + let pdas: Vec<_> = idl + .instructions + .iter() + .flat_map(|ix| ix.accounts.iter()) + .filter_map(|a| match a { + IdlInstructionAccountItem::Single(s) => s.pda.as_ref(), + IdlInstructionAccountItem::Composite(_) => None, + }) + .collect(); + assert!(pdas.len() >= 2, "expected multiple PDAs, got {}", pdas.len()); + + let composite_count = idl + .instructions + .iter() + .flat_map(|ix| ix.accounts.iter()) + .filter(|a| matches!(a, IdlInstructionAccountItem::Composite(_))) + .count(); + assert!(composite_count >= 1, "expected at least one composite account group"); + + let has_const_seed = pdas + .iter() + .flat_map(|p| &p.seeds) + .any(|s| matches!(s, IdlSeed::Const(_))); + assert!(has_const_seed, "expected at least one Const seed"); +} + +#[test] +fn parse_idl_dir_reads_all_jsons_sorted() { + let dir = Path::new("tests/fixtures"); + let idls = parse_idl_dir(dir).expect("fixtures dir should parse"); + + assert_eq!(idls.len(), 2); + assert_eq!(idls[0].0.file_name().unwrap(), "lever.json"); + assert_eq!(idls[1].0.file_name().unwrap(), "relations.json"); + assert_eq!(idls[0].1.metadata.name, "lever"); + assert_eq!(idls[1].1.metadata.name, "relations_derivation"); +} + +#[test] +fn parse_idl_invalid_json_returns_parse_error() { + let err = parse_idl("not valid json {{{").unwrap_err(); + assert!(matches!(err, SolanaError::IdlParseFailed { .. })); +} + +#[test] +fn parse_idl_dir_missing_returns_read_error() { + let err = parse_idl_dir(Path::new("/nonexistent/path/__ilold_test__")).unwrap_err(); + assert!(matches!(err, SolanaError::IdlReadFailed { .. })); +} diff --git a/crates/ilold-solana-core/tests/model_and_ingest.rs b/crates/ilold-solana-core/tests/model_and_ingest.rs new file mode 100644 index 0000000..18011bd --- /dev/null +++ b/crates/ilold-solana-core/tests/model_and_ingest.rs @@ -0,0 +1,137 @@ +use std::fs; + +use ilold_solana_core::error::SolanaError; +use ilold_solana_core::idl::parse_idl; +use ilold_solana_core::ingest::{detect, ProjectKind}; +use ilold_solana_core::model::{ProgramDef, SeedSpec, SolanaProject}; + +const LEVER_JSON: &str = include_str!("fixtures/lever.json"); +const RELATIONS_JSON: &str = include_str!("fixtures/relations.json"); + +#[test] +fn program_def_from_lever_idl() { + let idl = parse_idl(LEVER_JSON).unwrap(); + let program = ProgramDef::from_idl(idl).unwrap(); + + assert_eq!(program.name, "lever"); + assert_eq!(program.program_id.to_string(), "E64FVeubGC4NPNF2UBJYX4AkrVowf74fRJD9q6YhwstN"); + assert_eq!(program.instructions.len(), 2); + + let init = &program.instructions[0]; + assert_eq!(init.name, "initialize"); + assert_eq!(init.discriminator.len(), 8); + assert!(!init.accounts.is_empty()); + + let switch = &program.instructions[1]; + assert_eq!(switch.name, "switch_power"); + assert!(!switch.accounts.is_empty()); + + assert_eq!(program.account_types.len(), 1); + assert_eq!(program.account_types[0].name, "PowerStatus"); + assert_eq!(program.account_types[0].discriminator.len(), 8); +} + +#[test] +fn program_def_from_relations_flattens_composites_and_maps_seeds() { + let idl = parse_idl(RELATIONS_JSON).unwrap(); + let program = ProgramDef::from_idl(idl).unwrap(); + + let composite_paths: Vec<&str> = program + .instructions + .iter() + .flat_map(|ix| ix.accounts.iter()) + .filter(|a| a.path.contains('.')) + .map(|a| a.path.as_str()) + .collect(); + assert!( + !composite_paths.is_empty(), + "expected at least one dotted path from a composite group" + ); + + let any_const_seed = program + .instructions + .iter() + .flat_map(|ix| ix.accounts.iter()) + .filter_map(|a| a.pda.as_ref()) + .flat_map(|pda| pda.seeds.iter()) + .any(|s| matches!(s, SeedSpec::Const { .. })); + assert!(any_const_seed, "expected at least one Const seed mapped"); +} + +#[test] +fn solana_project_index_lookup() { + let lever = ProgramDef::from_idl(parse_idl(LEVER_JSON).unwrap()).unwrap(); + let relations = ProgramDef::from_idl(parse_idl(RELATIONS_JSON).unwrap()).unwrap(); + + let project = SolanaProject::new(vec![lever, relations]); + + assert!(project.find_program("lever").is_some()); + assert!(project.find_program("relations_derivation").is_some()); + assert!(project.find_program("missing").is_none()); +} + +#[test] +fn detect_anchor_project() { + let dir = tempfile::tempdir().unwrap(); + fs::write(dir.path().join("Anchor.toml"), "[programs.localnet]\n").unwrap(); + + let detected = detect(dir.path()).unwrap(); + assert_eq!(detected.kind, ProjectKind::Solana); + assert_eq!(detected.root, dir.path()); + assert!(detected.idl_paths.is_empty()); + assert!(detected.so_paths.is_empty()); +} + +#[test] +fn detect_solidity_project_via_foundry_toml() { + let dir = tempfile::tempdir().unwrap(); + fs::write(dir.path().join("foundry.toml"), "[profile.default]\n").unwrap(); + + let detected = detect(dir.path()).unwrap(); + assert_eq!(detected.kind, ProjectKind::Solidity); +} + +#[test] +fn detect_solidity_project_via_sol_files() { + let dir = tempfile::tempdir().unwrap(); + fs::create_dir_all(dir.path().join("src")).unwrap(); + fs::write( + dir.path().join("src/Token.sol"), + "// SPDX-License-Identifier: MIT\npragma solidity ^0.8.0;\ncontract Token {}\n", + ) + .unwrap(); + + let detected = detect(dir.path()).unwrap(); + assert_eq!(detected.kind, ProjectKind::Solidity); +} + +#[test] +fn detect_mixed_project_returns_error() { + let dir = tempfile::tempdir().unwrap(); + fs::write(dir.path().join("Anchor.toml"), "[programs.localnet]\n").unwrap(); + fs::write(dir.path().join("foundry.toml"), "[profile.default]\n").unwrap(); + + let err = detect(dir.path()).unwrap_err(); + assert!(matches!(err, SolanaError::MixedProject { .. })); +} + +#[test] +fn detect_empty_directory_returns_unknown_type() { + let dir = tempfile::tempdir().unwrap(); + let err = detect(dir.path()).unwrap_err(); + assert!(matches!(err, SolanaError::UnknownProjectType { .. })); +} + +#[test] +fn detect_anchor_finds_idls_under_target() { + let dir = tempfile::tempdir().unwrap(); + fs::write(dir.path().join("Anchor.toml"), "[programs.localnet]\n").unwrap(); + let idl_dir = dir.path().join("target").join("idl"); + fs::create_dir_all(&idl_dir).unwrap(); + fs::write(idl_dir.join("foo.json"), LEVER_JSON).unwrap(); + + let detected = detect(dir.path()).unwrap(); + assert_eq!(detected.kind, ProjectKind::Solana); + assert_eq!(detected.idl_paths.len(), 1); + assert!(detected.idl_paths[0].ends_with("foo.json")); +} diff --git a/crates/ilold-solana-core/tests/overlay_callfailed.rs b/crates/ilold-solana-core/tests/overlay_callfailed.rs new file mode 100644 index 0000000..954eab0 --- /dev/null +++ b/crates/ilold-solana-core/tests/overlay_callfailed.rs @@ -0,0 +1,87 @@ +use std::collections::HashMap; +use std::path::PathBuf; + +use ilold_session_core::exploration::session::ExplorationSession; +use ilold_solana_core::execute::VmHost; +use ilold_solana_core::exploration::{execute_call, SolanaCommandResult}; +use ilold_solana_core::idl::parse_idl; +use ilold_solana_core::model::ProgramDef; +use ilold_solana_core::overlay::RuntimeOverlay; +use solana_keypair::Keypair; +use solana_signer::Signer; + +const LEVER_JSON: &str = include_str!("fixtures/lever.json"); + +fn lever_so_path() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/programs/lever.so") +} + +fn read_lever_so() -> Vec<u8> { + std::fs::read(lever_so_path()).expect( + "lever.so missing — run `cd tests/fixtures/solana/cpi && anchor build` and copy \ + target/deploy/lever.so to crates/ilold-solana-core/tests/programs/lever.so", + ) +} + +#[test] +fn overlay_failed_per_ix_increments_on_real_callfailed_flow() { + let idl = parse_idl(LEVER_JSON).expect("parse lever idl"); + let program = ProgramDef::from_idl(idl).expect("build ProgramDef"); + let so_bytes = read_lever_so(); + let mut vm = VmHost::boot(vec![(program.program_id, so_bytes)]).expect("boot vm"); + + let admin = Keypair::new(); + vm.svm_mut() + .airdrop(&admin.pubkey(), 5_000_000_000) + .expect("airdrop admin"); + + let mut users: HashMap<String, Keypair> = HashMap::new(); + users.insert("admin".into(), admin.insecure_clone()); + + let mut session = ExplorationSession::new("lever", "ilold"); + + // switch_power against an uninitialized `power` account — Anchor rejects + // because the account holds zero bytes (no PowerStatus discriminator), so + // add_solana_step returns step_index: None and execute_call must surface + // CallFailed. Real flow exercising the CallFailed branch end-to-end. + let stray = Keypair::new(); + let mut accounts_in: HashMap<String, String> = HashMap::new(); + accounts_in.insert("power".into(), stray.pubkey().to_string()); + + let result = execute_call( + &program, + "switch_power", + serde_json::json!({"name": "claude"}), + accounts_in, + vec![], + &users, + &mut session, + &mut vm, + "2026-05-09T00:00:00Z", + ); + + assert!( + matches!(result, SolanaCommandResult::CallFailed { .. }), + "expected CallFailed, got {result:?}" + ); + assert!( + session.steps.is_empty(), + "failed Call must not push a session step" + ); + assert_eq!( + session.failed_calls_per_ix.get("switch_power").copied(), + Some(1), + "failed_calls_per_ix must be incremented from execute_call CallFailed branch" + ); + + let overlay = RuntimeOverlay::from_session(&session); + assert_eq!( + overlay.failed_per_ix.get("switch_power").copied(), + Some(1), + "RuntimeOverlay::from_session must surface failed_calls_per_ix" + ); + assert!( + overlay.calls_per_ix.is_empty(), + "no successful Call happened, calls_per_ix should stay empty" + ); +} diff --git a/crates/ilold-solana-core/tests/pda.rs b/crates/ilold-solana-core/tests/pda.rs new file mode 100644 index 0000000..099cc67 --- /dev/null +++ b/crates/ilold-solana-core/tests/pda.rs @@ -0,0 +1,146 @@ +use std::collections::HashMap; + +use anchor_lang_idl::types::IdlType; +use ilold_solana_core::error::SolanaError; +use ilold_solana_core::execute::derive_pda; +use ilold_solana_core::model::{PdaSpec, SeedSpec}; +use serde_json::json; +use solana_address::Address; + +fn fixed_program_id() -> Address { + "E64FVeubGC4NPNF2UBJYX4AkrVowf74fRJD9q6YhwstN" + .parse() + .unwrap() +} + +#[test] +fn const_seed_matches_native_find_program_address() { + let program = fixed_program_id(); + let spec = PdaSpec { + seeds: vec![SeedSpec::Const { + value: b"vault".to_vec(), + }], + program: None, + bump_arg: None, + }; + + let (got_pk, got_bump) = derive_pda(&spec, program, &json!({}), &HashMap::new()).unwrap(); + let (expected_pk, expected_bump) = Address::find_program_address(&[b"vault"], &program); + + assert_eq!(got_pk, expected_pk); + assert_eq!(got_bump, expected_bump); +} + +#[test] +fn string_arg_seed_uses_utf8_bytes() { + let program = fixed_program_id(); + let spec = PdaSpec { + seeds: vec![ + SeedSpec::Const { + value: b"user".to_vec(), + }, + SeedSpec::Arg { + path: "name".into(), + ty: IdlType::String, + }, + ], + program: None, + bump_arg: None, + }; + let args = json!({"name": "alice"}); + + let (got, _bump) = derive_pda(&spec, program, &args, &HashMap::new()).unwrap(); + let (expected, _) = Address::find_program_address(&[b"user", b"alice"], &program); + assert_eq!(got, expected); +} + +#[test] +fn u64_arg_seed_uses_le_bytes() { + let program = fixed_program_id(); + let spec = PdaSpec { + seeds: vec![SeedSpec::Arg { + path: "id".into(), + ty: IdlType::U64, + }], + program: None, + bump_arg: None, + }; + let args = json!({"id": 42_u64}); + + let (got, _) = derive_pda(&spec, program, &args, &HashMap::new()).unwrap(); + let (expected, _) = Address::find_program_address(&[&42_u64.to_le_bytes()], &program); + assert_eq!(got, expected); +} + +#[test] +fn account_seed_uses_pubkey_bytes() { + let program = fixed_program_id(); + let user_pk: Address = "11111111111111111111111111111111".parse().unwrap(); + let spec = PdaSpec { + seeds: vec![SeedSpec::Account { + path: "user".into(), + }], + program: None, + bump_arg: None, + }; + let mut accounts = HashMap::new(); + accounts.insert("user".to_string(), user_pk); + + let (got, _) = derive_pda(&spec, program, &json!({}), &accounts).unwrap(); + let (expected, _) = Address::find_program_address(&[&user_pk.to_bytes()], &program); + assert_eq!(got, expected); +} + +#[test] +fn missing_arg_returns_seed_arg_unresolved() { + let program = fixed_program_id(); + let spec = PdaSpec { + seeds: vec![SeedSpec::Arg { + path: "missing".into(), + ty: IdlType::String, + }], + program: None, + bump_arg: None, + }; + + let err = derive_pda(&spec, program, &json!({}), &HashMap::new()).unwrap_err(); + assert!(matches!( + err, + SolanaError::SeedArgUnresolved { ref path } if path == "missing" + )); +} + +#[test] +fn arg_type_mismatch_returns_typed_error() { + let program = fixed_program_id(); + let spec = PdaSpec { + seeds: vec![SeedSpec::Arg { + path: "name".into(), + ty: IdlType::String, + }], + program: None, + bump_arg: None, + }; + let args = json!({"name": 123}); + + let err = derive_pda(&spec, program, &args, &HashMap::new()).unwrap_err(); + assert!(matches!(err, SolanaError::SeedTypeMismatch { .. })); +} + +#[test] +fn arg_in_program_slot_is_rejected() { + let program = fixed_program_id(); + let spec = PdaSpec { + seeds: vec![SeedSpec::Const { + value: b"x".to_vec(), + }], + program: Some(SeedSpec::Arg { + path: "p".into(), + ty: IdlType::Pubkey, + }), + bump_arg: None, + }; + + let err = derive_pda(&spec, program, &json!({}), &HashMap::new()).unwrap_err(); + assert!(matches!(err, SolanaError::PdaProgramArgUnsupported { .. })); +} diff --git a/crates/ilold-solana-core/tests/program_view_snapshot.rs b/crates/ilold-solana-core/tests/program_view_snapshot.rs new file mode 100644 index 0000000..3b20a6a --- /dev/null +++ b/crates/ilold-solana-core/tests/program_view_snapshot.rs @@ -0,0 +1,158 @@ +use std::fs; +use std::path::{Path, PathBuf}; + +use ilold_session_core::exploration::session::ExplorationSession; +use ilold_solana_core::exploration::{ + execute_funcs, execute_pda, execute_who, SolanaCommandResult, +}; +use ilold_solana_core::idl::parse_idl; +use ilold_solana_core::model::ProgramDef; +use ilold_solana_core::overlay::RuntimeOverlay; + +const LEVER_JSON: &str = include_str!("fixtures/lever.json"); +const RELATIONS_JSON: &str = include_str!("fixtures/relations.json"); +const STAKING_JSON: &str = include_str!("../../../tests/fixtures/solana/staking/idls/staking.json"); + +fn snapshot_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests") + .join("snapshots") + .join("funcs_who_pda_baseline") +} + +fn regen_enabled() -> bool { + std::env::var("ILOLD_REGEN_SNAPSHOTS").is_ok() +} + +fn assert_snapshot(name: &str, value: &SolanaCommandResult) { + let json = serde_json::to_string_pretty(value).expect("serialize result"); + let path = snapshot_root().join(format!("{name}.json")); + if regen_enabled() { + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(&path, format!("{json}\n")).unwrap(); + return; + } + let expected = fs::read_to_string(&path) + .unwrap_or_else(|e| panic!("missing snapshot {}: {e}", path.display())); + let expected_trimmed = expected.trim_end_matches('\n'); + let json_trimmed = json.trim_end_matches('\n'); + assert_eq!( + json_trimmed, expected_trimmed, + "snapshot drift in {name}\nstored at {}", + path.display() + ); +} + +fn staking_program() -> ProgramDef { + ProgramDef::from_idl(parse_idl(STAKING_JSON).expect("parse staking")) + .expect("build staking ProgramDef") +} + +fn lever_program() -> ProgramDef { + ProgramDef::from_idl(parse_idl(LEVER_JSON).expect("parse lever")) + .expect("build lever ProgramDef") +} + +fn relations_program() -> ProgramDef { + ProgramDef::from_idl(parse_idl(RELATIONS_JSON).expect("parse relations")) + .expect("build relations ProgramDef") +} + +#[test] +fn snapshot_funcs_staking() { + let program = staking_program(); + assert_snapshot("staking_funcs", &execute_funcs(&program)); +} + +#[test] +fn snapshot_funcs_lever() { + let program = lever_program(); + assert_snapshot("lever_funcs", &execute_funcs(&program)); +} + +#[test] +fn snapshot_who_pool() { + let program = staking_program(); + assert_snapshot("staking_who_pool", &execute_who(&program, "Pool")); +} + +#[test] +fn snapshot_who_user_stake() { + let program = staking_program(); + assert_snapshot("staking_who_user_stake", &execute_who(&program, "UserStake")); +} + +#[test] +fn snapshot_pda_staking_per_ix() { + let program = staking_program(); + for ix in &program.instructions { + let result = execute_pda(&program, &ix.name); + assert_snapshot(&format!("staking_pda_{}", ix.name), &result); + } +} + +#[test] +fn snapshot_pda_lever_per_ix() { + let program = lever_program(); + for ix in &program.instructions { + let result = execute_pda(&program, &ix.name); + assert_snapshot(&format!("lever_pda_{}", ix.name), &result); + } +} + +#[test] +fn snapshot_pda_relations_per_ix() { + let program = relations_program(); + for ix in &program.instructions { + let result = execute_pda(&program, &ix.name); + assert_snapshot(&format!("relations_pda_{}", ix.name), &result); + } +} + +#[test] +fn program_view_wire_format_staking() { + let view = staking_program().compute_view(); + let json = serde_json::to_string_pretty(&view).expect("serialize ProgramView"); + let path = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests") + .join("snapshots") + .join("staking_view.json"); + if regen_enabled() { + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(&path, format!("{json}\n")).unwrap(); + return; + } + let expected = fs::read_to_string(&path) + .unwrap_or_else(|e| panic!("missing wire-format snapshot {}: {e}", path.display())); + let expected_trimmed = expected.trim_end_matches('\n'); + let json_trimmed = json.trim_end_matches('\n'); + assert_eq!( + json_trimmed, expected_trimmed, + "ProgramView wire-format drift; if intentional, regen with ILOLD_REGEN_SNAPSHOTS=1" + ); +} + +#[test] +fn runtime_overlay_wire_format_empty_staking() { + let session = ExplorationSession::new("staking", "ilold"); + let mut overlay = RuntimeOverlay::from_session(&session); + overlay.scenario = "main".to_string(); + let json = serde_json::to_string_pretty(&overlay).expect("serialize RuntimeOverlay"); + let path = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests") + .join("snapshots") + .join("staking_overlay.json"); + if regen_enabled() { + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(&path, format!("{json}\n")).unwrap(); + return; + } + let expected = fs::read_to_string(&path) + .unwrap_or_else(|e| panic!("missing wire-format snapshot {}: {e}", path.display())); + let expected_trimmed = expected.trim_end_matches('\n'); + let json_trimmed = json.trim_end_matches('\n'); + assert_eq!( + json_trimmed, expected_trimmed, + "RuntimeOverlay wire-format drift; if intentional, regen with ILOLD_REGEN_SNAPSHOTS=1" + ); +} diff --git a/crates/ilold-solana-core/tests/programs/.gitkeep b/crates/ilold-solana-core/tests/programs/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/crates/ilold-solana-core/tests/programs/lever.so b/crates/ilold-solana-core/tests/programs/lever.so new file mode 100755 index 0000000..7ae7974 Binary files /dev/null and b/crates/ilold-solana-core/tests/programs/lever.so differ diff --git a/crates/ilold-solana-core/tests/snapshots/funcs_who_pda_baseline/lever_funcs.json b/crates/ilold-solana-core/tests/snapshots/funcs_who_pda_baseline/lever_funcs.json new file mode 100644 index 0000000..99fdb7b --- /dev/null +++ b/crates/ilold-solana-core/tests/snapshots/funcs_who_pda_baseline/lever_funcs.json @@ -0,0 +1,23 @@ +{ + "InstructionList": { + "items": [ + { + "name": "initialize", + "args_count": 0, + "accounts_count": 3, + "has_pdas": false, + "signers": [ + "power", + "user" + ] + }, + { + "name": "switch_power", + "args_count": 1, + "accounts_count": 1, + "has_pdas": false, + "signers": [] + } + ] + } +} diff --git a/crates/ilold-solana-core/tests/snapshots/funcs_who_pda_baseline/lever_pda_initialize.json b/crates/ilold-solana-core/tests/snapshots/funcs_who_pda_baseline/lever_pda_initialize.json new file mode 100644 index 0000000..a93d209 --- /dev/null +++ b/crates/ilold-solana-core/tests/snapshots/funcs_who_pda_baseline/lever_pda_initialize.json @@ -0,0 +1,6 @@ +{ + "PdaList": { + "instruction": "initialize", + "pdas": [] + } +} diff --git a/crates/ilold-solana-core/tests/snapshots/funcs_who_pda_baseline/lever_pda_switch_power.json b/crates/ilold-solana-core/tests/snapshots/funcs_who_pda_baseline/lever_pda_switch_power.json new file mode 100644 index 0000000..b73fca5 --- /dev/null +++ b/crates/ilold-solana-core/tests/snapshots/funcs_who_pda_baseline/lever_pda_switch_power.json @@ -0,0 +1,6 @@ +{ + "PdaList": { + "instruction": "switch_power", + "pdas": [] + } +} diff --git a/crates/ilold-solana-core/tests/snapshots/funcs_who_pda_baseline/relations_pda_init_base.json b/crates/ilold-solana-core/tests/snapshots/funcs_who_pda_baseline/relations_pda_init_base.json new file mode 100644 index 0000000..a2cf4ab --- /dev/null +++ b/crates/ilold-solana-core/tests/snapshots/funcs_who_pda_baseline/relations_pda_init_base.json @@ -0,0 +1,14 @@ +{ + "PdaList": { + "instruction": "init_base", + "pdas": [ + { + "account_name": "account", + "seeds": [ + "const:'seed'" + ], + "program": "Re1ationsDerivation111111111111111111111111" + } + ] + } +} diff --git a/crates/ilold-solana-core/tests/snapshots/funcs_who_pda_baseline/relations_pda_test_relation.json b/crates/ilold-solana-core/tests/snapshots/funcs_who_pda_baseline/relations_pda_test_relation.json new file mode 100644 index 0000000..965f88c --- /dev/null +++ b/crates/ilold-solana-core/tests/snapshots/funcs_who_pda_baseline/relations_pda_test_relation.json @@ -0,0 +1,21 @@ +{ + "PdaList": { + "instruction": "test_relation", + "pdas": [ + { + "account_name": "account", + "seeds": [ + "const:'seed'" + ], + "program": "Re1ationsDerivation111111111111111111111111" + }, + { + "account_name": "account", + "seeds": [ + "const:'seed'" + ], + "program": "Re1ationsDerivation111111111111111111111111" + } + ] + } +} diff --git a/crates/ilold-solana-core/tests/snapshots/funcs_who_pda_baseline/staking_funcs.json b/crates/ilold-solana-core/tests/snapshots/funcs_who_pda_baseline/staking_funcs.json new file mode 100644 index 0000000..8448ec7 --- /dev/null +++ b/crates/ilold-solana-core/tests/snapshots/funcs_who_pda_baseline/staking_funcs.json @@ -0,0 +1,53 @@ +{ + "InstructionList": { + "items": [ + { + "name": "add_rewards", + "args_count": 1, + "accounts_count": 2, + "has_pdas": false, + "signers": [ + "admin" + ] + }, + { + "name": "claim_rewards", + "args_count": 0, + "accounts_count": 3, + "has_pdas": false, + "signers": [ + "user" + ] + }, + { + "name": "initialize_pool", + "args_count": 1, + "accounts_count": 3, + "has_pdas": false, + "signers": [ + "pool", + "admin" + ] + }, + { + "name": "stake", + "args_count": 1, + "accounts_count": 4, + "has_pdas": false, + "signers": [ + "user_stake", + "user" + ] + }, + { + "name": "unstake", + "args_count": 1, + "accounts_count": 3, + "has_pdas": false, + "signers": [ + "user" + ] + } + ] + } +} diff --git a/crates/ilold-solana-core/tests/snapshots/funcs_who_pda_baseline/staking_pda_add_rewards.json b/crates/ilold-solana-core/tests/snapshots/funcs_who_pda_baseline/staking_pda_add_rewards.json new file mode 100644 index 0000000..0178486 --- /dev/null +++ b/crates/ilold-solana-core/tests/snapshots/funcs_who_pda_baseline/staking_pda_add_rewards.json @@ -0,0 +1,6 @@ +{ + "PdaList": { + "instruction": "add_rewards", + "pdas": [] + } +} diff --git a/crates/ilold-solana-core/tests/snapshots/funcs_who_pda_baseline/staking_pda_claim_rewards.json b/crates/ilold-solana-core/tests/snapshots/funcs_who_pda_baseline/staking_pda_claim_rewards.json new file mode 100644 index 0000000..f9cebc6 --- /dev/null +++ b/crates/ilold-solana-core/tests/snapshots/funcs_who_pda_baseline/staking_pda_claim_rewards.json @@ -0,0 +1,6 @@ +{ + "PdaList": { + "instruction": "claim_rewards", + "pdas": [] + } +} diff --git a/crates/ilold-solana-core/tests/snapshots/funcs_who_pda_baseline/staking_pda_initialize_pool.json b/crates/ilold-solana-core/tests/snapshots/funcs_who_pda_baseline/staking_pda_initialize_pool.json new file mode 100644 index 0000000..96a7871 --- /dev/null +++ b/crates/ilold-solana-core/tests/snapshots/funcs_who_pda_baseline/staking_pda_initialize_pool.json @@ -0,0 +1,6 @@ +{ + "PdaList": { + "instruction": "initialize_pool", + "pdas": [] + } +} diff --git a/crates/ilold-solana-core/tests/snapshots/funcs_who_pda_baseline/staking_pda_stake.json b/crates/ilold-solana-core/tests/snapshots/funcs_who_pda_baseline/staking_pda_stake.json new file mode 100644 index 0000000..2555a96 --- /dev/null +++ b/crates/ilold-solana-core/tests/snapshots/funcs_who_pda_baseline/staking_pda_stake.json @@ -0,0 +1,6 @@ +{ + "PdaList": { + "instruction": "stake", + "pdas": [] + } +} diff --git a/crates/ilold-solana-core/tests/snapshots/funcs_who_pda_baseline/staking_pda_unstake.json b/crates/ilold-solana-core/tests/snapshots/funcs_who_pda_baseline/staking_pda_unstake.json new file mode 100644 index 0000000..50fc3b4 --- /dev/null +++ b/crates/ilold-solana-core/tests/snapshots/funcs_who_pda_baseline/staking_pda_unstake.json @@ -0,0 +1,6 @@ +{ + "PdaList": { + "instruction": "unstake", + "pdas": [] + } +} diff --git a/crates/ilold-solana-core/tests/snapshots/funcs_who_pda_baseline/staking_who_pool.json b/crates/ilold-solana-core/tests/snapshots/funcs_who_pda_baseline/staking_who_pool.json new file mode 100644 index 0000000..685d9bb --- /dev/null +++ b/crates/ilold-solana-core/tests/snapshots/funcs_who_pda_baseline/staking_who_pool.json @@ -0,0 +1,86 @@ +{ + "WhoList": { + "account_type": "Pool", + "instructions": [ + { + "instruction": "add_rewards", + "account_field": "pool", + "writable": true, + "signer": false, + "account_type": "Pool", + "ix_args": [ + { + "name": "amount", + "ty": "u64" + } + ] + }, + { + "instruction": "claim_rewards", + "account_field": "pool", + "writable": true, + "signer": false, + "account_type": "Pool", + "ix_args": [] + }, + { + "instruction": "initialize_pool", + "account_field": "pool", + "writable": true, + "signer": true, + "account_type": "Pool", + "ix_args": [ + { + "name": "reward_rate", + "ty": "u64" + } + ] + }, + { + "instruction": "stake", + "account_field": "pool", + "writable": true, + "signer": false, + "account_type": "Pool", + "ix_args": [ + { + "name": "amount", + "ty": "u64" + } + ] + }, + { + "instruction": "unstake", + "account_field": "pool", + "writable": true, + "signer": false, + "account_type": "Pool", + "ix_args": [ + { + "name": "amount", + "ty": "u64" + } + ] + } + ], + "query_kind": "AccountType", + "owner_fields": [ + { + "name": "admin", + "ty": "Pubkey" + }, + { + "name": "total_staked", + "ty": "u64" + }, + { + "name": "total_rewards", + "ty": "u64" + }, + { + "name": "reward_rate", + "ty": "u64" + } + ] + } +} diff --git a/crates/ilold-solana-core/tests/snapshots/funcs_who_pda_baseline/staking_who_user_stake.json b/crates/ilold-solana-core/tests/snapshots/funcs_who_pda_baseline/staking_who_user_stake.json new file mode 100644 index 0000000..802f8d4 --- /dev/null +++ b/crates/ilold-solana-core/tests/snapshots/funcs_who_pda_baseline/staking_who_user_stake.json @@ -0,0 +1,56 @@ +{ + "WhoList": { + "account_type": "UserStake", + "instructions": [ + { + "instruction": "claim_rewards", + "account_field": "user_stake", + "writable": true, + "signer": false, + "account_type": "UserStake", + "ix_args": [] + }, + { + "instruction": "stake", + "account_field": "user_stake", + "writable": true, + "signer": true, + "account_type": "UserStake", + "ix_args": [ + { + "name": "amount", + "ty": "u64" + } + ] + }, + { + "instruction": "unstake", + "account_field": "user_stake", + "writable": true, + "signer": false, + "account_type": "UserStake", + "ix_args": [ + { + "name": "amount", + "ty": "u64" + } + ] + } + ], + "query_kind": "AccountType", + "owner_fields": [ + { + "name": "user", + "ty": "Pubkey" + }, + { + "name": "amount", + "ty": "u64" + }, + { + "name": "claimed_rewards", + "ty": "u64" + } + ] + } +} diff --git a/crates/ilold-solana-core/tests/snapshots/staking_overlay.json b/crates/ilold-solana-core/tests/snapshots/staking_overlay.json new file mode 100644 index 0000000..b75158c --- /dev/null +++ b/crates/ilold-solana-core/tests/snapshots/staking_overlay.json @@ -0,0 +1,8 @@ +{ + "program": "staking", + "scenario": "main", + "calls_per_ix": {}, + "failed_per_ix": {}, + "cu_stats_per_ix": {}, + "cpi_edges": [] +} diff --git a/crates/ilold-solana-core/tests/snapshots/staking_view.json b/crates/ilold-solana-core/tests/snapshots/staking_view.json new file mode 100644 index 0000000..d65be5f --- /dev/null +++ b/crates/ilold-solana-core/tests/snapshots/staking_view.json @@ -0,0 +1,322 @@ +{ + "name": "staking", + "program_id": "AQjgX8bsAU8CvvEoP9q36vAbT83dRxdjK4zGEhhn6SFc", + "instructions": [ + { + "name": "add_rewards", + "discriminator_hex": "0x58ba19e326895117", + "args": [ + { + "name": "amount", + "ty": "u64" + } + ], + "accounts": [ + { + "path": "pool", + "name": "pool", + "kind": "program", + "writable": true, + "signer": false, + "optional": false, + "relations": [] + }, + { + "path": "admin", + "name": "admin", + "kind": "other", + "writable": false, + "signer": true, + "optional": false, + "relations": [] + } + ] + }, + { + "name": "claim_rewards", + "discriminator_hex": "0x0490844774179750", + "args": [], + "accounts": [ + { + "path": "pool", + "name": "pool", + "kind": "program", + "writable": true, + "signer": false, + "optional": false, + "relations": [] + }, + { + "path": "user_stake", + "name": "user_stake", + "kind": "program", + "writable": true, + "signer": false, + "optional": false, + "relations": [] + }, + { + "path": "user", + "name": "user", + "kind": "other", + "writable": false, + "signer": true, + "optional": false, + "relations": [] + } + ] + }, + { + "name": "initialize_pool", + "discriminator_hex": "0x5fb40aac54aee828", + "args": [ + { + "name": "reward_rate", + "ty": "u64" + } + ], + "accounts": [ + { + "path": "pool", + "name": "pool", + "kind": "program", + "writable": true, + "signer": true, + "optional": false, + "relations": [] + }, + { + "path": "admin", + "name": "admin", + "kind": "other", + "writable": true, + "signer": true, + "optional": false, + "relations": [] + }, + { + "path": "system_program", + "name": "system_program", + "kind": "system", + "writable": false, + "signer": false, + "optional": false, + "address": "11111111111111111111111111111111", + "relations": [] + } + ] + }, + { + "name": "stake", + "discriminator_hex": "0xceb0ca12c8d1b36c", + "args": [ + { + "name": "amount", + "ty": "u64" + } + ], + "accounts": [ + { + "path": "pool", + "name": "pool", + "kind": "program", + "writable": true, + "signer": false, + "optional": false, + "relations": [] + }, + { + "path": "user_stake", + "name": "user_stake", + "kind": "program", + "writable": true, + "signer": true, + "optional": false, + "relations": [] + }, + { + "path": "user", + "name": "user", + "kind": "other", + "writable": true, + "signer": true, + "optional": false, + "relations": [] + }, + { + "path": "system_program", + "name": "system_program", + "kind": "system", + "writable": false, + "signer": false, + "optional": false, + "address": "11111111111111111111111111111111", + "relations": [] + } + ] + }, + { + "name": "unstake", + "discriminator_hex": "0x5a5f6b2acd7c32e1", + "args": [ + { + "name": "amount", + "ty": "u64" + } + ], + "accounts": [ + { + "path": "pool", + "name": "pool", + "kind": "program", + "writable": true, + "signer": false, + "optional": false, + "relations": [] + }, + { + "path": "user_stake", + "name": "user_stake", + "kind": "program", + "writable": true, + "signer": false, + "optional": false, + "relations": [] + }, + { + "path": "user", + "name": "user", + "kind": "other", + "writable": false, + "signer": true, + "optional": false, + "relations": [] + } + ] + } + ], + "accounts": [ + { + "name": "Pool", + "discriminator_hex": "0xf19a6d0411b16dbc", + "fields": [ + { + "name": "admin", + "ty": "Pubkey" + }, + { + "name": "total_staked", + "ty": "u64" + }, + { + "name": "total_rewards", + "ty": "u64" + }, + { + "name": "reward_rate", + "ty": "u64" + } + ] + }, + { + "name": "UserStake", + "discriminator_hex": "0x6635a36b098a5799", + "fields": [ + { + "name": "user", + "ty": "Pubkey" + }, + { + "name": "amount", + "ty": "u64" + }, + { + "name": "claimed_rewards", + "ty": "u64" + } + ] + } + ], + "state_coupling": [ + { + "a": "claim_rewards", + "b": "stake", + "shared_writable": [ + "pool", + "user_stake" + ] + }, + { + "a": "claim_rewards", + "b": "unstake", + "shared_writable": [ + "pool", + "user_stake" + ] + }, + { + "a": "stake", + "b": "unstake", + "shared_writable": [ + "pool", + "user_stake" + ] + }, + { + "a": "add_rewards", + "b": "claim_rewards", + "shared_writable": [ + "pool" + ] + }, + { + "a": "add_rewards", + "b": "initialize_pool", + "shared_writable": [ + "pool" + ] + }, + { + "a": "add_rewards", + "b": "stake", + "shared_writable": [ + "pool" + ] + }, + { + "a": "add_rewards", + "b": "unstake", + "shared_writable": [ + "pool" + ] + }, + { + "a": "claim_rewards", + "b": "initialize_pool", + "shared_writable": [ + "pool" + ] + }, + { + "a": "initialize_pool", + "b": "stake", + "shared_writable": [ + "pool" + ] + }, + { + "a": "initialize_pool", + "b": "unstake", + "shared_writable": [ + "pool" + ] + } + ], + "admin_gated": [ + "add_rewards", + "initialize_pool" + ], + "system_accounts": [ + "system_program" + ] +} diff --git a/crates/ilold-solana-core/tests/vm.rs b/crates/ilold-solana-core/tests/vm.rs new file mode 100644 index 0000000..6d7ee65 --- /dev/null +++ b/crates/ilold-solana-core/tests/vm.rs @@ -0,0 +1,43 @@ +use ilold_solana_core::error::SolanaError; +use ilold_solana_core::execute::{vm::DEFAULT_PAYER_LAMPORTS, VmHost}; +use solana_keypair::Keypair; +use solana_signer::Signer; + +#[test] +fn boot_empty_vm_funds_payer() { + let host = VmHost::boot(Vec::new()).expect("empty boot should succeed"); + assert_eq!(host.balance(&host.payer_pubkey()), DEFAULT_PAYER_LAMPORTS); +} + +#[test] +fn airdrop_credits_target_address() { + let mut host = VmHost::boot(Vec::new()).unwrap(); + let target = Keypair::new().pubkey(); + + let amount = 5_000_000_000; + host.airdrop(target, amount).unwrap(); + + assert_eq!(host.balance(&target), amount); +} + +#[test] +fn warp_clock_updates_slot_and_timestamp() { + let mut host = VmHost::boot(Vec::new()).unwrap(); + + host.warp_clock(1_000, 1_700_000_000); + let clock = host.clock(); + + assert_eq!(clock.slot, 1_000); + assert_eq!(clock.unix_timestamp, 1_700_000_000); +} + +#[test] +fn boot_with_invalid_program_bytes_returns_boot_failed() { + let fake_program_id = solana_keypair::Keypair::new().pubkey(); + let invalid_elf = vec![0u8; 16]; + + match VmHost::boot(vec![(fake_program_id, invalid_elf)]) { + Ok(_) => panic!("expected VmBootFailed for invalid ELF bytes"), + Err(e) => assert!(matches!(e, SolanaError::VmBootFailed(_))), + } +} diff --git a/crates/ilold-web/Cargo.toml b/crates/ilold-web/Cargo.toml index 8286d9a..bc01213 100644 --- a/crates/ilold-web/Cargo.toml +++ b/crates/ilold-web/Cargo.toml @@ -1,11 +1,15 @@ [package] name = "ilold-web" -version = "0.1.0" -edition = "2021" +version.workspace = true +edition.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"] } +solana-keypair = "3.1" axum = { workspace = true } tokio = { workspace = true } tower-http = { workspace = true } @@ -13,6 +17,8 @@ serde = { workspace = true } serde_json = { workspace = true } anyhow = { workspace = true } portable-pty = "0.8" +syn = { version = "2", features = ["full"] } +proc-macro2 = { version = "1", features = ["span-locations"] } [dev-dependencies] tokio-tungstenite = "0.24" diff --git a/crates/ilold-web/frontend/package.json b/crates/ilold-web/frontend/package.json index 083b029..dffa32f 100644 --- a/crates/ilold-web/frontend/package.json +++ b/crates/ilold-web/frontend/package.json @@ -9,7 +9,9 @@ "preview": "vite preview", "prepare": "svelte-kit sync || echo ''", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", - "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch" + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", + "check:program-view": "node scripts/check-program-view.mjs && node scripts/check-runtime-overlay.mjs", + "check:overlay": "node scripts/check-runtime-overlay.mjs" }, "devDependencies": { "@sveltejs/adapter-auto": "^7.0.0", diff --git a/crates/ilold-web/frontend/scripts/check-program-view.mjs b/crates/ilold-web/frontend/scripts/check-program-view.mjs new file mode 100644 index 0000000..fc9e28a --- /dev/null +++ b/crates/ilold-web/frontend/scripts/check-program-view.mjs @@ -0,0 +1,104 @@ +#!/usr/bin/env node +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; +import assert from 'node:assert/strict'; + +const here = dirname(fileURLToPath(import.meta.url)); +const snapshotPath = resolve( + here, + '../../../ilold-solana-core/tests/snapshots/staking_view.json', +); + +const raw = readFileSync(snapshotPath, 'utf8'); +const view = JSON.parse(raw); + +assert.equal(typeof view.name, 'string', 'name is string'); +assert.equal(typeof view.program_id, 'string', 'program_id is string'); +assert.ok(Array.isArray(view.instructions), 'instructions is array'); +assert.ok(view.instructions.length > 0, 'instructions non-empty'); +assert.ok(Array.isArray(view.accounts), 'accounts is array'); + +const KINDS = new Set(['program', 'system', 'sysvar', 'pda', 'other']); +const SEED_KINDS = new Set(['const', 'arg', 'account']); + +function checkSeed(seed, where) { + assert.equal(typeof seed, 'object', `${where}: seed is object`); + assert.ok(SEED_KINDS.has(seed.kind), `${where}: seed.kind unknown: ${seed.kind}`); + if (seed.kind === 'const') { + assert.equal(typeof seed.value_hex, 'string', `${where}: const.value_hex is string`); + if (seed.value_utf8 !== undefined) { + assert.equal(typeof seed.value_utf8, 'string', `${where}: const.value_utf8 is string`); + } + } else if (seed.kind === 'arg') { + assert.equal(typeof seed.name, 'string', `${where}: arg.name is string`); + assert.equal(typeof seed.ty, 'string', `${where}: arg.ty is string`); + } else if (seed.kind === 'account') { + assert.equal(typeof seed.path, 'string', `${where}: account.path is string`); + } +} + +function checkPda(pda, where) { + assert.equal(typeof pda, 'object', `${where}: pda is object`); + assert.ok(Array.isArray(pda.seeds), `${where}: pda.seeds is array`); + pda.seeds.forEach((s, i) => checkSeed(s, `${where}.seeds[${i}]`)); + if (pda.program !== undefined) { + assert.equal(typeof pda.program, 'string', `${where}: pda.program is string`); + } + if (pda.bump_arg !== undefined) { + assert.equal(typeof pda.bump_arg, 'string', `${where}: pda.bump_arg is string`); + } +} + +let pdaCount = 0; +view.instructions.forEach((ix, ixIdx) => { + const where = `ix[${ixIdx}] (${ix.name})`; + assert.equal(typeof ix.name, 'string', `${where}: name is string`); + assert.equal(typeof ix.discriminator_hex, 'string', `${where}: discriminator_hex is string`); + assert.ok(Array.isArray(ix.args), `${where}: args is array`); + assert.ok(Array.isArray(ix.accounts), `${where}: accounts is array`); + for (const a of ix.args) { + assert.equal(typeof a.name, 'string', `${where}: arg.name is string`); + assert.equal(typeof a.ty, 'string', `${where}: arg.ty is string`); + } + ix.accounts.forEach((acc, accIdx) => { + const accWhere = `${where}.accounts[${accIdx}] (${acc.name})`; + assert.equal(typeof acc.name, 'string', `${accWhere}: name is string`); + assert.equal(typeof acc.path, 'string', `${accWhere}: path is string`); + assert.ok(KINDS.has(acc.kind), `${accWhere}: kind unknown: ${acc.kind}`); + assert.equal(typeof acc.signer, 'boolean', `${accWhere}: signer is boolean`); + assert.equal(typeof acc.writable, 'boolean', `${accWhere}: writable is boolean`); + assert.equal(typeof acc.optional, 'boolean', `${accWhere}: optional is boolean`); + if (acc.pda !== undefined) { + checkPda(acc.pda, `${accWhere}.pda`); + pdaCount += 1; + } + }); +}); + +const pool = view.accounts.find((a) => a.name === 'Pool'); +assert.ok(pool, 'Pool account-type present'); +assert.ok(Array.isArray(pool.fields), 'Pool.fields is array'); +assert.ok(pool.fields.length >= 4, `Pool.fields >= 4 (got ${pool.fields.length})`); +for (const f of pool.fields) { + assert.equal(typeof f.name, 'string', 'field.name is string'); + assert.equal(typeof f.ty, 'string', 'field.ty is string'); +} + +if (view.state_coupling) { + for (const pair of view.state_coupling) { + assert.equal(typeof pair.a, 'string'); + assert.equal(typeof pair.b, 'string'); + assert.ok(Array.isArray(pair.shared_writable)); + } +} +if (view.admin_gated) { + assert.ok(Array.isArray(view.admin_gated), 'admin_gated is array'); +} +if (view.system_accounts) { + assert.ok(Array.isArray(view.system_accounts), 'system_accounts is array'); +} + +console.log( + `ok: ProgramView snapshot matches TS contract (${view.instructions.length} ixs, ${view.accounts.length} account-types, ${pdaCount} pda accounts)`, +); diff --git a/crates/ilold-web/frontend/scripts/check-runtime-overlay.mjs b/crates/ilold-web/frontend/scripts/check-runtime-overlay.mjs new file mode 100644 index 0000000..abb0a43 --- /dev/null +++ b/crates/ilold-web/frontend/scripts/check-runtime-overlay.mjs @@ -0,0 +1,52 @@ +#!/usr/bin/env node +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; +import assert from 'node:assert/strict'; + +const here = dirname(fileURLToPath(import.meta.url)); +const snapshotPath = resolve( + here, + '../../../ilold-solana-core/tests/snapshots/staking_overlay.json', +); + +const raw = readFileSync(snapshotPath, 'utf8'); +const overlay = JSON.parse(raw); + +assert.equal(typeof overlay.program, 'string', 'program is string'); +assert.equal(typeof overlay.scenario, 'string', 'scenario is string'); + +assert.equal(typeof overlay.calls_per_ix, 'object', 'calls_per_ix is object'); +assert.ok(!Array.isArray(overlay.calls_per_ix), 'calls_per_ix is map, not array'); +for (const [k, v] of Object.entries(overlay.calls_per_ix)) { + assert.equal(typeof k, 'string', `calls_per_ix key is string`); + assert.equal(typeof v, 'number', `calls_per_ix["${k}"] is number`); +} + +assert.equal(typeof overlay.failed_per_ix, 'object', 'failed_per_ix is object'); +assert.ok(!Array.isArray(overlay.failed_per_ix), 'failed_per_ix is map, not array'); +for (const [k, v] of Object.entries(overlay.failed_per_ix)) { + assert.equal(typeof k, 'string', `failed_per_ix key is string`); + assert.equal(typeof v, 'number', `failed_per_ix["${k}"] is number`); +} + +assert.equal(typeof overlay.cu_stats_per_ix, 'object', 'cu_stats_per_ix is object'); +assert.ok(!Array.isArray(overlay.cu_stats_per_ix), 'cu_stats_per_ix is map, not array'); +for (const [k, stats] of Object.entries(overlay.cu_stats_per_ix)) { + assert.equal(typeof stats.min, 'number', `${k}.min is number`); + assert.equal(typeof stats.max, 'number', `${k}.max is number`); + assert.equal(typeof stats.avg, 'number', `${k}.avg is number`); + assert.equal(typeof stats.samples, 'number', `${k}.samples is number`); +} + +assert.ok(Array.isArray(overlay.cpi_edges), 'cpi_edges is array'); +overlay.cpi_edges.forEach((edge, i) => { + assert.equal(typeof edge.from_ix, 'string', `cpi_edges[${i}].from_ix is string`); + assert.equal(typeof edge.to_program, 'string', `cpi_edges[${i}].to_program is string`); + assert.equal(typeof edge.depth, 'number', `cpi_edges[${i}].depth is number`); + assert.equal(typeof edge.samples, 'number', `cpi_edges[${i}].samples is number`); +}); + +console.log( + `ok: RuntimeOverlay snapshot matches TS contract (program=${overlay.program}, scenario=${overlay.scenario}, ${overlay.cpi_edges.length} cpi edges)`, +); diff --git a/crates/ilold-web/frontend/src/lib/api/rest.ts b/crates/ilold-web/frontend/src/lib/api/rest.ts index c8291e0..02739d5 100644 --- a/crates/ilold-web/frontend/src/lib/api/rest.ts +++ b/crates/ilold-web/frontend/src/lib/api/rest.ts @@ -76,10 +76,114 @@ export interface SearchSuggestions { } export interface ProjectMap { + kind: string; contracts: MapContract[]; + programs: MapProgram[]; relationships: MapRelationship[]; } +export interface MapProgram { + name: string; + program_id: string; + instructions: MapInstruction[]; + account_types: { name: string }[]; +} + +export interface MapInstruction { + name: string; + args_count: number; + accounts_count: number; + has_pdas: boolean; +} + +export type AccountKind = 'program' | 'system' | 'sysvar' | 'pda' | 'other'; + +export interface ArgView { + name: string; + ty: string; +} + +export interface FieldView { + name: string; + ty: string; +} + +export type SeedView = + | { kind: 'const'; value_hex: string; value_utf8?: string } + | { kind: 'arg'; name: string; ty: string } + | { kind: 'account'; path: string }; + +export interface PdaView { + seeds: SeedView[]; + program?: string; + bump_arg?: string; +} + +export interface IxAccountView { + path: string; + name: string; + kind: AccountKind; + writable: boolean; + signer: boolean; + optional: boolean; + address?: string; + pda?: PdaView; + relations: string[]; +} + +export interface IxView { + name: string; + discriminator_hex: string; + args: ArgView[]; + accounts: IxAccountView[]; + returns?: string; +} + +export interface AccountView { + name: string; + discriminator_hex: string; + fields: FieldView[]; +} + +export interface CouplingPair { + a: string; + b: string; + shared_writable: string[]; +} + +export interface ProgramView { + name: string; + program_id: string; + instructions: IxView[]; + accounts: AccountView[]; + state_coupling?: CouplingPair[]; + admin_gated?: string[]; + system_accounts?: string[]; +} + +export interface CuStats { + min: number; + max: number; + avg: number; + samples: number; +} + +export interface CpiEdge { + from_ix: string; + to_program: string; + depth: number; + samples: number; +} + +export interface RuntimeOverlay { + program: string; + scenario: string; + calls_per_ix: Record<string, number>; + failed_per_ix: Record<string, number>; + cu_stats_per_ix: Record<string, CuStats>; + cpi_edges: CpiEdge[]; +} + export interface MapContract { name: string; kind: string; @@ -124,6 +228,36 @@ export async function getContract(name: string): Promise<ContractDetail> { return res.json(); } +export async function getProgramView(name: string): Promise<ProgramView> { + const res = await fetch(`${BASE}/api/program/${encodeURIComponent(name)}/view`); + if (!res.ok) throw new Error(`Program ${name} not found`); + return res.json(); +} + +export async function getProgramOverlay( + name: string, + scenario?: string, +): Promise<RuntimeOverlay> { + const qs = scenario ? `?scenario=${encodeURIComponent(scenario)}` : ''; + const res = await fetch( + `${BASE}/api/program/${encodeURIComponent(name)}/overlay${qs}`, + ); + if (!res.ok) throw new Error(`Overlay for ${name} not found`); + return res.json(); +} + +/** Per-scenario pubkey -> user-name map (e.g. "Bxk7..." -> "alice"). Used by + * the canvas to render authority labels instead of raw base58 pubkeys. */ +export async function getUserLabels( + scenario: string, +): Promise<Record<string, string>> { + const res = await fetch( + `${BASE}/api/users/${encodeURIComponent(scenario)}/labels`, + ); + if (!res.ok) throw new Error(`User labels for ${scenario} not found`); + return res.json(); +} + export async function getCallGraph(contractName: string): Promise<CytoscapeGraph> { const res = await fetch(`${BASE}/api/contract/${contractName}/callgraph`); return res.json(); @@ -160,6 +294,19 @@ export async function getFunctionSource( return res.json(); } +// Solana counterpart — same response shape as `getFunctionSource` so the +// `FunctionSourcePanel` component is agnostic to backend. +export async function getInstructionSource( + programName: string, + ixName: string, +): Promise<FunctionSourceResponse> { + const res = await fetch( + `${BASE}/api/program/${encodeURIComponent(programName)}/${encodeURIComponent(ixName)}/source`, + ); + if (!res.ok) throw new Error(`Source for ${ixName} not found`); + return res.json(); +} + export interface SequenceAnalysis { functions: { name: string; diff --git a/crates/ilold-web/frontend/src/lib/api/session.ts b/crates/ilold-web/frontend/src/lib/api/session.ts index 95de975..c9182a2 100644 --- a/crates/ilold-web/frontend/src/lib/api/session.ts +++ b/crates/ilold-web/frontend/src/lib/api/session.ts @@ -49,6 +49,37 @@ export async function postCommand(command: SessionCommand, contract?: string) { return res.json(); } +export type SolanaCommand = + | { Call: { ix: string; args: any; accounts: Record<string, string>; signers: string[] } } + | 'Back' + | 'Clear' + | 'Funcs' + | 'State' + | 'Session' + | 'Users' + | { UsersNew: { name: string; lamports: number } } + | { Airdrop: { user: string; lamports: number } } + | { TimeWarp: { delta_seconds: number } } + | { Pda: { instruction: string } } + | { Inspect: { pubkey: string } } + | { Note: { text: string } } + | { Scenario: { sub: ScenarioAction } }; + +export async function postSolanaCommand(command: SolanaCommand, contract?: string) { + const body: { contract?: string; command: SolanaCommand } = { command }; + if (contract) body.contract = contract; + const res = await fetch(`${BASE}/api/cmd`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(text || `Solana command failed: ${res.status}`); + } + return res.json(); +} + // ── Session queries ───────────────────────────────────────────────────────── export async function getSessionState() { diff --git a/crates/ilold-web/frontend/src/lib/api/types.ts b/crates/ilold-web/frontend/src/lib/api/types.ts index 1973897..d6da823 100644 --- a/crates/ilold-web/frontend/src/lib/api/types.ts +++ b/crates/ilold-web/frontend/src/lib/api/types.ts @@ -16,6 +16,15 @@ export interface SessionAddNode { function: string; access: AccessLevel; step_index: number; + /** Solana-only: runtime metadata so other CLIs/browsers see CU/diffs/logs + * without having to re-fetch. None for Solidity (no VM). */ + runtime?: { + compute_units: number; + diffs_count: number; + logs_excerpt: string[]; + error?: string | null; + trace?: unknown; + }; } export interface SessionRemoveNode { @@ -136,10 +145,30 @@ export type ServerMessage = | ScenarioDeleted | ScenarioForked | ScenarioStoreReloaded + | SolanaUsersChanged + | SessionOverlayUpdate | SearchResult | SearchComplete | SearchError; +export interface SolanaUsersChanged { + type: 'solana_users_changed'; + scenario: string; +} + +/** Incremental runtime-overlay delta. Backend emits this right after + * each StepAdded / CallFailed so the badges (called Nx, ~CU avg, + * rejected Nx) stay live without re-fetching /overlay. */ +export interface SessionOverlayUpdate { + type: 'session_overlay_update'; + scenario: string; + ix_name: string; + calls_added: number; + failed_added: number; + cu?: number; + cpi_targets_added: string[]; +} + // ── Connection events (synthetic, frontend-only) ──────────────────────────── export interface ConnectionEvent { @@ -154,10 +183,12 @@ export interface TopicMap { search_result: SearchResult; search_complete: SearchComplete; error: SearchError; + solana_users_changed: SolanaUsersChanged; session_add_node: SessionAddNode; session_remove_node: SessionRemoveNode; session_clear: SessionClear; session_highlight: SessionHighlight; + session_overlay_update: SessionOverlayUpdate; scenario_created: ScenarioCreated; scenario_switched: ScenarioSwitched; scenario_deleted: ScenarioDeleted; diff --git a/crates/ilold-web/frontend/src/lib/api/ws.ts b/crates/ilold-web/frontend/src/lib/api/ws.ts index 0175fdf..28f73d4 100644 --- a/crates/ilold-web/frontend/src/lib/api/ws.ts +++ b/crates/ilold-web/frontend/src/lib/api/ws.ts @@ -32,6 +32,13 @@ const knownTopics: ReadonlySet<string> = new Set([ 'session_remove_node', 'session_clear', 'session_highlight', + 'session_overlay_update', + 'scenario_created', + 'scenario_switched', + 'scenario_deleted', + 'scenario_forked', + 'scenario_store_reloaded', + 'solana_users_changed', ]); // ── Pub/Sub core ──────────────────────────────────────────────────────────── diff --git a/crates/ilold-web/frontend/src/lib/components/contract/ContextMenu.svelte b/crates/ilold-web/frontend/src/lib/components/contract/ContextMenu.svelte index 22d91b1..65dc9b7 100644 --- a/crates/ilold-web/frontend/src/lib/components/contract/ContextMenu.svelte +++ b/crates/ilold-web/frontend/src/lib/components/contract/ContextMenu.svelte @@ -26,15 +26,17 @@ /** Open the file in the user's IDE (VS Code / Cursor / fork) via the * `vscode://` URL scheme. */ onopenide: (funcName: string) => void; + /** Solana-only: trigger the Run panel for an instruction node. */ + onsolanarun?: (instructionName: string) => void; onclose: () => void; } - let { menu, expandedFuncs, seqExpanded, mode, onexpandcfg, onremovefunc, onremovenode, onforkscenario, onremovefromhere, onviewsource, onopenide, onclose }: Props = $props(); + let { menu, expandedFuncs, seqExpanded, mode, onexpandcfg, onremovefunc, onremovenode, onforkscenario, onremovefromhere, onviewsource, onopenide, onsolanarun, onclose }: Props = $props(); // View source / Open in code are read-only operations meaningful for // any canvas node that identifies a function. Shown in every mode. const canViewSource = $derived( - menu != null && (menu.nodeType === 'function' || menu.nodeType === 'seq-next') + menu != null && (menu.nodeType === 'function' || menu.nodeType === 'seq-next' || menu.nodeType === 'instruction') ); // Canvas-action buttons (Expand CFG, Remove from canvas, Collapse, Remove @@ -95,6 +97,25 @@ > ✕ Remove node </button> + {:else if menu.nodeType === 'instruction'} + {#if onsolanarun} + <button + class="block w-full px-3 py-1.5 bg-transparent border-none text-text text-xs cursor-pointer text-left font-[inherit] transition-colors duration-150 hover:text-accent-hover" + style="border-radius: 6px;" + onclick={() => onsolanarun?.(menu!.funcName)} + >▶ Execute instruction</button> + {/if} + <button + class="block w-full px-3 py-1.5 bg-transparent border-none text-text text-xs cursor-pointer text-left font-[inherit] transition-colors duration-150 hover:text-danger" + style="border-radius: 6px;" + onclick={() => onremovenode(menu!.nodeId)} + >✕ Remove from canvas</button> + {:else if menu.nodeType === 'account' || menu.nodeType === 'trace'} + <button + class="block w-full px-3 py-1.5 bg-transparent border-none text-text text-xs cursor-pointer text-left font-[inherit] transition-colors duration-150 hover:text-danger" + style="border-radius: 6px;" + onclick={() => onremovenode(menu!.nodeId)} + >✕ Remove from canvas</button> {:else if showCanvasActions && menu.nodeType === 'block'} <button class="block w-full px-3 py-1.5 bg-transparent border-none text-text text-xs cursor-pointer text-left font-[inherit] transition-colors duration-150 hover:text-accent-hover" 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 3720198..1a4e532 100644 --- a/crates/ilold-web/frontend/src/lib/components/contract/FunctionSidebar.svelte +++ b/crates/ilold-web/frontend/src/lib/components/contract/FunctionSidebar.svelte @@ -1,84 +1,116 @@ <script lang="ts"> - import type { ContractDetail } from '$lib/api/rest'; + import type { ContractDetail, ProgramView } from '$lib/api/rest'; import { visibilityLabel } from '$lib/utils/visibility'; - // Sidebar that lists every function declared in the contract (plus - // inherited ones), with a live search + visibility/access filters so it - // scales to large codebases (Aave has ~300 functions per pool). + // Sidebar that lists every entry point of the loaded artifact: Solidity + // functions (own + inherited) or Solana instructions. The component is + // parametrized by `kind` so the chrome stays single-source while the + // filters and badges adapt to each backend. // // Click semantics (mode-aware): // - Session mode: click dispatches "add step to active scenario" - // - CFG/Seq mode: click always adds to canvas (idempotent). To remove, - // the row shows an explicit ✕ on hover when the function is already - // on canvas. A top-level `Clear · N` button wipes the canvas at once. + // - CFG/Seq/Program mode: click always adds to canvas (idempotent). To + // remove, the row shows an explicit ✕ on hover when the entry is + // already on canvas. A top-level `Clear · N` wipes everything. let { - contract, + contract = null, + program = null, canvasFuncs, mode, + kind = 'solidity', onadd, onremove, }: { - contract: ContractDetail; + contract?: ContractDetail | null; + program?: ProgramView | null; canvasFuncs: Set<string>; mode: 'cfg' | 'sequences' | 'session'; - onadd: (func: string) => void; - onremove: (func: string) => void; + kind?: 'solidity' | 'solana'; + onadd: (entry: string) => void; + onremove: (entry: string) => void; } = $props(); let sidebarOpen = $state(true); let query = $state(''); - // Visibility filter — multi-select. Defaults to entry points only - // (Public + External) to preserve the previous default UX; auditors can - // expand to Internal/Private with one click. type Visibility = 'Public' | 'External' | 'Internal' | 'Private'; const ALL_VISIBILITIES: Visibility[] = ['External', 'Public', 'Internal', 'Private']; let visFilter = $state<Set<Visibility>>(new Set<Visibility>(['Public', 'External'])); let onlyAccessControl = $state(false); - // Merge own + inherited; tag each row with its source so we can group - // under a divider. + let onlyPdas = $state(false); + let onlySigners = $state(false); + type Row = { name: string; visibility: string; path_count: number; modifiers: string[]; source: 'own' | 'inherited'; + hasPdas?: boolean; + signers?: string[]; }; - const allRows = $derived<Row[]>([ - ...(contract.functions ?? []).map((f): Row => ({ - name: f.name, - visibility: f.visibility, - path_count: f.path_count, - modifiers: f.modifiers ?? [], - source: 'own', - })), - ...(contract.inherited_functions ?? []).map((f): Row => ({ - name: f.name, - visibility: f.visibility, - path_count: f.path_count, - modifiers: f.modifiers ?? [], - source: 'inherited', - })), - ]); + const allRows = $derived<Row[]>(() => { + if (kind === 'solana' && program) { + return (program.instructions ?? []).map((ix): Row => ({ + name: ix.name, + visibility: 'Public', + path_count: 0, + modifiers: [], + source: 'own', + hasPdas: (ix.accounts ?? []).some((a: any) => a.pda != null), + signers: (ix.accounts ?? []).filter((a: any) => a.signer).map((a: any) => a.name), + })); + } + if (contract) { + return [ + ...(contract.functions ?? []).map((f): Row => ({ + name: f.name, + visibility: f.visibility, + path_count: f.path_count, + modifiers: f.modifiers ?? [], + source: 'own', + })), + ...(contract.inherited_functions ?? []).map((f): Row => ({ + name: f.name, + visibility: f.visibility, + path_count: f.path_count, + modifiers: f.modifiers ?? [], + source: 'inherited', + })), + ]; + } + return []; + }); const filtered = $derived.by(() => { const q = query.trim().toLowerCase(); - return allRows.filter((r) => { + const rows = allRows(); + return rows.filter((r) => { + if (q && !r.name.toLowerCase().includes(q)) return false; + if (kind === 'solana') { + if (onlyPdas && !r.hasPdas) return false; + if (onlySigners && (!r.signers || r.signers.length === 0)) return false; + return true; + } if (!visFilter.has(r.visibility as Visibility)) return false; if (onlyAccessControl && r.modifiers.length === 0) return false; - if (q && !r.name.toLowerCase().includes(q)) return false; return true; }); }); const ownFiltered = $derived(filtered.filter((r) => r.source === 'own')); const inheritedFiltered = $derived(filtered.filter((r) => r.source === 'inherited')); - const totalCount = $derived(allRows.length); + const totalCount = $derived(allRows().length); const visibleCount = $derived(filtered.length); const canvasCount = $derived(canvasFuncs.size); const canClear = $derived(mode !== 'session' && canvasCount > 0); + const headerLabel = $derived(kind === 'solana' ? 'Instructions' : 'Functions'); + const searchPlaceholder = $derived( + kind === 'solana' ? 'Search instructions…' : 'Search functions…', + ); + function toggleVisibility(v: Visibility) { const next = new Set(visFilter); if (next.has(v)) next.delete(v); @@ -90,6 +122,8 @@ query = ''; visFilter = new Set<Visibility>(['Public', 'External']); onlyAccessControl = false; + onlyPdas = false; + onlySigners = false; } // Snapshot to Array before iterating because `onremove` mutates the @@ -113,7 +147,7 @@ <div class="header flex items-center justify-between px-2.5 py-2"> {#if sidebarOpen} <span class="text-[10px] text-text-muted uppercase tracking-wider font-semibold"> - Functions <span class="text-text-dim font-normal">· {visibleCount}/{totalCount}</span> + {headerLabel} <span class="text-text-dim font-normal">· {visibleCount}/{totalCount}</span> </span> {/if} <button @@ -131,9 +165,9 @@ <input type="text" class="search-input" - placeholder="Search functions..." + placeholder={searchPlaceholder} bind:value={query} - aria-label="Search functions" + aria-label={searchPlaceholder} /> {#if query} <button @@ -148,21 +182,38 @@ <!-- Filter chips + canvas clear --> <div class="filters px-2 pb-2"> - {#each ALL_VISIBILITIES as v} + {#if kind === 'solana'} <button class="chip" - class:active={visFilter.has(v)} - onclick={() => toggleVisibility(v)} - title="Toggle {v} visibility" - >{visShort(v)}</button> - {/each} - <button - class="chip" - class:active={onlyAccessControl} - onclick={() => onlyAccessControl = !onlyAccessControl} - title="Only functions with modifiers (access control)" - aria-pressed={onlyAccessControl} - >🔒</button> + class:active={onlyPdas} + onclick={() => (onlyPdas = !onlyPdas)} + title="Only instructions that derive PDAs" + aria-pressed={onlyPdas} + >PDA</button> + <button + class="chip" + class:active={onlySigners} + onclick={() => (onlySigners = !onlySigners)} + title="Only instructions that require signers" + aria-pressed={onlySigners} + >🔑</button> + {:else} + {#each ALL_VISIBILITIES as v} + <button + class="chip" + class:active={visFilter.has(v)} + onclick={() => toggleVisibility(v)} + title="Toggle {v} visibility" + >{visShort(v)}</button> + {/each} + <button + class="chip" + class:active={onlyAccessControl} + onclick={() => (onlyAccessControl = !onlyAccessControl)} + title="Only functions with modifiers (access control)" + aria-pressed={onlyAccessControl} + >🔒</button> + {/if} {#if canClear} <button class="chip chip-clear" @@ -221,11 +272,20 @@ : (onCanvas ? 'On canvas — click ✕ to remove' : 'Add to canvas')} > <span class="row-name">{row.name}</span> - <span class="row-vis {visShort(row.visibility)}">{visShort(row.visibility)}</span> - {#if row.modifiers.length > 0} - <span class="row-lock" title={row.modifiers.join(', ')}>🔒</span> + {#if kind === 'solana'} + {#if row.hasPdas} + <span class="row-vis P" title="Declares PDAs">pda</span> + {/if} + {#if row.signers && row.signers.length > 0} + <span class="row-lock" title={`Signers: ${row.signers.join(', ')}`}>🔑</span> + {/if} + {:else} + <span class="row-vis {visShort(row.visibility)}">{visShort(row.visibility)}</span> + {#if row.modifiers.length > 0} + <span class="row-lock" title={row.modifiers.join(', ')}>🔒</span> + {/if} + <span class="row-paths">{row.path_count}p</span> {/if} - <span class="row-paths">{row.path_count}p</span> </button> {#if showActive} <button 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 a7dde95..dbcf7eb 100644 --- a/crates/ilold-web/frontend/src/lib/components/contract/FunctionSourcePanel.svelte +++ b/crates/ilold-web/frontend/src/lib/components/contract/FunctionSourcePanel.svelte @@ -4,14 +4,16 @@ import { oneDark } from '@codemirror/theme-one-dark'; import { EditorView } from '@codemirror/view'; import DraggablePanel from '$lib/DraggablePanel.svelte'; - import { getFunctionSource, type FunctionSourceResponse } from '$lib/api/rest'; + import { getFunctionSource, getInstructionSource, type FunctionSourceResponse } from '$lib/api/rest'; - // Modern Solidity source viewer. Styled after VSCode's peek view + GitHub - // blob view. Uses the CodeMirror `oneDark` theme and the JS grammar as a - // Solidity fallback. - let { contract, func, onclose }: { + // Modern source viewer. Styled after VSCode's peek view + GitHub blob view. + // 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 }: { contract: string; func: string; + kind?: 'solidity' | 'solana'; onclose: () => void; } = $props(); @@ -55,7 +57,8 @@ loading = true; error = null; source = ''; - getFunctionSource(currentContract, currentFunc) + const fetcher = kind === 'solana' ? getInstructionSource : getFunctionSource; + fetcher(currentContract, currentFunc) .then((res: FunctionSourceResponse) => { if (currentFunc !== func || currentContract !== contract) return; source = res.source; diff --git a/crates/ilold-web/frontend/src/lib/components/contract/GraphCanvasFlow.svelte b/crates/ilold-web/frontend/src/lib/components/contract/GraphCanvasFlow.svelte index 7ba958e..638c67a 100644 --- a/crates/ilold-web/frontend/src/lib/components/contract/GraphCanvasFlow.svelte +++ b/crates/ilold-web/frontend/src/lib/components/contract/GraphCanvasFlow.svelte @@ -7,6 +7,9 @@ import FunctionNode from './nodes/FunctionNode.svelte'; import BlockNode from './nodes/BlockNode.svelte'; import SequenceNode from './nodes/SequenceNode.svelte'; + import InstructionNode from './nodes/InstructionNode.svelte'; + import AccountNode from './nodes/AccountNode.svelte'; + import TraceNode from './nodes/TraceNode.svelte'; import { getNodes, getEdges, @@ -45,6 +48,9 @@ function: FunctionNode, block: BlockNode, sequence: SequenceNode, + instruction: InstructionNode, + account: AccountNode, + trace: TraceNode, } as unknown as NodeTypes; // ── Reactive bridge: graph store → SvelteFlow ────────────── 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 230f4e0..2a161c0 100644 --- a/crates/ilold-web/frontend/src/lib/components/contract/Legend.svelte +++ b/crates/ilold-web/frontend/src/lib/components/contract/Legend.svelte @@ -1,5 +1,11 @@ <script lang="ts"> - let { mode }: { mode: 'cfg' | 'sequences' | 'session' } = $props(); + let { + mode, + kind = 'solidity', + }: { + mode: 'cfg' | 'sequences' | 'session'; + kind?: 'solidity' | 'solana'; + } = $props(); </script> <div @@ -15,7 +21,18 @@ 0 0 0 1px rgba(91, 155, 213, 0.04); " > - {#if mode === 'cfg'} + {#if kind === 'solana' && mode === 'cfg'} + <span><span class="inline-block w-2 h-2 align-middle mr-1" style="border-radius: 4px; background: var(--color-accent); box-shadow: 0 0 6px color-mix(in srgb, var(--color-accent) 40%, transparent);"></span>Instruction</span> + <span><span class="inline-block w-2 h-2 align-middle mr-1" style="border-radius: 4px; border: 1px solid var(--color-success);"></span>Account type</span> + <span><span class="inline-block w-2 h-2 align-middle mr-1" style="border-radius: 4px; background: var(--color-warning); box-shadow: 0 0 6px color-mix(in srgb, var(--color-warning) 40%, transparent);"></span>PDA / signer</span> + <span class="text-text-dim">Click an instruction → add to canvas</span> + {:else if kind === 'solana' && mode === 'session'} + <span><span class="inline-block w-2 h-2 align-middle mr-1" style="border-radius: 4px; background: var(--color-accent); box-shadow: 0 0 6px color-mix(in srgb, var(--color-accent) 40%, transparent);"></span>Trace step</span> + <span><span class="inline-block w-2 h-2 align-middle mr-1" style="border-radius: 4px; background: var(--color-danger); box-shadow: 0 0 6px color-mix(in srgb, var(--color-danger) 40%, transparent);"></span>Failed</span> + <span class="text-text-dim">Right-click → fork scenario · ⌘K → Execute</span> + {:else if kind === 'solana'} + <span class="text-text-dim">Sequences view is not implemented for Solana yet — switch to Program or Session</span> + {:else if mode === 'cfg'} <span><span class="inline-block w-2 h-2 align-middle mr-1" style="border-radius: 4px; background: var(--color-accent); box-shadow: 0 0 6px color-mix(in srgb, var(--color-accent) 40%, transparent);"></span>Function</span> <span><span class="inline-block w-2 h-2 align-middle mr-1" style="border-radius: 4px; background: var(--color-accent-dark); box-shadow: 0 0 6px color-mix(in srgb, var(--color-accent-dark) 40%, transparent);"></span>Entry block</span> <span><span class="inline-block w-2 h-2 align-middle mr-1" style="border-radius: 4px; background: var(--color-success); box-shadow: 0 0 6px color-mix(in srgb, var(--color-success) 40%, transparent);"></span>Return</span> diff --git a/crates/ilold-web/frontend/src/lib/components/contract/NodeInspector.svelte b/crates/ilold-web/frontend/src/lib/components/contract/NodeInspector.svelte index d8f1129..8a059c6 100644 --- a/crates/ilold-web/frontend/src/lib/components/contract/NodeInspector.svelte +++ b/crates/ilold-web/frontend/src/lib/components/contract/NodeInspector.svelte @@ -1,11 +1,7 @@ <script lang="ts"> - // F4 — the floating NodeDetailPanel used to pop up on top of the canvas - // whenever a node was selected. Now it's a tab inside SessionSidebar, so - // this component renders plain content (no DraggablePanel wrapper, no - // close button — "closing" means clicking the pane or switching tabs). - // - // Empty state is the default: if nothing is selected we show a hint. import Collapsible from '$lib/Collapsible.svelte'; + import SolanaRunForm from './SolanaRunForm.svelte'; + import type { ProgramView, IxView } from '$lib/api/rest'; interface Props { selectedNode: any; @@ -19,6 +15,13 @@ lookupBlock: (blockId: string) => { statements: string[]; node_type: string } | null; onpathselect: (funcName: string, path: any) => void; onexpandcfg: (funcName: string, nodeId?: string) => void; + onsolanarun?: (instructionName: string) => void; + program?: ProgramView | null; + solanaUsers?: { name: string; pubkey: string }[]; + onsolanasubmit?: ( + ix: IxView, + payload: { args: Record<string, any>; accounts: Record<string, string>; signers: string[] }, + ) => Promise<void>; } let { @@ -33,8 +36,17 @@ lookupBlock, onpathselect, onexpandcfg, + onsolanarun, + program = null, + solanaUsers = [], + onsolanasubmit, }: Props = $props(); + const inspectedIx = $derived.by<IxView | null>(() => { + if (!program || !selectedNode || selectedNode._type !== 'instruction') return null; + return program.instructions.find((i) => i.name === selectedNode.label) ?? null; + }); + function termColor(t: string): string { return t === 'Return' ? 'var(--color-success)' : t === 'Revert' ? 'var(--color-danger)' : 'var(--color-text-muted)'; } @@ -338,12 +350,126 @@ {:else} <div class="d-hint">Click a path above to see the execution flow</div> {/if} + + {:else if selectedNode._type === 'instruction'} + <div class="d-row"><span class="d-label">Program</span><span>{selectedNode.programName}</span></div> + <div class="d-row"><span class="d-label">Args</span><span>{(selectedNode.args ?? []).length}</span></div> + <div class="d-row"><span class="d-label">Accounts</span><span>{selectedNode.accountsCount}</span></div> + {#if selectedNode.adminGated} + <div class="d-row"><span class="d-label">Admin</span><span class="text-danger">admin-gated (heuristic)</span></div> + {/if} + {#if selectedNode.hasPdas} + <div class="d-row"><span class="d-label">PDAs</span><span class="text-warning">declares PDAs</span></div> + {/if} + {#if selectedNode.signers && selectedNode.signers.length > 0} + <div class="d-row"><span class="d-label">Signers</span><span>{selectedNode.signers.join(', ')}</span></div> + {/if} + {#if selectedNode.discriminator_hex} + <div class="d-row"><span class="d-label">Disc</span><span class="font-mono">{selectedNode.discriminator_hex}</span></div> + {/if} + {#if program && inspectedIx && onsolanasubmit} + <SolanaRunForm + {program} + ix={inspectedIx} + users={solanaUsers} + onsubmit={(payload) => onsolanasubmit!(inspectedIx, payload)} + /> + {:else if onsolanarun} + <div class="d-actions"> + <button class="d-action-btn" onclick={() => onsolanarun?.(selectedNode.label)}> + ▶ Execute instruction + </button> + </div> + {/if} + + {:else if selectedNode._type === 'account'} + <div class="d-row"><span class="d-label">Program</span><span>{selectedNode.programName}</span></div> + {#if selectedNode.account_type} + <div class="d-row"><span class="d-label">Type</span><span class="font-mono">{selectedNode.account_type}</span></div> + {/if} + {#if selectedNode.discriminator_hex} + <div class="d-row"><span class="d-label">Disc</span><span class="font-mono">{selectedNode.discriminator_hex}</span></div> + {/if} + {#if selectedNode.fields && selectedNode.fields.length > 0} + <div class="d-section-label">Fields</div> + {#each selectedNode.fields as f} + <div class="d-row"><span class="d-label">{f.name}</span><span class="font-mono">{f.ty}</span></div> + {/each} + {:else} + <div class="d-hint">Layout fields not declared in this IDL</div> + {/if} + {#if selectedNode.signer || selectedNode.writable || selectedNode.pda} + <div class="d-section-label">IX flags</div> + <div class="d-row"> + <span class="d-label">Flags</span> + <span> + {selectedNode.signer ? 'signer ' : ''}{selectedNode.writable ? 'writable ' : ''}{selectedNode.pda ? 'pda' : ''} + </span> + </div> + {/if} + + {:else if selectedNode._type === 'trace'} + {#if selectedNode.error} + <div class="trace-failed-banner"> + <strong>FAILED</strong> + <span class="trace-failed-msg">{selectedNode.error}</span> + </div> + {/if} + <div class="d-row"><span class="d-label">Step</span><span>#{selectedNode.stepIndex}</span></div> + <div class="d-row"><span class="d-label">Instruction</span><span>{selectedNode.instruction}</span></div> + <div class="d-row"><span class="d-label">Status</span> + {#if selectedNode.error} + <span class="text-danger">FAILED — VM rejected the call (step kept as audit trail; use back to drop it)</span> + {:else} + <span style="color: var(--color-success)">ok</span> + {/if} + </div> + <div class="d-row"><span class="d-label">Compute units</span><span>{selectedNode.computeUnits}</span></div> + <div class="d-row"><span class="d-label">Account diffs</span><span>{selectedNode.diffsCount}</span></div> + <div class="d-row"><span class="d-label">Scenario</span><span>{selectedNode.scenario}</span></div> + {#if selectedNode.logsExcerpt && selectedNode.logsExcerpt.length > 0} + <div class="d-section-label">Logs</div> + <pre class="trace-logs">{selectedNode.logsExcerpt.join('\n')}</pre> + {/if} {/if} </div> </div> {/if} <style> + .trace-failed-banner { + border: 1px solid var(--color-danger); + background: rgba(220, 80, 80, 0.08); + border-radius: 6px; + padding: 8px 12px; + margin-bottom: 8px; + color: var(--color-danger); + display: flex; + flex-direction: column; + gap: 4px; + } + .trace-failed-msg { + font-size: 11px; + font-family: var(--font-mono, monospace); + word-break: break-word; + color: var(--color-text); + opacity: 0.85; + } + .trace-logs { + background: var(--color-hover); + border: 1px solid var(--color-border-subtle); + border-radius: 4px; + padding: 8px; + font-family: var(--font-mono, monospace); + font-size: 10px; + color: var(--color-text-muted); + white-space: pre-wrap; + max-height: 280px; + overflow-y: auto; + margin-top: 6px; + } + .text-danger { color: var(--color-danger); } + .text-warning { color: var(--color-warning); } .empty-state { display: flex; flex-direction: column; diff --git a/crates/ilold-web/frontend/src/lib/components/contract/SolanaRunForm.svelte b/crates/ilold-web/frontend/src/lib/components/contract/SolanaRunForm.svelte new file mode 100644 index 0000000..c53401a --- /dev/null +++ b/crates/ilold-web/frontend/src/lib/components/contract/SolanaRunForm.svelte @@ -0,0 +1,247 @@ +<script lang="ts"> + import type { IxView, ProgramView } from '$lib/api/rest'; + + let { + program, + ix, + users, + onsubmit, + }: { + program: ProgramView; + ix: IxView; + users: { name: string; pubkey: string }[]; + onsubmit: (payload: { + args: Record<string, any>; + accounts: Record<string, string>; + signers: string[]; + }) => Promise<void>; + } = $props(); + + let argValues = $state<Record<string, string>>({}); + let accountValues = $state<Record<string, string>>({}); + let signerValues = $state<Record<string, boolean>>({}); + let submitting = $state(false); + let errorMsg = $state<string | null>(null); + + $effect(() => { + const a: Record<string, string> = {}; + for (const arg of ix.args ?? []) a[arg.name] = ''; + argValues = a; + + const accs: Record<string, string> = {}; + const sgn: Record<string, boolean> = {}; + for (const acc of ix.accounts ?? []) { + accs[acc.name] = ''; + if (acc.signer) sgn[acc.name] = true; + } + accountValues = accs; + signerValues = sgn; + }); + + const visibleAccounts = $derived((ix.accounts ?? []).filter((a) => !a.address)); + const constantAccounts = $derived((ix.accounts ?? []).filter((a) => a.address)); + + const NUMBER_INTS = new Set(['u8','u16','u32','u64','i8','i16','i32','i64','f32','f64']); + const STRING_INTS = new Set(['u128','i128','u256','i256']); + + function coerceArg(raw: string, ty: string): any { + if (ty === 'bool') return raw === 'true' || raw === '1'; + if (NUMBER_INTS.has(ty)) { + const n = Number(raw); + return Number.isFinite(n) ? n : raw; + } + if (STRING_INTS.has(ty)) { + return raw; + } + return raw; + } + + async function handleSubmit(e: SubmitEvent) { + e.preventDefault(); + errorMsg = null; + submitting = true; + try { + const args: Record<string, any> = {}; + for (const arg of ix.args ?? []) { + const raw = argValues[arg.name] ?? ''; + if (raw === '') continue; + args[arg.name] = coerceArg(raw, arg.ty); + } + const accounts: Record<string, string> = {}; + for (const [k, v] of Object.entries(accountValues)) { + if (v && v.trim() !== '') accounts[k] = v.trim(); + } + const signers: string[] = []; + for (const [k, on] of Object.entries(signerValues)) { + if (!on) continue; + const candidate = accountValues[k]; + if (candidate && users.some((u) => u.name === candidate)) { + signers.push(candidate); + } + } + await onsubmit({ args, accounts, signers }); + } catch (e) { + errorMsg = e instanceof Error ? e.message : String(e); + } finally { + submitting = false; + } + } +</script> + +<form class="run-form" onsubmit={handleSubmit}> + <div class="d-section-label">Args</div> + {#if (ix.args ?? []).length === 0} + <div class="run-empty">no args declared</div> + {/if} + {#each ix.args ?? [] as arg} + <label class="run-row"> + <span class="run-label">{arg.name}</span> + <input + class="run-input" + type="text" + bind:value={argValues[arg.name]} + placeholder={arg.ty} + /> + </label> + {/each} + + <div class="d-section-label">Accounts</div> + {#each visibleAccounts as acc} + <label class="run-row"> + <span class="run-label"> + {acc.name} + {#if acc.signer}<span class="acc-flag">signer</span>{/if} + {#if acc.writable}<span class="acc-flag">writable</span>{/if} + {#if acc.pda}<span class="acc-flag pda">pda</span>{/if} + </span> + <input + class="run-input" + type="text" + bind:value={accountValues[acc.name]} + placeholder={users.length > 0 ? 'user name or pubkey' : 'pubkey'} + /> + {#if acc.signer} + <label class="run-signer"> + <input type="checkbox" bind:checked={signerValues[acc.name]} /> + sign + </label> + {/if} + </label> + {/each} + + {#if constantAccounts.length > 0} + <div class="run-auto-fill"> + Auto-filled: {constantAccounts.map((a: any) => a.name).join(', ')} + </div> + {/if} + + {#if users.length > 0} + <div class="run-hint">Users: {users.map((u) => u.name).join(', ')}</div> + {/if} + {#if errorMsg} + <div class="run-error">{errorMsg}</div> + {/if} + + <button class="run-submit" type="submit" disabled={submitting}> + {submitting ? 'Running…' : '▶ Execute'} + </button> + <div class="run-program">in {program.name}</div> +</form> + +<style> + .run-form { + display: flex; + flex-direction: column; + gap: 6px; + padding: 6px 4px; + } + .d-section-label { + font-size: 10px; + color: var(--color-text-muted); + text-transform: uppercase; + letter-spacing: 0.08em; + margin-top: 6px; + } + .run-empty { + color: var(--color-text-dim); + font-size: 11px; + font-style: italic; + } + .run-row { + display: flex; + flex-direction: column; + gap: 4px; + } + .run-label { + font-size: 11px; + font-family: var(--font-mono, monospace); + color: var(--color-text); + } + .run-input { + background: var(--color-dark); + border: 1px solid var(--color-border-subtle); + border-radius: 4px; + padding: 5px 8px; + color: var(--color-text); + font-family: var(--font-mono, monospace); + font-size: 11px; + } + .acc-flag { + font-size: 9px; + margin-left: 4px; + padding: 1px 4px; + border-radius: 4px; + background: var(--color-border-subtle); + color: var(--color-text-muted); + } + .acc-flag.pda { + background: rgba(196, 154, 74, 0.15); + color: var(--color-warning); + } + .run-signer { + font-size: 10px; + color: var(--color-text-muted); + display: flex; + gap: 4px; + align-items: center; + } + .run-hint { + font-size: 10px; + color: var(--color-text-dim); + font-style: italic; + } + .run-auto-fill { + font-size: 10px; + color: var(--color-text-dim); + font-style: italic; + background: var(--color-hover); + border-radius: 4px; + padding: 4px 6px; + } + .run-error { + font-size: 11px; + color: var(--color-danger); + background: rgba(220, 80, 80, 0.1); + padding: 6px 8px; + border-radius: 4px; + } + .run-submit { + margin-top: 8px; + padding: 6px 12px; + border-radius: 4px; + border: 1px solid var(--color-accent); + background: var(--color-accent); + color: var(--color-dark); + font-weight: 700; + cursor: pointer; + } + .run-submit:disabled { + opacity: 0.6; + cursor: not-allowed; + } + .run-program { + font-size: 10px; + color: var(--color-text-dim); + text-align: center; + } +</style> 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 a0eb42a..28472c6 100644 --- a/crates/ilold-web/frontend/src/lib/components/contract/TopBar.svelte +++ b/crates/ilold-web/frontend/src/lib/components/contract/TopBar.svelte @@ -9,24 +9,28 @@ contractName, mode, seqDirection, + kind = 'solidity', + hideSystem = false, onmodechange, onsearch, oncenter, onseqdirection, onsessionback, onsessionclear, + onhidesystem, }: { contractName: string; mode: 'cfg' | 'sequences' | 'session'; seqDirection: 'TB' | 'LR'; + kind?: 'solidity' | 'solana'; + hideSystem?: boolean; onmodechange: (mode: 'cfg' | 'sequences' | 'session') => void; onsearch: () => void; oncenter: () => void; onseqdirection: (dir: 'TB' | 'LR') => void; - /** Remove the last step of the active scenario (REPL `b`). */ onsessionback: () => void; - /** Clear every step of the active scenario (REPL `cl`). */ onsessionclear: () => void; + onhidesystem?: (next: boolean) => void; } = $props(); // Detect Mac to show ⌘ vs Ctrl on the search shortcut chip. Safe on SSR @@ -61,13 +65,13 @@ class="seg-btn" class:active={mode === 'cfg'} onclick={() => onmodechange('cfg')} - title="Control-flow graph view" + title={kind === 'solana' ? 'Click an instruction to add it; expand to see its accounts' : 'Control-flow graph view'} >CFG</button> <button class="seg-btn" class:active={mode === 'sequences'} onclick={() => onmodechange('sequences')} - title="Function sequence view" + title={kind === 'solana' ? 'Sequence view — instructions linked by shared accounts' : 'Function sequence view'} >Seq</button> <button class="seg-btn" @@ -127,6 +131,15 @@ title="Center all nodes in view" aria-label="Center canvas" >Center</button> + {#if kind === 'solana' && onhidesystem} + <button + class="tool-btn" + class:active={hideSystem} + onclick={() => onhidesystem(!hideSystem)} + title={hideSystem ? 'Show system_program / sysvars / token_program' : 'Hide system_program / sysvars / token_program'} + aria-pressed={hideSystem} + >{hideSystem ? 'Show system' : 'Hide system'}</button> + {/if} <span class="divider"></span> <button class="tool-btn mono" diff --git a/crates/ilold-web/frontend/src/lib/components/contract/nodes/AccountNode.svelte b/crates/ilold-web/frontend/src/lib/components/contract/nodes/AccountNode.svelte new file mode 100644 index 0000000..5abd8e9 --- /dev/null +++ b/crates/ilold-web/frontend/src/lib/components/contract/nodes/AccountNode.svelte @@ -0,0 +1,125 @@ +<script lang="ts"> + import { Handle, Position } from '@xyflow/svelte'; + import type { AccountNodeData } from '$lib/stores/graph.svelte'; + + let { data }: { data: AccountNodeData } = $props(); + + const MAX_FIELDS = 6; + let fields = $derived(data.fields ?? []); + let visibleFields = $derived(fields.slice(0, MAX_FIELDS)); + let extraCount = $derived(Math.max(0, fields.length - MAX_FIELDS)); + let discriminatorTooltip = $derived( + data.discriminator_hex ? `discriminator: ${data.discriminator_hex}` : '', + ); +</script> + +<div + class="account-node py-1.5 px-3 rounded-md bg-surface-alt border-[1.5px] text-text font-mono text-xs min-w-[120px] cursor-pointer" + class:dimmed={data._dimmed} + class:has-fields={fields.length > 0} + title={discriminatorTooltip} +> + <div class="head text-center"> + <div class="font-semibold">{data.label}</div> + {#if data.account_type} + <div class="text-[8px] text-text-dim mt-0.5 uppercase tracking-wider">{data.account_type}</div> + {/if} + </div> + + {#if visibleFields.length > 0} + <ul class="field-list mt-1.5"> + {#each visibleFields as f (f.name)} + <li class="field-row"> + <span class="field-name">{f.name}</span> + <span class="field-type">{f.ty}</span> + </li> + {/each} + {#if extraCount > 0} + <li class="field-more">+{extraCount} more</li> + {/if} + </ul> + {/if} + + {#if data.signer || data.writable || data.pda} + <div class="badges"> + {#if data.signer}<span class="badge signer">signer</span>{/if} + {#if data.writable}<span class="badge writable">writable</span>{/if} + {#if data.pda}<span class="badge pda">pda</span>{/if} + </div> + {/if} +</div> +<Handle type="target" id="t" position={Position.Top} /> +<Handle type="source" id="b" position={Position.Bottom} /> +<Handle type="target" id="l" position={Position.Left} /> +<Handle type="source" id="r" position={Position.Right} /> + +<style> + .account-node { + border-color: var(--color-success); + } + .account-node.dimmed { + opacity: 0.55; + } + .field-list { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: 1px; + } + .field-row { + display: flex; + justify-content: space-between; + gap: 8px; + font-size: 9px; + line-height: 1.3; + } + .field-name { + color: var(--color-text); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .field-type { + color: var(--color-text-dim); + font-style: italic; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .field-more { + font-size: 8px; + color: var(--color-text-dim); + text-align: center; + margin-top: 2px; + } + .badges { + display: flex; + flex-wrap: wrap; + gap: 3px; + justify-content: center; + margin-top: 4px; + } + .badge { + font-size: 8px; + padding: 1px 5px; + border-radius: 3px; + background: var(--color-border-subtle); + color: var(--color-text-muted); + text-transform: lowercase; + letter-spacing: 0.04em; + } + .badge.signer { + background: color-mix(in srgb, var(--color-accent) 18%, transparent); + color: var(--color-accent-hover); + } + .badge.writable { + background: color-mix(in srgb, var(--color-warning) 18%, transparent); + color: var(--color-warning); + } + .badge.pda { + background: color-mix(in srgb, var(--color-warning) 22%, transparent); + color: var(--color-warning); + } +</style> diff --git a/crates/ilold-web/frontend/src/lib/components/contract/nodes/InstructionNode.svelte b/crates/ilold-web/frontend/src/lib/components/contract/nodes/InstructionNode.svelte new file mode 100644 index 0000000..f8112a1 --- /dev/null +++ b/crates/ilold-web/frontend/src/lib/components/contract/nodes/InstructionNode.svelte @@ -0,0 +1,206 @@ +<script lang="ts"> + import { Handle, Position } from '@xyflow/svelte'; + import type { InstructionNodeData } from '$lib/stores/graph.svelte'; + import { + getCallsPerIx, + getCuStatsPerIx, + getFailedPerIx, + } from '$lib/stores/runtimeOverlay.svelte'; + + let { data }: { data: InstructionNodeData } = $props(); + + const MAX_ARGS = 4; + let args = $derived(data.args ?? []); + let visibleArgs = $derived(args.slice(0, MAX_ARGS)); + let extraArgs = $derived(Math.max(0, args.length - MAX_ARGS)); + let signerCount = $derived(data.signers?.length ?? 0); + let discriminatorShort = $derived( + data.discriminator_hex && data.discriminator_hex.length > 10 + ? `${data.discriminator_hex.slice(0, 10)}...` + : data.discriminator_hex ?? '', + ); + let discriminatorTooltip = $derived( + data.discriminator_hex ? `discriminator: ${data.discriminator_hex}` : '', + ); + let nodeTitle = $derived( + data.adminGated + ? `admin-gated (heuristic)${discriminatorTooltip ? ' · ' + discriminatorTooltip : ''}` + : discriminatorTooltip, + ); + + let callsCount = $derived(getCallsPerIx()[data.label] ?? 0); + let failedCount = $derived(getFailedPerIx()[data.label] ?? 0); + let cuStats = $derived(getCuStatsPerIx()[data.label]); + let cuLabel = $derived(cuStats ? formatCu(cuStats.avg) : ''); + + function formatCu(n: number): string { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`; + if (n >= 1_000) return `${(n / 1_000).toFixed(1).replace(/\.0$/, '')}k`; + return `${n}`; + } +</script> + +<div + class="instruction-node py-1.5 px-3 rounded-md bg-surface-alt border-[1.5px] border-accent text-text font-mono text-xs min-w-[140px] cursor-pointer" + class:dimmed={data._dimmed} + class:has-pdas={data.hasPdas} + class:admin-gated={data.adminGated} + title={nodeTitle} +> + <div class="head"> + <span class="label">{data.label}</span> + {#if discriminatorShort} + <span class="disc">{discriminatorShort}</span> + {/if} + </div> + + {#if visibleArgs.length > 0} + <ul class="arg-list mt-1"> + {#each visibleArgs as arg (arg.name)} + <li class="arg-row"> + <span class="arg-name">{arg.name}</span> + <span class="arg-type">{arg.ty}</span> + </li> + {/each} + {#if extraArgs > 0} + <li class="arg-more">+{extraArgs} more</li> + {/if} + </ul> + {/if} + + <div class="badges mt-1"> + <span class="badge meta">{data.accountsCount}acc</span> + {#if signerCount > 0} + <span class="badge signer" title={`Signers: ${data.signers.join(', ')}`}> + {signerCount} signer{signerCount > 1 ? 's' : ''} + </span> + {/if} + {#if data.hasPdas} + <span class="badge pda">pda</span> + {/if} + {#if data.adminGated} + <span class="badge admin">admin</span> + {/if} + {#if callsCount > 0} + <span class="badge runtime-calls" title={`called ${callsCount} time${callsCount > 1 ? 's' : ''}`}> + called {callsCount}x + </span> + {/if} + {#if cuLabel} + <span class="badge runtime-cu" title={cuStats ? `min ${cuStats.min} · avg ${cuStats.avg} · max ${cuStats.max} (${cuStats.samples} samples)` : ''}> + ~{cuLabel} CU + </span> + {/if} + {#if failedCount > 0} + <span class="badge runtime-failed" title={`rejected ${failedCount} time${failedCount > 1 ? 's' : ''}`}> + rejected {failedCount}x + </span> + {/if} + </div> +</div> +<Handle type="target" id="t" position={Position.Top} /> +<Handle type="source" id="b" position={Position.Bottom} /> +<Handle type="target" id="l" position={Position.Left} /> +<Handle type="source" id="r" position={Position.Right} /> + +<style> + .instruction-node.has-pdas { + border-color: var(--color-warning); + } + .instruction-node.admin-gated { + border-color: var(--color-danger); + box-shadow: 0 0 0 1px color-mix(in srgb, var(--color-danger) 35%, transparent) inset; + } + .instruction-node.dimmed { + opacity: 0.55; + } + .head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 6px; + } + .label { + font-weight: 700; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .disc { + font-size: 8px; + color: var(--color-text-dim); + font-family: var(--font-mono, monospace); + } + .arg-list { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: 1px; + } + .arg-row { + display: flex; + justify-content: space-between; + gap: 8px; + font-size: 9px; + line-height: 1.3; + } + .arg-name { + color: var(--color-text); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .arg-type { + color: var(--color-text-dim); + font-style: italic; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .arg-more { + font-size: 8px; + color: var(--color-text-dim); + text-align: center; + } + .badges { + display: flex; + flex-wrap: wrap; + gap: 3px; + justify-content: center; + } + .badge { + font-size: 8px; + padding: 1px 5px; + border-radius: 3px; + background: var(--color-border-subtle); + color: var(--color-text-muted); + text-transform: lowercase; + letter-spacing: 0.04em; + } + .badge.signer { + background: color-mix(in srgb, var(--color-accent) 18%, transparent); + color: var(--color-accent-hover); + } + .badge.pda { + background: color-mix(in srgb, var(--color-warning) 22%, transparent); + color: var(--color-warning); + } + .badge.admin { + background: color-mix(in srgb, var(--color-danger) 22%, transparent); + color: var(--color-danger); + } + .badge.runtime-calls { + background: color-mix(in srgb, var(--color-success, #4ade80) 22%, transparent); + color: var(--color-success, #4ade80); + } + .badge.runtime-cu { + background: color-mix(in srgb, var(--color-info, #60a5fa) 18%, transparent); + color: var(--color-info, #60a5fa); + } + .badge.runtime-failed { + background: color-mix(in srgb, var(--color-danger) 28%, transparent); + color: var(--color-danger); + } +</style> diff --git a/crates/ilold-web/frontend/src/lib/components/contract/nodes/TraceNode.svelte b/crates/ilold-web/frontend/src/lib/components/contract/nodes/TraceNode.svelte new file mode 100644 index 0000000..25079c6 --- /dev/null +++ b/crates/ilold-web/frontend/src/lib/components/contract/nodes/TraceNode.svelte @@ -0,0 +1,42 @@ +<script lang="ts"> + import { Handle, Position } from '@xyflow/svelte'; + import type { TraceNodeData } from '$lib/stores/graph.svelte'; + + let { data }: { data: TraceNodeData } = $props(); + + let hasError = $derived(data.error != null && data.error !== ''); +</script> + +<div + class="trace-node py-1.5 px-3 rounded-md bg-surface-alt border-[1.5px] text-text font-mono text-xs min-w-[140px] text-center cursor-pointer" + class:dimmed={data._dimmed} + class:errored={hasError} +> + <div class="flex items-center justify-center gap-1"> + <span class="text-[8px] text-text-dim">#{data.stepIndex}</span> + <span class="font-semibold">{data.instruction}</span> + </div> + <div class="flex items-center justify-center gap-1 mt-0.5"> + <span class="text-[8px] text-text-dim">{data.computeUnits} CU</span> + <span class="text-[8px] text-text-dim">{data.diffsCount} diffs</span> + {#if hasError} + <span class="text-[8px] px-1 rounded bg-danger/15 text-danger">err</span> + {/if} + </div> +</div> +<Handle type="target" id="t" position={Position.Top} /> +<Handle type="source" id="b" position={Position.Bottom} /> +<Handle type="target" id="l" position={Position.Left} /> +<Handle type="source" id="r" position={Position.Right} /> + +<style> + .trace-node { + border-color: var(--color-accent-hover); + } + .trace-node.errored { + border-color: var(--color-danger); + } + .trace-node.dimmed { + opacity: 0.55; + } +</style> 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 0fdd0ad..55e26b3 100644 --- a/crates/ilold-web/frontend/src/lib/components/session/SessionSidebar.svelte +++ b/crates/ilold-web/frontend/src/lib/components/session/SessionSidebar.svelte @@ -7,12 +7,10 @@ import { getScenarios, getActiveScenario } from '$lib/stores/session.svelte'; import { promptScenarioName } from '$lib/scenarios/name'; import { dispatchScenarioAction } from '$lib/scenarios/dispatch'; + import { getNodes } from '$lib/stores/graph.svelte'; + import type { ProgramView } from '$lib/api/rest'; + import type { TraceNodeData } from '$lib/stores/graph.svelte'; - // Inspector props are optional so existing callers don't break, but - // +page.svelte passes them all as part of F4 to replace the floating - // NodeDetailPanel. When `selectedNode` is null the Inspector shows an - // empty state in-place instead of the panel disappearing — the tab - // itself stays available. let { contract, selectedNode = null, @@ -26,6 +24,13 @@ lookupBlock = () => null, onpathselect = () => {}, onexpandcfg = () => {}, + kind = 'solidity', + program = null, + solanaUsers = [], + onsolanarun = () => {}, + onnewuser = async () => {}, + onairdrop = async () => {}, + onsolanasubmit, }: { contract: string; selectedNode?: any; @@ -39,28 +44,83 @@ lookupBlock?: (blockId: string) => { statements: string[]; node_type: string } | null; onpathselect?: (funcName: string, path: any) => void; onexpandcfg?: (funcName: string, nodeId?: string) => void; + kind?: 'solidity' | 'solana'; + program?: ProgramView | null; + solanaUsers?: { name: string; pubkey: string; lamports: number }[]; + onsolanarun?: (instruction: string) => void; + onnewuser?: (name: string, lamports: number) => Promise<void>; + onairdrop?: (name: string, lamports: number) => Promise<void>; + onsolanasubmit?: ( + ix: any, + payload: { args: Record<string, any>; accounts: Record<string, string>; signers: string[] }, + ) => Promise<void>; } = $props(); + let newUserName = $state(''); + let newUserLamports = $state(10_000_000_000); + let pendingUser = $state(false); + let userError = $state<string | null>(null); + + const traceSteps = $derived.by<TraceNodeData[]>(() => { + if (kind !== 'solana') return []; + return getNodes() + .filter((n) => (n.data as any)?._type === 'trace') + .map((n) => n.data as TraceNodeData) + .sort((a, b) => { + if (a.scenario !== b.scenario) return a.scenario.localeCompare(b.scenario); + return a.stepIndex - b.stepIndex; + }); + }); + + async function handleCreateUser(e: SubmitEvent) { + e.preventDefault(); + userError = null; + if (!newUserName.trim()) { + userError = 'name required'; + return; + } + pendingUser = true; + try { + await onnewuser(newUserName.trim(), newUserLamports); + newUserName = ''; + } catch (e) { + userError = e instanceof Error ? e.message : String(e); + } finally { + pendingUser = false; + } + } + + async function topUp(name: string) { + try { + await onairdrop(name, 1_000_000_000); + } catch (e) { + alert(`airdrop failed:\n\n${e instanceof Error ? e.message : String(e)}`); + } + } + let open = $state(true); let activeTab: 'timeline' | 'state' | 'inspector' = $state('timeline'); + // Once the user picks a tab manually we stop auto-switching: otherwise + // selecting a node after a fork yanks the user back to Inspector even + // though they explicitly clicked Timeline/State. + let userPickedTab = $state(false); - // Auto-switch to the Inspector tab the moment the user selects a node - // on the canvas. Mirrors Figma / VSCode — selection should reveal the - // details without the user having to hunt for a tab. We only switch on - // the null → non-null transition so re-selecting a different node - // doesn't keep yanking the user back to this tab mid-navigation. let prevSelectedId = $state<string | null>(null); $effect(() => { const currentId = selectedNode?.id ?? null; - // Read the tracker via untrack so writing it at the end doesn't - // re-trigger this effect — only selectedNode changes should flow in. const prev = untrack(() => prevSelectedId); - if (currentId && !prev) { + const manual = untrack(() => userPickedTab); + if (currentId && !prev && !manual) { activeTab = 'inspector'; } prevSelectedId = currentId; }); + function pickTab(tab: 'timeline' | 'state' | 'inspector') { + activeTab = tab; + userPickedTab = true; + } + // Reactive view of scenarios (ordered map + active name, both from session store) const scenarioEntries = $derived(Array.from(getScenarios().entries())); const activeScenario = $derived(getActiveScenario()); @@ -214,21 +274,21 @@ <button class="flex-1 py-2 bg-transparent border-none text-[10px] font-semibold uppercase tracking-wider cursor-pointer transition-colors duration-150 {activeTab === 'timeline' ? 'text-accent' : 'text-text-muted hover:text-text'}" style="border-bottom: 2px solid {activeTab === 'timeline' ? 'var(--color-accent)' : 'transparent'};" - onclick={() => activeTab = 'timeline'} + onclick={() => pickTab('timeline')} > Timeline </button> <button class="flex-1 py-2 bg-transparent border-none text-[10px] font-semibold uppercase tracking-wider cursor-pointer transition-colors duration-150 {activeTab === 'state' ? 'text-accent' : 'text-text-muted hover:text-text'}" style="border-bottom: 2px solid {activeTab === 'state' ? 'var(--color-accent)' : 'transparent'};" - onclick={() => activeTab = 'state'} + onclick={() => pickTab('state')} > State </button> <button class="flex-1 py-2 bg-transparent border-none text-[10px] font-semibold uppercase tracking-wider cursor-pointer transition-colors duration-150 {activeTab === 'inspector' ? 'text-accent' : 'text-text-muted hover:text-text'}" style="border-bottom: 2px solid {activeTab === 'inspector' ? 'var(--color-accent)' : 'transparent'};" - onclick={() => activeTab = 'inspector'} + onclick={() => pickTab('inspector')} title={selectedNode ? `Inspect: ${selectedNode._funcName || selectedNode.label}` : 'Inspector — select a node to populate'} > Inspector{#if selectedNode}<span class="ml-1 text-accent-light">●</span>{/if} @@ -237,9 +297,79 @@ <div class="flex-1 overflow-y-auto min-h-0 px-1"> {#if activeTab === 'timeline'} - <SessionTimeline {contract} /> + {#if kind === 'solana'} + {#if traceSteps.length === 0} + <div class="solana-empty"> + <div class="solana-empty-title">No steps yet</div> + <div class="solana-empty-hint">Click an instruction in the sidebar and press Execute, or run <code>call <ix> <json></code> in the terminal.</div> + </div> + {/if} + {#each traceSteps as step (`${step.scenario}:${step.stepIndex}`)} + <div class="trace-step"> + <div class="trace-step-head"> + <span class="trace-step-idx">#{step.stepIndex}</span> + <span class="trace-step-name">{step.instruction}</span> + {#if step.error}<span class="trace-step-err">err</span>{/if} + </div> + <div class="trace-step-meta"> + <span>{step.computeUnits} CU</span> + <span>{step.diffsCount} diffs</span> + <span class="trace-step-scn">{step.scenario}</span> + </div> + {#if step.logsExcerpt && step.logsExcerpt.length > 0} + <pre class="trace-step-logs">{step.logsExcerpt.slice(0, 5).join('\n')}</pre> + {/if} + </div> + {/each} + {:else} + <SessionTimeline {contract} /> + {/if} {:else if activeTab === 'state'} - <StatePanel {contract} /> + {#if kind === 'solana' && program} + <form class="user-form" onsubmit={handleCreateUser}> + <div class="user-form-row"> + <input + class="user-input" + type="text" + bind:value={newUserName} + placeholder="user name (e.g. admin)" + disabled={pendingUser} + /> + <input + class="user-input lamports" + type="number" + bind:value={newUserLamports} + placeholder="lamports" + disabled={pendingUser} + /> + <button class="user-add" type="submit" disabled={pendingUser}> + {pendingUser ? '…' : '+'} + </button> + </div> + {#if userError} + <div class="user-error">{userError}</div> + {/if} + </form> + + {#if solanaUsers.length === 0} + <div class="solana-empty"> + <div class="solana-empty-title">No users yet</div> + <div class="solana-empty-hint">Create one above or run <code>users new <name></code> in the terminal.</div> + </div> + {/if} + {#each solanaUsers as u (u.name)} + <div class="user-row"> + <div class="user-head"> + <span class="user-name">{u.name}</span> + <span class="user-balance">{u.lamports.toLocaleString()} lamports</span> + </div> + <div class="user-pk">{u.pubkey}</div> + <button class="user-air" onclick={() => topUp(u.name)} title="Airdrop +1 SOL">+1 SOL</button> + </div> + {/each} + {:else} + <StatePanel {contract} /> + {/if} {:else if contractDetail} <NodeInspector {selectedNode} @@ -252,7 +382,11 @@ contract={contractDetail} {lookupBlock} {onpathselect} + {onsolanarun} {onexpandcfg} + {program} + {solanaUsers} + {onsolanasubmit} /> {/if} </div> @@ -261,3 +395,164 @@ <!-- Floating terminal (positions itself fixed, outside sidebar flow) --> <EmbeddedTerminal /> </div> + +<style> + .solana-empty { + text-align: center; + padding: 24px 12px; + } + .solana-empty-title { + font-weight: 700; + color: var(--color-text-muted); + text-transform: uppercase; + font-size: 11px; + letter-spacing: 0.08em; + } + .solana-empty-hint { + margin-top: 6px; + font-size: 11px; + line-height: 1.5; + color: var(--color-text-dim); + } + .solana-empty-hint code { + background: var(--color-dark); + padding: 1px 5px; + border-radius: 3px; + } + .trace-step { + background: var(--color-dark); + border: 1px solid var(--color-border-subtle); + border-radius: 6px; + padding: 8px; + margin: 6px 4px; + } + .trace-step-head { + display: flex; + align-items: center; + gap: 6px; + } + .trace-step-idx { + font-size: 10px; + color: var(--color-text-dim); + font-family: var(--font-mono, monospace); + } + .trace-step-name { + font-family: var(--font-mono, monospace); + font-weight: 700; + color: var(--color-text); + } + .trace-step-err { + font-size: 9px; + padding: 1px 4px; + border-radius: 4px; + background: rgba(220, 80, 80, 0.15); + color: var(--color-danger); + } + .trace-step-meta { + display: flex; + gap: 10px; + font-size: 10px; + color: var(--color-text-dim); + margin-top: 4px; + } + .trace-step-scn { + margin-left: auto; + color: var(--color-accent-hover); + } + .trace-step-logs { + margin-top: 6px; + font-size: 10px; + color: var(--color-text-muted); + background: var(--color-hover); + border-radius: 4px; + padding: 6px; + max-height: 80px; + overflow-y: auto; + white-space: pre-wrap; + } + .user-form { + margin: 6px 4px 12px; + } + .user-form-row { + display: flex; + gap: 4px; + } + .user-input { + flex: 1; + background: var(--color-dark); + border: 1px solid var(--color-border-subtle); + border-radius: 4px; + padding: 5px 8px; + font-size: 11px; + color: var(--color-text); + font-family: var(--font-mono, monospace); + } + .user-input.lamports { + flex: 0 0 110px; + } + .user-add { + width: 28px; + background: var(--color-accent); + border: none; + border-radius: 4px; + color: var(--color-dark); + cursor: pointer; + font-weight: 700; + } + .user-add:disabled, + .user-input:disabled { + opacity: 0.5; + cursor: not-allowed; + } + .user-error { + margin-top: 6px; + color: var(--color-danger); + font-size: 11px; + } + .user-row { + background: var(--color-dark); + border: 1px solid var(--color-border-subtle); + border-radius: 6px; + padding: 8px; + margin: 6px 4px; + position: relative; + } + .user-head { + display: flex; + align-items: center; + gap: 6px; + } + .user-name { + font-weight: 700; + font-family: var(--font-mono, monospace); + color: var(--color-text); + } + .user-balance { + margin-left: auto; + font-size: 10px; + color: var(--color-text-muted); + } + .user-pk { + margin-top: 4px; + font-size: 10px; + color: var(--color-text-dim); + font-family: var(--font-mono, monospace); + word-break: break-all; + } + .user-air { + position: absolute; + bottom: 6px; + right: 6px; + background: var(--color-hover); + border: 1px solid var(--color-border-subtle); + border-radius: 4px; + padding: 2px 6px; + font-size: 10px; + color: var(--color-text-muted); + cursor: pointer; + } + .user-air:hover { + color: var(--color-accent); + border-color: var(--color-accent); + } +</style> diff --git a/crates/ilold-web/frontend/src/lib/stores/graph.svelte.ts b/crates/ilold-web/frontend/src/lib/stores/graph.svelte.ts index 111dff6..75a2fcb 100644 --- a/crates/ilold-web/frontend/src/lib/stores/graph.svelte.ts +++ b/crates/ilold-web/frontend/src/lib/stores/graph.svelte.ts @@ -1,6 +1,5 @@ import type { Node, Edge } from '@xyflow/svelte'; - -// ── Node data types ───────────────────────────────────────── +import type { AccountKind, ArgView, FieldView } from '$lib/api/rest'; export interface FunctionNodeData { [key: string]: unknown; @@ -9,24 +8,22 @@ export interface FunctionNodeData { is_external: boolean; contractName?: string; _dimmed?: boolean; - // Enrichment fields (from ContractDetail.functions) visibility?: string; mutability?: string; path_count?: number; modifiers?: string[]; - // Scenarios: composed session-step metadata _sessionStep?: true; - _scenario?: string; // the scenario that owns this rendered node - _scenariosPassingThrough?: string[]; // full set of scenarios whose path includes this node (inherited + own) - _activeScenario?: string; // current active scenario (for highlight/mute classes) - stepIndex?: number; // session-step index, used by right-click "Fork scenario here" + _scenario?: string; + _scenariosPassingThrough?: string[]; + _activeScenario?: string; + stepIndex?: number; } export interface BlockNodeData { [key: string]: unknown; _type: 'block'; label: string; - node_type: string; // "Entry" | "Return" | "Revert" | "Block" | "LoopCondition" + node_type: string; _parentFunc: string; statements?: string[]; _dimmed?: boolean; @@ -40,30 +37,69 @@ export interface SequenceNodeData { _seqParent: string; pathCount?: number; readOnly?: boolean; - /** Solidity visibility (Public/External/Internal/Private) — drives the - * ext/int/pub/priv badge on the seq-next card. */ visibility?: string; - /** Function modifier names — presence of any drives the 🔒 access-control - * badge. Same semantics as FunctionNode. */ modifiers?: string[]; _transition?: any; _chainTransitions?: any[]; _dimmed?: boolean; } -export type GraphNodeData = FunctionNodeData | BlockNodeData | SequenceNodeData; +export interface InstructionNodeData { + [key: string]: unknown; + _type: 'instruction'; + label: string; + programName: string; + programId: string; + args: ArgView[]; + accountsCount: number; + hasPdas: boolean; + signers: string[]; + adminGated: boolean; + discriminator_hex?: string; + _dimmed?: boolean; +} + +export interface AccountNodeData { + [key: string]: unknown; + _type: 'account'; + label: string; + programName: string; + fields: FieldView[]; + discriminator_hex?: string; + account_type?: string; + signer?: boolean; + writable?: boolean; + pda?: boolean; + kind?: AccountKind; + parentInstruction?: string; + _dimmed?: boolean; +} + +export interface TraceNodeData { + [key: string]: unknown; + _type: 'trace'; + label: string; + stepIndex: number; + instruction: string; + computeUnits: number; + diffsCount: number; + logsExcerpt: string[]; + scenario: string; + error?: string | null; + _dimmed?: boolean; +} -// ── Reactive state ────────────────────────────────────────── -// SvelteFlow uses $bindable nodes/edges — the wrapper component -// binds directly to these arrays via getNodes()/getEdges() and -// setNodes()/setEdges(). SvelteFlow mutates them internally for -// drag, selection, etc. +export type GraphNodeData = + | FunctionNodeData + | BlockNodeData + | SequenceNodeData + | InstructionNodeData + | AccountNodeData + | TraceNodeData; let nodes = $state<Node<GraphNodeData>[]>([]); let edges = $state<Edge[]>([]); -// ── Getters ───────────────────────────────────────────────── - export function getNodes(): Node<GraphNodeData>[] { return nodes; } @@ -72,8 +108,6 @@ export function getEdges(): Edge[] { return edges; } -// ── Mutations ─────────────────────────────────────────────── - export function setNodes(newNodes: Node<GraphNodeData>[]) { nodes = newNodes; } @@ -126,8 +160,6 @@ export function clearGraph() { edges = []; } -// ── Queries ───────────────────────────────────────────────── - export function findNode(id: string): Node<GraphNodeData> | undefined { return nodes.find((n) => n.id === id); } diff --git a/crates/ilold-web/frontend/src/lib/stores/palette.svelte.ts b/crates/ilold-web/frontend/src/lib/stores/palette.svelte.ts index 0390da5..10ccdc1 100644 --- a/crates/ilold-web/frontend/src/lib/stores/palette.svelte.ts +++ b/crates/ilold-web/frontend/src/lib/stores/palette.svelte.ts @@ -1,7 +1,3 @@ -// Command-palette state. Kept in a tiny store so `+layout.svelte` can own -// the <CommandPalette /> instance (single mount, always-live Cmd+K binding) -// while each route publishes its context-specific commands here. - import type { Command } from '$lib/commands/registry'; let _open = $state(false); @@ -14,10 +10,6 @@ export function togglePalette() { _open = !_open; } export function getPaletteCommands(): Command[] { return _commands; } -/** Replace the full command list. Routes typically call this inside an - * `$effect` so the list stays in sync with the reactive state it derives - * from (active scenario, loaded contract, etc.). */ export function setPaletteCommands(cmds: Command[]) { _commands = cmds; } -/** Convenience for unmounting routes: clear everything. */ export function clearPaletteCommands() { _commands = []; } diff --git a/crates/ilold-web/frontend/src/lib/stores/runtimeOverlay.svelte.ts b/crates/ilold-web/frontend/src/lib/stores/runtimeOverlay.svelte.ts new file mode 100644 index 0000000..9fb4e9f --- /dev/null +++ b/crates/ilold-web/frontend/src/lib/stores/runtimeOverlay.svelte.ts @@ -0,0 +1,131 @@ +import { getProgramOverlay, type CpiEdge, type CuStats, type RuntimeOverlay } from '$lib/api/rest'; +import type { SessionOverlayUpdate } from '$lib/api/types'; + +let program = $state<string>(''); +let scenario = $state<string>(''); +let callsPerIx = $state<Record<string, number>>({}); +let failedPerIx = $state<Record<string, number>>({}); +let cuStatsPerIx = $state<Record<string, CuStats>>({}); +let cpiEdges = $state<CpiEdge[]>([]); +let initialized = $state<boolean>(false); + +export function getCallsPerIx(): Record<string, number> { + return callsPerIx; +} + +export function getFailedPerIx(): Record<string, number> { + return failedPerIx; +} + +export function getCuStatsPerIx(): Record<string, CuStats> { + return cuStatsPerIx; +} + +export function getCpiEdges(): CpiEdge[] { + return cpiEdges; +} + +export function getOverlayProgram(): string { + return program; +} + +export function getOverlayScenario(): string { + return scenario; +} + +export function clearOverlay(): void { + program = ''; + scenario = ''; + callsPerIx = {}; + failedPerIx = {}; + cuStatsPerIx = {}; + cpiEdges = []; + initialized = false; +} + +function applySnapshot(overlay: RuntimeOverlay): void { + program = overlay.program; + scenario = overlay.scenario; + callsPerIx = { ...overlay.calls_per_ix }; + failedPerIx = { ...overlay.failed_per_ix }; + cuStatsPerIx = { ...overlay.cu_stats_per_ix }; + cpiEdges = overlay.cpi_edges.map((e) => ({ ...e })); + initialized = true; +} + +export async function loadInitialOverlay(name: string, scenarioName?: string): Promise<void> { + try { + const overlay = await getProgramOverlay(name, scenarioName); + applySnapshot(overlay); + } catch (err) { + console.warn('runtimeOverlay loadInitial failed:', err); + clearOverlay(); + program = name; + if (scenarioName) scenario = scenarioName; + initialized = true; + } +} + +function recomputeStats(prev: CuStats | undefined, sample: number): CuStats { + if (!prev) { + return { min: sample, max: sample, avg: sample, samples: 1 }; + } + const samples = prev.samples + 1; + const sum = prev.avg * prev.samples + sample; + return { + min: Math.min(prev.min, sample), + max: Math.max(prev.max, sample), + avg: Math.round(sum / samples), + samples, + }; +} + +export function applyOverlayUpdate(patch: SessionOverlayUpdate): void { + if (!initialized) return; + if (scenario && patch.scenario !== scenario) return; + + const ix = patch.ix_name; + + if (patch.calls_added > 0) { + callsPerIx = { + ...callsPerIx, + [ix]: (callsPerIx[ix] ?? 0) + patch.calls_added, + }; + } + + if (patch.failed_added > 0) { + failedPerIx = { + ...failedPerIx, + [ix]: (failedPerIx[ix] ?? 0) + patch.failed_added, + }; + } + + if (typeof patch.cu === 'number' && patch.calls_added > 0) { + cuStatsPerIx = { + ...cuStatsPerIx, + [ix]: recomputeStats(cuStatsPerIx[ix], patch.cu), + }; + } + + if (patch.cpi_targets_added.length > 0) { + const map = new Map<string, CpiEdge>(); + for (const e of cpiEdges) { + map.set(`${e.from_ix}|${e.to_program}|${e.depth}`, { ...e }); + } + for (const to of patch.cpi_targets_added) { + const key = `${ix}|${to}|1`; + const prev = map.get(key); + if (prev) { + prev.samples += 1; + map.set(key, prev); + } else { + map.set(key, { from_ix: ix, to_program: to, depth: 1, samples: 1 }); + } + } + cpiEdges = Array.from(map.values()).sort((a, b) => { + if (a.from_ix !== b.from_ix) return a.from_ix.localeCompare(b.from_ix); + if (a.to_program !== b.to_program) return a.to_program.localeCompare(b.to_program); + return a.depth - b.depth; + }); + } +} diff --git a/crates/ilold-web/frontend/src/lib/stores/search.svelte.ts b/crates/ilold-web/frontend/src/lib/stores/search.svelte.ts index 6149bf0..6e81be1 100644 --- a/crates/ilold-web/frontend/src/lib/stores/search.svelte.ts +++ b/crates/ilold-web/frontend/src/lib/stores/search.svelte.ts @@ -1,9 +1,3 @@ -// Navigation channel used by the Cmd+K palette. When the palette runs a -// path-search result, it writes to _searchNavigate; the contract page's -// $effect picks it up and focuses the target function + path. The -// "search open" / toggle state moved to `$lib/stores/palette.svelte` with -// the rest of the command-palette plumbing. - import type { SearchNavigatePayload } from '$lib/api/types'; let _searchContext = $state<string | null>(null); diff --git a/crates/ilold-web/frontend/src/lib/stores/session.svelte.ts b/crates/ilold-web/frontend/src/lib/stores/session.svelte.ts index fc41451..e7ad719 100644 --- a/crates/ilold-web/frontend/src/lib/stores/session.svelte.ts +++ b/crates/ilold-web/frontend/src/lib/stores/session.svelte.ts @@ -15,18 +15,9 @@ import type { ForkOrigin, } from '$lib/api/types'; -// ── Reactive state (Svelte 5 $state runes) ────────────────────────────────── -// -// Svelte 5 Maps: mutations (set/delete) do NOT trigger reactivity. We reassign -// with `new Map(scenarios)` after each mutation so downstream $derived/$effect -// recomputes. `activeScenario` and `highlightedFunction` are plain scalars. - let scenarios = $state<Map<string, SessionStep[]>>(new Map([['main', []]])); let activeScenario = $state<string>('main'); let highlightedFunction = $state<string | null>(null); -// Fork origin per scenario (backend-authoritative, populated by resync). -// Main has no entry — only forked scenarios appear here. Used by the canvas -// to render forks as branches emerging from their origin node. let forkOrigins = $state<Map<string, ForkOrigin>>(new Map()); function resetState() { @@ -36,12 +27,6 @@ function resetState() { forkOrigins = new Map(); } -// ── WebSocket subscriptions (created once on module import) ───────────────── - -// session_* events carry a `scenario` field (design §4.1). We route each -// mutation to the matching map entry; if the scenario is unknown we create -// it on the fly — resync() will reconcile on reconnect. - subscribe('session_add_node', (msg: SessionAddNode) => { const next = new Map(scenarios); const existing = next.get(msg.scenario) ?? []; @@ -61,19 +46,15 @@ subscribe('session_remove_node', (msg: SessionRemoveNode) => { }); subscribe('session_clear', (msg: SessionClear) => { - // Scoped clear (design §10.2) — only the target scenario is emptied. const next = new Map(scenarios); next.set(msg.scenario, []); scenarios = next; }); subscribe('session_highlight', (msg: SessionHighlight) => { - // highlightedFunction is global in frontend v1; scenario field is ignored. highlightedFunction = msg.function; }); -// ── Scenario lifecycle events ─────────────────────────────────────────────── - subscribe('scenario_created', (msg: ScenarioCreated) => { const next = new Map(scenarios); next.set(msg.name, []); @@ -88,9 +69,6 @@ subscribe('scenario_deleted', (msg: ScenarioDeleted) => { const next = new Map(scenarios); next.delete(msg.name); scenarios = next; - // Drop the fork-origin entry for the deleted scenario. Forks that pointed - // TO this scenario keep their origin; the canvas renders them as standalone - // when the referenced origin no longer exists. if (forkOrigins.has(msg.name)) { const nextOrigins = new Map(forkOrigins); nextOrigins.delete(msg.name); @@ -99,15 +77,10 @@ subscribe('scenario_deleted', (msg: ScenarioDeleted) => { }); subscribe('scenario_forked', (_msg: ScenarioForked) => { - // Computing the fork locally would require deep-cloning the source steps - // at at_step; cheaper and safer to resync from backend. resync(); }); subscribe('scenario_store_reloaded', (_msg: ScenarioStoreReloaded) => { - // LoadSession replaced the entire backend store. Pull the new snapshot - // (scenarios + forkOrigins + active) instead of reconstructing from the - // event payload — same rationale as scenario_forked. resync(); }); @@ -117,21 +90,17 @@ subscribe('connection', (event: ConnectionEvent) => { } }); -// ── REST re-sync ──────────────────────────────────────────────────────────── - let resyncGen = 0; async function resync(): Promise<void> { const gen = ++resyncGen; try { const response = await getAllScenarios(); - if (gen !== resyncGen) return; // stale — a newer resync superseded this + if (gen !== resyncGen) return; const newMap = new Map<string, SessionStep[]>(); const newOrigins = new Map<string, ForkOrigin>(); for (const snapshot of response.scenarios) { - // SessionStepView has identical shape to SessionStep — copy fields - // explicitly so TS infers the narrowed type and future drift breaks here. newMap.set( snapshot.name, snapshot.steps.map((s) => ({ @@ -147,9 +116,6 @@ async function resync(): Promise<void> { }); } } - // Guarantee 'main' is always present — the store invariant on backend - // (ScenarioStore::DEFAULT) is "main exists"; if the response is somehow - // empty, reseed so UI never renders a blank scenario list. if (newMap.size === 0) newMap.set('main', []); scenarios = newMap; @@ -161,8 +127,6 @@ async function resync(): Promise<void> { } } -// ── Public API ────────────────────────────────────────────────────────────── - export function getScenarios(): Map<string, SessionStep[]> { return scenarios; } @@ -179,8 +143,6 @@ export function getScenarioSteps(name: string): SessionStep[] { return scenarios.get(name) ?? []; } -// Back-compat: returns active scenario's steps. Existing consumers -// (SessionTimeline, StatePanel, contract page) keep working unchanged. export function getSteps(): SessionStep[] { return getScenarioSteps(activeScenario); } diff --git a/crates/ilold-web/frontend/src/lib/stores/userLabels.svelte.ts b/crates/ilold-web/frontend/src/lib/stores/userLabels.svelte.ts new file mode 100644 index 0000000..6e3d458 --- /dev/null +++ b/crates/ilold-web/frontend/src/lib/stores/userLabels.svelte.ts @@ -0,0 +1,33 @@ +import { getUserLabels as fetchUserLabels } from '$lib/api/rest'; + +let scenario = $state<string>(''); +let labels = $state<Record<string, string>>({}); + +export function getUserLabelsScenario(): string { + return scenario; +} + +export function getUserLabelsMap(): Record<string, string> { + return labels; +} + +export function labelForPubkey(pubkey: string): string | null { + return labels[pubkey] ?? null; +} + +export function clearUserLabels(): void { + scenario = ''; + labels = {}; +} + +export async function loadUserLabels(scenarioName: string): Promise<void> { + try { + const map = await fetchUserLabels(scenarioName); + scenario = scenarioName; + labels = { ...map }; + } catch (err) { + console.warn('userLabels loadUserLabels failed:', err); + clearUserLabels(); + scenario = scenarioName; + } +} diff --git a/crates/ilold-web/frontend/src/routes/+page.svelte b/crates/ilold-web/frontend/src/routes/+page.svelte index 7a9b6fe..82a7828 100644 --- a/crates/ilold-web/frontend/src/routes/+page.svelte +++ b/crates/ilold-web/frontend/src/routes/+page.svelte @@ -1,7 +1,7 @@ <script lang="ts"> import { onMount, onDestroy } from 'svelte'; import { goto } from '$app/navigation'; - import { getProjectMap, type ProjectMap, type MapContract } from '$lib/api/rest'; + import { getProjectMap, type ProjectMap } from '$lib/api/rest'; import { setSearchContext } from '$lib/stores/search.svelte'; import { togglePalette, setPaletteCommands, clearPaletteCommands } from '$lib/stores/palette.svelte'; import type { Command } from '$lib/commands/registry'; @@ -31,18 +31,34 @@ } const seen = new Set<string>(); const cmds: Command[] = []; - 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)}`), - }); + 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)}`), + }); + } } setPaletteCommands(cmds); }); @@ -51,11 +67,21 @@ let contracts: any[] = $state([]); let interfaces: any[] = $state([]); + let programs: any[] = $state([]); + let kind = $state<'solidity' | 'solana'>('solidity'); $effect(() => { if (projectMap) { - contracts = projectMap.contracts.filter(c => c.kind !== 'Interface'); - interfaces = projectMap.contracts.filter(c => c.kind === 'Interface'); + 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 = []; + } } }); @@ -70,7 +96,11 @@ <span class="text-lg font-bold text-text">ilold</span> <span class="text-xs text-text-dim">execution path analyzer</span> {#if projectMap} - <span class="text-xs text-text-muted">{projectMap.contracts.length} contracts · {projectMap.relationships.length} cross-contract calls</span> + {#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} {/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> @@ -81,6 +111,43 @@ <div class="p-6 text-danger">{error}</div> {:else if !projectMap} <div class="p-6 text-text-muted">Analyzing...</div> + {:else if kind === 'solana'} + <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} + <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">solana program</span> + <h2 class="text-lg mt-0.5 mb-0"><a class="text-text no-underline hover:text-accent-hover" href="/contract/{program.name}">{program.name}</a></h2> + <div class="text-[10px] text-text-dim mt-0.5 font-mono truncate">{program.program_id}</div> + </div> + <div class="card-section"> + <div class="text-[9px] text-text-muted uppercase tracking-wide mb-1 font-semibold">Instructions</div> + {#each program.instructions as ix} + <a href="/contract/{program.name}/{ix.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 bg-accent-hover"></span> + <span class="text-text font-semibold font-mono flex-1">{ix.name}</span> + <span class="text-[10px] text-text-muted">{ix.args_count}a {ix.accounts_count}acc</span> + {#if ix.has_pdas} + <span class="text-[9px] px-1 py-px rounded-md bg-warning/10 text-warning">pda</span> + {/if} + </a> + {/each} + </div> + {#if program.account_types.length > 0} + <div class="card-section"> + <div class="text-[9px] text-text-muted uppercase tracking-wide mb-1 font-semibold">Account types</div> + {#each program.account_types as a} + <div class="flex justify-between px-1 py-0.5 text-[11px] font-mono"> + <span class="text-text">{a.name}</span> + </div> + {/each} + </div> + {/if} + </div> + {/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"> 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 ec8c48e..75d8f89 100644 --- a/crates/ilold-web/frontend/src/routes/contract/[name]/+page.svelte +++ b/crates/ilold-web/frontend/src/routes/contract/[name]/+page.svelte @@ -1,18 +1,29 @@ <script lang="ts"> import { page } from '$app/state'; import { onMount, onDestroy, tick, untrack } from 'svelte'; - import { getContract, getCallGraph, getCfg, getPaths, getSequences, getSequenceAnalysis, getFunctionSource, getProjectMap, type ContractDetail, type CytoscapeGraph, type SequenceAnalysis, type MapContract } from '$lib/api/rest'; + import { getContract, getCallGraph, getCfg, getPaths, getSequences, getSequenceAnalysis, getFunctionSource, getInstructionSource, getProjectMap, getProgramView, type ContractDetail, type CytoscapeGraph, type SequenceAnalysis, type MapContract, type MapProgram, type ProgramView, type IxView, type AccountView, type IxAccountView } from '$lib/api/rest'; + import { + applyOverlayUpdate as applyRuntimeOverlayUpdate, + clearOverlay as clearRuntimeOverlay, + loadInitialOverlay as loadRuntimeOverlay, + getCpiEdges, + } from '$lib/stores/runtimeOverlay.svelte'; + import { + loadUserLabels, + clearUserLabels, + } from '$lib/stores/userLabels.svelte'; import { goto } from '$app/navigation'; import { toggleTerminal } from '$lib/stores/terminal.svelte'; import { openInIde } from '$lib/utils/ide-links'; import { setSearchContext, getSearchNavigate, setSearchNavigate } from '$lib/stores/search.svelte'; + import { subscribe as subscribeWs } from '$lib/api/ws'; import { togglePalette, setPaletteCommands, clearPaletteCommands } from '$lib/stores/palette.svelte'; import type { Command } from '$lib/commands/registry'; import { getHighlightedFunction, getScenarios, getActiveScenario, getForkOrigins } from '$lib/stores/session.svelte'; import { composeScenarioTree, type ComposedNode } from '$lib/canvas/scenarios'; import { promptScenarioName } from '$lib/scenarios/name'; import { dispatchScenarioAction } from '$lib/scenarios/dispatch'; - import { postCommand } from '$lib/api/session'; + import { postCommand, postSolanaCommand } from '$lib/api/session'; import Legend from '$lib/components/contract/Legend.svelte'; import FunctionSidebar from '$lib/components/contract/FunctionSidebar.svelte'; import TopBar from '$lib/components/contract/TopBar.svelte'; @@ -36,25 +47,32 @@ import type { Node, Edge } from '@xyflow/svelte'; let contract: ContractDetail | null = $state(null); + let solanaProgram: ProgramView | null = $state(null); + let kind: 'solidity' | 'solana' = $state('solidity'); + let solanaCanvasIxs: Set<string> = $state(new Set()); + let solanaExpandedIxs: Set<string> = $state(new Set()); + let solanaUsers: { name: string; pubkey: string; lamports: number }[] = $state([]); + let solanaTraceCount = $state(0); + let hideSystem = $state(false); + type SolanaRuntimeInfo = { + computeUnits: number; + diffsCount: number; + logsExcerpt: string[]; + error?: string | null; + }; + let solanaRuntimeByStep: Map<string, SolanaRuntimeInfo> = $state(new Map()); let error: string | null = $state(null); let selectedNode: any = $state(null); let selectedPath: any = $state(null); let funcPaths: Record<string, any> = $state({}); let expandedFuncs: Set<string> = $state(new Set()); - // Driven by SvelteFlow's onselectionchange — used only for the status bar - // selection chip. Kept as a plain count (not the full node list) since no - // other consumer needs per-node selection data at this level. let selectionCount: number = $state(0); - // Default: Seq mode is the auditor-friendly view; Session mode flips the - // sidebar click into "add step" and hides exploration nodes on the canvas - // so only the scenarios tree is visible. let mode: 'cfg' | 'sequences' | 'session' = $state('sequences'); let seqTree: any = $state(null); let seqAnalysis: SequenceAnalysis | null = $state(null); let seqExpanded: Map<string, boolean> = $state(new Map()); let seqDirection: 'TB' | 'LR' = $state('TB'); - // Context menu: right-click on nodes let contextMenu: { x: number; y: number; @@ -64,18 +82,12 @@ sessionStep?: { stepIndex: number }; } | null = $state(null); - let canvasFuncs: Set<string> = $state(new Set()); // functions currently on canvas + let canvasFuncs: Set<string> = $state(new Set()); - // Project map (all contracts in the workspace) — fetched once on mount so - // the Cmd+K palette can offer cross-contract navigation without every - // keystroke hitting the REST endpoint. let projectMap: MapContract[] = $state([]); - // Inline source-viewer panel state. Null when closed. Set by the - // ContextMenu "View source" entry. let sourcePanel: { func: string } | null = $state(null); - // Session → canvas auto-paint state let sessionVisCount = $state(0); const sessionHighlight = $derived(getHighlightedFunction()); @@ -106,28 +118,16 @@ return null; } - // BFS tree layout constants for seq subtrees (shared by relayoutSeqTree) const SEQ_NODE_W = 220; const SEQ_NODE_H = 80; - const SEQ_SIBLING_GAP = 30; // gap between siblings at the same level - const SEQ_LEVEL_GAP = 120; // gap between parent and children rank - - /** - * Re-layout a single seq subtree rooted at `rootId` (a function node). - * - Anchors the root to its live (drag-aware) position so user drags are preserved. - * - BFS from root via seq-edges; siblings are distributed perpendicular to seqDirection. - * - Updates positions of all seq-next nodes in the subtree. - * - Updates sourceHandle/targetHandle on all seq-edges in the subtree to match seqDirection. - * - * Callers MUST add any new nodes/edges to the store BEFORE invoking this helper, - * so the BFS walk includes them. Placeholder positions on new nodes are fine. - */ + const SEQ_SIBLING_GAP = 30; + const SEQ_LEVEL_GAP = 120; + function relayoutSeqTree(rootId: string) { const root = findNode(rootId); if (!root) return; const rootPos = liveNodePosition(rootId) ?? root.position; - // 1. Collect the full seq subtree (root + all transitively-linked seq-next nodes) const subtreeIds = new Set<string>([rootId]); let added = true; while (added) { @@ -143,7 +143,6 @@ } } - // 2. Build children index from seq-edges restricted to the subtree const childrenMap = new Map<string, string[]>(); const subtreeEdgeIds = new Set<string>(); for (const e of getEdges()) { @@ -155,7 +154,6 @@ } } - // 3. BFS from root, assigning levels const levels = new Map<string, number>(); levels.set(rootId, 0); const queue = [rootId]; @@ -172,7 +170,6 @@ } } - // 4. Group nodes by level and compute positions anchored at rootPos const byLevel: string[][] = Array.from({ length: maxLevel + 1 }, () => []); for (const [id, lvl] of levels) byLevel[lvl].push(id); @@ -182,7 +179,6 @@ const ids = byLevel[lvl]; const count = ids.length; if (isLR) { - // Children to the right, stacked vertically at same X const totalH = count * SEQ_NODE_H + (count - 1) * SEQ_SIBLING_GAP; const startY = rootPos.y + SEQ_NODE_H / 2 - totalH / 2; const x = rootPos.x + lvl * (SEQ_NODE_W + SEQ_LEVEL_GAP); @@ -190,7 +186,6 @@ posMap.set(id, { x, y: startY + i * (SEQ_NODE_H + SEQ_SIBLING_GAP) }); }); } else { - // Children below, in a horizontal row at same Y const totalW = count * SEQ_NODE_W + (count - 1) * SEQ_SIBLING_GAP; const startX = rootPos.x + SEQ_NODE_W / 2 - totalW / 2; const y = rootPos.y + lvl * (SEQ_NODE_H + SEQ_LEVEL_GAP); @@ -200,7 +195,6 @@ } } - // 5. Apply positions to seq-next nodes in this subtree setNodes(getNodes().map(n => { if (n.data._type === 'seq-next' && posMap.has(n.id)) { return { ...n, position: posMap.get(n.id)! }; @@ -208,7 +202,6 @@ return n; })); - // 6. Update handle orientation on seq-edges in this subtree to match seqDirection const sh = isLR ? 'r' : 'b'; const th = isLR ? 'l' : 't'; setEdges(getEdges().map(e => { @@ -219,9 +212,7 @@ })); } - /** Re-layout and re-orient all expanded seq subtrees (used when seqDirection changes) */ function reorientAllSeqSubtrees() { - // Find all root functions that have seq-next children const roots = new Set<string>(); for (const n of getNodes()) { if (n.data._type === 'seq-next') { @@ -234,15 +225,12 @@ } } - /** Merge an opacity value into an edge's style string */ function edgeStyle(base: string | undefined, opacity: number): string { - // Remove existing opacity from base style, then append new one const cleaned = (base ?? '').replace(/opacity:\s*[\d.]+;?/g, '').trim(); const sep = cleaned && !cleaned.endsWith(';') ? '; ' : ' '; return `${cleaned}${cleaned ? sep : ''}opacity: ${opacity}`.trim(); } - /** Reset all _dimmed state on nodes and edges */ function resetAllDimmed() { setNodes(getNodes().map(n => { if ('_dimmed' in n.data && n.data._dimmed) { @@ -258,7 +246,6 @@ })); } - /** Remove every seq-next descendant of a seq node. */ function collapseAllDescendants(nodeId: string) { const allDesc = findDescendants(nodeId); const toRemove = new Set<string>(); @@ -377,7 +364,6 @@ targetHandle: 't', }; } - // Unconditional / fallback return { color: 'var(--color-text-dim)', animated: false, @@ -386,29 +372,19 @@ }; } - // ── Session → canvas auto-paint (Phase S5) ────────────────── - // The session store owns an `activeScenario` + Map<name, steps[]>. This - // effect composes ALL scenarios into a unified tree (shared prefix + - // divergent tails) via `composeScenarioTree`, then syncs the canvas. - // - // Strategy: on every run, remove all `session:*` step nodes/edges and - // re-emit from the composed tree. Cheap (nodes are tiny) and avoids a - // fragile per-id diff. `activeScenario` is read so restyling (pill colors, - // active glow vs muted) re-runs when the user switches scenarios even if - // the tree shape is identical. $effect(() => { - // Reactive reads — trigger re-run on scenario changes + active-scenario flip. const scenarios = getScenarios(); const forkOrigins = getForkOrigins(); const active = getActiveScenario(); + if (kind === 'solana') { + if (!solanaProgram) return; + paintSolanaScenarioTree(scenarios, forkOrigins, active); + return; + } if (!contract || !callgraphRaw) return; const tree = composeScenarioTree(scenarios, forkOrigins); - // Graph-store reads/writes are wrapped in untrack() to prevent a reactive - // cycle: reading getNodes() would subscribe this effect to `nodes`, and - // the subsequent removeNodesById/addNodes/addEdges would re-trigger it - // (infinite loop that froze the canvas on every c <func>). untrack(() => { const toRemove = new Set<string>(); for (const n of getNodes()) { @@ -423,11 +399,6 @@ const allFuncs = [...(contract?.functions ?? []), ...(contract?.inherited_functions ?? [])]; - // Lane-per-scenario tree. Each scenario renders only its divergent - // tail (`steps[at_step..end]`) on its own horizontal lane; the - // inherited prefix is reused from the origin's lane. A fork edge - // connects origin:step:{at_step-1} → self:step:{at_step} so branches - // visibly emerge from their fork point. const SESSION_BASE_X = 200; const SESSION_BASE_Y = 300; const SESSION_STEP_WIDTH = 280; @@ -497,35 +468,22 @@ }); }); - // Session mode visibility filter: hide every non-session node and every - // non-session-path edge so the canvas shows the scenarios tree alone. - // Switching mode back unhides — no state is destroyed. $effect(() => { const currentMode = mode; - // Wrapped in untrack: setNodes/setEdges would otherwise re-trigger this - // effect via getNodes()/getEdges() subscriptions. untrack(() => { setNodes(getNodes().map((n) => { - const isSessionNode = (n.data as any)._sessionStep === true; - const shouldHide = currentMode === 'session' && !isSessionNode; + const isSessionNode = (n.data as any)?._sessionStep === true; + const shouldHide = currentMode === 'session' ? !isSessionNode : isSessionNode; return n.hidden === shouldHide ? n : { ...n, hidden: shouldHide }; })); setEdges(getEdges().map((e) => { const isSessionEdge = (e.data as any)?._type === 'session-path'; - const shouldHide = currentMode === 'session' && !isSessionEdge; + const shouldHide = currentMode === 'session' ? !isSessionEdge : isSessionEdge; return e.hidden === shouldHide ? e : { ...e, hidden: shouldHide }; })); }); }); - // Invalidate stale NodeInspector selection. Any flow that removes - // nodes (CFG collapse, removeFuncFromCanvas, removeSeqNode, DEL key, - // Clear from the sidebar) converges here — callers don't need to - // remember to null out selectedNode themselves. The read inside - // findNode creates a reactive dependency so the guard re-runs whenever - // the graph store mutates. Safe against loops: when we set - // selectedNode = null the effect re-runs, short-circuits on the null - // check, and exits without further writes. $effect(() => { if (!selectedNode) return; if (!findNode(selectedNode.id)) { @@ -534,7 +492,6 @@ } }); - // Highlight the function node when the session broadcasts session_highlight $effect(() => { const funcName = sessionHighlight; if (!funcName) return; @@ -544,43 +501,533 @@ if (node) selectedNode = node; }); + function snakeToPascal(s: string): string { + return s + .split('_') + .filter((p) => p.length > 0) + .map((p) => p.charAt(0).toUpperCase() + p.slice(1)) + .join(''); + } + + function findAccountType(program: ProgramView, accountName: string): AccountView | undefined { + const target = snakeToPascal(accountName); + return program.accounts.find((a) => a.name === target); + } + + function isAdminGated(program: ProgramView, ixName: string): boolean { + return (program.admin_gated ?? []).includes(ixName); + } + + function handleSolanaIxAdd(ixName: string) { + if (!solanaProgram) return; + if (solanaCanvasIxs.has(ixName)) { + const existing = findNode(`ix:${ixName}`); + if (existing && flowApi) flowApi.fitView({ nodes: [{ id: existing.id }], padding: 0.5, duration: 400 }); + return; + } + const ix = solanaProgram.instructions.find((i) => i.name === ixName); + if (!ix) return; + const idx = solanaCanvasIxs.size; + addNode({ + id: `ix:${ixName}`, + type: 'instruction', + position: { x: idx * 220, y: 200 }, + data: { + _type: 'instruction', + label: ixName, + programName: solanaProgram.name, + programId: solanaProgram.program_id, + args: ix.args ?? [], + accountsCount: (ix.accounts ?? []).length, + hasPdas: (ix.accounts ?? []).some((a) => a.pda != null), + signers: (ix.accounts ?? []).filter((a) => a.signer).map((a) => a.name), + adminGated: isAdminGated(solanaProgram, ixName), + discriminator_hex: ix.discriminator_hex, + }, + }); + solanaCanvasIxs = new Set([...solanaCanvasIxs, ixName]); + paintCpiEdges(); + if (flowApi) flowApi.fitView({ nodes: [{ id: `ix:${ixName}` }], padding: 0.5, duration: 400 }); + } + + function handleSolanaIxRemove(ixName: string) { + const next = new Set(solanaCanvasIxs); + next.delete(ixName); + solanaCanvasIxs = next; + const ids = new Set<string>([`ix:${ixName}`]); + for (const n of getNodes()) { + const data: any = n.data; + if (data?._type === 'account' && data.parentInstruction === ixName) ids.add(n.id); + } + removeNodesById(ids); + const edgePrefix = `cpi:${ixName}->`; + const filtered = getEdges().filter((e) => !e.id.startsWith(edgePrefix)); + if (filtered.length !== getEdges().length) setEdges(filtered); + pruneOrphanExternals(); + solanaExpandedIxs = new Set([...solanaExpandedIxs].filter((n) => n !== ixName)); + } + + /** Remove external-program placeholder nodes that have no incoming cpi edge + * left. Keeps the canvas clean after an ix is removed or after the overlay + * drops samples for a target. */ + function pruneOrphanExternals() { + const usedTargets = new Set<string>(); + for (const e of getEdges()) { + if (e.id.startsWith('cpi:')) usedTargets.add(e.target); + } + const orphans = new Set<string>(); + for (const n of getNodes()) { + if (n.id.startsWith('external:') && !usedTargets.has(n.id)) orphans.add(n.id); + } + if (orphans.size > 0) removeNodesById(orphans); + } + + function paintSolanaScenarioTree( + scenarios: Map<string, any[]>, + forkOrigins: Map<string, any>, + active: string, + ) { + untrack(() => { + const toRemove = new Set<string>(); + for (const n of getNodes()) { + if (n.id.startsWith('session:') || n.id.startsWith('trace:')) toRemove.add(n.id); + } + if (toRemove.size > 0) removeNodesById(toRemove); + + const tree = composeScenarioTree(scenarios, forkOrigins); + if (tree.nodes.length === 0) { + solanaTraceCount = 0; + return; + } + + const SESSION_BASE_X = 200; + const SESSION_BASE_Y = 200; + const SESSION_STEP_WIDTH = 240; + const SESSION_LANE_HEIGHT = 130; + + const composedNodes = tree.nodes.map((cn) => { + const runtimeKey = `${cn._scenario}:${cn.stepIndex}`; + const runtime = solanaRuntimeByStep.get(runtimeKey); + return { + id: cn.id, + type: 'trace', + position: { + x: SESSION_BASE_X + cn.stepIndex * SESSION_STEP_WIDTH, + y: SESSION_BASE_Y + cn.lane * SESSION_LANE_HEIGHT, + }, + data: { + _type: 'trace', + _sessionStep: true, + _scenario: cn._scenario, + _scenariosPassingThrough: cn._scenariosPassingThrough, + _activeScenario: active, + stepIndex: cn.stepIndex, + label: `${cn.function} #${cn.stepIndex}`, + instruction: cn.function, + scenario: cn._scenario, + computeUnits: runtime?.computeUnits ?? 0, + diffsCount: runtime?.diffsCount ?? 0, + logsExcerpt: runtime?.logsExcerpt ?? [], + error: runtime?.error ?? null, + } as any, + } as Node<GraphNodeData>; + }); + addNodes(composedNodes); + + const nodeScenarios = new Map<string, string[]>( + tree.nodes.map((n) => [n.id, n._scenariosPassingThrough]), + ); + + const composedEdges = tree.edges.map((ce) => { + const isFork = ce._forkEdge === true; + const sourceScns = nodeScenarios.get(ce.source) ?? []; + const targetScns = nodeScenarios.get(ce.target) ?? []; + const onActivePath = sourceScns.includes(active) && targetScns.includes(active); + const color = onActivePath + ? (isFork ? 'var(--color-accent)' : 'var(--color-accent-hover)') + : 'var(--color-text-dim)'; + const opacity = onActivePath ? 1 : 0.4; + return { + id: ce.id, + source: ce.source, + target: ce.target, + sourceHandle: 'r', + targetHandle: 'l', + type: 'default', + animated: isFork && onActivePath, + style: `stroke: ${color}; opacity: ${opacity}; ${isFork ? 'stroke-dasharray: 4 4;' : ''}`, + markerEnd: { type: MarkerType.ArrowClosed, width: 12, height: 12, color }, + labelBgStyle: { fill: 'var(--color-surface)', fillOpacity: 0.85 }, + labelBgPadding: [3, 5] as [number, number], + data: { _type: 'session-path', _scenario: ce._scenario, _forkEdge: isFork }, + }; + }); + addEdges(composedEdges); + + solanaTraceCount = tree.nodes.length; + + const liveKeys = new Set(tree.nodes.map((n) => `${n._scenario}:${n.stepIndex}`)); + let mutated = false; + const next = new Map(solanaRuntimeByStep); + for (const k of next.keys()) { + if (!liveKeys.has(k)) { + next.delete(k); + mutated = true; + } + } + if (mutated) solanaRuntimeByStep = next; + }); + } + + function isHiddenAccount(accName: string): boolean { + if (!hideSystem || !solanaProgram?.system_accounts) return false; + return solanaProgram.system_accounts.includes(accName); + } + + function paintIxAccounts(ixName: string) { + if (!solanaProgram) return; + const ix = solanaProgram.instructions.find((i) => i.name === ixName); + if (!ix) return; + const parent = findNode(`ix:${ixName}`); + const baseX = parent?.position?.x ?? 0; + const baseY = (parent?.position?.y ?? 200) - 200; + const accounts = (ix.accounts ?? []).filter((acc) => !isHiddenAccount(acc.name)); + const totalWidth = (accounts.length - 1) * 170; + const newNodes: any[] = []; + const newEdges: any[] = []; + accounts.forEach((acc: IxAccountView, i: number) => { + const id = `ix:${ixName}:acc:${acc.name}`; + const matched = findAccountType(solanaProgram!, acc.name); + newNodes.push({ + id, + type: 'account', + position: { x: baseX - totalWidth / 2 + i * 170, y: baseY }, + data: { + _type: 'account', + label: acc.name, + programName: solanaProgram!.name, + parentInstruction: ixName, + fields: matched?.fields ?? [], + discriminator_hex: matched?.discriminator_hex, + account_type: matched?.name, + signer: acc.signer, + writable: acc.writable, + pda: acc.pda != null, + kind: acc.kind, + }, + }); + const edgeColor = acc.writable ? 'var(--color-accent-hover)' : 'var(--color-text-muted)'; + newEdges.push({ + id: `e:${ixName}:${acc.name}`, + source: `ix:${ixName}`, + sourceHandle: 't', + target: id, + targetHandle: 'b', + style: acc.writable ? `stroke: ${edgeColor};` : `stroke-dasharray: 5 3; stroke: ${edgeColor};`, + markerEnd: { type: MarkerType.ArrowClosed, width: 12, height: 12, color: edgeColor }, + }); + }); + if (newNodes.length > 0) addNodes(newNodes); + if (newEdges.length > 0) addEdges(newEdges); + } + + /** Friendly labels for well-known Solana programs. Anything not in this map + * falls back to a base58-truncated id rendered by ExternalProgramNode. */ + const KNOWN_PROGRAMS: Record<string, string> = { + '11111111111111111111111111111111': 'system_program', + 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA': 'token_program', + 'TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb': 'token_program_2022', + 'ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL': 'associated_token_program', + 'SysvarRent111111111111111111111111111111111': 'sysvar:rent', + 'SysvarC1ock11111111111111111111111111111111': 'sysvar:clock', + }; + + function externalNodeId(programId: string): string { + return `external:${programId}`; + } + function cpiEdgeId(fromIx: string, programId: string): string { + return `cpi:${fromIx}->${programId}`; + } + + /** Paint dashed `cpi` edges from on-canvas instruction nodes to placeholder + * external-program nodes. Reads from the runtime overlay store, so the + * same handler covers both the initial REST snapshot and incremental WS + * updates. Edges persist until the user clears the canvas or switches + * scenario; cleanup happens in clearGraph / scenario_switched. */ + function paintCpiEdges() { + if (!solanaProgram) return; + const edges = getCpiEdges(); + if (edges.length === 0) return; + + const existing = new Set(getEdges().map((e) => e.id)); + const liveNodes = new Set(getNodes().map((n) => n.id)); + const usedExternals = new Set<string>(); + const newNodes: any[] = []; + const newEdges: any[] = []; + + for (const e of edges) { + const fromId = `ix:${e.from_ix}`; + if (!liveNodes.has(fromId)) continue; + + const extId = externalNodeId(e.to_program); + const label = KNOWN_PROGRAMS[e.to_program] ?? e.to_program; + + if (!liveNodes.has(extId) && !usedExternals.has(extId)) { + const parent = findNode(fromId); + const baseX = (parent?.position?.x ?? 0) + 220; + const baseY = (parent?.position?.y ?? 200) + 80; + newNodes.push({ + id: extId, + type: 'function', + position: { x: baseX, y: baseY }, + data: { + _type: 'function', + label, + is_external: true, + }, + }); + usedExternals.add(extId); + } + + const edgeId = cpiEdgeId(e.from_ix, e.to_program); + if (existing.has(edgeId)) continue; + newEdges.push({ + id: edgeId, + source: fromId, + sourceHandle: 'r', + target: extId, + targetHandle: 'l', + label: `cpi (${e.samples}x)`, + style: 'stroke-dasharray: 5 3; stroke: var(--color-warning);', + markerEnd: { type: MarkerType.ArrowClosed, width: 12, height: 12, color: 'var(--color-warning)' }, + labelBgStyle: { fill: 'var(--color-surface)', fillOpacity: 0.85 }, + labelBgPadding: [3, 5] as [number, number], + labelStyle: 'font-size: 9px; fill: var(--color-warning);', + data: { _type: 'cpi-edge' }, + }); + } + + if (newNodes.length > 0) addNodes(newNodes); + if (newEdges.length > 0) addEdges(newEdges); + } + + /** Drop existing CPI placeholder nodes and edges. Used when switching + * scenarios or reloading the overlay snapshot — the new overlay state + * determines what gets repainted via paintCpiEdges. */ + function clearCpiEdges() { + const ids = new Set<string>(); + for (const n of getNodes()) { + if (n.id.startsWith('external:')) ids.add(n.id); + } + if (ids.size > 0) removeNodesById(ids); + const remainingEdges = getEdges().filter((e) => !e.id.startsWith('cpi:')); + if (remainingEdges.length !== getEdges().length) setEdges(remainingEdges); + } + + function clearIxAccounts(ixName: string) { + const ids = new Set<string>(); + for (const n of getNodes()) { + const data: any = n.data; + if (data?._type === 'account' && data.parentInstruction === ixName) ids.add(n.id); + } + if (ids.size > 0) removeNodesById(ids); + } + + function handleSolanaIxExpand(ixName: string) { + if (!solanaProgram) return; + if (solanaExpandedIxs.has(ixName)) { + clearIxAccounts(ixName); + solanaExpandedIxs = new Set([...solanaExpandedIxs].filter((n) => n !== ixName)); + return; + } + paintIxAccounts(ixName); + solanaExpandedIxs = new Set([...solanaExpandedIxs, ixName]); + } + + function handleHideSystemToggle(next: boolean) { + if (hideSystem === next) return; + hideSystem = next; + if (kind !== 'solana' || !solanaProgram) return; + for (const ixName of solanaExpandedIxs) { + clearIxAccounts(ixName); + paintIxAccounts(ixName); + } + } + + function handleSolanaRun(name: string) { + if (!solanaProgram) return; + const ix = solanaProgram.instructions.find((i) => i.name === name); + if (!ix) return; + selectedNode = { ...findNode(`ix:${ix.name}`)?.data, id: `ix:${ix.name}` } as any; + flowApi?.fitView({ nodes: [{ id: `ix:${ix.name}` }], padding: 0.5, duration: 400 }); + } + + async function handleSolanaSubmitFromInspector( + ix: any, + payload: { args: Record<string, any>; accounts: Record<string, string>; signers: string[] }, + ) { + await handleSolanaSubmit(payload, ix); + } + + onMount(() => { + const unsub = subscribeWs('solana_users_changed', () => { + if (solanaProgram) refreshSolanaUsers(); + }); + const unsubAdd = subscribeWs('session_add_node', (msg) => { + const runtime = (msg as any).runtime; + if (!runtime) return; + const key = `${msg.scenario}:${msg.step_index}`; + const logs: string[] = runtime.logs_excerpt ?? []; + const inferredError = logs.find((l: string) => + l.includes('AnchorError') || l.includes('failed:') || l.includes('panicked') + ); + const next = new Map(solanaRuntimeByStep); + next.set(key, { + computeUnits: runtime.compute_units ?? 0, + diffsCount: runtime.diffs_count ?? 0, + logsExcerpt: logs, + error: runtime.error ?? inferredError ?? null, + }); + solanaRuntimeByStep = next; + }); + const unsubOverlay = subscribeWs('session_overlay_update', (msg) => { + applyRuntimeOverlayUpdate(msg); + paintCpiEdges(); + }); + const unsubScenarioSwitch = subscribeWs('scenario_switched', async (msg) => { + if (solanaProgram) { + clearCpiEdges(); + await loadRuntimeOverlay(solanaProgram.name, msg.to); + await loadUserLabels(msg.to); + paintCpiEdges(); + } + }); + return () => { unsub(); unsubAdd(); unsubOverlay(); unsubScenarioSwitch(); }; + }); + + async function refreshSolanaUsers() { + if (!solanaProgram) return; + try { + const result = await postSolanaCommand('Users', solanaProgram.name); + if (result?.UserList?.users) { + solanaUsers = result.UserList.users; + } + } catch {} + } + + async function handleSolanaSubmit( + payload: { + args: Record<string, any>; + accounts: Record<string, string>; + signers: string[]; + }, + targetIx?: any, + ) { + if (!solanaProgram) return; + const ix = targetIx; + if (!ix) return; + const ixName = ix.name; + const result = await postSolanaCommand( + { + Call: { + ix: ixName, + args: payload.args, + accounts: payload.accounts, + signers: payload.signers, + }, + }, + solanaProgram.name, + ); + if (result?.Error) { + throw new Error(result.Error.message ?? 'Call failed'); + } + if (result?.StepAdded) { + const sa = result.StepAdded; + const scenario = getActiveScenario() ?? 'main'; + const runtimeKey = `${scenario}:${sa.step_index}`; + const next = new Map(solanaRuntimeByStep); + const explicitError: string | null = (sa.error as string | null | undefined) ?? null; + const logs: string[] = sa.logs_excerpt ?? []; + const inferredError = logs.find((l) => + l.includes('AnchorError') || l.includes('failed:') || l.includes('panicked') + ); + next.set(runtimeKey, { + computeUnits: sa.compute_units ?? 0, + diffsCount: sa.account_diffs_count ?? 0, + logsExcerpt: logs, + error: explicitError ?? inferredError ?? null, + }); + solanaRuntimeByStep = next; + } + await refreshSolanaUsers(); + } + + let mountCancelled = $state(false); + onDestroy(() => { + mountCancelled = true; + clearRuntimeOverlay(); + clearUserLabels(); + }); + onMount(async () => { const contractName = page.params.name; if (!contractName) return; - // Graph store is global — stale nodes from a previous contract must be - // wiped or they'd pollute this contract's canvas (and leave the sidebar - // out of sync because local Sets re-init empty on re-mount). + const expected = contractName; + const stillFresh = () => !mountCancelled && page.params.name === expected; clearGraph(); canvasFuncs = new Set(); expandedFuncs = new Set(); seqExpanded = new Map(); selectedNode = null; selectedPath = null; + clearRuntimeOverlay(); + clearUserLabels(); setSearchContext(contractName); try { - contract = await getContract(contractName); + const pm = 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; - try { seqTree = await getSequences(contractName); } catch {} - try { seqAnalysis = await getSequenceAnalysis(contractName); } catch {} - // Fire-and-forget — palette commands render fine without it, cross- - // contract nav just stays empty until the list lands. try { - const pm = await getProjectMap(); - projectMap = pm.contracts; - } catch {} + 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); + } } catch (e) { - error = `Contract "${contractName}" not found`; + if (stillFresh()) error = `Contract "${contractName}" not found`; } }); - // Listen for search result navigation. Only `getSearchNavigate()` is - // tracked — everything else is accessed via untrack so mutations inside - // the IIFE (canvasFuncs, funcPaths, expandedFuncs, edges via - // highlightPath) don't re-enter this effect and trigger an - // effect_update_depth_exceeded loop. The effect re-runs exactly when - // the palette publishes a new navigation target; subsequent state - // writes are handled inside the async task. $effect(() => { const nav = getSearchNavigate(); if (!nav) return; @@ -628,10 +1075,6 @@ }); }); - // Sidebar click dispatcher. In Session mode, the sidebar is the entry - // point for building a scenario — clicking a function fires a Call - // command and the WS session_add_node event repaints the scenario tree. - // In CFG/Seq modes, clicking adds the function as an exploration node. function notifyFailure(label: string, e: unknown) { const reason = e instanceof Error ? e.message : String(e); console.warn(`${label} failed:`, e); @@ -639,6 +1082,10 @@ } async function handleSidebarAdd(funcName: string) { + if (kind === 'solana') { + handleSolanaIxAdd(funcName); + return; + } if (mode === 'session') { try { await postCommand({ Call: { func: funcName } }, contract?.name); @@ -655,7 +1102,6 @@ const nodeData = callgraphRaw.nodes.find(n => n.data.label === funcName); if (!nodeData) return; - // Look up enrichment data from ContractDetail const allFuncs = [...(contract?.functions ?? []), ...(contract?.inherited_functions ?? [])]; const funcDetail = allFuncs.find((f: any) => f.name === funcName); @@ -681,7 +1127,6 @@ }, } as Node<GraphNodeData>); - // Add call edges where BOTH source and target are on canvas for (const e of callgraphRaw.edges) { const srcOnCanvas = canvasFuncs.has( callgraphRaw.nodes.find(n => n.data.id === e.data.source)?.data.label ?? '' @@ -738,18 +1183,15 @@ const toRemove = new Set<string>([nodeId]); - // CFG children (blocks with _parentFunc === funcName) for (const n of getNodes()) { if ('_parentFunc' in n.data && n.data._parentFunc === funcName) { toRemove.add(n.id); } } - // Seq descendants (recursive via _seqParent) const seqDesc = findDescendants(nodeId); for (const id of seqDesc) toRemove.add(id); - // Also find seq nodes whose _seqParent starts with nodeId→ for (const n of getNodes()) { if ('_seqParent' in n.data) { const sp = n.data._seqParent as string; @@ -773,10 +1215,6 @@ seqExpanded = new Map(seqExpanded); } - // Keyboard-driven delete from the canvas (Figma/Excalidraw pattern). - // Dispatches each selected node to the right existing helper so all the - // store bookkeeping (canvasFuncs, expandedFuncs, seqExpanded, dim state) - // stays in one place. Session mode is guarded at the canvas prop level. function handleNodesDelete(nodes: Node<GraphNodeData>[]) { if (mode === 'session') return; const funcsToRemove = new Set<string>(); @@ -797,14 +1235,12 @@ selectedNode = null; } - // --- Event handlers --- async function handleNodeTap(node: Node<GraphNodeData>) { const data = node.data; if (!selectedNode || selectedNode.id !== node.id) { selectedPath = null; - // Reset CFG block highlighting when clicking a different node setNodes(getNodes().map(n => { if (n.data._type === 'block' && '_dimmed' in n.data && n.data._dimmed) { return { ...n, data: { ...n.data, _dimmed: false } as GraphNodeData }; @@ -836,9 +1272,6 @@ resetAllDimmed(); } - // Right-click "⎇ Fork scenario here": forks the active scenario, keeping - // steps [0..=stepIndex] (i.e. truncate at stepIndex + 1). Surfaces backend - // errors via console.warn — ScenarioStore enforces uniqueness of names. async function handleForkScenario(stepIndex: number) { contextMenu = null; const name = promptScenarioName(); @@ -850,8 +1283,15 @@ ); } - // Toolbar ↶: remove the last step of the active scenario. async function handleSessionBack() { + if (kind === 'solana' && solanaProgram) { + try { + await postSolanaCommand('Back', solanaProgram.name); + } catch (e) { + notifyFailure('session back', e); + } + return; + } try { await postCommand('Back', contract?.name); } catch (e) { @@ -859,12 +1299,19 @@ } } - // Toolbar 🗑: wipe every step of the active scenario (with confirm). async function handleSessionClear() { const active = getActiveScenario(); const steps = getScenarios().get(active) ?? []; if (steps.length === 0) return; if (!confirm(`Clear all ${steps.length} step(s) from scenario "${active}"?`)) return; + if (kind === 'solana' && solanaProgram) { + try { + await postSolanaCommand('Clear', solanaProgram.name); + } catch (e) { + notifyFailure('session clear', e); + } + return; + } try { await postCommand('Clear', contract?.name); } catch (e) { @@ -872,30 +1319,25 @@ } } - // Right-click "{} View source": open the inline CodeMirror panel. function handleViewSource(funcName: string) { contextMenu = null; sourcePanel = { func: funcName }; } - // Right-click "↗ Open in code": fetch the function's absolute path + line - // from the source endpoint and fire the `vscode://` deep link. If no IDE - // is registered the browser silently drops the request — no UI nag. async function handleOpenInIde(funcName: string) { contextMenu = null; - if (!contract) return; + const projectName = kind === 'solana' ? solanaProgram?.name : contract?.name; + if (!projectName) return; try { - const res = await getFunctionSource(contract.name, funcName); + const res = kind === 'solana' + ? await getInstructionSource(projectName, funcName) + : await getFunctionSource(projectName, funcName); openInIde(res.file_path, res.span.start_line, res.span.start_col); } catch (e) { notifyFailure('open in IDE', e); } } - // Right-click "✕ Remove from here": truncate the active scenario at - // `stepIndex` by firing N Back commands. N = current length - stepIndex. - // Using Back (which the backend already supports) avoids needing a new - // truncate command on the server. async function handleRemoveFromHere(stepIndex: number) { contextMenu = null; const active = getActiveScenario(); @@ -914,12 +1356,8 @@ function handleContextMenu(event: MouseEvent, node: Node<GraphNodeData>) { const data = node.data; - // "Fork scenario here" is only meaningful when the active scenario's - // path passes through this node — either it owns the node or inherits - // it from an ancestor. `_scenariosPassingThrough` already encodes both - // cases so the check is a single Array.includes. let sessionStep: { stepIndex: number } | undefined; - if (data._type === 'function' && data._sessionStep === true) { + if ((data._type === 'function' || data._type === 'trace') && data._sessionStep === true) { const { _scenariosPassingThrough: scns, _activeScenario: active, stepIndex: idx } = data; if (typeof idx === 'number' && active && scns?.includes(active)) { sessionStep = { stepIndex: idx }; @@ -936,13 +1374,13 @@ } async function handleNodeClick(node: Node<GraphNodeData>, event?: MouseEvent) { - // Selection first (sync), then expand/collapse (async) handleNodeTap(node); - // In Session mode the canvas is read-only for exploration — clicks only - // select. Expansion would add CFG blocks / seq-next children that we - // deliberately hide in this mode. if (mode === 'session') return; const d = node.data; + if (d._type === 'instruction' && mode === 'cfg') { + handleSolanaIxExpand(d.label as string); + return; + } if (d._type === 'function' && !d.is_external) { await handleFunctionTap(d.label, node.id); } else if (d._type === 'seq-next') { @@ -959,10 +1397,6 @@ } async function handleSeqNodeTap(funcName: string, nodeId: string, seqParent: string, event?: MouseEvent) { - // Plain click commits to one sibling path: collapse the auto-expanded - // sub-trees of all siblings at this level. Shift+click skips the - // collapse so the user can keep multiple branches open in parallel — - // matches the "Shift+click → add branch" hint shown in Legend. const keepSiblings = event?.shiftKey === true; if (seqParent && !keepSiblings) { const siblings = getNodes().filter( @@ -988,7 +1422,6 @@ const parentId = anchorNodeId || `${contract.name}::${funcName}`; if (expandedFuncs.has(funcName)) { - // --- COLLAPSE --- const toRemove = new Set<string>(); for (const n of getNodes()) { if ('_parentFunc' in n.data && n.data._parentFunc === funcName) { @@ -1003,14 +1436,12 @@ return; } - // --- EXPAND --- if (!cfgCache[funcName]) { cfgCache[funcName] = await getCfg(contract.name, funcName); } const cfg = cfgCache[funcName]; const parentPos = liveNodePosition(parentId) ?? { x: 300, y: 200 }; - // 1. Build Svelte Flow nodes (initially at parent position for animation) const cfgNodes: Node<GraphNodeData>[] = cfg.nodes.map(n => ({ id: `cfg:${funcName}:${n.data.id}`, type: 'block', @@ -1024,7 +1455,6 @@ }, })); - // 2. Build edges with color-coded styles, arrows, and explicit handles const cfgEdges: Edge[] = cfg.edges.map((e, i) => { const es = cfgEdgeStyle(e.data.kind); return { @@ -1048,7 +1478,6 @@ }; }); - // 3. Link edge: function node → CFG entry block const entryNode = cfg.nodes.find(n => n.data.node_type === 'Entry'); if (entryNode) { cfgEdges.push({ @@ -1064,12 +1493,10 @@ }); } - // 4. Run dagre on CFG subset to get positions const layoutNodes = runDagreLayout(cfgNodes, cfgEdges, { rankDir: 'TB', nodeSep: 40, rankSep: 60, nodeWidth: 180, }); - // 5. Offset all positions below the parent function node let minX = Infinity, minY = Infinity, maxX = -Infinity; for (const n of layoutNodes) { if (n.position.x < minX) minX = n.position.x; @@ -1085,7 +1512,6 @@ finalPositions.set(n.id, { x: n.position.x + offsetX, y: n.position.y + offsetY }); } - // Add nodes at their final dagre-computed positions (no animation, predictable) for (const n of cfgNodes) { const final = finalPositions.get(n.id); if (final) n.position = final; @@ -1093,7 +1519,6 @@ addNodes(cfgNodes); addEdges(cfgEdges); - // Dim function nodes + call edges dimFunctionLayer(parentId); expandedFuncs.add(funcName); @@ -1101,40 +1526,27 @@ } async function toggleSeqExpand(funcName: string, parentNodeId: string) { - // ── COLLAPSE ── if (seqExpanded.has(parentNodeId)) { collapseAllDescendants(parentNodeId); seqExpanded.delete(parentNodeId); seqExpanded = new Map(seqExpanded); - // If no seq-next nodes remain, un-dim everything const anySeq = getNodes().some(n => n.data._type === 'seq-next'); if (!anySeq) resetAllDimmed(); return; } - // ── EXPAND ── if (!seqTree || !seqTree.functions) return; - // Find the root function node for this seq subtree (walk up _seqParent chain) const rootFunc = findSeqRootFunction(parentNodeId); if (!rootFunc) return; const seqFunctions: Array<{ name: string; visibility: string; read_only: boolean; path_count: number }> = seqTree.functions; - // Show every contract function as a candidate next-step, matching the - // CLI `f` listing. The "interesting transition" signal (⚠ conditions - // badge + dashed border) is preserved automatically via the per-child - // `_transition` lookup below — no filtering needed here. const targets = seqFunctions; - // Reuse the same lookup the scenarios canvas uses to pull modifier/ - // mutability info that isn't on `SequenceFunction`. `contract` already - // holds the full function detail. const allFuncs = [...(contract?.functions ?? []), ...(contract?.inherited_functions ?? [])]; - // Build new seq-next children with placeholder positions — relayoutSeqTree - // will assign final positions from the shared BFS walk. const newNodes: Node<GraphNodeData>[] = []; const newEdges: Edge[] = []; for (const func of targets) { @@ -1176,8 +1588,6 @@ }); } - // Commit new nodes/edges to the store first, then let the shared helper - // re-run BFS over the whole subtree (root + existing + new) coherently. addNodes(newNodes); addEdges(newEdges); relayoutSeqTree(rootFunc.id); @@ -1189,7 +1599,19 @@ } function switchMode(newMode: 'cfg' | 'sequences' | 'session') { - // Remove expanded nodes from graph store + if (kind === 'solana') { + const toRemove = new Set<string>(); + for (const n of getNodes()) { + const isSessionNode = (n.data as any)?._sessionStep === true; + if (!isSessionNode) toRemove.add(n.id); + } + if (toRemove.size > 0) removeNodesById(toRemove); + solanaCanvasIxs = new Set(); + solanaExpandedIxs = new Set(); + selectedNode = null; + mode = newMode; + return; + } const toRemove = new Set<string>(); for (const n of getNodes()) { if (n.data._type === 'block' || n.data._type === 'seq-next') { @@ -1209,19 +1631,16 @@ function highlightPath(funcName: string, path: any) { selectedPath = path; - // Build set of highlighted block IDs const highlightedIds = new Set<string>( path.nodes.map((n: any) => `cfg:${funcName}:b${n.block_id}`) ); - // Build set of highlighted edge pairs (consecutive path nodes) const highlightedEdgePairs = new Set<string>(); const blockIds = [...highlightedIds]; for (let i = 0; i < blockIds.length - 1; i++) { highlightedEdgePairs.add(`${blockIds[i]}→${blockIds[i + 1]}`); } - // Update nodes: dim all CFG blocks except highlighted ones setNodes(getNodes().map(n => { if (n.data._type === 'block' && n.data._parentFunc === funcName) { const dimmed = !highlightedIds.has(n.id); @@ -1230,7 +1649,6 @@ return n; })); - // Update edges: dim all CFG edges except path edges setEdges(getEdges().map(e => { if (e.data?._parentFunc === funcName && e.data?._type === 'cfg-edge') { const key = `${e.source}→${e.target}`; @@ -1241,12 +1659,60 @@ })); } - // ── Cmd+K palette: publish context commands ────────────────────────── - // Rebuilds whenever any input state changes (contract, scenarios, - // active scenario, mode, canvas state, project map). The palette store - // is global, so on route unmount we clear the list to avoid leaking - // stale handlers back to the next page. $effect(() => { + if (kind === 'solana' && solanaProgram) { + const prog = solanaProgram; + const cmds: Command[] = []; + cmds.push( + { id: 'mode:cfg', label: 'Mode: Program', category: 'Mode', icon: '⊟', keywords: ['cfg', 'program', 'instructions'], run: () => switchMode('cfg') }, + { id: 'mode:sequences', label: 'Mode: Sequences', category: 'Mode', icon: '⇵', keywords: ['seq', 'flow'], run: () => switchMode('sequences') }, + { id: 'mode:session', label: 'Mode: Session', category: 'Mode', icon: '⎇', keywords: ['scenario', 'session', 'trace'], run: () => switchMode('session') }, + ); + cmds.push({ + id: 'canvas:center', + label: 'Center canvas', + category: 'Action', + icon: '⊙', + keywords: ['fit', 'zoom', 'reset view'], + run: () => { flowApi?.fitView({ padding: 0.1 }); }, + }); + for (const ix of prog.instructions ?? []) { + cmds.push({ + id: `solana-ix:${ix.name}`, + label: ix.name, + category: 'Function', + icon: 'ƒ', + detail: `${(ix.args ?? []).length} args · ${(ix.accounts ?? []).length} accounts`, + keywords: ['instruction', 'jump', 'canvas'], + run: () => handleSolanaIxAdd(ix.name), + }); + cmds.push({ + id: `solana-run:${ix.name}`, + label: `Execute ${ix.name}`, + category: 'Action', + icon: '▶', + keywords: ['call', 'execute', 'instruction', 'run'], + run: () => handleSolanaRun(ix.name), + }); + } + for (const a of prog.accounts ?? []) { + cmds.push({ + id: `solana-acc:${a.name}`, + label: a.name, + category: 'Contract', + icon: '◇', + detail: 'account type', + keywords: ['account', 'type'], + run: () => { + const node = findNode(`account:${a.name}`); + if (node && flowApi) flowApi.fitView({ nodes: [{ id: node.id }], padding: 0.5, duration: 400 }); + }, + }); + } + setPaletteCommands(cmds); + return; + } + if (!contract) { setPaletteCommands([]); return; @@ -1254,14 +1720,12 @@ const ctr = contract; const cmds: Command[] = []; - // Modes — always present. Icon hints the layout. cmds.push( { id: 'mode:cfg', label: 'Mode: CFG', category: 'Mode', icon: '⊟', keywords: ['cfg', 'control flow'], run: () => switchMode('cfg') }, { id: 'mode:sequences', label: 'Mode: Sequences', category: 'Mode', icon: '⇵', keywords: ['seq', 'calls'], run: () => switchMode('sequences') }, { id: 'mode:session', label: 'Mode: Session', category: 'Mode', icon: '⎇', keywords: ['scenario', 'session'], run: () => switchMode('session') }, ); - // Canvas / terminal actions. cmds.push( { id: 'canvas:center', label: 'Center canvas', category: 'Action', icon: '⊙', keywords: ['fit', 'zoom', 'reset view'], run: () => { flowApi?.fitView({ padding: 0.1 }); } }, { id: 'canvas:clear', label: 'Clear canvas', category: 'Action', icon: '✕', keywords: ['reset', 'wipe'], run: () => { @@ -1271,16 +1735,11 @@ { id: 'terminal:toggle', label: 'Toggle terminal', category: 'Action', icon: '>_', keywords: ['console', 'repl', 'pty'], run: () => toggleTerminal() }, ); - // Session controls — only meaningful while there is an active scenario - // with at least one step. We still expose them otherwise so users can - // discover the shortcut; the handlers already guard empty scenarios. cmds.push( { id: 'session:back', label: 'Back — remove last step', category: 'Action', icon: '↶', keywords: ['undo', 'step'], run: () => handleSessionBack() }, { id: 'session:clear', label: 'Clear scenario', category: 'Action', icon: '🗑', keywords: ['reset scenario'], run: () => handleSessionClear() }, ); - // Scenario lifecycle. "Switch to X" and "Delete X" per existing - // scenario; "New scenario" always available. cmds.push({ id: 'scenario:new', label: 'New scenario', @@ -1313,11 +1772,6 @@ } } - // Functions — own + inherited. Jump = add to canvas (Session mode - // turns it into an add-step, which matches the sidebar click). - // Solidity allows overloading by signature so names may repeat; we - // include the index in the id to keep every row uniquely keyed even - // when two rows end up with the same label. const allFuncs = [ ...(ctr.functions ?? []).map((f) => ({ name: f.name, source: 'own' as const })), ...(ctr.inherited_functions ?? []).map((f) => ({ name: f.name, source: 'inherited' as const })), @@ -1334,9 +1788,6 @@ }); }); - // Cross-contract navigation. Skip the current one and dedupe by name - // — ProjectMap may list the same interface twice (keyed each would - // throw on duplicate ids). const seenContracts = new Set<string>([ctr.name]); for (const c of projectMap) { if (seenContracts.has(c.name)) continue; @@ -1355,9 +1806,6 @@ setPaletteCommands(cmds); }); - // Clear published commands on unmount so the palette doesn't render - // stale handlers if the user lands on a page that doesn't publish its - // own list. onDestroy(() => { clearPaletteCommands(); }); @@ -1366,22 +1814,31 @@ <div class="fixed inset-0 flex flex-col bg-dark"> {#if error} <div class="p-6 text-danger">{error}</div> - {:else} + {:else if contract || (kind === 'solana' && solanaProgram)} <TopBar - contractName={contract?.name ?? '...'} + contractName={kind === 'solana' && solanaProgram ? solanaProgram.name : (contract?.name ?? '...')} {mode} {seqDirection} + {kind} + {hideSystem} onmodechange={switchMode} onsearch={togglePalette} oncenter={() => flowApi?.fitView({ padding: 0.1 })} onseqdirection={(dir) => { seqDirection = dir; reorientAllSeqSubtrees(); }} onsessionback={handleSessionBack} onsessionclear={handleSessionClear} + onhidesystem={handleHideSystemToggle} /> <div class="flex-1 flex overflow-hidden h-full"> - {#if contract} - <FunctionSidebar {contract} {canvasFuncs} {mode} onadd={handleSidebarAdd} onremove={removeFuncFromCanvas} /> - {/if} + <FunctionSidebar + contract={kind === 'solana' ? null : contract} + program={kind === 'solana' ? solanaProgram : null} + {kind} + canvasFuncs={kind === 'solana' ? solanaCanvasIxs : canvasFuncs} + {mode} + onadd={handleSidebarAdd} + onremove={kind === 'solana' ? handleSolanaIxRemove : removeFuncFromCanvas} + /> <GraphCanvasFlow bind:this={graphCanvas} @@ -1394,32 +1851,48 @@ onready={(api) => { flowApi = api; }} /> - {#if contract} - <SessionSidebar - contract={contract.name} - {selectedNode} - {selectedPath} - {funcPaths} - {expandedFuncs} - {seqExpanded} - {mode} - {seqAnalysis} - contractDetail={{ name: contract.name, functions: contract.functions }} - lookupBlock={(blockId) => { - const node = findNode(blockId); - if (!node || node.data._type !== 'block') return null; - return { statements: (node.data as any).statements ?? [], node_type: (node.data as any).node_type }; - }} - onpathselect={(funcName, path) => { selectedPath = path; highlightPath(funcName, path); }} - onexpandcfg={(funcName, nodeId) => toggleFuncExpand(funcName, nodeId)} - /> - {/if} + <SessionSidebar + contract={kind === 'solana' && solanaProgram ? solanaProgram.name : (contract?.name ?? '')} + {kind} + program={kind === 'solana' ? solanaProgram : null} + {selectedNode} + {selectedPath} + {funcPaths} + {expandedFuncs} + {seqExpanded} + {mode} + {seqAnalysis} + contractDetail={kind === 'solana' && solanaProgram + ? { name: solanaProgram.name, functions: [] } + : (contract ? { name: contract.name, functions: contract.functions } : null)} + lookupBlock={(blockId) => { + const node = findNode(blockId); + if (!node || node.data._type !== 'block') return null; + return { statements: (node.data as any).statements ?? [], node_type: (node.data as any).node_type }; + }} + onpathselect={(funcName, path) => { selectedPath = path; highlightPath(funcName, path); }} + onexpandcfg={(funcName, nodeId) => toggleFuncExpand(funcName, nodeId)} + solanaUsers={solanaUsers} + onsolanarun={handleSolanaRun} + onsolanasubmit={handleSolanaSubmitFromInspector} + onnewuser={async (name, lamports) => { + if (!solanaProgram) return; + const result = await postSolanaCommand({ UsersNew: { name, lamports } }, solanaProgram.name); + if (result?.Error) throw new Error(result.Error.message ?? 'create user failed'); + await refreshSolanaUsers(); + }} + onairdrop={async (name, lamports) => { + if (!solanaProgram) return; + await postSolanaCommand({ Airdrop: { user: name, lamports } }, solanaProgram.name); + await refreshSolanaUsers(); + }} + /> </div> <StatusBar {mode} - canvasCount={canvasFuncs.size} - expandedCount={expandedFuncs.size} + canvasCount={kind === 'solana' ? solanaCanvasIxs.size : canvasFuncs.size} + expandedCount={kind === 'solana' ? solanaTraceCount : expandedFuncs.size} activeScenario={getActiveScenario()} {selectionCount} /> @@ -1431,23 +1904,33 @@ {mode} onexpandcfg={(func, nodeId) => { toggleFuncExpand(func, nodeId); contextMenu = null; }} onremovefunc={(func) => { removeFuncFromCanvas(func); contextMenu = null; selectedNode = null; }} - onremovenode={(nodeId) => { removeSeqNode(nodeId); contextMenu = null; selectedNode = null; }} + onremovenode={(nodeId) => { + if (nodeId.startsWith('ix:')) { + handleSolanaIxRemove(nodeId.slice(3)); + } else { + removeSeqNode(nodeId); + } + contextMenu = null; + selectedNode = null; + }} onforkscenario={handleForkScenario} onremovefromhere={handleRemoveFromHere} onviewsource={handleViewSource} onopenide={handleOpenInIde} + onsolanarun={(name) => { handleSolanaRun(name); contextMenu = null; }} onclose={() => contextMenu = null} /> - {#if sourcePanel && contract} + {#if sourcePanel && (contract || (kind === 'solana' && solanaProgram))} <FunctionSourcePanel - contract={contract.name} + contract={kind === 'solana' ? solanaProgram!.name : contract!.name} func={sourcePanel.func} + kind={kind} onclose={() => sourcePanel = null} /> {/if} - <Legend {mode} /> + <Legend {mode} {kind} /> {/if} </div> diff --git a/crates/ilold-web/src/api/contract.rs b/crates/ilold-web/src/api/contract.rs index 2e90fc1..0aa2f2d 100644 --- a/crates/ilold-web/src/api/contract.rs +++ b/crates/ilold-web/src/api/contract.rs @@ -9,11 +9,7 @@ use ilold_core::model::common::SourceSpan; use ilold_core::pathtree::types::PathTree; use ilold_core::sequence::types::SequenceTree; -use crate::state::AppState; - -// ============================================================================ -// Contract detail -// ============================================================================ +use crate::state::{require_solidity, AppState}; #[derive(Serialize)] pub struct ContractDetail { @@ -58,7 +54,8 @@ pub async fn get_contract( State(state): State<Arc<AppState>>, Path(name): Path<String>, ) -> Result<Json<ContractDetail>, StatusCode> { - let contract = state + let s = require_solidity(&state)?; + let contract = s .project .contracts .iter() @@ -70,7 +67,7 @@ pub async fn get_contract( .iter() .map(|f| { let key = (contract.name.clone(), f.name.clone()); - let pt = state.path_trees.get(&key); + let pt = s.path_trees.get(&key); FunctionSummary { name: f.name.clone(), kind: format!("{:?}", f.kind), @@ -100,14 +97,14 @@ pub async fn get_contract( }) .collect(); - let inherited_functions: Vec<FunctionSummary> = state.project + 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 = state.path_trees.get(&key); + let pt = s.path_trees.get(&key); FunctionSummary { name: f.name.clone(), kind: format!("{:?}", f.kind), @@ -128,7 +125,7 @@ pub async fn get_contract( let own_var_names: std::collections::HashSet<String> = contract.state_vars.iter() .map(|sv| sv.name.clone()) .collect(); - let inherited_state_vars: Vec<StateVarSummary> = state.project + let inherited_state_vars: Vec<StateVarSummary> = s.project .inherited_state_vars(contract) .into_iter() .filter(|sv| !own_var_names.contains(&sv.name)) @@ -152,10 +149,6 @@ pub async fn get_contract( })) } -// ============================================================================ -// Call graph (Cytoscape-compatible JSON) -// ============================================================================ - #[derive(Serialize)] pub struct CytoscapeGraph { pub nodes: Vec<CytoscapeNode>, @@ -196,7 +189,8 @@ pub async fn get_callgraph( State(state): State<Arc<AppState>>, Path(name): Path<String>, ) -> Result<Json<CytoscapeGraph>, StatusCode> { - let cg = state + let s = require_solidity(&state)?; + let cg = s .call_graphs .get(&name) .ok_or(StatusCode::NOT_FOUND)?; @@ -239,23 +233,20 @@ pub async fn get_callgraph( Ok(Json(CytoscapeGraph { nodes, edges })) } -// ============================================================================ -// CFG (Cytoscape-compatible JSON) -// ============================================================================ - pub async fn get_cfg( State(state): State<Arc<AppState>>, Path((contract_name, func_name)): Path<(String, String)>, ) -> Result<Json<CytoscapeGraph>, StatusCode> { - let contract = state.project.contracts.iter() + 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) = state.project.resolve_function(contract, &func_name) + let (owning, _func) = s.project.resolve_function(contract, &func_name) .ok_or(StatusCode::NOT_FOUND)?; let key = (owning.name.clone(), func_name); - let cfg = state.cfgs.get(&key).ok_or(StatusCode::NOT_FOUND)?; + let cfg = s.cfgs.get(&key).ok_or(StatusCode::NOT_FOUND)?; let nodes: Vec<CytoscapeNode> = cfg .node_indices() @@ -297,19 +288,10 @@ pub async fn get_cfg( Ok(Json(CytoscapeGraph { nodes, edges })) } -// ============================================================================ -// Function source (for the canvas "View source" panel + IDE deep link) -// ============================================================================ - #[derive(Serialize)] pub struct FunctionSourceResponse { - /// Absolute path to the Solidity file — used as-is for the `vscode://` - /// deep link on the frontend. pub file_path: String, - /// Source text sliced to the function's line range (inclusive, 1-based). pub source: String, - /// Original span; frontend uses `start_line` / `start_col` for the IDE - /// jump, and nothing else today. pub span: SourceSpan, } @@ -317,17 +299,15 @@ pub async fn get_function_source( State(state): State<Arc<AppState>>, Path((contract_name, func_name)): Path<(String, String)>, ) -> Result<Json<FunctionSourceResponse>, StatusCode> { - let contract = state.project.contracts.iter() + let s = require_solidity(&state)?; + let contract = s.project.contracts.iter() .find(|c| c.name == contract_name) .ok_or(StatusCode::NOT_FOUND)?; - // `resolve_function` walks the inheritance chain, so a function declared - // in a parent contract resolves to the parent's FunctionDef (with its - // own span + file_index). - let (_owning, func) = state.project.resolve_function(contract, &func_name) + let (_owning, func) = s.project.resolve_function(contract, &func_name) .ok_or(StatusCode::NOT_FOUND)?; - let file = state.project.source_files.get(func.span.file_index) + let file = s.project.source_files.get(func.span.file_index) .ok_or(StatusCode::NOT_FOUND)?; let source = slice_lines( @@ -343,9 +323,6 @@ pub async fn get_function_source( })) } -/// Return the text between `start_1based` and `end_1based` lines (inclusive). -/// Returns an empty string for malformed ranges (`start > end`) so a bad -/// span from the parser never panics the handler. fn slice_lines(src: &str, start_1based: usize, end_1based: usize) -> String { if start_1based == 0 || end_1based < start_1based { return String::new(); @@ -357,34 +334,29 @@ fn slice_lines(src: &str, start_1based: usize, end_1based: usize) -> String { .join("\n") } -// ============================================================================ -// Path tree -// ============================================================================ - pub async fn get_paths( State(state): State<Arc<AppState>>, Path((contract_name, func_name)): Path<(String, String)>, ) -> Result<Json<PathTree>, StatusCode> { - let contract = state.project.contracts.iter() + 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) = state.project.resolve_function(contract, &func_name) + let (owning, _func) = s.project.resolve_function(contract, &func_name) .ok_or(StatusCode::NOT_FOUND)?; let key = (owning.name.clone(), func_name); - state - .path_trees + s.path_trees .get(&key) .cloned() .map(Json) .ok_or(StatusCode::NOT_FOUND) } -// ============================================================================ -// Sequence tree -// ============================================================================ - #[derive(Deserialize)] pub struct SequenceQuery { pub depth: Option<usize>, @@ -395,32 +367,25 @@ pub async fn get_sequences( Path(name): Path<String>, Query(_query): Query<SequenceQuery>, ) -> Result<Json<SequenceTree>, StatusCode> { - state - .sequence_trees + let s = require_solidity(&state)?; + s.sequence_trees .get(&name) .cloned() .map(Json) .ok_or(StatusCode::NOT_FOUND) } -// ============================================================================ -// Sequence analysis — conditions between function transitions -// ============================================================================ - 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 analysis = analyze_sequences(&state.path_trees, &name); + let s = require_solidity(&state)?; + let analysis = analyze_sequences(&s.path_trees, &name); Ok(Json(analysis)) } -// ============================================================================ -// Search suggestions — what's searchable in this contract -// ============================================================================ - #[derive(Serialize)] pub struct SearchSuggestions { pub functions: Vec<String>, @@ -440,21 +405,22 @@ pub async fn get_search_suggestions( State(state): State<Arc<AppState>>, Path(name): Path<String>, ) -> Result<Json<SearchSuggestions>, StatusCode> { - let contract = state + let s = require_solidity(&state)?; + let contract = s .project .contracts .iter() .find(|c| c.name == name) .ok_or(StatusCode::NOT_FOUND)?; - let accessible = state.project.accessible_functions(contract); + 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> = state.project + let state_vars: Vec<String> = s.project .inherited_state_vars(contract) .into_iter() .map(|sv| sv.name) @@ -466,7 +432,6 @@ pub async fn get_search_suggestions( .map(|e| e.name.clone()) .collect(); - // Collect unique origins (current contract + any ancestor with accessible functions) let mut origins: std::collections::HashSet<String> = std::collections::HashSet::new(); origins.insert(name.clone()); for af in &accessible { @@ -475,9 +440,8 @@ pub async fn get_search_suggestions( } } - // Collect unique external calls from all paths keyed by any origin let mut ext_calls = std::collections::HashSet::new(); - for ((c, _), pt) in &state.path_trees { + for ((c, _), pt) in &s.path_trees { if !origins.contains(c) { continue; } for path in &pt.paths { for call in &path.annotations.external_calls { @@ -519,10 +483,6 @@ pub async fn get_search_suggestions( })) } -// ============================================================================ -// Helpers -// ============================================================================ - use ilold_core::cfg::types::{BasicBlock, BlockKind, CfgStatement}; fn summarize_stmt(stmt: &CfgStatement) -> String { diff --git a/crates/ilold-web/src/api/project.rs b/crates/ilold-web/src/api/project.rs index 5c2f037..3673da0 100644 --- a/crates/ilold-web/src/api/project.rs +++ b/crates/ilold-web/src/api/project.rs @@ -1,10 +1,32 @@ +use std::collections::HashMap; use std::sync::Arc; -use axum::extract::State; +use axum::extract::{Path, Query, State}; +use axum::http::StatusCode; use axum::Json; -use serde::Serialize; +use ilold_core::model::common::SourceSpan; +use ilold_solana_core::model::ProgramDef; +use ilold_solana_core::overlay::RuntimeOverlay; +use ilold_solana_core::view::ProgramView; +use serde::{Deserialize, Serialize}; +use solana_keypair::Signer; +use syn::spanned::Spanned; -use crate::state::AppState; +use crate::state::{require_solidity_msg, AppState, Backend}; + +fn find_solana_program( + state: &Arc<AppState>, + name: &str, +) -> Result<ProgramDef, (StatusCode, String)> { + let solana = state + .solana() + .ok_or((StatusCode::BAD_REQUEST, "endpoint is Solana-only".into()))?; + solana + .project + .find_program(name) + .cloned() + .ok_or((StatusCode::NOT_FOUND, format!("program '{name}' not found"))) +} #[derive(Serialize)] pub struct ProjectSummary { @@ -21,8 +43,11 @@ pub struct ContractSummary { pub inherits: Vec<String>, } -pub async fn get_project(State(state): State<Arc<AppState>>) -> Json<ProjectSummary> { - let contracts = state +pub async fn get_project( + State(state): State<Arc<AppState>>, +) -> Result<Json<ProjectSummary>, (StatusCode, String)> { + let s = require_solidity_msg(&state)?; + let contracts = s .project .contracts .iter() @@ -35,22 +60,41 @@ pub async fn get_project(State(state): State<Arc<AppState>>) -> Json<ProjectSumm }) .collect(); - Json(ProjectSummary { - files: state.project.source_files.len(), + Ok(Json(ProjectSummary { + files: s.project.source_files.len(), contracts, - }) + })) } -// ============================================================================ -// Project Map — full contract details with cross-contract relationships -// ============================================================================ - #[derive(Serialize)] pub struct ProjectMap { + pub kind: &'static str, pub contracts: Vec<MapContract>, + pub programs: Vec<MapProgram>, pub relationships: Vec<MapRelationship>, } +#[derive(Serialize)] +pub struct MapProgram { + pub name: String, + pub program_id: String, + pub instructions: Vec<MapInstruction>, + pub account_types: Vec<MapAccountType>, +} + +#[derive(Serialize)] +pub struct MapInstruction { + pub name: String, + pub args_count: usize, + pub accounts_count: usize, + pub has_pdas: bool, +} + +#[derive(Serialize)] +pub struct MapAccountType { + pub name: String, +} + #[derive(Serialize)] pub struct MapContract { pub name: String, @@ -87,18 +131,117 @@ pub struct MapRelationship { pub kind: String, } -pub async fn get_project_map(State(state): State<Arc<AppState>>) -> Json<ProjectMap> { +pub async fn get_program_view( + State(state): State<Arc<AppState>>, + Path(name): Path<String>, +) -> Result<Json<ProgramView>, (StatusCode, String)> { + let program = find_solana_program(&state, &name)?; + Ok(Json(program.compute_view())) +} + +pub async fn get_user_labels( + State(state): State<Arc<AppState>>, + Path(scenario): Path<String>, +) -> Result<Json<HashMap<String, String>>, (StatusCode, String)> { + let solana = state + .solana() + .ok_or((StatusCode::BAD_REQUEST, "endpoint is Solana-only".into()))?; + let users_lock = solana.users.read().unwrap(); + let scn_users = users_lock.get(&scenario).ok_or(( + StatusCode::NOT_FOUND, + format!("scenario '{scenario}' has no user registry"), + ))?; + let map: HashMap<String, String> = scn_users + .iter() + .map(|(name, kp)| (kp.pubkey().to_string(), name.clone())) + .collect(); + Ok(Json(map)) +} + +#[derive(Deserialize, Default)] +pub struct OverlayQuery { + pub scenario: Option<String>, +} + +pub async fn get_program_overlay( + State(state): State<Arc<AppState>>, + Path(name): Path<String>, + Query(params): Query<OverlayQuery>, +) -> Result<Json<RuntimeOverlay>, (StatusCode, String)> { + let program = find_solana_program(&state, &name)?; + let scenarios = state.scenarios.read().unwrap(); + let scenario_name = params + .scenario + .clone() + .unwrap_or_else(|| scenarios.active().to_string()); + let session = scenarios.get(&scenario_name).ok_or(( + StatusCode::NOT_FOUND, + format!("scenario '{scenario_name}' not found"), + ))?; + let mut overlay = RuntimeOverlay::from_session(session); + if overlay.program.is_empty() { + overlay.program = program.name.clone(); + } + overlay.scenario = scenario_name; + Ok(Json(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)), + } +} + +fn build_solana_map(s: &crate::state::SolanaState) -> ProjectMap { + let programs: Vec<MapProgram> = s + .project + .programs + .iter() + .map(|p| MapProgram { + name: p.name.clone(), + program_id: p.program_id.to_string(), + instructions: p + .instructions + .iter() + .map(|ix| MapInstruction { + name: ix.name.clone(), + args_count: ix.args.len(), + accounts_count: ix.accounts.len(), + has_pdas: ix.accounts.iter().any(|a| a.pda.is_some()), + }) + .collect(), + account_types: p + .account_types + .iter() + .map(|a| MapAccountType { name: a.name.clone() }) + .collect(), + }) + .collect(); + + 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 &state.project.contracts { + 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 = state.path_trees.get(&key); + 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); @@ -132,8 +275,7 @@ pub async fn get_project_map(State(state): State<Arc<AppState>>) -> Json<Project state_vars, }); - // Extract cross-contract relationships from call graph - if let Some(cg) = state.call_graphs.get(&contract.name) { + 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]; @@ -153,8 +295,153 @@ pub async fn get_project_map(State(state): State<Arc<AppState>>) -> Json<Project } } - Json(ProjectMap { + ProjectMap { + kind: "solidity", contracts, + programs: Vec::new(), relationships, - }) + } +} + +#[derive(Serialize)] +pub struct InstructionSourceResponse { + pub file_path: String, + pub source: String, + pub span: SourceSpan, +} + +pub async fn get_instruction_source( + State(state): State<Arc<AppState>>, + Path((name, ix)): Path<(String, String)>, +) -> Result<Json<InstructionSourceResponse>, (StatusCode, String)> { + let program = find_solana_program(&state, &name)?; + if !program.instructions.iter().any(|i| i.name == ix) { + return Err(( + StatusCode::NOT_FOUND, + format!("instruction '{ix}' not found in program '{name}'"), + )); + } + + let file_path = state + .project_root + .join("programs") + .join(&program.name) + .join("src") + .join("lib.rs"); + let file_path = std::fs::canonicalize(&file_path).map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("canonicalize {}: {e}", file_path.display()), + ) + })?; + let content = std::fs::read_to_string(&file_path).map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("read {}: {e}", file_path.display()), + ) + })?; + + let span = extract_handler_span(&content, &ix).ok_or(( + StatusCode::NOT_FOUND, + format!("handler '{ix}' not found in {}", file_path.display()), + ))?; + + let source = slice_lines(&content, span.start_line as usize, span.end_line as usize); + + Ok(Json(InstructionSourceResponse { + file_path: file_path.to_string_lossy().into_owned(), + source, + span, + })) +} + +pub(crate) fn extract_handler_span(source: &str, ix: &str) -> Option<SourceSpan> { + let file = syn::parse_file(source).ok()?; + find_fn_in_items(&file.items, ix) +} + +fn find_fn_in_items(items: &[syn::Item], ix: &str) -> Option<SourceSpan> { + for item in items { + if let syn::Item::Mod(m) = item { + if has_program_attr(&m.attrs) { + if let Some((_, inner)) = &m.content { + if let Some(span) = find_fn_in_items(inner, ix) { + return Some(span); + } + } + } + } + } + for item in items { + match item { + syn::Item::Fn(f) if f.sig.ident == ix => return Some(span_of(f)), + syn::Item::Mod(m) => { + if let Some((_, inner)) = &m.content { + if let Some(span) = find_fn_in_items(inner, ix) { + return Some(span); + } + } + } + _ => {} + } + } + None +} + +fn has_program_attr(attrs: &[syn::Attribute]) -> bool { + attrs.iter().any(|a| a.path().is_ident("program")) +} + +fn span_of(item: &syn::ItemFn) -> SourceSpan { + let span = item.span(); + let start = span.start(); + let end = span.end(); + SourceSpan { + file_index: 0, + start_line: start.line as u32, + start_col: start.column as u32, + end_line: end.line as u32, + end_col: end.column as u32, + } +} + +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") +} + +#[cfg(test)] +mod tests { + use super::*; + + const STAKING_LIB: &str = include_str!( + "../../../../tests/fixtures/solana/staking/programs/staking/src/lib.rs" + ); + + #[test] + fn extract_handler_span_finds_stake() { + let span = extract_handler_span(STAKING_LIB, "stake").expect("stake handler"); + assert_eq!(span.start_line, 19, "start line off: {span:?}"); + assert!( + span.end_line > span.start_line, + "stake body must span >1 lines: {span:?}", + ); + let body = slice_lines(STAKING_LIB, span.start_line as usize, span.end_line as usize); + assert!(body.contains("pub fn stake"), "body missing signature:\n{body}"); + assert!( + body.contains("StakingError::ZeroAmount"), + "body missing require!:\n{body}", + ); + } + + #[test] + fn extract_handler_span_missing_returns_none() { + assert!(extract_handler_span(STAKING_LIB, "does_not_exist").is_none()); + } } diff --git a/crates/ilold-web/src/api/session.rs b/crates/ilold-web/src/api/session.rs index 6d616f2..289bdd0 100644 --- a/crates/ilold-web/src/api/session.rs +++ b/crates/ilold-web/src/api/session.rs @@ -4,6 +4,7 @@ use axum::extract::{Path, Query, 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::{ @@ -17,39 +18,50 @@ use ilold_core::exploration::timeline::{build_variable_timeline, VariableTimelin 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_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, +}; +use solana_keypair::Keypair; -use crate::state::{AppState, ScenarioStore}; +use crate::state::{require_solidity_msg, AppState, Backend, ScenarioStore}; #[derive(Deserialize)] pub struct CommandRequest { pub contract: Option<String>, - pub command: SessionCommand, + pub command: Value, } fn build_analysis_data<'a>( state: &'a AppState, contract_name: &str, ) -> Result<AnalysisData<'a>, (StatusCode, String)> { - let contract = state.project.contracts.iter() + 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 = state.sequence_analyses.get(contract_name) + let seq_analysis = s.sequence_analyses.get(contract_name) .ok_or((StatusCode::NOT_FOUND, "No analysis for contract".into()))?; - let classifs = state.classifications.get(contract_name) + let classifs = s.classifications.get(contract_name) .ok_or((StatusCode::NOT_FOUND, "No classifications for contract".into()))?; Ok(AnalysisData { - project: &state.project, + project: &s.project, contract, - cfgs: &state.cfgs, - path_trees: &state.path_trees, + cfgs: &s.cfgs, + path_trees: &s.path_trees, behaviors: &seq_analysis.functions, transitions: &seq_analysis.transitions, classifications: classifs, - all_sequence_analyses: &state.sequence_analyses, - all_classifications: &state.classifications, + all_sequence_analyses: &s.sequence_analyses, + all_classifications: &s.classifications, }) } @@ -65,7 +77,8 @@ fn resolve_contract(state: &AppState, explicit: Option<&str>) -> Result<String, } drop(scenarios_guard); - state.project.find_contract(None) + let s = require_solidity_msg(state)?; + s.project.find_contract(None) .map(|c| c.name.clone()) .map_err(|e| (StatusCode::BAD_REQUEST, e)) } @@ -77,9 +90,6 @@ fn timestamp_now() -> String { .unwrap_or_default() } -/// Validate a scenario name and check it is not already taken in the store. -/// Shared guard for both `ScenarioAction::New` and `ScenarioAction::Fork`. -/// Returns `Err(CommandResult::Error)` ready to be propagated. fn reserve_name(store: &ScenarioStore, name: &str) -> Result<(), CommandResult> { if let Err(e) = validate_scenario_name(name) { return Err(CommandResult::Error { message: e }); @@ -92,9 +102,6 @@ fn reserve_name(store: &ScenarioStore, name: &str) -> Result<(), CommandResult> Ok(()) } -/// Execute scenario lifecycle commands against the `ScenarioStore`. Lives -/// in the web crate because it needs `&mut ScenarioStore` (crate boundary); -/// `commands.rs` only defines the data types. fn execute_scenario( store: &mut ScenarioStore, action: ScenarioAction, @@ -126,8 +133,6 @@ fn execute_scenario( ScenarioAction::Switch { name } => { let from = store.active().to_string(); if name == from { - // idempotent no-op per spec S3.4; caller is responsible for - // suppressing WS broadcast when from == to. return CommandResult::ScenarioSwitched { from, to: name }; } match store.set_active(name.clone()) { @@ -169,11 +174,8 @@ fn fork_scenario( } let from = store.active().to_string(); let mut cloned = store.active_session().clone(); + cloned.reset_scenario_local_observations(); - // Resolve effective step count. None (legacy) → keep all steps. - // Some(N) → truncate to first N; error if N > current length. - // Mutations live inside each ExplorationStep, so truncating `steps` - // drops their owning step's mutations as well. let len = cloned.steps.len(); let effective = match at_step { None => len, @@ -191,16 +193,10 @@ fn fork_scenario( } }; - // Record the fork origin on the cloned session itself. The frontend - // reads this (via /api/scenarios/all) to render the scenario as a - // branch from its source instead of a parallel timeline. cloned.forked_from = Some(ForkOrigin { scenario: from.clone(), at_step: effective, }); - // The `BranchCreated` variant's field names (`from_function`/`branch_function`) - // are reused here as scenario names per design §2.4 — intentionally not - // renamed to preserve save-file compatibility. cloned.journal.record(JournalEntry::BranchCreated { from_function: from.clone(), branch_function: new_name.clone(), @@ -217,24 +213,25 @@ fn fork_scenario( pub async fn handle_command( State(state): State<Arc<AppState>>, Json(req): Json<CommandRequest>, -) -> Result<Json<CommandResult>, (StatusCode, String)> { +) -> 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(); - // SaveSession/LoadSession bypass the contract-switch reset: Save doesn't - // care about the request's contract field, and Load carries its own - // contract inside the JSON. Without the bypass, loading a file from a - // different contract would clear the store before the load runs. let is_persistence = matches!( - req.command, + solidity_command, SessionCommand::SaveSession | SessionCommand::LoadSession { .. } ); - // Contract switch: only reset if the active session has actual steps. - // An empty session with a mismatched contract means the auto-seed picked - // a default contract that didn't match the first real request — just - // swap it transparently (no ClearAll, nothing was ever on the canvas). let needs_reset = !is_persistence && scenarios_guard.active_session().contract != contract_name; if needs_reset { @@ -247,37 +244,29 @@ pub async fn handle_command( }).ok(); } } - // Scenario / Save / Load commands operate on the store itself; all - // other commands are delegated to the active session. Dispatch happens - // here (before `active_session_mut`) to avoid partial-move errors on - // `req.command`. - match req.command { + let result: CommandResult = match solidity_command { SessionCommand::Scenario { sub } => { - // Capture the active scenario BEFORE executing the scenario - // command. Switch/Delete mutate `active` or remove scenarios; the - // lifecycle patch embeds the pre-call name for consistent - // routing on the frontend. 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(); } - Ok(Json(result)) + result } SessionCommand::SaveSession => { let active_before = scenarios_guard.active().to_string(); - let result = match scenarios_guard.save_to_json() { + 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(); } - Ok(Json(result)) + result } SessionCommand::LoadSession { json } => { let result = match ScenarioStore::load_from_json(&json) { - Ok(loaded) => { + Ok((loaded, _kp_bundle)) => { let contract = loaded.contract.clone(); let step_names: Vec<String> = loaded .active_session() @@ -290,26 +279,536 @@ pub async fn handle_command( } Err(message) => CommandResult::Error { message }, }; - // Use the post-load active so the WS Reloaded event names the - // scenario the frontend should switch to. let active_after = scenarios_guard.active().to_string(); if let Some(patch) = canvas_patch_from(&result, &active_after) { state.session_tx.send(patch).ok(); } - Ok(Json(result)) + result } other => { - // Capture the active scenario name BEFORE delegating — the - // session mutation below doesn't change `active`, but grabbing - // it here keeps the pattern symmetric with the scenario branch - // and avoids a second borrow after `active_session_mut`. 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(); } - Ok(Json(result)) + result + } + }; + Ok(Json(serde_json::to_value(result).unwrap_or(Value::Null))) +} + +async fn handle_solana_command( + state: Arc<AppState>, + req: CommandRequest, +) -> Result<Json<Value>, (StatusCode, String)> { + let solana = state + .solana() + .ok_or((StatusCode::INTERNAL_SERVER_ERROR, "solana state missing".into()))?; + + let command: SolanaCommand = serde_json::from_value(req.command) + .map_err(|e| (StatusCode::BAD_REQUEST, format!("invalid Solana command: {e}")))?; + + let timestamp = timestamp_now(); + + let program = match req.contract.as_deref() { + Some(name) => solana + .project + .find_program(name) + .ok_or(( + StatusCode::NOT_FOUND, + format!("program '{name}' not found"), + ))? + .clone(), + None => solana + .project + .programs + .first() + .ok_or((StatusCode::INTERNAL_SERVER_ERROR, "no programs available".into()))? + .clone(), + }; + + if let SolanaCommand::Scenario { sub } = command { + let mut scenarios = state.scenarios.write().unwrap(); + let active_before = scenarios.active().to_string(); + let result = solana_scenario_action( + &mut scenarios, + solana, + sub, + &active_before, + ×tamp, + &program.name, + ); + for patch in canvas_patches_from_solana(&result, &active_before, &[]) { + state.session_tx.send(patch).ok(); + } + return Ok(Json(serde_json::to_value(result).unwrap_or(Value::Null))); + } + + if let SolanaCommand::SaveSession { with_keypairs } = &command { + let scenarios = state.scenarios.read().unwrap(); + let users_snapshot = if *with_keypairs { + let users_lock = solana.users.read().unwrap(); + let cloned: std::collections::HashMap< + String, + std::collections::HashMap<String, Keypair>, + > = users_lock + .iter() + .map(|(scn, map)| { + let inner: std::collections::HashMap<String, Keypair> = map + .iter() + .map(|(name, kp)| (name.clone(), kp.insecure_clone())) + .collect(); + (scn.clone(), inner) + }) + .collect(); + Some(cloned) + } else { + None + }; + let opts = crate::state::SaveOpts { + keypairs: users_snapshot.as_ref(), + }; + let result = match scenarios.save_to_json(opts) { + Ok(json) => SolanaCommandResult::SessionSaved { json }, + Err(message) => SolanaCommandResult::Error { message }, + }; + return Ok(Json(serde_json::to_value(result).unwrap_or(Value::Null))); + } + if let SolanaCommand::LoadSession { json } = command { + let mut scenarios = state.scenarios.write().unwrap(); + let mut vms = solana.vms.write().unwrap(); + let mut users_lock = solana.users.write().unwrap(); + let mut snapshots = solana.step_snapshots.write().unwrap(); + let result = match ScenarioStore::load_from_json(&json) { + Ok((loaded, kp_bundle)) => { + let prog = loaded.contract.clone(); + let step_names: Vec<String> = loaded + .active_session() + .steps + .iter() + .map(|s| s.function.clone()) + .collect(); + *scenarios = loaded; + + vms.clear(); + snapshots.clear(); + + if let Some(bundle) = kp_bundle { + users_lock.clear(); + for (scn, kps) in bundle { + users_lock.insert(scn, kps); + } + } + + let mut replay_errors: Vec<String> = Vec::new(); + let scenario_names: Vec<String> = scenarios.names().to_vec(); + for scn_name in &scenario_names { + let mut vm = match ilold_solana_core::execute::VmHost::boot( + solana.program_artifacts.clone(), + ) { + Ok(v) => v, + Err(e) => { + replay_errors.push(format!("boot {scn_name}: {e:?}")); + continue; + } + }; + let scn_users = users_lock + .entry(scn_name.clone()) + .or_insert_with(std::collections::HashMap::new); + const REPLAY_LAMPORTS: u64 = 10_000_000_000; + for kp in scn_users.values() { + use solana_keypair::Signer; + let _ = vm.airdrop(kp.pubkey(), REPLAY_LAMPORTS); + } + let mut stack: Vec<ilold_solana_core::execute::StateSnapshot> = Vec::new(); + let session = match scenarios.get_mut(scn_name) { + Some(s) => s, + None => continue, + }; + let steps_clone = session.steps.clone(); + for (idx, step) in steps_clone.iter().enumerate() { + let payload = match &step.call_payload { + Some(p) => p, + None => continue, + }; + let ix_name = payload.get("ix").and_then(|v| v.as_str()).unwrap_or(""); + let args = payload.get("args").cloned().unwrap_or(Value::Null); + let accounts: std::collections::HashMap<String, String> = payload + .get("accounts") + .and_then(|v| v.as_object()) + .map(|m| { + m.iter() + .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string()))) + .collect() + }) + .unwrap_or_default(); + let signers: Vec<String> = payload + .get("signers") + .and_then(|v| v.as_array()) + .map(|a| { + a.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect() + }) + .unwrap_or_default(); + let pre = vm.snapshot_state(); + let res = ilold_solana_core::exploration::execute::execute_call( + &program, + ix_name, + args, + accounts, + signers, + scn_users, + session, + &mut vm, + "load-replay", + ); + if matches!(res, ilold_solana_core::exploration::SolanaCommandResult::StepAdded { .. }) { + stack.push(pre); + session.steps.pop(); + } else if let ilold_solana_core::exploration::SolanaCommandResult::Error { message } = res { + replay_errors.push(format!("{scn_name}#{idx}: {message}")); + } + } + vms.insert(scn_name.clone(), vm); + snapshots.insert(scn_name.clone(), stack); + } + + if replay_errors.is_empty() { + SolanaCommandResult::SessionLoaded { + program: prog, + steps: step_names, + } + } else { + SolanaCommandResult::Error { + message: format!( + "loaded session but {} step(s) failed to replay: {}", + replay_errors.len(), + replay_errors.join("; ") + ), + } + } + } + Err(message) => SolanaCommandResult::Error { message }, + }; + let active_after = scenarios.active().to_string(); + for patch in canvas_patches_from_solana(&result, &active_after, &[]) { + state.session_tx.send(patch).ok(); + } + return Ok(Json(serde_json::to_value(result).unwrap_or(Value::Null))); + } + + let mut scenarios = state.scenarios.write().unwrap(); + let mut vms = solana.vms.write().unwrap(); + let mut users = solana.users.write().unwrap(); + let mut snapshots = solana.step_snapshots.write().unwrap(); + let active_scenario = scenarios.active().to_string(); + + let vm = vms.get_mut(&active_scenario).ok_or(( + StatusCode::INTERNAL_SERVER_ERROR, + format!("VM for scenario '{active_scenario}' missing"), + ))?; + let scenario_users = users.get_mut(&active_scenario).ok_or(( + StatusCode::INTERNAL_SERVER_ERROR, + format!("users registry for scenario '{active_scenario}' missing"), + ))?; + let stack = snapshots + .entry(active_scenario.clone()) + .or_insert_with(Vec::new); + let session = scenarios.active_session_mut(); + + let pre_call_snapshot = match &command { + SolanaCommand::Call { .. } => Some(vm.snapshot_state()), + _ => None, + }; + + let result = match command { + SolanaCommand::Funcs => execute_funcs(&program), + SolanaCommand::State => execute_state(session, &program, vm), + SolanaCommand::Session => execute_session(session, &program, &active_scenario), + SolanaCommand::Users => execute_users(scenario_users, vm), + SolanaCommand::UsersNew { name, lamports } => { + execute_users_new(name, lamports, scenario_users, vm) + } + SolanaCommand::Airdrop { user, lamports } => { + execute_airdrop(&user, lamports, scenario_users, vm) + } + SolanaCommand::TimeWarp { delta_seconds } => execute_time_warp(delta_seconds, vm), + SolanaCommand::Pda { instruction } => execute_pda(&program, &instruction), + SolanaCommand::Inspect { pubkey } => execute_inspect(&program, vm, &pubkey), + SolanaCommand::Call { + ix, + args, + accounts, + signers, + } => execute_call( + &program, + &ix, + args, + accounts, + signers, + scenario_users, + session, + vm, + ×tamp, + ), + SolanaCommand::Back => { + let r = execute_back(session); + if matches!(r, SolanaCommandResult::StepRemoved { .. }) { + if let Some(snap) = stack.pop() { + if let Err(e) = vm.restore_state(snap) { + return Ok(Json(serde_json::to_value(SolanaCommandResult::Error { + message: format!("Back: rewind VM failed: {e:?}"), + }).unwrap_or(Value::Null))); + } + } + } + r + } + SolanaCommand::Clear => { + if let Some(genesis) = stack.first().cloned() { + if let Err(e) = vm.restore_state(genesis) { + return Ok(Json(serde_json::to_value(SolanaCommandResult::Error { + message: format!("Clear: rewind VM failed: {e:?}"), + }).unwrap_or(Value::Null))); + } + } + stack.clear(); + execute_clear(session) + } + SolanaCommand::Finding { + severity, + title, + description, + recommendation, + } => execute_finding(session, severity, title, description, recommendation, ×tamp), + SolanaCommand::Note { text } => execute_note(session, &text, ×tamp), + SolanaCommand::Status { ix, status } => { + execute_status(session, &program, &ix, status, ×tamp) + } + SolanaCommand::Step { index } => execute_step(session, index, &program), + SolanaCommand::Findings => execute_findings_list(session), + SolanaCommand::Export { metadata } => { + let names: Vec<String> = scenarios.names().to_vec(); + let entries: Vec<(&str, &ilold_session_core::exploration::session::ExplorationSession)> = + names + .iter() + .filter_map(|n| scenarios.get(n).map(|s| (n.as_str(), s))) + .collect(); + execute_export(entries, &active_scenario, &program, metadata.as_ref()) + } + SolanaCommand::Who { account_type } => execute_who(&program, &account_type), + SolanaCommand::Timeline { pubkey } => { + execute_timeline(session, &program, &pubkey, &active_scenario, scenario_users) + } + SolanaCommand::Info { ix } => execute_info(&program, &ix), + SolanaCommand::Coupling => execute_coupling(&program), + SolanaCommand::Vars => execute_vars(&program), + SolanaCommand::Coverage => execute_coverage(&program, session, &active_scenario), + SolanaCommand::SaveSession { .. } + | SolanaCommand::LoadSession { .. } + | SolanaCommand::Scenario { .. } => unreachable!("handled above"), + }; + + if let (Some(snap), SolanaCommandResult::StepAdded { .. }) = (pre_call_snapshot, &result) { + stack.push(snap); + } + + let cpi_targets = match &result { + SolanaCommandResult::StepAdded { .. } => scenarios + .active_session() + .steps + .last() + .and_then(|s| s.runtime_trace.as_ref()) + .and_then(|v| serde_json::from_value::<ilold_session_core::runtime_trace::RuntimeTrace>(v.clone()).ok()) + .map(|t| ilold_solana_core::overlay::extract_cpi_programs(&t)) + .unwrap_or_default(), + _ => Vec::new(), + }; + + for patch in canvas_patches_from_solana(&result, &active_scenario, &cpi_targets) { + state.session_tx.send(patch).ok(); + } + Ok(Json(serde_json::to_value(result).unwrap_or(Value::Null))) +} + +fn solana_scenario_action( + scenarios: &mut ScenarioStore, + solana: &crate::state::SolanaState, + sub: SharedScenarioAction, + active_before: &str, + timestamp: &str, + program_name: &str, +) -> SolanaCommandResult { + match sub { + SharedScenarioAction::New { name } => { + if let Err(e) = ilold_session_core::exploration::scenario::validate_scenario_name(&name) + { + return SolanaCommandResult::Error { message: e }; + } + if scenarios.contains(&name) { + return SolanaCommandResult::Error { + message: format!("scenario '{name}' already exists"), + }; + } + let session = ExplorationSession::new(program_name, "ilold"); + let fresh_vm = match ilold_solana_core::execute::VmHost::boot( + solana.program_artifacts.clone(), + ) { + Ok(v) => v, + Err(e) => { + return SolanaCommandResult::Error { + message: format!("boot VM for '{name}': {e:?}"), + }; + } + }; + scenarios.insert(name.clone(), session); + solana + .vms + .write() + .unwrap() + .insert(name.clone(), fresh_vm); + solana + .users + .write() + .unwrap() + .insert(name.clone(), std::collections::HashMap::new()); + SolanaCommandResult::ScenarioCreated { name } + } + SharedScenarioAction::List => { + let active = scenarios.active().to_string(); + let items = scenarios + .names() + .iter() + .map(|n| ilold_session_core::exploration::scenario::ScenarioInfo { + name: n.clone(), + active: n == &active, + step_count: scenarios.get(n).map(|s| s.steps.len()).unwrap_or(0), + }) + .collect(); + SolanaCommandResult::ScenarioList { items } + } + SharedScenarioAction::Switch { name } => { + let from = scenarios.active().to_string(); + if name == from { + return SolanaCommandResult::ScenarioSwitched { from, to: name }; + } + match scenarios.set_active(name.clone()) { + Ok(()) => SolanaCommandResult::ScenarioSwitched { from, to: name }, + Err(e) => SolanaCommandResult::Error { message: e }, + } + } + SharedScenarioAction::Fork { name, at_step } => { + if let Err(e) = ilold_session_core::exploration::scenario::validate_scenario_name(&name) + { + return SolanaCommandResult::Error { message: e }; + } + if scenarios.contains(&name) { + return SolanaCommandResult::Error { + message: format!("scenario '{name}' already exists"), + }; + } + let from = active_before.to_string(); + let mut cloned = scenarios.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 SolanaCommandResult::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: name.clone(), + timestamp: timestamp.to_string(), + }); + + let mut vms = solana.vms.write().unwrap(); + let snap = match vms.get(&from) { + Some(vm) => vm.snapshot(), + None => { + return SolanaCommandResult::Error { + message: format!("VM for scenario '{from}' missing"), + }; + } + }; + let mut new_vm = match ilold_solana_core::execute::VmHost::restore(snap) { + Ok(v) => v, + Err(e) => { + return SolanaCommandResult::Error { + message: format!("restore VM for '{name}': {e:?}"), + }; + } + }; + + let mut snapshots = solana.step_snapshots.write().unwrap(); + let origin_stack = snapshots.entry(from.clone()).or_insert_with(Vec::new); + let cloned_stack: Vec<ilold_solana_core::execute::StateSnapshot> = + origin_stack.iter().take(effective).cloned().collect(); + if let Some(rewind_to) = origin_stack.get(effective) { + if let Err(e) = new_vm.restore_state(rewind_to.clone()) { + return SolanaCommandResult::Error { + message: format!("rewind branch VM to step {effective}: {e:?}"), + }; + } + } + + let mut users = solana.users.write().unwrap(); + let cloned_users: std::collections::HashMap<String, Keypair> = users + .get(&from) + .map(|m| { + m.iter() + .map(|(k, kp)| (k.clone(), kp.insecure_clone())) + .collect() + }) + .unwrap_or_default(); + + scenarios.insert(name.clone(), cloned); + vms.insert(name.clone(), new_vm); + users.insert(name.clone(), cloned_users); + snapshots.insert(name.clone(), cloned_stack); + SolanaCommandResult::ScenarioForked { + from, + to: name, + at_step: effective, + } + } + SharedScenarioAction::Delete { name } => { + if name == scenarios.active() { + return SolanaCommandResult::Error { + message: "cannot delete active scenario — switch first".into(), + }; + } + if scenarios.len() == 1 { + return SolanaCommandResult::Error { + message: "cannot delete the only remaining scenario".into(), + }; + } + if !scenarios.contains(&name) { + return SolanaCommandResult::Error { + message: format!("scenario '{name}' does not exist"), + }; + } + scenarios.remove(&name); + solana.vms.write().unwrap().remove(&name); + solana.users.write().unwrap().remove(&name); + solana.step_snapshots.write().unwrap().remove(&name); + SolanaCommandResult::ScenarioDeleted { name } } } } @@ -329,13 +828,10 @@ pub async fn get_step_detail( Ok(Json(narrative)) } -/// Return the persisted FlowTree of a session step. The tree is read -/// directly from `step.flow_tree` — no recomputation against the source -/// — so the result reflects what the auditor saw when `c <func>` ran. pub async fn get_session_step_trace( State(state): State<Arc<AppState>>, Path(step_index): Path<usize>, -) -> Result<Json<FlowTree>, (StatusCode, String)> { +) -> Result<Json<serde_json::Value>, (StatusCode, String)> { let scenarios_guard = state.scenarios.read().unwrap(); let session = scenarios_guard.active_session(); @@ -355,7 +851,6 @@ pub async fn get_session_step_trace( Ok(Json(tree)) } -/// Cross-step variable history with path conditions for each write. pub async fn get_variable_timeline_handler( State(state): State<Arc<AppState>>, Path(variable): Path<String>, @@ -408,8 +903,6 @@ pub struct TraceQuery { pub depth: Option<usize>, #[serde(default)] pub reverts: Option<bool>, - /// Comma-separated step_ids to force-inline beyond `depth`. Example: - /// `?expand=17,24` will inline both calls regardless of max_depth. #[serde(default)] pub expand: Option<String>, } @@ -434,16 +927,10 @@ pub async fn get_flow_trace( #[derive(Deserialize)] pub struct SliceQuery { - /// `backward`, `forward`, or `both`. Defaults to `both` when absent. - /// Short forms `b`/`f` and synonyms `back`/`fwd`/`all` are accepted. #[serde(default)] pub direction: Option<String>, } -/// Dataflow slice for `variable` inside `function` of the session's -/// current contract. The function is resolved from the active session so -/// the auditor doesn't have to re-type the contract name; if no session -/// exists the endpoint returns 404. pub async fn get_function_slice( State(state): State<Arc<AppState>>, Path((func_name, variable)): Path<(String, String)>, @@ -454,7 +941,8 @@ pub async fn get_function_slice( guard.active_session().contract.clone() }; - let contract = state.project.contracts.iter() + let s = require_solidity_msg(&state)?; + let contract = s.project.contracts.iter() .find(|c| c.name == contract_name) .ok_or(( StatusCode::NOT_FOUND, @@ -472,7 +960,7 @@ pub async fn get_function_slice( .map_err(|e| (StatusCode::BAD_REQUEST, e))?; Ok(Json(build_slice_result( - &state.project, + &s.project, contract, function, &variable, @@ -495,10 +983,6 @@ fn parse_slice_direction(raw: Option<&str>) -> Result<SliceDirection, String> { } } -/// Lightweight per-step view for the `/api/scenarios/all` snapshot. The -/// access level is resolved against `AppState.classifications` (same lookup -/// pattern used by `execute_who` in the core crate) so the frontend can -/// colour the node without re-classifying. #[derive(Serialize)] pub struct SessionStepView { pub function: String, @@ -506,8 +990,6 @@ pub struct SessionStepView { pub step_index: usize, } -/// Per-scenario snapshot in `AllScenariosResponse`. `forked_from` is `None` -/// for `main` and for any scenario loaded from a pre-fork-origin save file. #[derive(Serialize)] pub struct ScenarioSnapshot { pub name: String, @@ -518,13 +1000,9 @@ pub struct ScenarioSnapshot { #[derive(Serialize)] pub struct AllScenariosResponse { pub active: String, - /// Ordered by creation order (main first, then insertion order). Not a - /// HashMap — the frontend composes scenarios into a visual tree and needs - /// stable iteration so "main" always anchors the canvas. pub scenarios: Vec<ScenarioSnapshot>, } -/// Return the list of scenarios — mirrors the `scenario list` CLI command. pub async fn get_scenarios( State(state): State<Arc<AppState>>, ) -> Json<Vec<ScenarioInfo>> { @@ -542,29 +1020,25 @@ pub async fn get_scenarios( Json(items) } -/// Bulk snapshot of every scenario's step list. Used by the frontend canvas -/// to paint all scenarios at once (e.g. on reconnect / initial load). pub async fn get_all_scenarios( State(state): State<Arc<AppState>>, -) -> Json<AllScenariosResponse> { +) -> 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 = state.classifications.get(&session.contract); + let classifs = solidity.and_then(|s| s.classifications.get(&session.contract)); let steps = session .steps .iter() .enumerate() .map(|(idx, step)| { - // Same lookup pattern as `execute_who`'s `access_for` - // (`commands.rs:515-519`): fall back to `Internal` when the - // classification is missing so the response shape is stable. let access = classifs .and_then(|c| c.iter().find(|(n, _)| n == &step.function)) .map(|(_, a)| a.clone()) - .unwrap_or(AccessLevel::Internal); + .unwrap_or(AccessLevel::Public); SessionStepView { function: step.function.clone(), access, @@ -578,12 +1052,9 @@ pub async fn get_all_scenarios( forked_from: session.forked_from.clone(), }); } - Json(AllScenariosResponse { active, scenarios }) + Ok(Json(AllScenariosResponse { active, scenarios })) } -/// Parse a comma-separated `expand` query value into a set of step_ids. -/// Empty input → empty set. Whitespace around values is tolerated. -/// Returns `Err` with a descriptive message if any value is not a usize. fn parse_expand_set(raw: Option<&str>) -> Result<std::collections::HashSet<usize>, String> { let mut set = std::collections::HashSet::new(); let raw = match raw { diff --git a/crates/ilold-web/src/lib.rs b/crates/ilold-web/src/lib.rs index e51aa19..ed23e9b 100644 --- a/crates/ilold-web/src/lib.rs +++ b/crates/ilold-web/src/lib.rs @@ -7,6 +7,8 @@ use std::sync::Arc; use axum::routing::{delete, get, post, put}; use axum::Router; +use ilold_solana_core::ingest::DetectedProject; +use ilold_solana_core::model::{ProgramDef, SolanaProject}; use tower_http::cors::CorsLayer; use state::AppState; @@ -15,6 +17,10 @@ fn build_router(state: Arc<AppState>) -> Router { Router::new() .route("/api/project", get(api::project::get_project)) .route("/api/project/map", get(api::project::get_project_map)) + .route("/api/program/{name}/view", get(api::project::get_program_view)) + .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)) @@ -46,15 +52,17 @@ fn build_router(state: Arc<AppState>) -> Router { pub async fn serve(paths: Vec<PathBuf>, port: u16, max_seq_depth: usize) -> anyhow::Result<()> { println!("Analyzing {} file(s)...", paths.len()); - let contract_path = paths.first().map(|p| p.parent().unwrap_or(p).to_path_buf()).unwrap_or_default(); + 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, contract_path)?); - println!( - "Ready: {} contracts, {} functions analyzed\n", - state.project.contracts.len(), - state.cfgs.len(), - ); + 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}"); @@ -67,10 +75,10 @@ pub async fn start_server( port: u16, max_seq_depth: usize, ) -> anyhow::Result<(Arc<AppState>, u16)> { - let contract_path = paths.first().map(|p| p.parent().unwrap_or(p).to_path_buf()).unwrap_or_default(); + 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, contract_path)?); + 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 { @@ -79,3 +87,84 @@ pub async fn start_server( 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)?; + let artifacts = load_program_artifacts(&detected, &project); + 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_solana( + project, + artifacts, + 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 app = build_router(state); + println!("Server running at http://localhost:{actual_port}"); + axum::serve(listener, app).await?; + Ok(()) +} + +pub async fn start_solana_server( + detected: DetectedProject, + port: u16, +) -> anyhow::Result<(Arc<AppState>, u16)> { + let project = build_solana_project(&detected)?; + let artifacts = load_program_artifacts(&detected, &project); + 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_solana( + project, + artifacts, + actual_port, + detected.root.clone(), + )?); + let app = build_router(state.clone()); + + tokio::spawn(async move { + axum::serve(listener, app).await.ok(); + }); + + Ok((state, actual_port)) +} + +fn build_solana_project(detected: &DetectedProject) -> anyhow::Result<SolanaProject> { + let mut programs = Vec::with_capacity(detected.idl_paths.len()); + for idl_path in &detected.idl_paths { + let json = std::fs::read_to_string(idl_path)?; + let idl = ilold_solana_core::idl::parse_idl(&json)?; + let program = ProgramDef::from_idl(idl)?; + programs.push(program); + } + Ok(SolanaProject::new(programs)) +} + +fn load_program_artifacts( + detected: &DetectedProject, + project: &SolanaProject, +) -> Vec<(solana_address::Address, Vec<u8>)> { + let mut out = Vec::new(); + for program in &project.programs { + for so_path in &detected.so_paths { + let stem = so_path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or(""); + if stem == program.name { + if let Ok(bytes) = std::fs::read(so_path) { + out.push((program.program_id, bytes)); + } + break; + } + } + } + out +} diff --git a/crates/ilold-web/src/state.rs b/crates/ilold-web/src/state.rs index 1d3db17..4739699 100644 --- a/crates/ilold-web/src/state.rs +++ b/crates/ilold-web/src/state.rs @@ -2,6 +2,7 @@ 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; @@ -20,15 +21,15 @@ 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_solana_core::execute::VmHost; +use ilold_solana_core::model::SolanaProject; +use solana_address::Address; +use solana_keypair::Keypair; use serde::{Deserialize, Serialize}; -/// The default scenario name, auto-created for every fresh session. pub const DEFAULT_SCENARIO: &str = "main"; -/// Holds all scenarios for a contract. One is "active" at any time; commands -/// without explicit scenario targeting operate on it. Insertion order is -/// preserved via `order` for deterministic `names()` output. pub struct ScenarioStore { pub version: u32, pub contract: String, @@ -113,39 +114,61 @@ impl ScenarioStore { Ok(()) } - /// Serialize the entire store as v2 JSON (`{ version: 2, contract, active, - /// scenarios, order }`). All scenarios + their `forked_from` chains travel - /// together so a load reconstructs the full state. - pub fn save_to_json(&self) -> Result<String, String> { + pub fn save_to_json(&self, opts: SaveOpts<'_>) -> Result<String, String> { + let (keypairs_present, keypairs) = match opts.keypairs { + Some(map) => { + let serialised: HashMap<String, HashMap<String, Vec<u8>>> = map + .iter() + .map(|(scn, users)| { + let inner: HashMap<String, Vec<u8>> = users + .iter() + .map(|(name, kp)| (name.clone(), kp.to_bytes().to_vec())) + .collect(); + (scn.clone(), inner) + }) + .collect(); + (true, Some(serialised)) + } + None => (false, None), + }; let file = ScenarioStoreFile { version: 2, contract: self.contract.clone(), active: self.active.clone(), scenarios: self.sessions.clone(), order: self.order.clone(), + keypairs_present, + keypairs, }; serde_json::to_string_pretty(&file).map_err(|e| format!("Serialize failed: {e}")) } - /// Parse a save file. Tries v2 (`ScenarioStoreFile`) first; on failure - /// falls back to v1 (bare `ExplorationSession`) and wraps it as a single - /// `main` scenario. Any structural anomaly (active not in scenarios, - /// empty order) is repaired so the returned store is always valid. - pub fn load_from_json(json: &str) -> Result<Self, String> { + pub fn load_from_json(json: &str) -> Result<(Self, Option<KeypairBundle>), String> { match serde_json::from_str::<ScenarioStoreFile>(json) { - Ok(file) => Self::from_file(file), + Ok(file) => { + let raw_kps = file.keypairs.clone(); + let store = Self::from_file(file)?; + let bundle = match raw_kps { + Some(map) => Some(decode_keypair_bundle(map)?), + None => None, + }; + Ok((store, bundle)) + } Err(_) => match serde_json::from_str::<ExplorationSession>(json) { Ok(legacy) => { let contract = legacy.contract.clone(); let mut sessions = HashMap::new(); sessions.insert(DEFAULT_SCENARIO.to_string(), legacy); - Ok(Self { - version: 2, - contract, - active: DEFAULT_SCENARIO.to_string(), - sessions, - order: vec![DEFAULT_SCENARIO.to_string()], - }) + Ok(( + Self { + version: 2, + contract, + active: DEFAULT_SCENARIO.to_string(), + sessions, + order: vec![DEFAULT_SCENARIO.to_string()], + }, + None, + )) } Err(e) => Err(format!("Deserialize failed: {e}")), }, @@ -156,7 +179,6 @@ impl ScenarioStore { if file.scenarios.is_empty() { return Err("Save file has no scenarios".into()); } - // Repair `order`: if missing names or empty, rebuild from scenarios. let mut order = file.order; order.retain(|n| file.scenarios.contains_key(n)); for name in file.scenarios.keys() { @@ -164,8 +186,6 @@ impl ScenarioStore { order.push(name.clone()); } } - // Repair `active`: fall back to first ordered name if the recorded - // active was deleted out-of-band before the save. let active = if file.scenarios.contains_key(&file.active) { file.active } else { @@ -184,10 +204,6 @@ impl ScenarioStore { } } -/// On-disk wire format for `ScenarioStore`. Decoupled from the in-memory -/// type to keep private fields private and allow the wire format to evolve -/// independently. v1 saves are bare `ExplorationSession` JSON — they're -/// detected by the failed parse + retry in `ScenarioStore::load_from_json`. #[derive(Serialize, Deserialize)] struct ScenarioStoreFile { version: u32, @@ -195,9 +211,48 @@ struct ScenarioStoreFile { active: String, scenarios: HashMap<String, ExplorationSession>, order: Vec<String>, + #[serde(default)] + keypairs_present: bool, + #[serde(default)] + keypairs: Option<HashMap<String, HashMap<String, Vec<u8>>>>, } -pub struct AppState { +pub type KeypairBundle = HashMap<String, HashMap<String, Keypair>>; + +pub struct SaveOpts<'a> { + pub keypairs: Option<&'a HashMap<String, HashMap<String, Keypair>>>, +} + +impl<'a> SaveOpts<'a> { + pub fn none() -> Self { + Self { keypairs: None } + } +} + +fn decode_keypair_bundle( + raw: HashMap<String, HashMap<String, Vec<u8>>>, +) -> Result<KeypairBundle, String> { + let mut out: KeypairBundle = HashMap::new(); + for (scn, users) in raw { + let mut inner: HashMap<String, Keypair> = HashMap::new(); + for (name, bytes) in users { + if bytes.len() != 64 { + return Err(format!( + "keypair for {scn}/{name} must be 64 bytes, got {}", + bytes.len() + )); + } + let kp = Keypair::try_from(bytes.as_slice()).map_err(|_| { + format!("invalid keypair bytes for {scn}/{name} (ed25519 decode failed)") + })?; + inner.insert(name, kp); + } + out.insert(scn, inner); + } + Ok(out) +} + +pub struct SolidityState { pub project: Project, pub cfgs: HashMap<(String, String), CfgGraph>, pub path_trees: HashMap<(String, String), PathTree>, @@ -205,11 +260,60 @@ pub struct AppState { 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>)>, + pub vms: RwLock<HashMap<String, VmHost>>, + pub users: RwLock<HashMap<String, HashMap<String, Keypair>>>, + pub step_snapshots: RwLock<HashMap<String, Vec<ilold_solana_core::execute::StateSnapshot>>>, +} + +pub enum Backend { + Solidity(SolidityState), + Solana(SolanaState), +} + +pub struct AppState { + pub backend: Backend, pub annotations: RwLock<Vec<Annotation>>, pub scenarios: RwLock<ScenarioStore>, pub session_tx: broadcast::Sender<CanvasPatch>, pub port: u16, - pub contract_path: PathBuf, + pub project_root: PathBuf, +} + +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") + } +} + +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)] @@ -243,7 +347,48 @@ pub enum AnnotationStatus { } impl AppState { - pub fn from_paths(paths: &[PathBuf], max_seq_depth: usize, port: u16, contract_path: PathBuf) -> anyhow::Result<Self> { + pub fn from_solana( + project: SolanaProject, + program_artifacts: Vec<(Address, Vec<u8>)>, + port: u16, + project_root: PathBuf, + ) -> anyhow::Result<Self> { + let (session_tx, _) = broadcast::channel(64); + let default_program = project + .programs + .first() + .map(|p| p.name.clone()) + .unwrap_or_else(|| "unknown".to_string()); + + let mut vms = HashMap::new(); + let main_vm = VmHost::boot(program_artifacts.clone()) + .map_err(|e| anyhow::anyhow!("boot main VM: {e:?}"))?; + vms.insert(DEFAULT_SCENARIO.to_string(), main_vm); + + let mut users: HashMap<String, HashMap<String, Keypair>> = HashMap::new(); + users.insert(DEFAULT_SCENARIO.to_string(), HashMap::new()); + + let mut step_snapshots: HashMap<String, Vec<ilold_solana_core::execute::StateSnapshot>> = + HashMap::new(); + step_snapshots.insert(DEFAULT_SCENARIO.to_string(), Vec::new()); + + Ok(Self { + backend: Backend::Solana(SolanaState { + project, + program_artifacts, + vms: RwLock::new(vms), + users: RwLock::new(users), + step_snapshots: RwLock::new(step_snapshots), + }), + annotations: RwLock::new(Vec::new()), + scenarios: RwLock::new(ScenarioStore::new_for_contract(default_program)), + session_tx, + port, + 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(); @@ -293,7 +438,6 @@ impl AppState { classifications.insert(contract.name.clone(), classify_all(contract)); } - // Compute transitive effects across contracts (inheritance-aware). analyze_project(&project, &mut sequence_analyses); let (session_tx, _) = broadcast::channel(64); @@ -304,18 +448,20 @@ impl AppState { .unwrap_or_else(|| "unknown".to_string()); Ok(Self { - project, - cfgs, - path_trees, - call_graphs, - sequence_trees, - sequence_analyses, - classifications, + 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, - contract_path, + project_root, }) } } diff --git a/crates/ilold-web/src/ws/handler.rs b/crates/ilold-web/src/ws/handler.rs index baeaf9a..8615340 100644 --- a/crates/ilold-web/src/ws/handler.rs +++ b/crates/ilold-web/src/ws/handler.rs @@ -29,7 +29,14 @@ enum ServerMessage { #[serde(rename = "error")] Error { message: String }, #[serde(rename = "session_add_node")] - SessionAddNode { scenario: String, function: String, access: AccessLevel, step_index: usize }, + SessionAddNode { + scenario: String, + function: String, + access: AccessLevel, + step_index: usize, + #[serde(default, skip_serializing_if = "Option::is_none")] + runtime: Option<ilold_session_core::exploration::canvas::RuntimeMeta>, + }, #[serde(rename = "session_remove_node")] SessionRemoveNode { scenario: String }, #[serde(rename = "session_clear")] @@ -46,6 +53,18 @@ enum ServerMessage { ScenarioForked { from: String, to: String, at_step: usize }, #[serde(rename = "scenario_store_reloaded")] ScenarioStoreReloaded { active: String }, + #[serde(rename = "solana_users_changed")] + SolanaUsersChanged { scenario: String }, + #[serde(rename = "session_overlay_update")] + SessionOverlayUpdate { + scenario: String, + ix_name: String, + calls_added: u32, + failed_added: u32, + #[serde(skip_serializing_if = "Option::is_none")] + cu: Option<u64>, + cpi_targets_added: Vec<String>, + }, } pub async fn ws_handler( @@ -57,8 +76,8 @@ pub async fn ws_handler( fn server_message_from_patch(patch: CanvasPatch) -> ServerMessage { match patch { - CanvasPatch::AddNode { scenario, function, access, step_index } => { - ServerMessage::SessionAddNode { scenario, function, access, step_index } + CanvasPatch::AddNode { scenario, function, access, step_index, runtime } => { + ServerMessage::SessionAddNode { scenario, function, access, step_index, runtime } } CanvasPatch::RemoveLastNode { scenario } => ServerMessage::SessionRemoveNode { scenario }, CanvasPatch::ClearAll { scenario } => ServerMessage::SessionClear { scenario }, @@ -76,6 +95,24 @@ fn server_message_from_patch(patch: CanvasPatch) -> ServerMessage { ServerMessage::ScenarioStoreReloaded { active } } }, + CanvasPatch::SolanaUsersChanged { scenario } => { + ServerMessage::SolanaUsersChanged { scenario } + } + CanvasPatch::OverlayUpdate { + scenario, + ix_name, + calls_added, + failed_added, + cu, + cpi_targets_added, + } => ServerMessage::SessionOverlayUpdate { + scenario, + ix_name, + calls_added, + failed_added, + cu, + cpi_targets_added, + }, } } @@ -107,7 +144,10 @@ async fn handle_socket(mut socket: WebSocket, state: Arc<AppState>) { match parsed { ClientMessage::Search(query) => { - let results = search::search_paths(&state, &query); + let results = match state.solidity() { + Some(s) => search::search_paths(s, &query), + None => Vec::new(), + }; let total = results.len(); for result in results { diff --git a/crates/ilold-web/src/ws/pty.rs b/crates/ilold-web/src/ws/pty.rs index ac6a188..2bdf396 100644 --- a/crates/ilold-web/src/ws/pty.rs +++ b/crates/ilold-web/src/ws/pty.rs @@ -15,7 +15,7 @@ pub async fn ws_pty_handler( State(state): State<Arc<AppState>>, ) -> impl IntoResponse { let port = state.port; - let contract_path = state.contract_path.clone(); + let contract_path = state.project_root.clone(); ws.on_upgrade(move |socket| handle_pty_session(socket, port, contract_path)) } @@ -89,7 +89,6 @@ async fn handle_pty_session(mut socket: WebSocket, port: u16, contract_path: Pat } }; - // PTY stdout -> mpsc channel (blocking read in dedicated thread) let (stdout_tx, mut stdout_rx) = mpsc::channel::<Vec<u8>>(64); tokio::task::spawn_blocking(move || { let mut reader = reader; @@ -106,7 +105,6 @@ async fn handle_pty_session(mut socket: WebSocket, port: u16, contract_path: Pat } }); - // PTY stdin writes happen in a blocking thread to avoid Send issues let (stdin_tx, stdin_rx) = mpsc::channel::<Vec<u8>>(64); tokio::task::spawn_blocking(move || { let mut writer = writer; @@ -118,7 +116,6 @@ async fn handle_pty_session(mut socket: WebSocket, port: u16, contract_path: Pat } }); - // Resize channel — master stays in a blocking thread for resize calls let (resize_tx, resize_rx) = mpsc::channel::<(u16, u16)>(4); tokio::task::spawn_blocking(move || { let master = pair.master; diff --git a/crates/ilold-web/src/ws/search.rs b/crates/ilold-web/src/ws/search.rs index e52c6cb..a3dca7c 100644 --- a/crates/ilold-web/src/ws/search.rs +++ b/crates/ilold-web/src/ws/search.rs @@ -1,6 +1,6 @@ use serde::{Deserialize, Serialize}; -use crate::state::AppState; +use crate::state::SolidityState; #[derive(Debug, Deserialize)] pub struct SearchQuery { @@ -30,15 +30,15 @@ pub struct SearchComplete { pub total: usize, } -pub fn search_paths(state: &AppState, query: &SearchQuery) -> Vec<SearchResult> { +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(ref filter_contract) = query.contract { + if let Some(filter_contract) = query.contract.as_ref() { if contract != filter_contract { continue; } } - if let Some(ref filter_function) = query.function { + if let Some(filter_function) = query.function.as_ref() { if function != filter_function { continue; } } @@ -104,7 +104,6 @@ pub fn search_paths(state: &AppState, query: &SearchQuery) -> Vec<SearchResult> }); } - // Match on terminal type let terminal_str = format!("{:?}", path.terminal); if terminal_str.to_lowercase().contains(&q) { matches.push(SearchMatch { @@ -113,7 +112,6 @@ pub fn search_paths(state: &AppState, query: &SearchQuery) -> Vec<SearchResult> }); } - // Match on function name if function.to_lowercase().contains(&q) { matches.push(SearchMatch { field: "function".into(), diff --git a/crates/ilold-web/tests/instruction_source_test.rs b/crates/ilold-web/tests/instruction_source_test.rs new file mode 100644 index 0000000..319e1c0 --- /dev/null +++ b/crates/ilold-web/tests/instruction_source_test.rs @@ -0,0 +1,111 @@ +//! Integration tests for `GET /api/program/:name/:ix/source`. +//! +//! Mirrors `contract_source_test.rs` (Solidity) but boots a Solana server +//! against the staking Anchor fixture. + +use std::path::PathBuf; + +use ilold_solana_core::ingest::detect; + +fn staking_fixture() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .unwrap() + .parent() + .unwrap() + .join("tests/fixtures/solana/staking") +} + +async fn start_staking() -> (reqwest::Client, u16) { + let detected = detect(&staking_fixture()).expect("detect staking fixture"); + let (_state, port) = ilold_web::start_solana_server(detected, 0) + .await + .expect("start solana server"); + (reqwest::Client::new(), port) +} + +#[tokio::test] +async fn get_instruction_source_returns_source_for_stake() { + let (client, port) = start_staking().await; + + let res = client + .get(format!( + "http://127.0.0.1:{port}/api/program/staking/stake/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("pub fn stake"), + "expected handler signature, got:\n{source}" + ); + assert!( + source.contains("StakingError::ZeroAmount"), + "expected handler body, got:\n{source}" + ); + + 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, + "handler span must cover multiple lines, got {start_line}..{end_line}" + ); + + let file_path = body.get("file_path").and_then(|v| v.as_str()).unwrap(); + assert!( + std::path::Path::new(file_path).is_absolute(), + "file_path must be absolute, got: {file_path}" + ); + assert!( + file_path.ends_with("programs/staking/src/lib.rs"), + "unexpected file_path: {file_path}" + ); +} + +#[tokio::test] +async fn get_instruction_source_returns_404_for_missing_ix() { + let (client, port) = start_staking().await; + + let res = client + .get(format!( + "http://127.0.0.1:{port}/api/program/staking/does_not_exist/source" + )) + .send() + .await + .unwrap(); + assert_eq!( + res.status(), + reqwest::StatusCode::NOT_FOUND, + "unknown instruction must 404, got: {}", + res.status(), + ); +} + +#[tokio::test] +async fn get_instruction_source_returns_404_for_unknown_program() { + let (client, port) = start_staking().await; + + let res = client + .get(format!( + "http://127.0.0.1:{port}/api/program/nope/stake/source" + )) + .send() + .await + .unwrap(); + assert_eq!( + res.status(), + reqwest::StatusCode::NOT_FOUND, + "unknown program must 404, got: {}", + res.status(), + ); +} diff --git a/crates/ilold-web/tests/solana_command_pipeline.rs b/crates/ilold-web/tests/solana_command_pipeline.rs new file mode 100644 index 0000000..dd6380b --- /dev/null +++ b/crates/ilold-web/tests/solana_command_pipeline.rs @@ -0,0 +1,750 @@ +use std::path::PathBuf; + +use ilold_solana_core::ingest::detect; + +fn cpi_fixture() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .unwrap() + .parent() + .unwrap() + .join("tests/fixtures/solana/cpi") +} + +fn staking_fixture() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .unwrap() + .parent() + .unwrap() + .join("tests/fixtures/solana/staking") +} + +async fn start_staking() -> (reqwest::Client, u16) { + let detected = detect(&staking_fixture()).expect("detect staking fixture"); + let (_state, port) = ilold_web::start_solana_server(detected, 0) + .await + .expect("start solana server"); + (reqwest::Client::new(), port) +} + +async fn start_solana() -> (reqwest::Client, u16) { + let detected = detect(&cpi_fixture()).expect("detect cpi fixture"); + let (_state, port) = ilold_web::start_solana_server(detected, 0) + .await + .expect("start solana server"); + (reqwest::Client::new(), port) +} + +async fn cmd( + client: &reqwest::Client, + port: u16, + program: &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": program, "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") +} + +#[tokio::test] +async fn solana_funcs_lists_lever_instructions() { + let (client, port) = start_solana().await; + let result = cmd(&client, port, "lever", serde_json::json!("Funcs")).await; + let items = result + .get("InstructionList") + .and_then(|v| v.get("items")) + .and_then(|v| v.as_array()) + .expect("InstructionList.items"); + assert_eq!(items.len(), 2); +} + +#[tokio::test] +async fn solana_users_new_then_list() { + let (client, port) = start_solana().await; + let _ = cmd( + &client, + port, + "lever", + serde_json::json!({"UsersNew": {"name": "admin", "lamports": 5_000_000_000u64}}), + ) + .await; + let listed = cmd(&client, port, "lever", serde_json::json!("Users")).await; + let users = listed + .get("UserList") + .and_then(|v| v.get("users")) + .and_then(|v| v.as_array()) + .expect("UserList.users"); + assert_eq!(users.len(), 1); + assert_eq!(users[0].get("name").and_then(|v| v.as_str()), Some("admin")); +} + +#[tokio::test] +async fn solana_time_warp_returns_new_clock() { + let (client, port) = start_solana().await; + let result = cmd( + &client, + port, + "lever", + serde_json::json!({"TimeWarp": {"delta_seconds": 86400}}), + ) + .await; + let warped = result.get("TimeWarped").expect("TimeWarped variant"); + assert!(warped.get("unix_timestamp").is_some()); + assert!(warped.get("slot").is_some()); +} + +#[tokio::test] +async fn solana_scenario_fork_clones_vm_and_users() { + let (client, port) = start_solana().await; + + let _ = cmd( + &client, + port, + "lever", + serde_json::json!({"UsersNew": {"name": "admin", "lamports": 1_000_000_000u64}}), + ) + .await; + + let forked = cmd( + &client, + port, + "lever", + serde_json::json!({"Scenario": {"sub": {"Fork": {"name": "branch1", "at_step": null}}}}), + ) + .await; + assert!(forked.get("ScenarioForked").is_some(), "got: {forked}"); + + let _ = cmd( + &client, + port, + "lever", + serde_json::json!({"Scenario": {"sub": {"Switch": {"name": "branch1"}}}}), + ) + .await; + + let listed = cmd(&client, port, "lever", serde_json::json!("Users")).await; + let users = listed + .get("UserList") + .and_then(|v| v.get("users")) + .and_then(|v| v.as_array()) + .expect("UserList.users"); + assert_eq!(users.len(), 1, "fork should have cloned the admin user"); + assert_eq!(users[0].get("name").and_then(|v| v.as_str()), Some("admin")); +} + +#[tokio::test] +async fn solana_call_switch_power_via_http() { + let (client, port) = start_solana().await; + + let _ = cmd( + &client, + port, + "lever", + serde_json::json!({"UsersNew": {"name": "admin", "lamports": 5_000_000_000u64}}), + ) + .await; + let _ = cmd( + &client, + port, + "lever", + serde_json::json!({"UsersNew": {"name": "power", "lamports": 5_000_000_000u64}}), + ) + .await; + + let init = cmd( + &client, + port, + "lever", + serde_json::json!({ + "Call": { + "ix": "initialize", + "args": {}, + "accounts": { + "power": "power", + "user": "admin", + "system_program": "11111111111111111111111111111111" + }, + "signers": ["admin", "power"] + } + }), + ) + .await; + assert!(init.get("StepAdded").is_some(), "got: {init}"); + + let switch = cmd( + &client, + port, + "lever", + serde_json::json!({ + "Call": { + "ix": "switch_power", + "args": {"name": "claude"}, + "accounts": {"power": "power"}, + "signers": [] + } + }), + ) + .await; + let added = switch.get("StepAdded").expect("StepAdded for switch_power"); + let logs = added + .get("logs_excerpt") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str()) + .collect::<Vec<_>>() + .join("\n") + }) + .unwrap_or_default(); + assert!( + logs.contains("pulling the power switch"), + "expected lever log line, got: {logs}" + ); +} + +// Regression for T-R40: LiteSVM does not rotate latest_blockhash after +// send_transaction (verified in litesvm 0.6.1 src/lib.rs:1141-1188 — only +// expire_blockhash mutates the field). Without our explicit rotation, the +// 2nd Call shares the 1st's blockhash and silently fails with cu=0 / no +// state change. This test runs three consecutive switch_power calls and +// asserts that each one actually executed (cu > 0 and a fresh log line). +#[tokio::test] +async fn solana_consecutive_calls_actually_execute() { + let (client, port) = start_solana().await; + + for n in ["admin", "power"] { + let _ = cmd( + &client, + port, + "lever", + serde_json::json!({"UsersNew": {"name": n, "lamports": 5_000_000_000u64}}), + ) + .await; + } + + let init = cmd( + &client, + port, + "lever", + serde_json::json!({ + "Call": { + "ix": "initialize", + "args": {}, + "accounts": { + "power": "power", + "user": "admin", + "system_program": "11111111111111111111111111111111" + }, + "signers": ["admin", "power"] + } + }), + ) + .await; + assert!(init.get("StepAdded").is_some(), "init: {init}"); + + // Three consecutive switch_power calls — pre-T-R40 only the first ran. + for label in ["alpha", "beta", "gamma"] { + let res = cmd( + &client, + port, + "lever", + serde_json::json!({ + "Call": { + "ix": "switch_power", + "args": {"name": label}, + "accounts": {"power": "power"}, + "signers": [] + } + }), + ) + .await; + let step = res.get("StepAdded").unwrap_or_else(|| panic!("StepAdded for {label}: {res}")); + let cu = step + .get("compute_units") + .and_then(|v| v.as_u64()) + .unwrap_or(0); + assert!(cu > 0, "consecutive Call '{label}' executed with cu=0 — blockhash rotation regression"); + } +} + +// Regression for T-R33: Back must rewind the VM to the pre-Call snapshot, +// not just remove the step from the timeline. Verified by re-issuing the +// same Call after Back and checking the state mutates as if it were the +// first time. +#[tokio::test] +async fn solana_back_rewinds_vm_state() { + let (client, port) = start_solana().await; + + for n in ["admin", "power"] { + let _ = cmd( + &client, + port, + "lever", + serde_json::json!({"UsersNew": {"name": n, "lamports": 5_000_000_000u64}}), + ) + .await; + } + let _ = cmd( + &client, + port, + "lever", + serde_json::json!({ + "Call": { + "ix": "initialize", + "args": {}, + "accounts": { + "power": "power", + "user": "admin", + "system_program": "11111111111111111111111111111111" + }, + "signers": ["admin", "power"] + } + }), + ) + .await; + + let _ = cmd( + &client, + port, + "lever", + serde_json::json!({ + "Call": { + "ix": "switch_power", + "args": {"name": "first"}, + "accounts": {"power": "power"}, + "signers": [] + } + }), + ) + .await; + let session_after_two = cmd(&client, port, "lever", serde_json::json!("Session")).await; + assert_eq!( + session_after_two + .get("SessionView") + .and_then(|v| v.get("steps")) + .and_then(|v| v.as_array()) + .map(|a| a.len()), + Some(2) + ); + + let back = cmd(&client, port, "lever", serde_json::json!("Back")).await; + assert!(back.get("StepRemoved").is_some(), "Back: {back}"); + + // Re-issue the same switch_power; if Back didn't rewind the VM the second + // time the program logs the "previous" state which would still reflect + // "first". After T-R33 it should be back to whatever initialize left. + let replay = cmd( + &client, + port, + "lever", + serde_json::json!({ + "Call": { + "ix": "switch_power", + "args": {"name": "second"}, + "accounts": {"power": "power"}, + "signers": [] + } + }), + ) + .await; + let logs = replay + .get("StepAdded") + .and_then(|v| v.get("logs_excerpt")) + .and_then(|v| v.as_array()) + .map(|a| { + a.iter() + .filter_map(|v| v.as_str()) + .collect::<Vec<_>>() + .join("\n") + }) + .unwrap_or_default(); + assert!( + logs.contains("pulling the power switch"), + "switch_power should still run after Back+replay; got: {logs}" + ); +} + +#[tokio::test] +async fn solana_info_returns_typed_ix_view() { + let (client, port) = start_solana().await; + let result = cmd( + &client, + port, + "lever", + serde_json::json!({"Info": {"ix": "switch_power"}}), + ) + .await; + let info = result.get("IxInfo").expect("IxInfo variant"); + let ix = info.get("ix").expect("ix slice"); + assert_eq!( + ix.get("name").and_then(|v| v.as_str()), + Some("switch_power") + ); + let disc = ix + .get("discriminator_hex") + .and_then(|v| v.as_str()) + .expect("discriminator_hex"); + assert!(disc.starts_with("0x") && disc.len() == 18); + let args = ix + .get("args") + .and_then(|v| v.as_array()) + .expect("args array"); + assert_eq!(args.len(), 1); + assert_eq!( + args[0].get("ty").and_then(|v| v.as_str()), + Some("string") + ); + assert_eq!( + info.get("admin_gated").and_then(|v| v.as_bool()), + Some(false) + ); +} + +#[tokio::test] +async fn solana_coupling_returns_pairs() { + let (client, port) = start_solana().await; + let result = cmd(&client, port, "lever", serde_json::json!("Coupling")).await; + let pairs = result + .get("CouplingList") + .and_then(|v| v.get("pairs")) + .and_then(|v| v.as_array()) + .expect("CouplingList.pairs"); + // lever has 2 ixs both writing `power` → exactly one pair. + assert_eq!(pairs.len(), 1); + let only = &pairs[0]; + assert_eq!(only.get("a").and_then(|v| v.as_str()), Some("initialize")); + assert_eq!( + only.get("b").and_then(|v| v.as_str()), + Some("switch_power") + ); +} + +#[tokio::test] +async fn solana_vars_returns_account_types() { + let (client, port) = start_solana().await; + let result = cmd(&client, port, "lever", serde_json::json!("Vars")).await; + let accounts = result + .get("AccountTypes") + .and_then(|v| v.get("accounts")) + .and_then(|v| v.as_array()) + .expect("AccountTypes.accounts"); + assert_eq!(accounts.len(), 1); + assert_eq!( + accounts[0].get("name").and_then(|v| v.as_str()), + Some("PowerStatus") + ); + let fields = accounts[0] + .get("fields") + .and_then(|v| v.as_array()) + .expect("fields"); + assert_eq!(fields.len(), 1); + assert_eq!( + fields[0].get("ty").and_then(|v| v.as_str()), + Some("bool") + ); +} + +#[tokio::test] +async fn solana_who_account_type_pool_returns_writers() { + let (client, port) = start_staking().await; + let result = cmd( + &client, + port, + "staking", + serde_json::json!({"Who": {"account_type": "Pool"}}), + ) + .await; + let who = result.get("WhoList").expect("WhoList variant"); + assert_eq!( + who.get("query_kind").and_then(|v| v.as_str()), + Some("AccountType") + ); + assert_eq!( + who.get("account_type").and_then(|v| v.as_str()), + Some("Pool") + ); + let ixs = who + .get("instructions") + .and_then(|v| v.as_array()) + .expect("instructions array"); + assert_eq!(ixs.len(), 5); + let names: Vec<_> = ixs + .iter() + .filter_map(|i| i.get("instruction").and_then(|v| v.as_str())) + .collect(); + assert_eq!( + names, + vec!["add_rewards", "claim_rewards", "initialize_pool", "stake", "unstake"] + ); + let fields = who + .get("owner_fields") + .and_then(|v| v.as_array()) + .expect("owner_fields populated for AccountType"); + assert!(fields.iter().any(|f| f.get("name").and_then(|v| v.as_str()) == Some("total_staked"))); +} + +#[tokio::test] +async fn solana_who_field_total_staked_marks_owner_pool() { + let (client, port) = start_staking().await; + let result = cmd( + &client, + port, + "staking", + serde_json::json!({"Who": {"account_type": "total_staked"}}), + ) + .await; + let who = result.get("WhoList").expect("WhoList variant"); + assert_eq!(who.get("query_kind").and_then(|v| v.as_str()), Some("Field")); + assert_eq!( + who.get("field_owner").and_then(|v| v.as_str()), + Some("Pool") + ); + let ixs = who + .get("instructions") + .and_then(|v| v.as_array()) + .expect("instructions array"); + assert_eq!(ixs.len(), 5); +} + +#[tokio::test] +async fn solana_who_instruction_claim_rewards_lists_accounts() { + let (client, port) = start_staking().await; + let result = cmd( + &client, + port, + "staking", + serde_json::json!({"Who": {"account_type": "claim_rewards"}}), + ) + .await; + let who = result.get("WhoList").expect("WhoList variant"); + assert_eq!( + who.get("query_kind").and_then(|v| v.as_str()), + Some("Instruction") + ); + let accs = who + .get("ix_accounts") + .and_then(|v| v.as_array()) + .expect("ix_accounts populated"); + let pool = accs + .iter() + .find(|a| a.get("name").and_then(|v| v.as_str()) == Some("pool")) + .expect("pool account in ix_accounts"); + assert_eq!( + pool.get("account_type").and_then(|v| v.as_str()), + Some("Pool") + ); + assert_eq!(pool.get("writable").and_then(|v| v.as_bool()), Some(true)); + let user = accs + .iter() + .find(|a| a.get("name").and_then(|v| v.as_str()) == Some("user")) + .expect("user signer in ix_accounts"); + assert!(user.get("account_type").is_none() || user.get("account_type").map(|v| v.is_null()).unwrap_or(false)); +} + +#[tokio::test] +async fn solana_who_unknown_query_returns_not_found() { + let (client, port) = start_staking().await; + let result = cmd( + &client, + port, + "staking", + serde_json::json!({"Who": {"account_type": "foo"}}), + ) + .await; + let who = result.get("WhoList").expect("WhoList variant"); + assert_eq!( + who.get("query_kind").and_then(|v| v.as_str()), + Some("NotFound") + ); + let ixs = who + .get("instructions") + .and_then(|v| v.as_array()) + .expect("instructions array"); + assert!(ixs.is_empty()); +} + +#[tokio::test] +async fn program_view_endpoint_returns_typed_view() { + let (client, port) = start_staking().await; + let res = client + .get(format!("http://127.0.0.1:{port}/api/program/staking/view")) + .send() + .await + .expect("GET /api/program/staking/view"); + assert!(res.status().is_success(), "GET /view returned {}", res.status()); + let view: serde_json::Value = res.json().await.expect("parse /view body"); + + assert_eq!(view.get("name").and_then(|v| v.as_str()), Some("staking")); + assert!(view.get("program_id").and_then(|v| v.as_str()).is_some()); + + let instructions = view + .get("instructions") + .and_then(|v| v.as_array()) + .expect("instructions array"); + assert!(!instructions.is_empty()); + let ix_names: Vec<&str> = instructions + .iter() + .filter_map(|i| i.get("name").and_then(|v| v.as_str())) + .collect(); + assert!(ix_names.contains(&"initialize_pool")); + assert!(ix_names.contains(&"stake")); + + let init = instructions + .iter() + .find(|i| i.get("name").and_then(|v| v.as_str()) == Some("initialize_pool")) + .expect("initialize_pool entry"); + assert!(init + .get("discriminator_hex") + .and_then(|v| v.as_str()) + .map(|s| s.starts_with("0x")) + .unwrap_or(false)); + let init_args = init.get("args").and_then(|v| v.as_array()).expect("args"); + assert!(init_args + .iter() + .any(|a| a.get("name").and_then(|v| v.as_str()) == Some("reward_rate") + && a.get("ty").and_then(|v| v.as_str()) == Some("u64"))); + + let init_ix_accounts = init + .get("accounts") + .and_then(|v| v.as_array()) + .expect("ix accounts array"); + assert!(!init_ix_accounts.is_empty()); + + let accounts = view + .get("accounts") + .and_then(|v| v.as_array()) + .expect("accounts array"); + let account_names: Vec<&str> = accounts + .iter() + .filter_map(|a| a.get("name").and_then(|v| v.as_str())) + .collect(); + assert!(account_names.contains(&"Pool")); + + assert!(view.get("state_coupling").is_some()); + assert!(view.get("admin_gated").is_some()); + assert!(view.get("system_accounts").is_some()); +} + +#[tokio::test] +async fn program_view_endpoint_returns_404_for_missing_program() { + let (client, port) = start_staking().await; + let res = client + .get(format!("http://127.0.0.1:{port}/api/program/ghost/view")) + .send() + .await + .expect("GET /view ghost"); + assert_eq!(res.status().as_u16(), 404); +} + +#[tokio::test] +async fn coverage_command_returns_overlay() { + let (client, port) = start_staking().await; + let result = cmd(&client, port, "staking", serde_json::json!("Coverage")).await; + let overlay = result + .get("Coverage") + .and_then(|v| v.get("overlay")) + .expect("Coverage.overlay variant"); + assert_eq!( + overlay.get("program").and_then(|v| v.as_str()), + Some("staking"), + ); + let scenario = overlay + .get("scenario") + .and_then(|v| v.as_str()) + .expect("scenario field"); + assert!(!scenario.is_empty(), "scenario name should be set"); + let calls = overlay + .get("calls_per_ix") + .and_then(|v| v.as_object()) + .expect("calls_per_ix object"); + assert!(calls.is_empty(), "fresh session has no calls yet"); + let edges = overlay + .get("cpi_edges") + .and_then(|v| v.as_array()) + .expect("cpi_edges array"); + assert!(edges.is_empty()); +} + +#[tokio::test] +async fn program_overlay_endpoint_returns_typed_overlay() { + let (client, port) = start_staking().await; + let res = client + .get(format!("http://127.0.0.1:{port}/api/program/staking/overlay")) + .send() + .await + .expect("GET /overlay"); + assert!(res.status().is_success()); + let body: serde_json::Value = res.json().await.expect("json"); + assert_eq!( + body.get("program").and_then(|v| v.as_str()), + Some("staking"), + ); + assert!(body.get("scenario").and_then(|v| v.as_str()).is_some()); + assert!(body.get("calls_per_ix").is_some()); + assert!(body.get("failed_per_ix").is_some()); + assert!(body.get("cu_stats_per_ix").is_some()); + assert!(body.get("cpi_edges").is_some()); +} + +#[tokio::test] +async fn program_overlay_endpoint_returns_404_for_missing_program() { + let (client, port) = start_staking().await; + let res = client + .get(format!("http://127.0.0.1:{port}/api/program/ghost/overlay")) + .send() + .await + .expect("GET /overlay ghost"); + assert_eq!(res.status().as_u16(), 404); +} + +#[tokio::test] +async fn user_labels_endpoint_returns_pubkey_to_name_map() { + let (client, port) = start_staking().await; + let _ = cmd( + &client, + port, + "staking", + serde_json::json!({"UsersNew": {"name": "alice", "lamports": 1_000_000_000u64}}), + ) + .await; + let listed = cmd(&client, port, "staking", serde_json::json!("Users")).await; + let alice_pk = listed + .get("UserList") + .and_then(|v| v.get("users")) + .and_then(|v| v.as_array()) + .and_then(|arr| arr.first()) + .and_then(|u| u.get("pubkey")) + .and_then(|v| v.as_str()) + .expect("alice pubkey") + .to_string(); + + let res = client + .get(format!("http://127.0.0.1:{port}/api/users/main/labels")) + .send() + .await + .expect("GET /labels"); + assert!(res.status().is_success(), "got {}", res.status()); + let map: serde_json::Value = res.json().await.expect("json"); + assert_eq!(map.get(&alice_pk).and_then(|v| v.as_str()), Some("alice")); +} + +#[tokio::test] +async fn user_labels_endpoint_returns_404_for_missing_scenario() { + let (client, port) = start_staking().await; + let res = client + .get(format!("http://127.0.0.1:{port}/api/users/ghost/labels")) + .send() + .await + .expect("GET /labels ghost"); + assert_eq!(res.status().as_u16(), 404); +} diff --git a/docs/guide/src/SUMMARY.md b/docs/guide/src/SUMMARY.md index a353de2..0fc65bb 100644 --- a/docs/guide/src/SUMMARY.md +++ b/docs/guide/src/SUMMARY.md @@ -3,20 +3,50 @@ [Introduction](./introduction.md) [Getting Started](./getting-started.md) -# Commands +# Concepts -- [Session](./commands/session.md) -- [Analysis](./commands/analysis.md) -- [Contract](./commands/contract.md) -- [Findings](./commands/findings.md) -- [Workspace](./commands/workspace.md) +- [What ilold does](./concepts/overview.md) +- [Architecture](./concepts/architecture.md) -# Workflows +# Solidity Backend -- [Audit Walkthrough](./workflows/audit-walkthrough.md) -- [Taint Analysis](./workflows/taint-analysis.md) +- [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) +- [REPL: Session](./solana/repl/session.md) +- [REPL: Programs and IDL](./solana/repl/programs.md) +- [REPL: Solana runtime](./solana/repl/runtime.md) +- [REPL: Analysis](./solana/repl/analysis.md) +- [REPL: Findings](./solana/repl/findings.md) +- [REPL: Scenarios](./solana/repl/scenarios.md) +- [REPL: Workspace](./solana/repl/workspace.md) +- [REPL: Help and control](./solana/repl/help.md) +- [Workflow: Audit walkthrough](./solana/workflows/audit-walkthrough.md) +- [Workflow: Scenarios and forks](./solana/workflows/scenarios.md) +- [Limitations](./solana/limitations.md) # Reference - [HTTP API](./reference/api-endpoints.md) -- [Known Limitations](./reference/limitations.md) +- [WebSocket events](./reference/websocket.md) +- [MCP server](./reference/mcp.md) +- [Known limitations](./reference/limitations.md) + +# 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/commands/contract.md b/docs/guide/src/commands/contract.md deleted file mode 100644 index 7eb4169..0000000 --- a/docs/guide/src/commands/contract.md +++ /dev/null @@ -1,116 +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 - - [public] deposit writes state - [public] withdraw writes state, external calls - [public] claimRewards writes state, external calls - [public] getStakeInfo view - [restricted(onlyOwner)] pause writes state - [restricted(onlyOwner)] unpause writes state -``` - -Tags indicate `writes state`, `external calls`, or `view` (read-only, no external calls). - -## funcs-all - -`fa` or `funcs-all` - -Lists all accessible functions including those inherited from parent contracts. - -``` -ilold[Staking]> fa - - [public] deposit writes state - [public] withdraw writes state, external calls - [public] claimRewards writes state, external calls - [public] getStakeInfo view - [restricted(onlyOwner)] pause writes state - [restricted(onlyOwner)] unpause writes state - - inherited: - [public] owner from Ownable - [public] transferOwnership from Ownable -``` - -Inherited functions are listed separately with their origin contract. - -## vars - -`v` or `vars` - -Lists the state variables of the active contract with their type and mutability tag. - -``` -ilold[Staking]> v - - mutable balances mapping(address => uint256) - mutable totalStaked uint256 - mutable rewardDebt mapping(address => uint256) - mutable paused bool - const MIN_STAKE uint256 - immutable rewardToken address -``` - -Tags are `mutable`, `const`, or `immutable`. - -## vars-all - -`va` or `vars-all` - -Lists all accessible state variables including inherited ones. - -``` -ilold[Staking]> va - - mutable balances mapping(address => uint256) - mutable totalStaked uint256 - mutable rewardDebt mapping(address => uint256) - mutable paused bool - const MIN_STAKE uint256 - immutable rewardToken address - - inherited: - mutable _owner address from Ownable -``` - -## contracts - -`ct` or `contracts` - -Lists all contracts in the loaded project with their type, function count, state variable count, and inheritance. - -``` -ilold[Staking]> ct - - [C] Staking 6 functions, 6 state vars, inherits Ownable, ReentrancyGuard ← current - [C] Ownable 3 functions, 1 state vars - [A] ReentrancyGuard 0 functions, 1 state vars - [I] IERC20 6 functions, 0 state vars -``` - -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/concepts/architecture.md b/docs/guide/src/concepts/architecture.md new file mode 100644 index 0000000..3dfefb4 --- /dev/null +++ b/docs/guide/src/concepts/architecture.md @@ -0,0 +1,64 @@ +# 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`). + +## Solidity 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) + │ + ▼ +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, AnchorProject) + │ + ▼ +ilold-solana-core::runtime (LiteSVM-backed engine) + │ + ▼ +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-cli::explore (REPL, parses input, prints results) +``` + +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). + +## Shared layer + +| 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-solana-core` | Anchor IDL ingest, LiteSVM runtime, instruction execution, timeline reconstruction | + +The web canvas (`crates/ilold-web/frontend`) subscribes to the `/ws` stream and stays in sync with whatever the REPL does. diff --git a/docs/guide/src/concepts/overview.md b/docs/guide/src/concepts/overview.md new file mode 100644 index 0000000..4ddf8b9 --- /dev/null +++ b/docs/guide/src/concepts/overview.md @@ -0,0 +1,20 @@ +# 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. + +## Two backends, one shell + +| 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 | + +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). + +## 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. + +## 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. diff --git a/docs/guide/src/getting-started.md b/docs/guide/src/getting-started.md index 31d9213..ec3df85 100644 --- a/docs/guide/src/getting-started.md +++ b/docs/guide/src/getting-started.md @@ -10,17 +10,24 @@ cd ilold cargo build --release ``` -The binary is at `target/release/ilold`. +The binary is at `target/release/ilold`. The four subcommands are `analyze`, `context`, `serve`, and `explore` (see `crates/ilold-cli/src/main.rs`). -## Running +## Backend detection -Point ilold at a Solidity file or directory: +`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 contracts/staking.sol +cargo run -- explore tests/fixtures/staking.sol ``` -ilold parses the files, builds the model and CFGs, and drops you into the REPL: +ilold parses the files, builds the model and per-function CFGs, and drops the auditor into the REPL: ``` ╭──────────────────────────────────────────╮ @@ -32,87 +39,74 @@ ilold parses the files, builds the model and CFGs, and drops you into the REPL: ilold[Staking]> ``` -The port is assigned automatically unless you pass `--port`. +The port defaults to `0` (auto-assigned) for `explore` and `8080` for `serve`. Override with `--port`. -## First Session - -A typical first exploration of a staking contract: - -**1. Add a function call to the session:** +## Running against a Solana project ``` -ilold[Staking]> c deposit - - + Step 0: deposit [public] external - State writes: - · balances - · totalStaked - Sequence: deposit +cargo run -- explore tests/fixtures/solana/staking ``` -**2. Check accumulated state:** +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[→ deposit]> s - - ═══════════════════[ STATE ]═══════════════════ - balances - balances[msg.sender] += msg.value (deposit) - totalStaked - totalStaked += msg.value (deposit) +ilold[staking]> ``` -**3. See who else touches a variable:** +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. -``` -ilold[→ deposit]> who totalStaked - - who: totalStaked - Writers: - [public] deposit - [public] withdraw - Readers: - [public] getStakeInfo - → sl deposit totalStaked, sl withdraw totalStaked - → tl totalStaked -``` +## First session -**4. Slice the data flow:** +A typical first exploration of the Solidity staking contract: ``` -ilold[→ deposit]> sl deposit totalStaked +ilold[Staking]> f - deposit · totalStaked — dataflow slice - ════════════════════════════════════════════════════════════ - [backward] - L42 require(msg.value > 0, "Zero deposit") - L45 totalStaked += msg.value - [forward] - L47 emit Deposited(msg.sender, msg.value) - → tr deposit | tl totalStaked + [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 ``` -**5. Trace the full execution flow:** +``` +ilold[Staking]> c deposit + + Step 0: deposit [P] external + State writes: + · balances[msg.sender] + · lastUpdateTime + · rewardPerTokenStored + · rewards[account] + · totalStaked + · userRewardPerTokenPaid[account] + Sequence: deposit ``` -ilold[→ deposit]> tr deposit - ╭──────────────────────────────────────╮ - │ Staking::deposit() │ - │ modifiers: whenNotPaused │ - │ max inlining depth: 2 │ - ╰──────────────────────────────────────╯ +``` +ilold[Staking → deposit]> s - 001 │ ▶ deposit() - 002 │ ├─ ◇ require(!paused, "Paused") [from: whenNotPaused] - 003 │ ├─ ◇ require(msg.value > 0, "Zero deposit") - 004 │ ├─ ✏ balances[msg.sender] += msg.value - 005 │ ├─ ✏ totalStaked += msg.value - 006 │ └─ ◆ emit Deposited(msg.sender, msg.value) - → sl deposit balances, sl deposit totalStaked + ════════════════════════════════════════════[ 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) ``` -## Inline Help +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). + +## Inline help Type `?` at the prompt for the full command reference. Append `?` to any command for its usage: @@ -120,3 +114,5 @@ Type `?` at the prompt for the full command reference. Append `?` to any command ilold[Staking]> sl? slice <func> <var> [--backward] Dataflow slice. Example: sl deposit totalStaked --backward ``` + +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 04f9d9e..8c1d5a5 100644 --- a/docs/guide/src/introduction.md +++ b/docs/guide/src/introduction.md @@ -1,39 +1,32 @@ # Introduction -ilold is a static analysis tool for Solidity smart contracts that provides an interactive REPL for exploring execution paths, state mutations, and data flow. Instead of producing a batch report and leaving the auditor to interpret it, ilold builds an in-memory model of the contract and lets you query it interactively, building up call sequences step by step. +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. -## Core Concept +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. -A security audit is a conversation with the code. ilold models that conversation as a **session**: you add function calls one at a time, and the tool tracks how state accumulates across the sequence. At any point you can ask questions -- who writes this variable? what does the data flow look like? what is the full execution tree? -- and get answers scoped to the contract's actual structure. +ilold supports two backends: -The session is the central abstraction. Every analysis command operates either on a single function or on the accumulated session state, so the results stay grounded in realistic execution scenarios rather than theoretical possibilities. +- **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. -## Pipeline +The REPL surface is the same shell for both backends, with backend-specific commands documented in their respective sections. -``` -.sol files - | - v -Parser (solar-compiler) - | - v -Model (contracts, functions, modifiers, state variables) - | - v -CFG (control flow graph per function) - | - v -Analysis - ├── trace — execution flow tree with modifier inlining - ├── slice — backward/forward dataflow analysis - ├── timeline — cross-step mutation history - └── narrative — function and sequence summaries -``` +## Core concept -The parser produces a typed model of each contract. From the model, ilold builds a control flow graph per function, then layers analysis passes on top. The REPL exposes these passes as individual commands. +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. -## Key Differentiator +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. -Traditional static analyzers run a fixed set of detectors and produce a flat list of warnings. ilold does not detect vulnerabilities automatically. Instead, it gives the auditor tools to explore the contract interactively: build a call sequence, inspect state changes, trace execution flow, slice data dependencies. The auditor drives the analysis and records findings as they go. +## Key differentiator -This is closer to how manual audits actually work -- following a thread through the code, checking what happens when functions are called in a specific order, and documenting what you find. +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). + +## 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. +- [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. diff --git a/docs/guide/src/reference/api-endpoints.md b/docs/guide/src/reference/api-endpoints.md index 9338228..c5d41be 100644 --- a/docs/guide/src/reference/api-endpoints.md +++ b/docs/guide/src/reference/api-endpoints.md @@ -163,17 +163,29 @@ Returns the sequence analysis for a contract: per-function behavior summaries (s Returns search suggestions for the contract: function names, state variable names, event names, external call targets, and predefined categories (revert, return, assembly). -## WebSocket +## 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: + +| 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. | -### GET /ws +`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.). + +## WebSocket -Upgrades to a WebSocket connection. The server pushes `CanvasPatch` messages when session state changes: +`GET /ws` upgrades to a WebSocket connection. See [WebSocket events](./websocket.md) for the full event vocabulary and payload shapes. -- `AddNode`: a step was added (function, access level, step index) -- `RemoveLastNode`: the last step was removed -- `ClearAll`: all steps were cleared +A second WebSocket route `GET /ws/pty` provides a PTY bridge used by the embedded REPL in the web canvas. ## Related pages -- [Session commands](../commands/session.md) +- [WebSocket events](./websocket.md) +- [Solidity REPL: Session](../solidity/repl/session.md) +- [Solana 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 69b6c67..54fd247 100644 --- a/docs/guide/src/reference/limitations.md +++ b/docs/guide/src/reference/limitations.md @@ -1,40 +1,8 @@ # Known Limitations -This page documents the current analysis boundaries of ilold. Understanding these limitations is necessary for interpreting slice, timeline, and trace results correctly. +Limitations are documented per backend, since the boundaries are very different: -## Intraprocedural slicing only +- [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. -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](./api-endpoints.md) +For the planned remediations, see the [Roadmap](../roadmap/solidity.md). diff --git a/docs/guide/src/reference/mcp.md b/docs/guide/src/reference/mcp.md new file mode 100644 index 0000000..52be72a --- /dev/null +++ b/docs/guide/src/reference/mcp.md @@ -0,0 +1,256 @@ +# MCP Server + +ilold ships an MCP (Model Context Protocol) server that exposes the Solana REPL as a set of typed tools. Any MCP-compatible client (Claude Code, Claude Desktop, Cursor, Continue) can invoke those tools to drive an audit programmatically: list instructions, call them against the live LiteSVM, inspect state, record findings, and export the deliverable. The MCP server is a thin transport on top of the existing HTTP API; it adds no new domain logic. + +## Architecture + +``` +LLM client ──── stdio ────► ilold mcp ──── HTTP ────► ilold serve ────► LiteSVM + │ + └──── WebSocket ────► Web canvas (optional) +``` + +The MCP client launches `ilold mcp` as a local subprocess and talks to it over stdio (newline-delimited JSON-RPC). The MCP process is stateless: each `tools/call` translates the arguments into a `SolanaCommand` and forwards it to a running `ilold serve` instance via `POST /api/cmd`. The same backend broadcasts canvas patches over WebSocket, so a browser tab connected to the web canvas reflects every step the LLM takes. + +Only Solana is supported in v1. The MCP server refuses to start when the backend reports `kind != "solana"`. + +## Setup + +Two processes need to be running: + +1. **Backend**: an `ilold serve` instance pointing at the project to audit. + + ``` + ilold serve tests/fixtures/solana/staking --port 8080 + ``` + + The MCP server defaults to `http://127.0.0.1:8080`, so any free port works as long as `--server-url` matches. + +2. **MCP client**: configure the LLM client to spawn `ilold mcp` (see the client snippets below). The client launches the subprocess on demand and tears it down when the session ends. + +The `ilold` binary must be on the client's `PATH`. If it is not, use the absolute path returned by `which ilold` in the `command` field. + +## CLI reference + +``` +ilold mcp [OPTIONS] +``` + +| Flag | Required | Default | Description | +| --- | --- | --- | --- | +| `--server-url <URL>` | no | `http://127.0.0.1:8080` | Base URL of the `ilold serve` instance. Environment variable: `ILOLD_SERVER_URL`. | +| `--contract <NAME>` | no | unset | Optional initial active program. When unset the LLM (or the user) must call `ilold_use <program>` before any other tool. Pre-setting it is handy when the workspace has a single program. Environment variable: `ILOLD_CONTRACT`. | +| `--narration` | no | off | Emit a `notifications/progress` MCP message before each tool call describing intent (for example `Calling \`stake\` with amount=1000`). Environment variable: `ILOLD_NARRATION`. | + +The MCP server is agnostic to the active contract. A single registration in the client works against multi-program workspaces: the LLM lists programs with `ilold_programs` and then fixes the active one with `ilold_use`. + +The MCP transport reserves stdout for JSON-RPC; logs and panics go to stderr. + +## Client configuration + +Every snippet below assumes the backend is running on `http://127.0.0.1:8080`. The MCP server is registered once and stays agnostic to the active program — the LLM calls `ilold_use <program>` to switch contract during the session. Pre-setting `--contract <name>` is optional and only seeds the initial value. + +### Claude Code + +Two options. The first is project-scoped (`.mcp.json` at the repository root, checked into version control); the second is the `claude mcp add` CLI which writes to `~/.claude.json` by default. + +`.mcp.json`: + +```json +{ + "mcpServers": { + "ilold": { + "command": "ilold", + "args": [ + "mcp", + "--server-url", "http://127.0.0.1:8080" + ] + } + } +} +``` + +Add `"--contract", "<name>"` to the `args` list to pre-set the initial active program. + +Equivalent CLI form: + +``` +claude mcp add --transport stdio ilold -- ilold mcp --server-url http://127.0.0.1:8080 +``` + +### Claude Desktop + +Edit `claude_desktop_config.json` (Developer → Edit Config in the desktop settings): + +- macOS: `~/Library/Application Support/Claude/claude_desktop_config.json` +- Windows: `%APPDATA%\Claude\claude_desktop_config.json` + +```json +{ + "mcpServers": { + "ilold": { + "command": "ilold", + "args": [ + "mcp", + "--server-url", "http://127.0.0.1:8080" + ] + } + } +} +``` + +Restart Claude Desktop after saving. The MCP indicator in the input box lists `ilold` and its tools when the connection is healthy. Append `"--contract", "<name>"` to `args` to pre-set an initial program. + +### Cursor + +Place the file at `.cursor/mcp.json` (project) or `~/.cursor/mcp.json` (global): + +```json +{ + "mcpServers": { + "ilold": { + "command": "ilold", + "args": [ + "mcp", + "--server-url", "http://127.0.0.1:8080" + ] + } + } +} +``` + +Optional `env` and `envFile` keys are supported by Cursor for passing environment variables. Append `"--contract", "<name>"` to `args` to pre-set an initial program. + +### Continue + +Continue uses YAML. Edit `~/.continue/config.yaml`: + +```yaml +mcpServers: + - name: ilold + type: stdio + command: ilold + args: + - mcp + - --server-url + - http://127.0.0.1:8080 +``` + +Append `- --contract` and `- <name>` to `args` to pre-set an initial program. + +## Tools + +The registry is derived at startup from `crates/ilold-help/src/lib.rs::SOLANA_HELP_BLOCKS`. The table below lists every exposed tool with a one-line summary. Each tool returns the matching `SolanaCommandResult` variant as structured JSON plus a pretty-printed text block identical to the REPL output. + +### Discovery (read-only) + +| Tool | Purpose | +| --- | --- | +| `ilold_programs` | List every program detected in the workspace. | +| `ilold_funcs` | List the instructions exposed by the active program. | +| `ilold_funcs_all` | Same list with admin-gating and coupling hints. | +| `ilold_info` | Detail one instruction: args, accounts, signers, PDAs, discriminator. | +| `ilold_vars` | List declared account types with their Anchor discriminators. | +| `ilold_pda` | List the PDAs declared by an instruction (seeds, bumps). | +| `ilold_who` | Resolve a query against the IDL (account type, instruction, or field). | +| `ilold_coupling` | List instruction pairs that share a writable account. | + +### Session (mutate the timeline) + +| Tool | Purpose | +| --- | --- | +| `ilold_call` | Run an Anchor instruction against LiteSVM and append the result as a step. | +| `ilold_back` | Remove the last step from the active scenario and rewind the VM. | +| `ilold_clear` | Reset the active scenario steps and the underlying VM state. | +| `ilold_state` | Decoded view of every account mutated during the active scenario. | +| `ilold_session` | Active scenario summary: steps, findings, notes. | +| `ilold_step` | Re-inspect one step: CU, logs, decoded diffs. | + +### Runtime (mutate the VM) + +| Tool | Purpose | +| --- | --- | +| `ilold_users` | List every named keypair in the active scenario. | +| `ilold_users_new` | Create a new keypair and airdrop the initial lamports. | +| `ilold_airdrop` | Top up an existing keypair with extra lamports. | +| `ilold_time_warp` | Advance or rewind the Clock sysvar. | +| `ilold_inspect` | Read a VM account by pubkey and decode it via the Anchor discriminator. | + +### Analysis + +| Tool | Purpose | +| --- | --- | +| `ilold_timeline` | Cross-step mutation history of an account, decoded. | +| `ilold_coverage` | Aggregated runtime metrics over the active scenario (calls, failures, CU stats, CPI edges). | + +### Scenarios + +| Tool | Purpose | +| --- | --- | +| `ilold_scenario` | Manage scenarios: create, list, switch, fork, delete. | + +### Findings and journal + +| Tool | Purpose | +| --- | --- | +| `ilold_finding` | Record a security finding tied to the latest step. | +| `ilold_findings` | List every finding recorded in the active scenario. | +| `ilold_note` | Attach a free-form annotation to the active scenario. | +| `ilold_status` | Set the review status of an instruction: open, reviewed, finding. | +| `ilold_export` | Generate the audit deliverable (Markdown). | + +### Workspace + +| Tool | Purpose | +| --- | --- | +| `ilold_use` | Set the active program for the rest of the MCP session. Every other tool call routes to this program. | +| `ilold_save` | Serialise the active scenario to `~/.ilold/sessions/<name>.json`. | +| `ilold_load` | Restore a scenario JSON from disk and replay it into the VM. | + +Total: 30 tools. The REPL meta commands (`?`, `help`, `quit`, `browser`, `seq`) are intentionally excluded: the MCP client discovers tools via `tools/list`, the subprocess exits on stdin EOF, and the canvas URL is already on the human side. + +## Switching programs + +Multi-program workspaces are handled at runtime, not at registration time: + +1. `ilold_programs` lists every program detected by the backend. The active one is marked. +2. `ilold_use <program>` sets the active program. The handler validates the name against `/api/project/map` and rejects unknown names. +3. Subsequent tool calls (`ilold_funcs`, `ilold_call`, etc.) route to the active program automatically. + +If no contract is active (no `--contract` flag and no prior `ilold_use` call), every tool other than `ilold_programs` and `ilold_use` returns a clear error asking the LLM to set one. `ilold_use` can be called any number of times in the same session to switch back and forth between programs. + +## Example session + +A natural-language prompt for an MCP-aware client looks like this: + +> Audit the `staking` program. Look for paths where the admin signer check can be bypassed. Create a user `alice`, run `stake` for 1000 lamports, and produce a coverage report at the end. + +The client typically resolves it as the following tool sequence: + +1. `ilold_funcs_all` to enumerate instructions and admin-gating hints. +2. `ilold_info` on each instruction the model wants to inspect. +3. `ilold_users_new` to create `alice`. +4. `ilold_call` for `initialize_pool` and then `stake`. +5. `ilold_coverage` to read aggregated runtime metrics. +6. `ilold_finding` if the model identifies an issue, followed by `ilold_export`. + +Every step also fires a WebSocket patch from `ilold serve`, so a browser tab pointed at the canvas reflects the graph evolving in real time. + +## 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`). +- **Narration is best-effort.** `--narration` emits a `notifications/progress` message keyed by the request `progressToken`. Clients that do not declare a progress token in the request silently drop the notification. +- **stdio only.** SSE and streamable HTTP transports are out of scope for v1. Every supported client uses stdio. + +## Troubleshooting + +| 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 new file mode 100644 index 0000000..62a809d --- /dev/null +++ b/docs/guide/src/reference/websocket.md @@ -0,0 +1,43 @@ +# WebSocket Events + +The `/ws` route emits `ServerMessage` events (JSON, tagged on a `type` field) whenever the active session changes. The full mapping from internal `CanvasPatch` variants to wire events lives in `crates/ilold-web/src/ws/handler.rs`. + +## Session events + +| Event | Fields | Trigger | +| --- | --- | --- | +| `session_add_node` | `scenario`, `function`, `access`, `step_index`, optional `runtime` (CU + diffs + logs excerpt on Solana) | `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) | + +## Scenario events + +| Event | Fields | Trigger | +| --- | --- | --- | +| `scenario_created` | `name` | `scenario new` | +| `scenario_switched` | `from`, `to` | `scenario switch` | +| `scenario_deleted` | `name` | `scenario delete` | +| `scenario_forked` | `from`, `to`, `at_step` | `scenario fork` | +| `scenario_store_reloaded` | `active` | After `load`, when the entire scenario tree is rehydrated | + +## Solana-only events + +| Event | Fields | Trigger | +| --- | --- | --- | +| `solana_users_changed` | `scenario` | `users new`, `airdrop`, or anything that mutates the keypair set | +| `session_overlay_update` | `scenario`, `ix_name`, `calls_added`, `failed_added`, optional `cu`, `cpi_targets_added` | Runtime overlay aggregates updated after a `call` | + +## 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). + +## 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`. + +## 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. diff --git a/docs/guide/src/roadmap/cross-cutting.md b/docs/guide/src/roadmap/cross-cutting.md new file mode 100644 index 0000000..2cae727 --- /dev/null +++ b/docs/guide/src/roadmap/cross-cutting.md @@ -0,0 +1,10 @@ +# Cross-cutting Roadmap + +## Elozer integration + +Elozer is our in-house static analyzer. It produces a typed AST for smart-contract source today; a CFG layer needs to be built on top before slicing, taint analysis, and detectors can run on the Solana side. Wiring Elozer into ilold provides that AST foundation and unblocks the items listed in the [Solana roadmap](./solana.md). + +## 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 new file mode 100644 index 0000000..c3bf976 --- /dev/null +++ b/docs/guide/src/roadmap/solana.md @@ -0,0 +1,46 @@ +# Solana Roadmap + +Items below are tracked work without committed dates. + +## AST via Elozer + +Plug Elozer, our in-house static analyzer, into ilold to produce a typed AST for the program source: account validation, state writes, constraints, CPI sites. Foundation for everything below. + +## 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. + +## Detector engine + +Detectors for known Sealevel attack patterns (missing signer checks, missing owner checks, account confusion, arithmetic overflow, reinit, PDA seed collision) measured against the public sealevel-attacks corpus. Depends on AST + CFG. + +## LiteSVM register-tracing bridge + +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 + +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. + +## CPI graph in the UI + +The runtime already records CPI edges (`coverage` surfaces them in text). A dedicated CPI view in the canvas is the next visual step. + +## 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. + +## Open to ideas + +The Solana side is younger and the roadmap above is the current shape, not a fixed plan. Examples of directions we are open to: + +- New analysis passes once Elozer's AST and the CFG layer are in place. +- Integrations with other Solana tooling (anchor-cli, sealevel-attacks corpus consumers, custom IDL extensions). +- Alternative VMs or replay engines beyond LiteSVM if a use case justifies it. + +If you have a concrete use case the current backend does not cover, open an issue or reach out. + +## Related + +- [Solana: Limitations](../solana/limitations.md) +- [Cross-cutting](./cross-cutting.md) diff --git a/docs/guide/src/roadmap/solidity.md b/docs/guide/src/roadmap/solidity.md new file mode 100644 index 0000000..ba80299 --- /dev/null +++ b/docs/guide/src/roadmap/solidity.md @@ -0,0 +1,29 @@ +# 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 new file mode 100644 index 0000000..7d28382 --- /dev/null +++ b/docs/guide/src/solana/limitations.md @@ -0,0 +1,47 @@ +# Solana Known Limitations + +These boundaries reflect the current Solana backend. The corresponding [Roadmap entry](../roadmap/solana.md) tracks the Phase 2 work that will lift each of them. + +## No static control-flow analysis + +Solana programs are loaded as compiled binaries. There is no parsed handler AST and no per-instruction CFG, so the commands that depend on it are not implemented: + +- `slice <fn> <var>`: needs Anchor handler AST (see Solana roadmap). +- `trace <fn>`: same reason. +- `sequence`: aliased to `session`; no narrative engine with cross-step dependencies yet. + +## `who` is heuristic + +`who <field_name>` infers the owning account type via a snake_case → PascalCase fallback against the IDL. Programs with non-conventional naming will miss matches. `who <AccountType>` and `who <ix_name>` use exact lookups and are reliable. + +## `time-warp` is one-way for `slot` + +`time-warp <delta>` advances `unix_timestamp` linearly for both positive and negative deltas. The `slot` counter only moves forward; negative deltas do not rewind it. Programs that key off `slot` rather than `unix_timestamp` may see inconsistent values after a backward warp. + +## `time-warp` is not rewound by `back` + +`back` drops the last step and rewinds the VM to the pre-call snapshot of that step, but `time-warp` is a separate side effect on the `Clock` sysvar and is **not** undone. The auditor must reset the clock manually with an inverse `tw` if a later step expects a specific timestamp. + +## `save` / `load` regenerates keypairs by default + +Without `--with-keypairs`, `save` does not embed the test keypairs. On `load`, a fresh keypair is generated for each user; any PDA derived from a signer pubkey resolves to a different address than the original session. + +Pass `save <name> --with-keypairs` to opt into deterministic reload. The resulting JSON contains plaintext keypairs and must not be committed to public repositories. The CLI prints a reminder at both save and load time. + +## Legacy `load` without `call_payload` + +LoadSession is best-effort for legacy saves missing the `call_payload` field. The timeline restores, but the VM stays at genesis (no replay). + +## CPI visibility in the UI + +Cross-program CPI calls are exercised correctly by the VM and surface in logs, but the web canvas does not yet have a dedicated visualisation for CPI edges. See [Solana roadmap](../roadmap/solana.md) for the dedicated CPI view. + +## 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. + +## 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 new file mode 100644 index 0000000..00ff96a --- /dev/null +++ b/docs/guide/src/solana/overview.md @@ -0,0 +1,53 @@ +# Solana Backend Overview + +The Solana backend runs Anchor programs against a LiteSVM-backed engine. Every `call` runs in the VM, so the auditor sees real compute units, real logs, and real account state. Static control-flow analysis is not available yet; anything that requires it (`slice`, `trace`, `sequence` narrative) is deferred to Phase 2 (see [Roadmap](../roadmap/solana.md)). + +Two CLI entry points cover Solana: `ilold explore <project>` (REPL + API) and `ilold serve <project>` (API only). Both auto-detect the Solana backend when the path resolves to an Anchor workspace. + +## Project layout the loader expects + +``` +<root>/ + Anchor.toml + idls/ + <program>.json # Anchor IDL (required) + target/deploy/<program>.so # compiled program (or bin/<program>.so) +``` + +`crates/ilold-solana-core/src/ingest` resolves these paths. Without the `.so`, IDL navigation (`f`, `i`, `pda`, `vars`, `who`) still works; everything that drives the VM (`call`, `state`, `inspect`, `timeline`) fails until the program is compiled. + +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 | + +## REPL command groups + +The REPL command surface mirrors the Solidity one with backend-specific extensions. 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`. +- [Solana runtime](./repl/runtime.md): `users`, `airdrop`, `tw/time-warp`, `pda`, `inspect`. +- [Analysis](./repl/analysis.md): `who`, `tl/timeline`, `cp/coupling`, `cov/coverage`. +- [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`. +- [Help and control](./repl/help.md): `?/help`, `<cmd>?`, `q/quit/exit`, `seq` (aliased to `session`). + +## Workflows + +- [Audit walkthrough](./workflows/audit-walkthrough.md): staking program end-to-end, paralleling the Solidity walkthrough. +- [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 new file mode 100644 index 0000000..6b1b72d --- /dev/null +++ b/docs/guide/src/solana/repl/analysis.md @@ -0,0 +1,131 @@ +# Analysis Commands + +The Solana analysis surface is partial: there is no static CFG yet, so `slice`, `trace`, and the dedicated `sequence` narrative are not implemented (`sequence` is aliased to `session`). The following commands are available today: + +| Command | Status | What it reads | +| --- | --- | --- | +| `who <query>` | works | IDL: account types, instructions, struct fields | +| `timeline <pubkey>` | works | account diffs accumulated by the active scenario | +| `coupling` | works | IDL + accounts metadata | +| `coverage` | works | runtime metrics over the active scenario | +| `slice` / `trace` | not implemented | requires the Anchor handler AST (Phase 2) | + +## who + +`who <AccountType | ix_name | field_name>` + +Resolves a query against the IDL. The same command answers three different questions depending on the input. + +**Account type**: list instructions that reference accounts of that type. The lookup is case-insensitive with a snake_case → PascalCase fallback, so `who pool` and `who Pool` both work. + +``` +ilold[staking]> who Pool + · 'Pool' (account type) + fields: admin: Pubkey, reward_rate: u64, total_staked: u64, last_update_ts: i64 + + Referenced by 5 instructions: + + · initialize_pool (as pool) writable + args: reward_rate: u64 + · stake (as pool) writable + args: amount: u64 + · unstake (as pool) writable + args: amount: u64 + · add_rewards (as pool) writable + args: amount: u64 + · claim_rewards (as pool) writable + args: (none) +``` + +**Instruction**: list accounts the instruction touches, plus its args and discriminator: + +``` +ilold[staking]> who claim_rewards + · 'claim_rewards' (instruction) + args: (none) + discriminator 0xa1b2c3... + + Touches 4 accounts: + + · pool (Pool) writable + · user_stake (UserStake) writable + · user signer + · reward_vault writable +``` + +**Field**: identify the owning type and the instructions that write the owner account (heuristic without source-level analysis we cannot tell which writer actually mutates this field). + +``` +ilold[staking]> who total_staked + · 'total_staked' (field of Pool, type u64) + Pool struct: admin: Pubkey, reward_rate: u64, total_staked: u64, last_update_ts: i64 + + Heuristic: the following instructions write the owner account. + Without source-level analysis we cannot tell which one(s) + actually mutate this field; cross-check with `step <idx>`. + + · stake (as pool) writable + · unstake (as pool) writable +``` + +**Returns:** `WhoList { account_type, instructions, query_kind, field_owner, field_type, owner_fields, ix_args, ix_discriminator_hex, ix_accounts }`. `query_kind` is one of `AccountType`, `Field`, `Instruction`, `NotFound`. + +See also: [`info`](./programs.md#info), [`funcs`](./programs.md#functions), [`vars`](./programs.md#vars), [`coupling`](#coupling). + +## timeline + +`tl <pubkey>` or `timeline <pubkey>` + +Shows the cross-step mutation history of an account, decoded. The pubkey can be a named keypair, a named PDA, or a raw base58 string. + +``` +ilold[staking → initialize_pool → stake]> tl pool + · timeline for pool (7XzG…ABCd) + · #0 initialize_pool (main) data + {"admin":"AdminPubkey…","reward_rate":10,"total_staked":0,"last_update_ts":0} + · #1 stake (main) data + {"admin":"AdminPubkey…","reward_rate":10,"total_staked":1000,"last_update_ts":1714060800} +``` + +**Returns:** `TimelineView { pubkey, label, entries: [{ step_index, instruction, scenario, lamports_delta, data_changed, before_decoded, after_decoded }] }`. + +## coupling + +`cp` or `coupling` + +Lists instruction pairs that share a writable account. Surfaces instructions that may interfere through shared writable state (ProgramView heuristic). + +``` +ilold[staking]> coupling + · stake ↔ unstake [pool, user_stake] + · stake ↔ claim_rewards [pool, user_stake] + · add_rewards ↔ claim_rewards [pool] +``` + +**Returns:** `CouplingList { pairs: [{ a, b, shared_writable: [..] }] }`. + +## coverage + +`cov` or `coverage` + +Aggregated runtime metrics over the active scenario: calls, failures, CU stats, CPI edges (RuntimeOverlay). + +``` +ilold[staking → initialize_pool → stake]> cov + Coverage for program staking (scenario main) + + Instruction Calls Failed CU avg CU max CPIs + initialize_pool 1 0 12400 12400 0 + stake 1 0 18700 18700 0 + + Total: 2 calls, 0 failed +``` + +**Returns:** `Coverage { overlay: { program, scenario, calls_per_ix, failed_per_ix, cu_stats_per_ix, cpi_edges } }`. + +Coverage is the closest current surrogate for "have I exercised every instruction?": it makes it easy to spot instructions never called, instructions that always fail, and programs reached only through CPI. + +## 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. diff --git a/docs/guide/src/solana/repl/findings.md b/docs/guide/src/solana/repl/findings.md new file mode 100644 index 0000000..2187d29 --- /dev/null +++ b/docs/guide/src/solana/repl/findings.md @@ -0,0 +1,106 @@ +# Findings Commands + +Findings, notes, and per-instruction status flags are recorded against the active scenario and aggregated by `export`. The Solana export adds runtime metadata (CU, logs, account diffs) to each step in the report. + +## finding + +`fi <severity> <title>` or `finding <severity> <title>` (alias: `fi`) + +Records a security finding tied to the latest step of the active scenario. + +Flags: + +| Flag | Description | +| --- | --- | +| `--rec="..."` | Optional remediation recommendation. Quote it if it contains spaces. | + +Valid severities: `critical`, `high`, `medium`, `low`, `info`. + +``` +ilold[staking → … → stake]> fi high reentrancy via stake + ✓ finding F-001 +``` + +``` +ilold[staking → … → claim_rewards]> finding critical missing signer --rec="require admin signature" + ✓ finding F-002 +``` + +**Returns:** `FindingAdded { id }`. + +## findings + +`fl` or `findings` + +Lists every finding recorded in the active scenario, with severity, title, the step it is attached to, and the optional remediation. + +``` +ilold[staking]> fl + F-001 high [2026-05-09T10:12:00Z] reentrancy via stake + F-002 critical [2026-05-09T10:14:00Z] missing signer + require admin signature +``` + +**Returns:** `FindingsList { items: [{ id, severity, title, description, created_at }] }`. + +## note + +`n <text>` or `note <text>` + +Attaches a free-form annotation to the active scenario. Notes are stored alongside findings and surface in the exported report. + +``` +ilold[staking → … → stake]> n suspicious admin path here + ✓ note recorded +``` + +**Returns:** `NoteAdded`. + +## status + +`status <ix> <open | reviewed | finding>` + +Sets the review status of an instruction. Useful for tracking audit progress. + +``` +ilold[staking]> status stake reviewed + ✓ status updated +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`. + +**Returns:** `StatusUpdated`. + +## export + +`ex` or `export` + +Generates a Markdown deliverable aggregating audit metadata, severity matrix, methodology, findings (with step index, recommendation, and runtime metadata) and per-scenario step lists across **all** scenarios. + +Flags: + +| Flag | Description | +| --- | --- | +| `--auditor=<name>` | Auditor identity in the report metadata | +| `--version=<v>` | Project version pinned in the report | +| `--date=<YYYY-MM-DD>` | Audit date override (defaults to today) | + +``` +ilold[staking]> export + ✓ markdown report (4321 bytes) + + # ilold audit report + ... + +ilold[staking]> export --auditor="Alba S." --version=v1.2 --date=2026-05-09 + ✓ markdown report (4567 bytes) +``` + +**Returns:** `Exported { markdown, bytes }`. The CLI prints the full Markdown body after the header line. + +## 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 new file mode 100644 index 0000000..cf2adaa --- /dev/null +++ b/docs/guide/src/solana/repl/help.md @@ -0,0 +1,78 @@ +# Help and Control + +These commands print the command menu, the structured help block for an individual command, or exit the REPL. + +## help + +`?`, `h`, or `help` + +Prints the top-level command menu grouped by category (Session, Programs, Solana runtime, Analysis, Findings, Workspace). Hint at the bottom reminds the auditor that appending `?` to any command prints the full reference block for that command. + +``` +ilold[staking]> ? + + ilold explore — append ? to any command for inline help (e.g. sl?) + + Session + c | call <ix> arg=val acc=user Concise: keys auto-distributed; signers auto from IDL + 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) + ... +``` + +## inline help + +`<command>?` + +Renders the structured help block for that command: purpose, syntax, flags, examples, return shape, and related commands. The block lives in `crates/ilold-cli/src/help.rs::SOLANA_HELP_BLOCKS` and is the canonical reference for every Solana command. + +``` +ilold[staking]> call? + + c | call + + Purpose + Run an Anchor instruction against the LiteSVM and append the result as + a step on the active scenario. + + Syntax + c <ix> arg=val acc=user Concise key=value form (signers auto-resolved from IDL) + c <ix> {json} Full JSON form: {"args":{...},"accounts":{...},"signers":[...]} + + Flags + --signer=a,b Add signers (override IDL defaults) + --no-signer=name Remove a default signer (test negative cases) + + Examples + c stake amount=1000 pool=pool user_stake=alice_stake user=alice + c initialize_pool reward_rate=10 pool=pool admin=admin + c stake {"args":{"amount":1000},"accounts":{"pool":"pool", ...}} + + Returns + StepAdded { step_index, instruction, logs_excerpt, account_diffs_count, compute_units } + on success, or CallFailed { ... } when the VM rejects. + + See also + info, pda, state, step, back +``` + +Lookup is case-insensitive: `CALL?`, `call?`, and `c?` all return the same block. + +## sequence + +`seq` or `sequence` + +Solana has no dedicated cross-step narrative engine yet (Phase 2, see [Roadmap](../../roadmap/solana.md)). `seq` is aliased to `session`: it prints the active scenario summary so an auditor who reaches for `seq` out of habit still gets a useful view. + +## quit + +`q`, `quit`, or `exit` + +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. diff --git a/docs/guide/src/solana/repl/programs.md b/docs/guide/src/solana/repl/programs.md new file mode 100644 index 0000000..57df9a2 --- /dev/null +++ b/docs/guide/src/solana/repl/programs.md @@ -0,0 +1,120 @@ +# Programs and IDL Commands + +These commands inspect the static surface of the active program: instruction list, instruction detail, account types, and project-level navigation. + +## programs / contracts + +`ct` or `programs` (aliases: `contracts`, `progs`) + +Lists every program detected in the workspace. Multi-program Anchor workspaces (e.g. `tests/fixtures/solana/cpi`) show one entry per program. + +``` +ilold[staking]> ct + staking ← current + reward_oracle +``` + +## use + +`use <program>` + +Switches the active program. Subsequent commands target the new program; the prompt label updates. + +``` +ilold[staking]> use reward_oracle + ✓ now using reward_oracle +``` + +## functions + +`f` or `funcs` (alias: `functions`) + +Lists the instructions exposed by the active program, with arg / account counts and signer names. PDA-bearing instructions are tagged `[PDA]`. + +``` +ilold[staking]> f + [PDA] initialize_pool (args:1 accounts:3) signers: admin + [PDA] stake (args:1 accounts:4) signers: user + [PDA] unstake (args:1 accounts:4) signers: user + [ix] add_rewards (args:1 accounts:2) signers: admin + [PDA] claim_rewards (args:0 accounts:4) signers: user +``` + +**Returns:** `InstructionList { items: [InstructionEntry { name, args_count, accounts_count, has_pdas, signers }] }`. + +## funcs-all + +`fa` or `funcs-all` + +Currently identical to `funcs`: both dispatch to the same `Funcs` command and return `InstructionList`. The admin-gating and coupling hints surface separately via [`info`](#info) (`admin_gated` flag) and [`coupling`](./analysis.md#coupling). + +``` +ilold[staking]> fa + [PDA] initialize_pool (args:1 accounts:3) signers: admin + [PDA] stake (args:1 accounts:4) signers: user + ... +``` + +**Returns:** `InstructionList { items: [...] }`: same shape as `funcs`. + +## info + +`i <ix>` or `info <ix>` + +Full detail of an instruction: discriminator, typed args, accounts with their `signer` / `writable` / `optional` flags and kind (`system`, `sysvar`, `program`, `pda`, `other`), declared PDAs with their seeds, and the admin-gating heuristic. + +``` +ilold[staking]> i stake + instruction stake + discriminator 0xa1b2c3... + + args (1) + · amount u64 + + accounts (4) + · pool other writable + · user_stake pda writable + · user other signer writable + · system_program program const 11111111111111111111111111111111 + + pdas (1) + · user_stake seeds=["user-stake", user] program=self + + admin_gated false +``` + +**Returns:** `IxInfo { ix: IxView, admin_gated: bool }`. `IxView` carries `name`, `discriminator_hex`, `args`, `accounts`, and per-account `pda` metadata. + +See also: [`funcs-all`](#funcs-all), [`pda`](./runtime.md#pda), [`who`](./analysis.md#who), [`call`](./session.md#call). + +## vars + +`v` or `vars` + +Lists the account types declared in the IDL, with their Anchor discriminator and field layout. Solana does not split `vars` / `vars-all`: both alias to the same `Vars` command. + +``` +ilold[staking]> v + [T] Pool 0x4e2a... + · admin Pubkey + · reward_rate u64 + · total_staked u64 + · last_update_ts i64 + [T] UserStake 0x8c91... + · user Pubkey + · amount u64 + · reward_debt u64 +``` + +**Returns:** `AccountTypes { accounts: [AccountView { name, discriminator_hex, fields: [FieldView { name, ty }] }] }`. + +## vars-all + +`va` or `vars-all` + +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 new file mode 100644 index 0000000..42f781e --- /dev/null +++ b/docs/guide/src/solana/repl/runtime.md @@ -0,0 +1,94 @@ +# Solana Runtime Commands + +These commands operate directly on the LiteSVM owned by the active scenario. They have no Solidity counterpart. + +## users + +`users`: list keypairs in the active scenario. + +`users new <name> [lamports]`: create a keypair and airdrop it. Default airdrop is `10_000_000_000` lamports (10 SOL). + +``` +ilold[staking]> users new alice + ✓ user alice created at 8H7…Pq with 10000000000 lamports + +ilold[staking]> users new bob 5000000000 + ✓ user bob created at 6Yg…Tx with 5000000000 lamports + +ilold[staking]> users + [U] alice 8H7…Pq 10000000000 lamports + [U] bob 6Yg…Tx 5000000000 lamports +``` + +**Returns:** `UserList { users: [{ name, pubkey, lamports }] }` on listing, `UserCreated { name, pubkey, lamports }` on `users new`. + +## airdrop + +`airdrop <user> <lamports>` (alias: `air`) + +Tops up an existing keypair with extra lamports. + +``` +ilold[staking]> airdrop alice 1000000000 + ✓ alice now 11000000000 lamports 8H7…Pq +``` + +**Returns:** `Airdropped { name, pubkey, total_lamports }`. + +## time-warp + +`tw <delta_seconds>` or `time-warp <delta_seconds>` + +Advances (or rewinds) the `Clock` sysvar so vesting / reward / lockup logic can be exercised. Positive deltas move forward, negative deltas move backward. + +``` +ilold[staking]> tw 86400 + ✓ clock now ts=1714147200 slot=12345 + +ilold[staking]> tw -3600 + ✓ clock now ts=1714143600 slot=12345 +``` + +**Returns:** `TimeWarped { unix_timestamp, slot }`. + +`time-warp` is a global side effect on the `Clock` sysvar and is **not** undone by `back`. It is the auditor's responsibility to reset the clock manually if a later test expects a specific timestamp. Negative deltas adjust `unix_timestamp` linearly but do not move the `slot` counter backwards. + +## pda + +`pda <ix>` + +Lists the PDAs declared by an instruction (Anchor seeds plus bump). Read directly from the IDL, no VM execution required. + +``` +ilold[staking]> pda stake + [PDA] user_stake seeds=["user-stake", user] program=self +``` + +**Returns:** `PdaList { instruction, pdas: [{ account_name, seeds, program }] }`. + +## inspect + +`inspect <pubkey>` (alias: `acc`) + +Reads an account from the VM and decodes it via the Anchor discriminator. The pubkey can be a named keypair, a named PDA from a previous step, or a raw base58 string. + +``` +ilold[staking]> inspect alice + 8H7…Pq owner=11111111111111111111111111111111 lamports=10000000000 data_len=0 + +ilold[staking]> inspect 6Yg7... + 6Yg7…ABCd owner=StakingProgram… lamports=2039280 data_len=72 + { + "admin": "AdminPubkey…", + "reward_rate": 10, + "total_staked": 1000, + "last_update_ts": 1714060800 + } +``` + +**Returns:** `AccountInspected { pubkey, owner, lamports, data_len, decoded }`. `decoded` is `Some(Value)` when the Anchor discriminator matches a known account type, otherwise `None`. + +## Notes + +- All runtime commands are scoped to the **active scenario**. Forks own their own VM and their own keypair set; switching scenarios swaps the runtime. +- `inspect` is the easiest way to confirm what `state` and `timeline` will surface for a given account. diff --git a/docs/guide/src/solana/repl/scenarios.md b/docs/guide/src/solana/repl/scenarios.md new file mode 100644 index 0000000..a495cbd --- /dev/null +++ b/docs/guide/src/solana/repl/scenarios.md @@ -0,0 +1,80 @@ +# Scenario Commands + +Scenarios are independent branches of the session. On Solana, **each scenario owns its own VM and its own keypair set**: a fork carries the parent's VM state up to the fork step, then diverges. This is the core mechanism for testing "what happens if step 2 fails differently?" without losing the original timeline. + +## scenario new + +`sc new <name>` or `scenario new <name>` + +Creates an empty scenario with a fresh VM (the program is reloaded, no users yet). The new scenario is not activated automatically. + +``` +ilold[staking]> sc new attack + ✓ scenario attack created +``` + +**Returns:** `ScenarioCreated { name }`. + +## scenario list + +`sc list` (aliases: `sc ls`, `scenario list`, bare `sc`) + +Lists every scenario in the active session with the active marker and step count. + +``` +ilold[staking]> sc list + [S] main (3 steps) ← active + [S] attack (0 steps) +``` + +**Returns:** `ScenarioList { items: [ScenarioInfo { name, step_count, active }] }`. + +## scenario switch + +`sc switch <name>` + +Activates an existing scenario. The VM, keypairs, and step list are swapped to that scenario's state. + +``` +ilold[staking]> sc switch attack + → main → attack +ilold[staking/attack]> +``` + +**Returns:** `ScenarioSwitched { from, to }`. + +## scenario fork + +`sc fork <name> [step]` + +Creates a new scenario branching from the active one. With `[step]`, the new scenario inherits steps `0..step` and the VM is rewound to the **pre-call snapshot** of that step. Without `[step]`, the fork inherits the full step list and the VM state at HEAD. The new scenario is activated after forking. + +``` +ilold[staking → … → claim_rewards]> sc fork attack-v2 1 + ✓ forked main → attack-v2 at step 1 +ilold[staking/attack-v2 → initialize_pool]> +``` + +**Returns:** `ScenarioForked { from, to, at_step }`. + +The fork keeps the parent's keypair definitions so PDAs derived from them resolve to the same addresses (provided you opt into deterministic keypairs via `save --with-keypairs`; see [Workspace](./workspace.md)). + +## scenario delete + +`sc delete <name>` (aliases: `sc rm <name>`) + +Removes a scenario. The active scenario cannot be deleted; switch first. + +``` +ilold[staking]> sc delete attack + ✓ scenario attack deleted +``` + +**Returns:** `ScenarioDeleted { name }`. + +## Notes + +- `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). diff --git a/docs/guide/src/solana/repl/session.md b/docs/guide/src/solana/repl/session.md new file mode 100644 index 0000000..ff2c3a6 --- /dev/null +++ b/docs/guide/src/solana/repl/session.md @@ -0,0 +1,134 @@ +# Session Commands + +Session commands drive the active scenario: append a call, rewind, inspect what changed. Every state-changing command updates the LiteSVM as a side effect; `back` and `clear` rewind the VM to the corresponding pre-step snapshot. + +## call + +`c <ix> [arg=val ...] [account=user_or_pubkey ...]` or `call <ix> {json}` (alias: `c`) + +Runs an Anchor instruction against the VM and appends the result to the active scenario. Two payload forms are supported: + +- **Concise key=value form**: positional `arg=value` and `account_field=user_name` tokens. Unmapped names become local keypairs. Signers are auto-resolved from the IDL. +- **JSON form**: `{"args": {...}, "accounts": {...}, "signers": [...]}` for full control. + +Flags: + +| Flag | Description | +| --- | --- | +| `--signer=a,b` | Add signers on top of the IDL defaults | +| `--no-signer=name` | Remove a default signer (for negative cases) | + +``` +ilold[staking]> c initialize_pool reward_rate=10 pool=pool admin=admin + ✓ step 0 [ok]: initialize_pool (12400 CU, 1 diffs) +``` + +``` +ilold[staking → initialize_pool]> c stake amount=1000 pool=pool user_stake=alice_stake user=alice + ✓ step 1 [ok]: stake (18700 CU, 2 diffs) +``` + +``` +ilold[staking]> c stake {"args":{"amount":1000},"accounts":{"pool":"pool","user_stake":"alice_stake","user":"alice"}} +``` + +When the VM rejects the call (Anchor constraint, custom `require!`, etc.) no step is appended and the CLI prints the error inline: + +``` +ilold[staking]> c stake amount=0 pool=pool user_stake=alice_stake user=alice + ✗ FAILED: stake (4200 CU, not recorded) + error: AnchorError: Amount must be > 0 +``` + +**Returns:** `StepAdded { step_index, instruction, logs_excerpt, account_diffs_count, compute_units, error }` on success, `CallFailed { instruction, logs_excerpt, compute_units, error }` when the VM rejects. + +See also: [`info`](./programs.md#info), [`pda`](./runtime.md#pda), [`state`](#state), [`step`](#step), [`back`](#back). + +## back + +`b` or `back` + +Removes the last step from the active scenario and rewinds the VM to the pre-call snapshot of that step. Re-issuing the same `call` after `back` produces fresh CU and diffs. + +``` +ilold[staking → initialize_pool → stake]> b + ✓ step undone (1 remaining) +``` + +**Returns:** `StepRemoved { remaining }`. + +`time-warp` is a global side effect on the `Clock` sysvar and is **not** undone by `back`. Reset the clock manually if a test relies on a specific timestamp. + +## clear + +`cl` or `clear` + +Drops every step in the active scenario and rewinds the VM to the genesis snapshot of that scenario. + +``` +ilold[staking → initialize_pool → stake]> cl + ✓ session cleared +``` + +**Returns:** `Cleared`. + +## state + +`state` + +Decoded view of every account mutated during the active scenario. Each entry shows the pubkey, the owning program, and the decoded fields from the latest step. + +``` +ilold[staking → initialize_pool → stake]> state + [A] pool (2039280 lamports) 7XzG…ABCd + admin AdminPubkey… + reward_rate 10 + total_staked 1000 + last_update_ts 1714060800 + [A] alice_stake (1559040 lamports) 6Hj…Pq + user AlicePubkey… + amount 1000 + reward_debt 0 +``` + +**Returns:** `StateView { accounts: [AccountSummary { pubkey, label, lamports, decoded }] }`. `decoded` is the Anchor-decoded JSON snapshot, `None` for accounts whose discriminator does not match a known type. + +## session + +`s` or `session` + +Prints the active scenario summary: ordered steps, findings, notes, and the current scenario name. + +``` +ilold[staking → initialize_pool → stake]> s + program=staking scenario=main steps=2 findings=0 + 0. initialize_pool + 1. stake +``` + +**Returns:** `SessionView { program, scenario, steps, findings_count }`. + +## step + +`st <index>` or `step <index>` (no-space shortcut: `st0`, `step1`) + +Re-inspects a specific step of the active scenario, printing the persisted CU, logs, and decoded account diffs. + +``` +ilold[staking → initialize_pool → stake]> step 1 + · step 1 · stake + compute units: 18700 + logs: (4 lines) + diffs (2): + pool (Pool) + total_staked 0 → 1000 + alice_stake (UserStake) + amount — → 1000 +``` + +**Returns:** `StepDetail { step_index, instruction, runtime_trace, diff_summary }`. `runtime_trace` is a JSON blob carrying `compute_units`, `logs`, and (when present) `error`; `diff_summary` is a list of `{ address, name, lamports_delta, data_changed, decoded_before, decoded_after }`. + +## 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). +- `call` is the only command that drives the VM forward; everything else inspects or rewinds. diff --git a/docs/guide/src/solana/repl/workspace.md b/docs/guide/src/solana/repl/workspace.md new file mode 100644 index 0000000..d0fe144 --- /dev/null +++ b/docs/guide/src/solana/repl/workspace.md @@ -0,0 +1,64 @@ +# Workspace Commands + +Workspace commands handle persistence and external tooling. On Solana, `save` / `load` cover not just the step list but the entire scenario tree, including runtime traces, findings, fork origins, and the original call payloads needed to replay each call against a fresh VM. + +## save + +`save <name>` + +Serialises the active session to `~/.ilold/sessions/<name>.json`. + +Flags: + +| Flag | Description | +| --- | --- | +| `--with-keypairs` | Bundle plaintext test keypairs for deterministic reload. Do NOT commit the resulting file. | + +``` +ilold[staking]> save reentrancy-attack + ✓ session JSON (8421 bytes) + +ilold[staking]> save reentrancy-attack --with-keypairs + ✓ session JSON (9532 bytes) +``` + +**Returns:** `SessionSaved { json }`. The CLI prints the JSON byte count. The file is written by the dispatch layer; warnings about bundled keypairs come from the surrounding CLI flow, not the result variant itself. + +Without `--with-keypairs`, `load` regenerates user keypairs and any PDA derived from a signer pubkey will resolve to different addresses on reload. With the flag, the JSON embeds the keypairs in plaintext so the next `load` reproduces the exact same pubkeys. + +## load + +`load <name>` + +Reads `~/.ilold/sessions/<name>.json`, boots a fresh VM per scenario, re-airdrops the in-memory users, and replays each call from the persisted payload. + +``` +ilold[staking]> load reentrancy-attack + ✓ loaded program=staking steps=3 +``` + +**Returns:** `SessionLoaded { program, steps }` where `steps` is the list of instruction names that were replayed. + +The reconstructed VM state matches the saved snapshot for typical Anchor flows. Programs with non-deterministic behaviour or balances above the default replay cap may diverge; see [Solana: Limitations](../limitations.md). + +## browser + +`browser` + +Prints the base URL of the local HTTP API. The web canvas (`crates/ilold-web/frontend`) subscribes to the same `/api` and `/ws` endpoints, so anything the REPL runs surfaces there live. + +``` +ilold[staking]> browser + · Web UI not yet available in explore mode. + · API running at http://127.0.0.1:8080/api/ +``` + +See [HTTP API Reference](../../reference/api-endpoints.md) for the full surface and [WebSocket events](../../reference/websocket.md) for the live update stream. + +## quit + +`q`, `quit`, or `exit` + +Exits the REPL. `Ctrl+D` and `Ctrl+C` also work. + +Unsaved scenarios are lost on exit. Use `save` before quitting if the session needs to survive. diff --git a/docs/guide/src/solana/workflows/audit-walkthrough.md b/docs/guide/src/solana/workflows/audit-walkthrough.md new file mode 100644 index 0000000..2dd6411 --- /dev/null +++ b/docs/guide/src/solana/workflows/audit-walkthrough.md @@ -0,0 +1,185 @@ +# 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. + +## Starting the session + +Launch ilold against the Anchor workspace: + +``` +ilold explore tests/fixtures/solana/staking +``` + +The REPL boots a LiteSVM with `bin/staking.so` and auto-selects the program: + +``` +ilold[staking]> +``` + +List the instructions and account types to orient yourself: + +``` +ilold[staking]> f + initialize_pool args=1 accounts=3 signers=1 pdas=1 + stake args=1 accounts=4 signers=1 pdas=1 + unstake args=1 accounts=4 signers=1 pdas=1 + add_rewards args=1 accounts=2 signers=1 pdas=0 + claim_rewards args=0 accounts=4 signers=1 pdas=1 + +ilold[staking]> v + Pool disc=0x4e2a... + UserStake disc=0x8c91... +``` + +This is the starting map: five entry points, two account types. + +## Creating users and bootstrapping the pool + +Solana sessions need accounts to drive. Mint the keypairs you need: + +``` +ilold[staking]> users new admin 100000000 + ✓ admin pubkey=AdminPk… balance=0.1 SOL + +ilold[staking]> users new pool 2000000 +ilold[staking]> users new alice 50000000 +ilold[staking]> users new alice_stake 2000000 +``` + +`users new` creates a keypair and airdrops it. Unmapped account names in a later `call` will be coerced to local keypairs as well, but pre-creating them makes the session deterministic. + +Now initialize the pool: + +``` +ilold[staking]> call initialize_pool reward_rate=10 pool=pool admin=admin + + Step 0: initialize_pool CU 12.4k 1 account written +``` + +`call` runs the instruction in the VM and appends the result. The output lists CU consumed and the number of mutated accounts. + +## Staking and observing state + +``` +ilold[staking → initialize_pool]> call stake amount=1000 pool=pool user_stake=alice_stake user=alice + + Step 1: stake CU 18.7k 2 accounts written +``` + +Check the accumulated state: + +``` +ilold[staking → … → stake]> state + Pool 7XzG…ABCd + admin = AdminPk… + reward_rate = 10 + total_staked = 1000 + UserStake 6Hj…Pq + user = AlicePk… + amount = 1000 + reward_debt = 0 +``` + +## Inspecting a step in detail + +`step <i>` re-prints the persisted CU, logs, and decoded diffs for a specific step: + +``` +ilold[staking → … → stake]> step 1 + step 1 stake + CU 18.7k + accounts written: + Pool 7XzG…ABCd total_staked: 0 → 1000 + UserStake 6Hj…Pq amount: — → 1000 + logs: + Program log: Instruction: Stake + ... +``` + +## Cross-step questions + +`who` resolves a query against the IDL. Use it to find which instructions touch a given account type: + +``` +ilold[staking → … → stake]> who Pool + AccountType: Pool + Instructions: + initialize_pool accounts: pool [init, mut] + stake accounts: pool [mut] + unstake accounts: pool [mut] + add_rewards accounts: pool [mut] + claim_rewards accounts: pool [mut] +``` + +`timeline <pubkey>` shows how a specific account has evolved across the session, with decoded field diffs: + +``` +ilold[staking → … → stake]> timeline pool + Pool 7XzG…ABCd + step 0 initialize_pool + admin = AdminPk… (— → AdminPk…) + reward_rate = 10 (— → 10) + total_staked = 0 (— → 0) + step 1 stake + total_staked = 1000 (0 → 1000) +``` + +## Recording findings and exporting + +Capture an observation as a note and record a finding tied to the latest step: + +``` +ilold[staking → … → stake]> n staking does not enforce min stake amount + ✓ Note added + +ilold[staking → … → stake]> finding High "missing reentrancy guard" --rec="Apply checks-effects-interactions" + ✓ Finding F-001 added (High) +``` + +Mark instructions as you go: + +``` +ilold[staking]> status stake reviewed +ilold[staking]> status claim_rewards finding +``` + +Export the deliverable: + +``` +ilold[staking]> export --auditor="Demo Auditor" --version="v0.1.0" --date=2026-05-09 + ✓ Exported +``` + +## Persisting the session + +``` +ilold[staking]> save my-audit --with-keypairs + ✓ Saved to ~/.ilold/sessions/my-audit.json + ⚠ bundle includes plaintext test keypairs — do NOT commit it + +ilold[staking]> clear + Cleared 2 step(s). + +ilold[staking]> load my-audit + ⚠ bundle contains plaintext test keypairs — do NOT commit *.json files like this + ✓ Session loaded (2 steps) +``` + +`--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 + +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). +3. `state`, `step`, `timeline` to inspect what changed. +4. `who` to navigate cross-instruction relationships (vs. cross-function in Solidity). +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). + +## Related pages + +- [Session](../repl/session.md), [Programs and IDL](../repl/programs.md), [Solana runtime](../repl/runtime.md), [Analysis](../repl/analysis.md), [Findings](../repl/findings.md) +- [Scenarios and forks](./scenarios.md) +- [Solana: Limitations](../limitations.md) diff --git a/docs/guide/src/solana/workflows/scenarios.md b/docs/guide/src/solana/workflows/scenarios.md new file mode 100644 index 0000000..fe36370 --- /dev/null +++ b/docs/guide/src/solana/workflows/scenarios.md @@ -0,0 +1,114 @@ +# Scenarios and Forks + +Scenarios are the auditor's tool for asking "what if?" against a real VM without losing the current line of reasoning. This page walks through a forking session against `tests/fixtures/solana/staking` and shows how `back`, `clear`, `fork`, and `time-warp` interact. + +## Setup + +Boot the REPL with the staking fixture and set up the happy path: + +``` +ilold[staking]> users new admin 100000000 +ilold[staking]> users new pool 2000000 +ilold[staking]> users new alice 50000000 +ilold[staking]> users new alice_stake 2000000 + +ilold[staking]> call initialize_pool reward_rate=10 pool=pool admin=admin + + Step 0: initialize_pool CU 12.4k + +ilold[staking]> call stake amount=1000 pool=pool user_stake=alice_stake user=alice + + Step 1: stake CU 18.7k + +ilold[staking → initialize_pool → stake]> +``` + +## Branching with `scenario fork` + +Now diverge: what happens if a malicious caller tries `unstake` for more than the staked amount? + +``` +ilold[staking → initialize_pool → stake]> sc fork over-unstake 2 + ✓ Forked 'main' → 'over-unstake' at step 2 +ilold[staking/over-unstake → initialize_pool → stake]> +``` + +The fork inherits steps `0..2` from `main` and rewinds the VM to the snapshot taken just before step 2 would have executed. The new scenario is active. The `main` scenario keeps its original state untouched. + +Try the attack on the fork: + +``` +ilold[staking/over-unstake → initialize_pool → stake]> call unstake amount=999999 pool=pool user_stake=alice_stake user=alice + ✗ Step rejected CU 7.2k + error: Custom { code: 6001 } "InsufficientStake" +``` + +`CallFailed` is recorded as a step; the VM stays at the pre-call snapshot. + +## Rewinding with `back` + +If you want to retry, `back` drops the failed step **and** rewinds the VM: + +``` +ilold[staking/over-unstake → … → stake]> b + - Step removed. 2 remaining. + +ilold[staking/over-unstake → … → stake]> call unstake amount=500 pool=pool user_stake=alice_stake user=alice + + Step 2: unstake CU 16.1k +``` + +The replayed `call` produces fresh CU and diffs because the VM was rewound, not a cached replay. + +## Switching back to `main` + +``` +ilold[staking/over-unstake → … → unstake]> sc switch main +ilold[staking → initialize_pool → stake]> +``` + +`main` still has its original two steps; the fork's state never leaked. Each scenario carries its own VM, signers, and PDAs. + +## Time-warping vesting / reward logic + +`time-warp` advances the `Clock` sysvar. It is **scenario-local but step-independent**: `back` does not rewind the clock. Use it to exercise reward accrual: + +``` +ilold[staking → … → stake]> tw 86400 + ✓ Clock unix_timestamp += 86400 + +ilold[staking → … → stake]> call claim_rewards pool=pool user_stake=alice_stake user=alice + + Step 2: claim_rewards CU 22.3k +``` + +If a later step needs a different clock, undo the offset manually with `tw -86400`. + +## Persisting the scenario tree + +`save` and `load` cover the whole scenario tree, not just the active one. Use `--with-keypairs` whenever a PDA depends on a signer pubkey (most Anchor programs): + +``` +ilold[staking]> save staking-attack --with-keypairs + ✓ Saved to ~/.ilold/sessions/staking-attack.json + ⚠ bundle includes plaintext test keypairs — do NOT commit it + +ilold[staking]> clear +ilold[staking]> load staking-attack + ⚠ bundle contains plaintext test keypairs — do NOT commit *.json files like this + ✓ Session loaded (2 steps) +``` + +`load` reboots a fresh VM per scenario, re-airdrops the users, and replays each call. The final state matches the saved snapshot for typical Anchor flows. Non-deterministic programs may diverge, see [Solana: Limitations](../limitations.md). + +## Practical patterns + +| Pattern | Commands | +| --- | --- | +| "Keep the original timeline, try a divergent path" | `sc fork <name> <step>`, then continue with `call` | +| "Retry the last failed call" | `b`, edit args, `call` again | +| "Start over without losing other scenarios" | `cl` on the active scenario, then re-issue calls | +| "Reproduce later, same addresses" | `save <name> --with-keypairs`, `load <name>` | +| "Reproduce later, fresh randomness" | `save <name>`, `load <name>` | + +## Related pages + +- [Scenarios](../repl/scenarios.md): command reference. +- [Workspace](../repl/workspace.md): save/load details. +- [Solana: Limitations](../limitations.md): what survives `save`/`load` and what doesn't. diff --git a/docs/guide/src/solidity/cli-analyze.md b/docs/guide/src/solidity/cli-analyze.md new file mode 100644 index 0000000..7d57742 --- /dev/null +++ b/docs/guide/src/solidity/cli-analyze.md @@ -0,0 +1,74 @@ +# 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 new file mode 100644 index 0000000..ef73f14 --- /dev/null +++ b/docs/guide/src/solidity/cli-context.md @@ -0,0 +1,67 @@ +# 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 new file mode 100644 index 0000000..fb83df8 --- /dev/null +++ b/docs/guide/src/solidity/limitations.md @@ -0,0 +1,40 @@ +# 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 new file mode 100644 index 0000000..cfd4df9 --- /dev/null +++ b/docs/guide/src/solidity/overview.md @@ -0,0 +1,39 @@ +# 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/commands/analysis.md b/docs/guide/src/solidity/repl/analysis.md similarity index 94% rename from docs/guide/src/commands/analysis.md rename to docs/guide/src/solidity/repl/analysis.md index ba35118..2b03e40 100644 --- a/docs/guide/src/commands/analysis.md +++ b/docs/guide/src/solidity/repl/analysis.md @@ -13,15 +13,17 @@ ilold[Staking]> who totalStaked who: totalStaked Writers: - [public] deposit - [public] withdraw + [P] deposit + [P] withdraw Readers: - [public] getStakeInfo + [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. +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 diff --git a/docs/guide/src/solidity/repl/contract.md b/docs/guide/src/solidity/repl/contract.md new file mode 100644 index 0000000..fb1b3d8 --- /dev/null +++ b/docs/guide/src/solidity/repl/contract.md @@ -0,0 +1,122 @@ +# 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/commands/findings.md b/docs/guide/src/solidity/repl/findings.md similarity index 86% rename from docs/guide/src/commands/findings.md rename to docs/guide/src/solidity/repl/findings.md index 1b0ed29..290c7a4 100644 --- a/docs/guide/src/commands/findings.md +++ b/docs/guide/src/solidity/repl/findings.md @@ -45,23 +45,7 @@ ilold[→ deposit → withdraw]> n Check if msg.value can be zero here ✓ Note added ``` -## scenario - -`sc <name>` or `scenario <name>` - -Names the current call sequence. Run without arguments to see the current name. - -``` -ilold[→ deposit → withdraw]> sc reentrancy-attack - - Scenario: reentrancy-attack -``` - -``` -ilold[→ deposit → withdraw]> sc - - Current scenario: reentrancy-attack -``` +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 diff --git a/docs/guide/src/solidity/repl/scenarios.md b/docs/guide/src/solidity/repl/scenarios.md new file mode 100644 index 0000000..0536f90 --- /dev/null +++ b/docs/guide/src/solidity/repl/scenarios.md @@ -0,0 +1,81 @@ +# 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/commands/session.md b/docs/guide/src/solidity/repl/session.md similarity index 81% rename from docs/guide/src/commands/session.md rename to docs/guide/src/solidity/repl/session.md index 35b961d..9f0bc7b 100644 --- a/docs/guide/src/commands/session.md +++ b/docs/guide/src/solidity/repl/session.md @@ -11,7 +11,7 @@ Adds a function call to the session sequence. Only external and public functions ``` ilold[Staking]> c deposit - + Step 0: deposit [public] external + + Step 0: deposit [P] external State writes: · balances · totalStaked @@ -21,13 +21,15 @@ ilold[Staking]> c deposit ``` ilold[→ deposit]> c withdraw - + Step 1: withdraw [public] external + + 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: ``` @@ -75,14 +77,16 @@ ilold[→ deposit → withdraw]> s ═══════════════════[ STATE ]═══════════════════ balances - balances[msg.sender] += msg.value (deposit) - balances[msg.sender] -= amount (withdraw) + += msg.value (step 0, deposit) + -= amount (step 1, withdraw) totalStaked - totalStaked += msg.value (deposit) - totalStaked -= amount (withdraw) + += msg.value (step 0, deposit) + -= amount (step 1, withdraw) ``` -If the session is empty, `state` tells you to add steps first. +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 diff --git a/docs/guide/src/commands/workspace.md b/docs/guide/src/solidity/repl/workspace.md similarity index 66% rename from docs/guide/src/commands/workspace.md rename to docs/guide/src/solidity/repl/workspace.md index fcc9dda..a24a2f2 100644 --- a/docs/guide/src/commands/workspace.md +++ b/docs/guide/src/solidity/repl/workspace.md @@ -34,7 +34,7 @@ The prompt updates to reflect the loaded steps. The session replaces whatever is `browser` -Opens the web UI. ilold starts an API server on startup; `browser` provides the URL. +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 @@ -42,10 +42,12 @@ 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. You can also press `Ctrl+D` or `Ctrl+C`. +Exits the REPL. `Ctrl+D` and `Ctrl+C` also work. -Unsaved session data is lost on exit. Use [save](#save) before quitting if you want to resume later. +Unsaved session data is lost on exit. Use [save](#save) before quitting if the session needs to survive. diff --git a/docs/guide/src/workflows/audit-walkthrough.md b/docs/guide/src/solidity/workflows/audit-walkthrough.md similarity index 98% rename from docs/guide/src/workflows/audit-walkthrough.md rename to docs/guide/src/solidity/workflows/audit-walkthrough.md index 871b985..27033ee 100644 --- a/docs/guide/src/workflows/audit-walkthrough.md +++ b/docs/guide/src/solidity/workflows/audit-walkthrough.md @@ -228,7 +228,7 @@ Each command's output contains the variable names, function names, and modifier ## Related pages -- [Session commands](../commands/session.md) +- [Session commands](../repl/session.md) - [Taint Analysis](./taint-analysis.md) -- [HTTP API Reference](../reference/api-endpoints.md) -- [Known Limitations](../reference/limitations.md) +- [HTTP API Reference](../../reference/api-endpoints.md) +- [Known Limitations](../limitations.md) diff --git a/docs/guide/src/workflows/taint-analysis.md b/docs/guide/src/solidity/workflows/taint-analysis.md similarity index 98% rename from docs/guide/src/workflows/taint-analysis.md rename to docs/guide/src/solidity/workflows/taint-analysis.md index d6fd89c..67cc4ac 100644 --- a/docs/guide/src/workflows/taint-analysis.md +++ b/docs/guide/src/solidity/workflows/taint-analysis.md @@ -100,4 +100,4 @@ Forward slice results map to common vulnerability classes: ## Related pages - [Full Audit Walkthrough](./audit-walkthrough.md) -- [Known Limitations](../reference/limitations.md) -- forward slice caveats +- [Known Limitations](../limitations.md) -- forward slice caveats diff --git a/tests/fixtures/solana/cpi/.gitignore b/tests/fixtures/solana/cpi/.gitignore new file mode 100644 index 0000000..2f7896d --- /dev/null +++ b/tests/fixtures/solana/cpi/.gitignore @@ -0,0 +1 @@ +target/ diff --git a/tests/fixtures/solana/cpi/Anchor.toml b/tests/fixtures/solana/cpi/Anchor.toml new file mode 100644 index 0000000..8134bd5 --- /dev/null +++ b/tests/fixtures/solana/cpi/Anchor.toml @@ -0,0 +1,19 @@ +[toolchain] +solana_version = "3.1.8" + +[features] +resolution = true +skip-lint = false + +[programs.localnet] +hand = "Bi5N7SUQhpGknVcqPTzdFFVueQoxoUu8YTLz75J6fT8A" +lever = "E64FVeubGC4NPNF2UBJYX4AkrVowf74fRJD9q6YhwstN" + +# [registry] section removed — no longer used in Anchor 1.0 + +[provider] +cluster = "Localnet" +wallet = "~/.config/solana/id.json" + +[scripts] +test = "pnpm ts-mocha -p ./tsconfig.json -t 1000000 tests/**/*.ts" diff --git a/tests/fixtures/solana/cpi/Cargo.toml b/tests/fixtures/solana/cpi/Cargo.toml new file mode 100644 index 0000000..f397704 --- /dev/null +++ b/tests/fixtures/solana/cpi/Cargo.toml @@ -0,0 +1,14 @@ +[workspace] +members = [ + "programs/*" +] +resolver = "2" + +[profile.release] +overflow-checks = true +lto = "fat" +codegen-units = 1 +[profile.release.build-override] +opt-level = 3 +incremental = false +codegen-units = 1 diff --git a/tests/fixtures/solana/cpi/idls/lever.json b/tests/fixtures/solana/cpi/idls/lever.json new file mode 100644 index 0000000..3ec862d --- /dev/null +++ b/tests/fixtures/solana/cpi/idls/lever.json @@ -0,0 +1,68 @@ +{ + "address": "E64FVeubGC4NPNF2UBJYX4AkrVowf74fRJD9q6YhwstN", + "metadata": { + "name": "lever", + "version": "0.1.0", + "spec": "0.1.0", + "description": "Created with Anchor" + }, + "instructions": [ + { + "name": "initialize", + "discriminator": [175, 175, 109, 31, 13, 152, 155, 237], + "accounts": [ + { + "name": "power", + "writable": true, + "signer": true + }, + { + "name": "user", + "writable": true, + "signer": true + }, + { + "name": "system_program", + "address": "11111111111111111111111111111111" + } + ], + "args": [] + }, + { + "name": "switch_power", + "discriminator": [226, 238, 56, 172, 191, 45, 122, 87], + "accounts": [ + { + "name": "power", + "writable": true + } + ], + "args": [ + { + "name": "name", + "type": "string" + } + ] + } + ], + "accounts": [ + { + "name": "PowerStatus", + "discriminator": [145, 147, 198, 35, 253, 101, 231, 26] + } + ], + "types": [ + { + "name": "PowerStatus", + "type": { + "kind": "struct", + "fields": [ + { + "name": "is_on", + "type": "bool" + } + ] + } + } + ] +} diff --git a/tests/fixtures/solana/cpi/programs/hand/Cargo.toml b/tests/fixtures/solana/cpi/programs/hand/Cargo.toml new file mode 100644 index 0000000..397a22a --- /dev/null +++ b/tests/fixtures/solana/cpi/programs/hand/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "hand" +version = "0.1.0" +description = "Created with Anchor" +edition = "2021" + +[lib] +crate-type = ["cdylib", "lib"] +name = "hand" + +[features] +default = [] +cpi = ["no-entrypoint"] +no-entrypoint = [] +no-idl = [] +no-log-ix-name = [] +idl-build = ["anchor-lang/idl-build"] +anchor-debug = [] +custom-heap = [] +custom-panic = [] + +[dependencies] +# Anchor 1.0.0-rc.5 — pin to RC until stable release +anchor-lang = "1.0.0" + +[lints.rust] +unexpected_cfgs = { level = "warn", check-cfg = ['cfg(target_os, values("solana"))'] } diff --git a/tests/fixtures/solana/cpi/programs/hand/Xargo.toml b/tests/fixtures/solana/cpi/programs/hand/Xargo.toml new file mode 100644 index 0000000..475fb71 --- /dev/null +++ b/tests/fixtures/solana/cpi/programs/hand/Xargo.toml @@ -0,0 +1,2 @@ +[target.bpfel-unknown-unknown.dependencies.std] +features = [] diff --git a/tests/fixtures/solana/cpi/programs/hand/src/lib.rs b/tests/fixtures/solana/cpi/programs/hand/src/lib.rs new file mode 100644 index 0000000..2602e0e --- /dev/null +++ b/tests/fixtures/solana/cpi/programs/hand/src/lib.rs @@ -0,0 +1,33 @@ +use anchor_lang::prelude::*; + +declare_id!("Bi5N7SUQhpGknVcqPTzdFFVueQoxoUu8YTLz75J6fT8A"); + +// automatically generate module using program idl found in ./idls +declare_program!(lever); +use lever::accounts::PowerStatus; +use lever::cpi::accounts::SwitchPower; +use lever::cpi::switch_power; +use lever::program::Lever; + +#[program] +pub mod hand { + use super::*; + + pub fn pull_lever(ctx: Context<PullLever>, name: String) -> Result<()> { + let cpi_ctx = CpiContext::new( + ctx.accounts.lever_program.key(), + SwitchPower { + power: ctx.accounts.power.to_account_info(), + }, + ); + switch_power(cpi_ctx, name)?; + Ok(()) + } +} + +#[derive(Accounts)] +pub struct PullLever<'info> { + #[account(mut)] + pub power: Account<'info, PowerStatus>, + pub lever_program: Program<'info, Lever>, +} diff --git a/tests/fixtures/solana/cpi/programs/lever/Cargo.toml b/tests/fixtures/solana/cpi/programs/lever/Cargo.toml new file mode 100644 index 0000000..a70f02e --- /dev/null +++ b/tests/fixtures/solana/cpi/programs/lever/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "lever" +version = "0.1.0" +description = "Created with Anchor" +edition = "2021" + +[lib] +crate-type = ["cdylib", "lib"] +name = "lever" + +[features] +default = [] +cpi = ["no-entrypoint"] +no-entrypoint = [] +no-idl = [] +no-log-ix-name = [] +idl-build = ["anchor-lang/idl-build"] +anchor-debug = [] +custom-heap = [] +custom-panic = [] + +[dependencies] +# Anchor 1.0.0-rc.5 — pin to RC until stable release +anchor-lang = "1.0.0" + +[lints.rust] +unexpected_cfgs = { level = "warn", check-cfg = ['cfg(target_os, values("solana"))'] } diff --git a/tests/fixtures/solana/cpi/programs/lever/Xargo.toml b/tests/fixtures/solana/cpi/programs/lever/Xargo.toml new file mode 100644 index 0000000..475fb71 --- /dev/null +++ b/tests/fixtures/solana/cpi/programs/lever/Xargo.toml @@ -0,0 +1,2 @@ +[target.bpfel-unknown-unknown.dependencies.std] +features = [] diff --git a/tests/fixtures/solana/cpi/programs/lever/src/lib.rs b/tests/fixtures/solana/cpi/programs/lever/src/lib.rs new file mode 100644 index 0000000..d3ebd3f --- /dev/null +++ b/tests/fixtures/solana/cpi/programs/lever/src/lib.rs @@ -0,0 +1,46 @@ +use anchor_lang::prelude::*; + +declare_id!("E64FVeubGC4NPNF2UBJYX4AkrVowf74fRJD9q6YhwstN"); + +#[program] +pub mod lever { + use super::*; + + pub fn initialize(_ctx: Context<InitializeLever>) -> Result<()> { + Ok(()) + } + + pub fn switch_power(ctx: Context<SetPowerStatus>, name: String) -> Result<()> { + let power = &mut ctx.accounts.power; + power.is_on = !power.is_on; + + msg!("{} is pulling the power switch!", &name); + + match power.is_on { + true => msg!("The power is now on."), + false => msg!("The power is now off!"), + }; + + Ok(()) + } +} + +#[derive(Accounts)] +pub struct InitializeLever<'info> { + #[account(init, payer = user, space = 8 + 8)] + pub power: Account<'info, PowerStatus>, + #[account(mut)] + pub user: Signer<'info>, + pub system_program: Program<'info, System>, +} + +#[derive(Accounts)] +pub struct SetPowerStatus<'info> { + #[account(mut)] + pub power: Account<'info, PowerStatus>, +} + +#[account] +pub struct PowerStatus { + pub is_on: bool, +} diff --git a/tests/fixtures/solana/staking/.gitignore b/tests/fixtures/solana/staking/.gitignore new file mode 100644 index 0000000..2f7896d --- /dev/null +++ b/tests/fixtures/solana/staking/.gitignore @@ -0,0 +1 @@ +target/ diff --git a/tests/fixtures/solana/staking/Anchor.toml b/tests/fixtures/solana/staking/Anchor.toml new file mode 100644 index 0000000..2499e2c --- /dev/null +++ b/tests/fixtures/solana/staking/Anchor.toml @@ -0,0 +1,16 @@ +[toolchain] +solana_version = "3.1.8" + +[features] +resolution = true +skip-lint = false + +[programs.localnet] +staking = "AQjgX8bsAU8CvvEoP9q36vAbT83dRxdjK4zGEhhn6SFc" + +[provider] +cluster = "Localnet" +wallet = "~/.config/solana/id.json" + +[scripts] +test = "pnpm ts-mocha -p ./tsconfig.json -t 1000000 tests/**/*.ts" diff --git a/tests/fixtures/solana/staking/Cargo.toml b/tests/fixtures/solana/staking/Cargo.toml new file mode 100644 index 0000000..f397704 --- /dev/null +++ b/tests/fixtures/solana/staking/Cargo.toml @@ -0,0 +1,14 @@ +[workspace] +members = [ + "programs/*" +] +resolver = "2" + +[profile.release] +overflow-checks = true +lto = "fat" +codegen-units = 1 +[profile.release.build-override] +opt-level = 3 +incremental = false +codegen-units = 1 diff --git a/tests/fixtures/solana/staking/bin/staking.so b/tests/fixtures/solana/staking/bin/staking.so new file mode 100755 index 0000000..30cbed0 Binary files /dev/null and b/tests/fixtures/solana/staking/bin/staking.so differ diff --git a/tests/fixtures/solana/staking/idls/staking.json b/tests/fixtures/solana/staking/idls/staking.json new file mode 100644 index 0000000..33d9309 --- /dev/null +++ b/tests/fixtures/solana/staking/idls/staking.json @@ -0,0 +1,281 @@ +{ + "address": "AQjgX8bsAU8CvvEoP9q36vAbT83dRxdjK4zGEhhn6SFc", + "metadata": { + "name": "staking", + "version": "0.1.0", + "spec": "0.1.0", + "description": "Minimal staking program for Ilold Solana fixture" + }, + "instructions": [ + { + "name": "add_rewards", + "discriminator": [ + 88, + 186, + 25, + 227, + 38, + 137, + 81, + 23 + ], + "accounts": [ + { + "name": "pool", + "writable": true + }, + { + "name": "admin", + "signer": true + } + ], + "args": [ + { + "name": "amount", + "type": "u64" + } + ] + }, + { + "name": "claim_rewards", + "discriminator": [ + 4, + 144, + 132, + 71, + 116, + 23, + 151, + 80 + ], + "accounts": [ + { + "name": "pool", + "writable": true + }, + { + "name": "user_stake", + "writable": true + }, + { + "name": "user", + "signer": true + } + ], + "args": [] + }, + { + "name": "initialize_pool", + "discriminator": [ + 95, + 180, + 10, + 172, + 84, + 174, + 232, + 40 + ], + "accounts": [ + { + "name": "pool", + "writable": true, + "signer": true + }, + { + "name": "admin", + "writable": true, + "signer": true + }, + { + "name": "system_program", + "address": "11111111111111111111111111111111" + } + ], + "args": [ + { + "name": "reward_rate", + "type": "u64" + } + ] + }, + { + "name": "stake", + "discriminator": [ + 206, + 176, + 202, + 18, + 200, + 209, + 179, + 108 + ], + "accounts": [ + { + "name": "pool", + "writable": true + }, + { + "name": "user_stake", + "writable": true, + "signer": true + }, + { + "name": "user", + "writable": true, + "signer": true + }, + { + "name": "system_program", + "address": "11111111111111111111111111111111" + } + ], + "args": [ + { + "name": "amount", + "type": "u64" + } + ] + }, + { + "name": "unstake", + "discriminator": [ + 90, + 95, + 107, + 42, + 205, + 124, + 50, + 225 + ], + "accounts": [ + { + "name": "pool", + "writable": true + }, + { + "name": "user_stake", + "writable": true + }, + { + "name": "user", + "signer": true + } + ], + "args": [ + { + "name": "amount", + "type": "u64" + } + ] + } + ], + "accounts": [ + { + "name": "Pool", + "discriminator": [ + 241, + 154, + 109, + 4, + 17, + 177, + 109, + 188 + ] + }, + { + "name": "UserStake", + "discriminator": [ + 102, + 53, + 163, + 107, + 9, + 138, + 87, + 153 + ] + } + ], + "errors": [ + { + "code": 6000, + "name": "ZeroAmount", + "msg": "Amount must be greater than zero" + }, + { + "code": 6001, + "name": "Overflow", + "msg": "Arithmetic overflow" + }, + { + "code": 6002, + "name": "InsufficientBalance", + "msg": "Insufficient staked balance" + }, + { + "code": 6003, + "name": "WrongUser", + "msg": "Wrong user for this UserStake account" + }, + { + "code": 6004, + "name": "WrongAdmin", + "msg": "Wrong admin for this pool" + }, + { + "code": 6005, + "name": "EmptyPool", + "msg": "Pool has no stakers" + } + ], + "types": [ + { + "name": "Pool", + "type": { + "kind": "struct", + "fields": [ + { + "name": "admin", + "type": "pubkey" + }, + { + "name": "total_staked", + "type": "u64" + }, + { + "name": "total_rewards", + "type": "u64" + }, + { + "name": "reward_rate", + "type": "u64" + } + ] + } + }, + { + "name": "UserStake", + "type": { + "kind": "struct", + "fields": [ + { + "name": "user", + "type": "pubkey" + }, + { + "name": "amount", + "type": "u64" + }, + { + "name": "claimed_rewards", + "type": "u64" + } + ] + } + } + ] +} \ No newline at end of file diff --git a/tests/fixtures/solana/staking/programs/staking/Cargo.toml b/tests/fixtures/solana/staking/programs/staking/Cargo.toml new file mode 100644 index 0000000..fe6017b --- /dev/null +++ b/tests/fixtures/solana/staking/programs/staking/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "staking" +version = "0.1.0" +description = "Minimal staking program for Ilold Solana fixture" +edition = "2021" + +[lib] +crate-type = ["cdylib", "lib"] +name = "staking" + +[features] +default = [] +cpi = ["no-entrypoint"] +no-entrypoint = [] +no-idl = [] +no-log-ix-name = [] +idl-build = ["anchor-lang/idl-build"] +anchor-debug = [] +custom-heap = [] +custom-panic = [] + +[dependencies] +anchor-lang = { version = "1.0.0", features = ["init-if-needed"] } + +[lints.rust] +unexpected_cfgs = { level = "warn", check-cfg = ['cfg(target_os, values("solana"))'] } diff --git a/tests/fixtures/solana/staking/programs/staking/Xargo.toml b/tests/fixtures/solana/staking/programs/staking/Xargo.toml new file mode 100644 index 0000000..475fb71 --- /dev/null +++ b/tests/fixtures/solana/staking/programs/staking/Xargo.toml @@ -0,0 +1,2 @@ +[target.bpfel-unknown-unknown.dependencies.std] +features = [] diff --git a/tests/fixtures/solana/staking/programs/staking/src/lib.rs b/tests/fixtures/solana/staking/programs/staking/src/lib.rs new file mode 100644 index 0000000..1622203 --- /dev/null +++ b/tests/fixtures/solana/staking/programs/staking/src/lib.rs @@ -0,0 +1,139 @@ +use anchor_lang::prelude::*; + +declare_id!("AQjgX8bsAU8CvvEoP9q36vAbT83dRxdjK4zGEhhn6SFc"); + +#[program] +pub mod staking { + use super::*; + + pub fn initialize_pool(ctx: Context<InitializePool>, reward_rate: u64) -> Result<()> { + let pool = &mut ctx.accounts.pool; + pool.admin = ctx.accounts.admin.key(); + pool.total_staked = 0; + pool.total_rewards = 0; + pool.reward_rate = reward_rate; + msg!("Pool initialized by {} with reward_rate={}", pool.admin, reward_rate); + Ok(()) + } + + pub fn stake(ctx: Context<Stake>, amount: u64) -> Result<()> { + require!(amount > 0, StakingError::ZeroAmount); + let pool = &mut ctx.accounts.pool; + let user_stake = &mut ctx.accounts.user_stake; + if user_stake.user == Pubkey::default() { + user_stake.user = ctx.accounts.user.key(); + user_stake.amount = 0; + user_stake.claimed_rewards = 0; + } + user_stake.amount = user_stake.amount.checked_add(amount).ok_or(StakingError::Overflow)?; + pool.total_staked = pool.total_staked.checked_add(amount).ok_or(StakingError::Overflow)?; + msg!("{} staked {} (user_total={}, pool_total={})", user_stake.user, amount, user_stake.amount, pool.total_staked); + Ok(()) + } + + pub fn unstake(ctx: Context<Unstake>, amount: u64) -> Result<()> { + let pool = &mut ctx.accounts.pool; + let user_stake = &mut ctx.accounts.user_stake; + require!(user_stake.user == ctx.accounts.user.key(), StakingError::WrongUser); + require!(amount > 0, StakingError::ZeroAmount); + require!(user_stake.amount >= amount, StakingError::InsufficientBalance); + user_stake.amount -= amount; + pool.total_staked -= amount; + msg!("{} unstaked {} (user_remaining={}, pool_total={})", user_stake.user, amount, user_stake.amount, pool.total_staked); + Ok(()) + } + + pub fn add_rewards(ctx: Context<AddRewards>, amount: u64) -> Result<()> { + let pool = &mut ctx.accounts.pool; + require!(pool.admin == ctx.accounts.admin.key(), StakingError::WrongAdmin); + pool.total_rewards = pool.total_rewards.checked_add(amount).ok_or(StakingError::Overflow)?; + msg!("Admin added {} rewards (total_rewards={})", amount, pool.total_rewards); + Ok(()) + } + + pub fn claim_rewards(ctx: Context<ClaimRewards>) -> Result<()> { + let pool = &mut ctx.accounts.pool; + let user_stake = &mut ctx.accounts.user_stake; + require!(user_stake.user == ctx.accounts.user.key(), StakingError::WrongUser); + require!(pool.total_staked > 0, StakingError::EmptyPool); + let share = (pool.total_rewards as u128) + .checked_mul(user_stake.amount as u128) + .ok_or(StakingError::Overflow)? + .checked_div(pool.total_staked as u128) + .ok_or(StakingError::Overflow)?; + let pending = (share as u64).saturating_sub(user_stake.claimed_rewards); + user_stake.claimed_rewards = user_stake.claimed_rewards.checked_add(pending).ok_or(StakingError::Overflow)?; + msg!("{} claimed {} rewards (lifetime={})", user_stake.user, pending, user_stake.claimed_rewards); + Ok(()) + } +} + +#[derive(Accounts)] +pub struct InitializePool<'info> { + #[account(init, payer = admin, space = 8 + 32 + 8 + 8 + 8)] + pub pool: Account<'info, Pool>, + #[account(mut)] + pub admin: Signer<'info>, + pub system_program: Program<'info, System>, +} + +#[derive(Accounts)] +pub struct Stake<'info> { + #[account(mut)] + pub pool: Account<'info, Pool>, + #[account(init_if_needed, payer = user, space = 8 + 32 + 8 + 8)] + pub user_stake: Account<'info, UserStake>, + #[account(mut)] + pub user: Signer<'info>, + pub system_program: Program<'info, System>, +} + +#[derive(Accounts)] +pub struct Unstake<'info> { + #[account(mut)] + pub pool: Account<'info, Pool>, + #[account(mut)] + pub user_stake: Account<'info, UserStake>, + pub user: Signer<'info>, +} + +#[derive(Accounts)] +pub struct AddRewards<'info> { + #[account(mut)] + pub pool: Account<'info, Pool>, + pub admin: Signer<'info>, +} + +#[derive(Accounts)] +pub struct ClaimRewards<'info> { + #[account(mut)] + pub pool: Account<'info, Pool>, + #[account(mut)] + pub user_stake: Account<'info, UserStake>, + pub user: Signer<'info>, +} + +#[account] +pub struct Pool { + pub admin: Pubkey, + pub total_staked: u64, + pub total_rewards: u64, + pub reward_rate: u64, +} + +#[account] +pub struct UserStake { + pub user: Pubkey, + pub amount: u64, + pub claimed_rewards: u64, +} + +#[error_code] +pub enum StakingError { + #[msg("Amount must be greater than zero")] ZeroAmount, + #[msg("Arithmetic overflow")] Overflow, + #[msg("Insufficient staked balance")] InsufficientBalance, + #[msg("Wrong user for this UserStake account")] WrongUser, + #[msg("Wrong admin for this pool")] WrongAdmin, + #[msg("Pool has no stakers")] EmptyPool, +} diff --git a/tests/scenarios/01-happy-path.sh b/tests/scenarios/01-happy-path.sh new file mode 100755 index 0000000..949c858 --- /dev/null +++ b/tests/scenarios/01-happy-path.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# Happy path: init pool + 2 stakes accumulate total_staked. +set -e +. "$(dirname "$0")/_lib.sh" +NAME="01-happy-path" +echo "## $NAME" + +setup_users +expect_ok_call "initialize_pool" "$(init_pool)" +expect_ok_call "alice stake 1000" "$(stake_as alice alice_stake 1000)" +expect_ok_call "bob stake 2000" "$(stake_as bob bob_stake 2000)" +expect_eq "total_staked=3000" "3000" "$(pool_total_staked)" +expect_eq "session has 3 steps" "3" "$(session_steps_len)" + +scenario_summary "$NAME" diff --git a/tests/scenarios/02-attack-reinit-pool.sh b/tests/scenarios/02-attack-reinit-pool.sh new file mode 100755 index 0000000..d915860 --- /dev/null +++ b/tests/scenarios/02-attack-reinit-pool.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# Negative scenario: re-initializing an already-initialized pool must fail. +# Anchor's `init` constraint should reject the second call with "already in use". +set -e +. "$(dirname "$0")/_lib.sh" +NAME="02-attack-reinit-pool" +echo "## $NAME" + +setup_users +expect_ok_call "first initialize_pool" "$(init_pool)" +expect_failed_call "re-init same pool rejected" "$(init_pool)" + +scenario_summary "$NAME" diff --git a/tests/scenarios/03-attack-claim-without-stake.sh b/tests/scenarios/03-attack-claim-without-stake.sh new file mode 100755 index 0000000..3deabb2 --- /dev/null +++ b/tests/scenarios/03-attack-claim-without-stake.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +# Negative: claim_rewards without a UserStake account must fail (account +# constraint violation: AccountOwnedByWrongProgram). +set -e +. "$(dirname "$0")/_lib.sh" +NAME="03-attack-claim-without-stake" +echo "## $NAME" + +setup_users +post '{"contract":"staking","command":{"UsersNew":{"name":"carol","lamports":10000000}}}' >/dev/null +post '{"contract":"staking","command":{"UsersNew":{"name":"carol_stake","lamports":2000000}}}' >/dev/null + +expect_ok_call "init" "$(init_pool)" + +R=$(post '{"contract":"staking","command":{"Call":{"ix":"claim_rewards","args":{},"accounts":{"pool":"pool","user_stake":"carol_stake","user":"carol"},"signers":["carol"]}}}') +expect_failed_call "claim without stake rejected" "$R" + +scenario_summary "$NAME" diff --git a/tests/scenarios/04-attack-unstake-overflow.sh b/tests/scenarios/04-attack-unstake-overflow.sh new file mode 100755 index 0000000..a5518e9 --- /dev/null +++ b/tests/scenarios/04-attack-unstake-overflow.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# Negative: unstake more than staked must fail (InsufficientBalance). +set -e +. "$(dirname "$0")/_lib.sh" +NAME="04-attack-unstake-overflow" +echo "## $NAME" + +setup_users +expect_ok_call "init" "$(init_pool)" +expect_ok_call "alice stake 1000" "$(stake_as alice alice_stake 1000)" + +R=$(post '{"contract":"staking","command":{"Call":{"ix":"unstake","args":{"amount":99999999},"accounts":{"pool":"pool","user_stake":"alice_stake","user":"alice"},"signers":["alice"]}}}') +expect_failed_call "overflow unstake rejected" "$R" +expect_eq "state preserved at 1000" "1000" "$(pool_total_staked)" + +scenario_summary "$NAME" diff --git a/tests/scenarios/05-attack-non-admin-rewards.sh b/tests/scenarios/05-attack-non-admin-rewards.sh new file mode 100755 index 0000000..6b2aace --- /dev/null +++ b/tests/scenarios/05-attack-non-admin-rewards.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# Negative: add_rewards signed by a non-admin user must fail (WrongAdmin). +set -e +. "$(dirname "$0")/_lib.sh" +NAME="05-attack-non-admin-rewards" +echo "## $NAME" + +setup_users +expect_ok_call "init" "$(init_pool)" + +R=$(post '{"contract":"staking","command":{"Call":{"ix":"add_rewards","args":{"amount":100},"accounts":{"pool":"pool","admin":"bob"},"signers":["bob"]}}}') +expect_failed_call "non-admin add_rewards rejected" "$R" + +scenario_summary "$NAME" diff --git a/tests/scenarios/06-fork-isolation.sh b/tests/scenarios/06-fork-isolation.sh new file mode 100755 index 0000000..b79d027 --- /dev/null +++ b/tests/scenarios/06-fork-isolation.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# Forking a scenario at_step=N must rewind the cloned VM to that step's +# pre-Call snapshot, leaving main untouched. Regression for T-R33 fork rewind. +set -e +. "$(dirname "$0")/_lib.sh" +NAME="06-fork-isolation" +echo "## $NAME" + +setup_users +post '{"contract":"staking","command":{"UsersNew":{"name":"dave","lamports":50000000}}}' >/dev/null +post '{"contract":"staking","command":{"UsersNew":{"name":"dave_stake","lamports":2000000}}}' >/dev/null + +expect_ok_call "init" "$(init_pool)" +expect_ok_call "alice stake 500" "$(stake_as alice alice_stake 500)" +expect_eq "main pre-fork=500" "500" "$(pool_total_staked)" + +post '{"contract":"staking","command":{"Scenario":{"sub":{"Fork":{"name":"branch","at_step":1}}}}}' >/dev/null +post '{"contract":"staking","command":{"Scenario":{"sub":{"Switch":{"name":"branch"}}}}}' >/dev/null +expect_eq "branch starts at 0 (rewound)" "0" "$(pool_total_staked)" + +expect_ok_call "branch dave stake 777" "$(stake_as dave dave_stake 777)" +expect_eq "branch=777" "777" "$(pool_total_staked)" + +post '{"contract":"staking","command":{"Scenario":{"sub":{"Switch":{"name":"main"}}}}}' >/dev/null +expect_eq "main preserved=500" "500" "$(pool_total_staked)" + +scenario_summary "$NAME" diff --git a/tests/scenarios/07-back-rewinds-vm.sh b/tests/scenarios/07-back-rewinds-vm.sh new file mode 100755 index 0000000..2cec426 --- /dev/null +++ b/tests/scenarios/07-back-rewinds-vm.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# Back must restore the VM accounts to the pre-Call snapshot, not just pop +# the timeline entry. Regression for T-R33. +set -e +. "$(dirname "$0")/_lib.sh" +NAME="07-back-rewinds-vm" +echo "## $NAME" + +setup_users +expect_ok_call "init" "$(init_pool)" +expect_ok_call "alice stake 1000" "$(stake_as alice alice_stake 1000)" +expect_ok_call "bob stake 2000" "$(stake_as bob bob_stake 2000)" +expect_eq "after 2 stakes=3000" "3000" "$(pool_total_staked)" + +post '{"contract":"staking","command":"Back"}' >/dev/null +expect_eq "after 1 back=1000" "1000" "$(pool_total_staked)" +post '{"contract":"staking","command":"Back"}' >/dev/null +expect_eq "after 2 backs=0" "0" "$(pool_total_staked)" + +scenario_summary "$NAME" diff --git a/tests/scenarios/08-blockhash-rotation.sh b/tests/scenarios/08-blockhash-rotation.sh new file mode 100755 index 0000000..ce8980c --- /dev/null +++ b/tests/scenarios/08-blockhash-rotation.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# T-R40 regression: 50 consecutive add_rewards calls must each execute +# (not silently fail with cu=0 due to blockhash collision in LiteSVM). +set -e +. "$(dirname "$0")/_lib.sh" +NAME="08-blockhash-rotation" +echo "## $NAME" + +setup_users +expect_ok_call "init" "$(init_pool)" + +START=$(date +%s%N) +for _ in $(seq 1 50); do + post '{"contract":"staking","command":{"Call":{"ix":"add_rewards","args":{"amount":1},"accounts":{"pool":"pool","admin":"admin"},"signers":["admin"]}}}' >/dev/null +done +END=$(date +%s%N) +ELAPSED_MS=$(((END - START) / 1000000)) +expect_eq "total_rewards=50 after 50 calls" "50" "$(pool_total_rewards)" +echo " info: 50 calls in ${ELAPSED_MS}ms (avg $((ELAPSED_MS / 50))ms/call)" + +scenario_summary "$NAME" diff --git a/tests/scenarios/09-save-load-roundtrip.sh b/tests/scenarios/09-save-load-roundtrip.sh new file mode 100755 index 0000000..e6c6f39 --- /dev/null +++ b/tests/scenarios/09-save-load-roundtrip.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# T-R39 regression: SaveSession + Clear + LoadSession reconstructs VM via +# replay of call_payload. The pool's total_staked must come back. +set -e +. "$(dirname "$0")/_lib.sh" +NAME="09-save-load-roundtrip" +echo "## $NAME" + +setup_users +expect_ok_call "init" "$(init_pool)" +expect_ok_call "alice stake 888" "$(stake_as alice alice_stake 888)" +expect_eq "pre-save=888" "888" "$(pool_total_staked)" + +SAVED=$(post '{"contract":"staking","command":{"SaveSession":{}}}' | jq -r '.SessionSaved.json') +post '{"contract":"staking","command":"Clear"}' >/dev/null +# After Clear, the VM rewinds to pre-step-0 — pool account doesn't exist yet, +# so the State view either omits it (empty string) or jq returns "null". +post_clear=$(pool_total_staked) +case "$post_clear" in + ""|null) echo " PASS post-clear pool gone"; PASS=$((PASS+1)) ;; + *) echo " FAIL post-clear leaks pool=$post_clear"; FAIL=$((FAIL+1)) ;; +esac + +ESC=$(echo "$SAVED" | jq -Rs '.') +post "{\"contract\":\"staking\",\"command\":{\"LoadSession\":{\"json\":$ESC}}}" >/dev/null +expect_eq "post-load=888" "888" "$(pool_total_staked)" + +scenario_summary "$NAME" diff --git a/tests/scenarios/10-export-cross-scenario.sh b/tests/scenarios/10-export-cross-scenario.sh new file mode 100755 index 0000000..502c9ae --- /dev/null +++ b/tests/scenarios/10-export-cross-scenario.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# Export must aggregate findings from ALL scenarios, not just the active one. +# Regression for the bug discovered in audit round 8 (real-flow harness). +set -e +. "$(dirname "$0")/_lib.sh" +NAME="10-export-cross-scenario" +echo "## $NAME" + +setup_users +expect_ok_call "init" "$(init_pool)" +expect_ok_call "stake" "$(stake_as alice alice_stake 1000)" + +# Fork and record a finding in the branch. +post '{"contract":"staking","command":{"Scenario":{"sub":{"Fork":{"name":"attack","at_step":1}}}}}' >/dev/null +post '{"contract":"staking","command":{"Scenario":{"sub":{"Switch":{"name":"attack"}}}}}' >/dev/null +post '{"contract":"staking","command":{"Finding":{"severity":"High","title":"branch finding","description":"only in branch"}}}' >/dev/null + +# Switch back to main (no findings here) and export. +post '{"contract":"staking","command":{"Scenario":{"sub":{"Switch":{"name":"main"}}}}}' >/dev/null +MD=$(post '{"contract":"staking","command":{"Export":{}}}' | jq -r '.Exported.markdown') + +if echo "$MD" | grep -q "branch finding"; then + echo " PASS export aggregates findings across scenarios"; PASS=$((PASS+1)) +else + echo " FAIL export omitted findings from non-active scenario"; FAIL=$((FAIL+1)) +fi +if echo "$MD" | grep -qE "### \`attack\`"; then + echo " PASS export includes attack scenario section"; PASS=$((PASS+1)) +else + echo " FAIL export missing attack section"; FAIL=$((FAIL+1)) +fi + +scenario_summary "$NAME" diff --git a/tests/scenarios/11-export-deliverable.sh b/tests/scenarios/11-export-deliverable.sh new file mode 100755 index 0000000..d1c75a4 --- /dev/null +++ b/tests/scenarios/11-export-deliverable.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# SDD-02 verification: the markdown export now ships audit metadata, +# severity matrix, methodology, per-finding step index, and an opt-in +# recommendation block. +set -e +. "$(dirname "$0")/_lib.sh" +NAME="11-export-deliverable" +echo "## $NAME" + +setup_users +expect_ok_call "init" "$(init_pool)" +expect_ok_call "stake" "$(stake_as alice alice_stake 1000)" + +# Two findings: one with recommendation, one without. +post '{"contract":"staking","command":{"Finding":{"severity":"High","title":"missing reentrancy guard","description":"unstake calls user before zeroing balance","recommendation":"Apply checks-effects-interactions"}}}' >/dev/null +post '{"contract":"staking","command":{"Finding":{"severity":"Medium","title":"no max stake","description":"any user can stake unlimited"}}}' >/dev/null + +MD=$(post '{"contract":"staking","command":{"Export":{"metadata":{"auditor":"Demo Auditor","project_version":"v0.1.0","audit_date":"2026-05-09"}}}}' | jq -r '.Exported.markdown') + +echo "$MD" | head -3 | sed 's/^/ | /' + +# Audit metadata header. +echo "$MD" | grep -q "Auditor.*Demo Auditor" && echo " PASS auditor in header" && PASS=$((PASS+1)) || { echo " FAIL no auditor"; FAIL=$((FAIL+1)); } +echo "$MD" | grep -q "Version.*v0.1.0" && echo " PASS version in header" && PASS=$((PASS+1)) || { echo " FAIL no version"; FAIL=$((FAIL+1)); } +echo "$MD" | grep -q "Date.*2026-05-09" && echo " PASS date in header" && PASS=$((PASS+1)) || { echo " FAIL no date"; FAIL=$((FAIL+1)); } + +# Methodology paragraph. +echo "$MD" | grep -q "## Methodology" && echo " PASS methodology section" && PASS=$((PASS+1)) || { echo " FAIL no methodology"; FAIL=$((FAIL+1)); } +echo "$MD" | grep -q "LiteSVM" && echo " PASS methodology mentions LiteSVM" && PASS=$((PASS+1)) || { echo " FAIL methodology too vague"; FAIL=$((FAIL+1)); } + +# Severity matrix. +echo "$MD" | grep -q "## Severity Matrix" && echo " PASS severity matrix section" && PASS=$((PASS+1)) || { echo " FAIL no severity matrix"; FAIL=$((FAIL+1)); } +echo "$MD" | grep -qE "\| High \| 1 \|" && echo " PASS High count = 1" && PASS=$((PASS+1)) || { echo " FAIL High count wrong"; FAIL=$((FAIL+1)); } +echo "$MD" | grep -qE "\| Medium \| 1 \|" && echo " PASS Medium count = 1" && PASS=$((PASS+1)) || { echo " FAIL Medium count wrong"; FAIL=$((FAIL+1)); } + +# Step index in finding detail. +echo "$MD" | grep -q "Step.*#1" && echo " PASS step index rendered" && PASS=$((PASS+1)) || { echo " FAIL step index missing"; FAIL=$((FAIL+1)); } + +# Recommendation rendered when present. +echo "$MD" | grep -q "checks-effects-interactions" && echo " PASS recommendation present" && PASS=$((PASS+1)) || { echo " FAIL recommendation missing"; FAIL=$((FAIL+1)); } + +# Per-scenario step listing. +echo "$MD" | grep -q "## Scenarios" && echo " PASS scenarios section" && PASS=$((PASS+1)) || { echo " FAIL scenarios section missing"; FAIL=$((FAIL+1)); } + +scenario_summary "$NAME" diff --git a/tests/scenarios/12-save-load-deterministic.sh b/tests/scenarios/12-save-load-deterministic.sh new file mode 100755 index 0000000..3b0caf3 --- /dev/null +++ b/tests/scenarios/12-save-load-deterministic.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# SDD-03 verification: save --with-keypairs persists user keypairs and load +# rehydrates them so pubkeys (and any PDA derived from them) are identical +# across save/load. +set -e +. "$(dirname "$0")/_lib.sh" +NAME="12-save-load-deterministic" +echo "## $NAME" + +setup_users +expect_ok_call "init" "$(init_pool)" +expect_ok_call "alice stake 500" "$(stake_as alice alice_stake 500)" + +# Capture user pubkeys before save. +PRE_USERS=$(post '{"contract":"staking","command":"Users"}' \ + | jq -c '.UserList.users | sort_by(.name) | map({name, pubkey})') + +# Save with keypairs. +SAVED=$(post '{"contract":"staking","command":{"SaveSession":{"with_keypairs":true}}}' \ + | jq -r '.SessionSaved.json') +echo "$SAVED" | grep -q '"keypairs_present": true' \ + && { echo " PASS keypairs_present header set"; PASS=$((PASS+1)); } \ + || { echo " FAIL keypairs_present missing"; FAIL=$((FAIL+1)); } + +# Wipe state. Clear rewinds the VM and drops snapshots; this also forces +# the load path to actually rehydrate rather than reuse memory. +post '{"contract":"staking","command":"Clear"}' >/dev/null + +# Load with the persisted bundle. +ESC=$(echo "$SAVED" | jq -Rs '.') +LOAD=$(post "{\"contract\":\"staking\",\"command\":{\"LoadSession\":{\"json\":$ESC}}}") +LOADED=$(echo "$LOAD" | jq -r '.SessionLoaded.steps | length') +[ "$LOADED" = "2" ] \ + && { echo " PASS load reports 2 steps"; PASS=$((PASS+1)); } \ + || { echo " FAIL expected 2 steps got $LOADED"; FAIL=$((FAIL+1)); } + +# Capture user pubkeys after load. +POST_USERS=$(post '{"contract":"staking","command":"Users"}' \ + | jq -c '.UserList.users | sort_by(.name) | map({name, pubkey})') + +if [ "$PRE_USERS" = "$POST_USERS" ]; then + echo " PASS user pubkeys identical across save/load (deterministic)"; PASS=$((PASS+1)) +else + echo " FAIL user pubkeys diverged" + echo " pre: $PRE_USERS" + echo " post: $POST_USERS" + FAIL=$((FAIL+1)) +fi + +# State is also reproduced (also covered by scenario 09; double-check the +# --with-keypairs path doesn't regress the existing total). +TOTAL=$(pool_total_staked) +[ "$TOTAL" = "500" ] \ + && { echo " PASS pool.total_staked replayed=500"; PASS=$((PASS+1)); } \ + || { echo " FAIL pool.total_staked is '$TOTAL' (expected 500)"; FAIL=$((FAIL+1)); } + +# Negative path: a save WITHOUT keypairs must not embed the bundle. +PLAIN=$(post '{"contract":"staking","command":{"SaveSession":{"with_keypairs":false}}}' \ + | jq -r '.SessionSaved.json') +echo "$PLAIN" | grep -q '"keypairs_present": false' \ + && { echo " PASS default save reports keypairs_present=false"; PASS=$((PASS+1)); } \ + || { echo " FAIL default save header wrong"; FAIL=$((FAIL+1)); } +echo "$PLAIN" | grep -q '"keypairs": null' \ + && { echo " PASS default save has no keypairs payload"; PASS=$((PASS+1)); } \ + || { echo " FAIL default save leaks keypairs"; FAIL=$((FAIL+1)); } + +scenario_summary "$NAME" diff --git a/tests/scenarios/13-auditor-walkthrough.sh b/tests/scenarios/13-auditor-walkthrough.sh new file mode 100755 index 0000000..904f4aa --- /dev/null +++ b/tests/scenarios/13-auditor-walkthrough.sh @@ -0,0 +1,189 @@ +#!/usr/bin/env bash +# A long-form auditor walkthrough that exercises the 11 paths I listed in +# the hackathon walkthrough. Drives the SAME REST surface the REPL talks to +# but ALSO formats the responses in a REPL-style transcript so we can review +# every line as a human would. +set -uo pipefail +. "$(dirname "$0")/_lib.sh" +NAME="13-auditor-walkthrough" +echo "## $NAME" + +# ── Setup ────────────────────────────────────────────────────────────────── +for n in admin pool alice alice_stake bob bob_stake carol carol_stake dave dave_stake; do + L=100000000 + case "$n" in alice_stake|bob_stake|carol_stake|dave_stake|pool) L=2000000 ;; esac + post "{\"contract\":\"staking\",\"command\":{\"UsersNew\":{\"name\":\"$n\",\"lamports\":$L}}}" >/dev/null +done +echo " PASS users created (10)"; PASS=$((PASS+1)) + +# Helper: pretty-print a Call response in REPL style. After T-R48 a failed +# Call returns CallFailed (no step recorded) instead of StepAdded.error. +print_call() { + local label="$1"; local resp="$2" + local key=$(echo "$resp" | jq -r 'keys[0]') + case "$key" in + StepAdded) + local idx=$(echo "$resp" | jq -r '.StepAdded.step_index') + local ix=$(echo "$resp" | jq -r '.StepAdded.instruction') + local cu=$(echo "$resp" | jq -r '.StepAdded.compute_units') + local diffs=$(echo "$resp" | jq -r '.StepAdded.account_diffs_count') + echo " ✓ step $idx [ok]: $ix ($cu CU, $diffs diffs) ← $label" + ;; + CallFailed) + local ix=$(echo "$resp" | jq -r '.CallFailed.instruction') + local cu=$(echo "$resp" | jq -r '.CallFailed.compute_units') + local err=$(echo "$resp" | jq -r '.CallFailed.error') + echo " ✗ FAILED (not recorded): $ix ($cu CU) ← $label" + echo " error: $err" + ;; + *) + echo " ✗ Error response for $label: $(echo "$resp" | jq -c '.')" + ;; + esac +} + +# ── Path 1 — happy path baseline ─────────────────────────────────────────── +echo +echo " -- Path 1: happy path baseline --" +print_call "init" "$(init_pool)" +print_call "alice stake 1000" "$(stake_as alice alice_stake 1000)" +print_call "add_rewards 100" "$(post '{"contract":"staking","command":{"Call":{"ix":"add_rewards","args":{"amount":100},"accounts":{"pool":"pool","admin":"admin"},"signers":["admin"]}}}')" +print_call "alice claim" "$(post '{"contract":"staking","command":{"Call":{"ix":"claim_rewards","args":{},"accounts":{"pool":"pool","user_stake":"alice_stake","user":"alice"},"signers":["alice"]}}}')" +T=$(pool_total_staked); R=$(pool_total_rewards) +expect_eq "happy: total_staked=1000" "1000" "$T" +expect_eq "happy: total_rewards=100" "100" "$R" + +# ── Path 2 — proportional rewards with two stakers ───────────────────────── +echo +echo " -- Path 2: two stakers proportional --" +post '{"contract":"staking","command":"Clear"}' >/dev/null +print_call "init" "$(init_pool)" +print_call "alice stake 1000" "$(stake_as alice alice_stake 1000)" +print_call "bob stake 3000" "$(stake_as bob bob_stake 3000)" +print_call "add_rewards 400" "$(post '{"contract":"staking","command":{"Call":{"ix":"add_rewards","args":{"amount":400},"accounts":{"pool":"pool","admin":"admin"},"signers":["admin"]}}}')" +expect_eq "P2 total_staked=4000" "4000" "$(pool_total_staked)" +echo " hypothesis: alice should claim 100 (1000/4000*400), bob should claim 300" + +# ── Path 3 — front-running of add_rewards (HIGH severity finding) ────────── +echo +echo " -- Path 3: front-running add_rewards (fork into attack scenario) --" +post '{"contract":"staking","command":"Clear"}' >/dev/null +print_call "init" "$(init_pool)" +print_call "alice stake 100" "$(stake_as alice alice_stake 100)" +post '{"contract":"staking","command":{"Scenario":{"sub":{"Fork":{"name":"frontrun","at_step":2}}}}}' >/dev/null +post '{"contract":"staking","command":{"Scenario":{"sub":{"Switch":{"name":"frontrun"}}}}}' >/dev/null +echo " ⎇ switched to scenario 'frontrun' (forked at step 2)" +print_call "carol stake 1_000_000 (front-run)" "$(stake_as carol carol_stake 1000000)" +print_call "add_rewards 1000" "$(post '{"contract":"staking","command":{"Call":{"ix":"add_rewards","args":{"amount":1000},"accounts":{"pool":"pool","admin":"admin"},"signers":["admin"]}}}')" +T_FR=$(pool_total_staked); R_FR=$(pool_total_rewards) +echo " frontrun scenario: total_staked=$T_FR, total_rewards=$R_FR" +echo " finding: carol captures 1000*1000000/(100+1000000) ≈ 999 of the 1000 reward" +post '{"contract":"staking","command":{"Scenario":{"sub":{"Switch":{"name":"main"}}}}}' >/dev/null +echo " ⎇ back on scenario 'main' (untouched)" + +# ── Path 4 — empty pool after unstake locks future claims ────────────────── +echo +echo " -- Path 4: edge case — last staker unstakes, rewards stranded --" +post '{"contract":"staking","command":"Clear"}' >/dev/null +print_call "init" "$(init_pool)" +print_call "alice stake 500" "$(stake_as alice alice_stake 500)" +print_call "add_rewards 100" "$(post '{"contract":"staking","command":{"Call":{"ix":"add_rewards","args":{"amount":100},"accounts":{"pool":"pool","admin":"admin"},"signers":["admin"]}}}')" +print_call "alice unstake 500" "$(post '{"contract":"staking","command":{"Call":{"ix":"unstake","args":{"amount":500},"accounts":{"pool":"pool","user_stake":"alice_stake","user":"alice"},"signers":["alice"]}}}')" +expect_eq "P4 total_staked=0 after full unstake" "0" "$(pool_total_staked)" +expect_eq "P4 total_rewards=100 still in pool" "100" "$(pool_total_rewards)" +print_call "alice claim_rewards" "$(post '{"contract":"staking","command":{"Call":{"ix":"claim_rewards","args":{},"accounts":{"pool":"pool","user_stake":"alice_stake","user":"alice"},"signers":["alice"]}}}')" +echo " finding: rewards stranded — pool.total_staked=0 makes claim_rewards permanently revert" + +# ── Path 5 — re-init blocked by Anchor ───────────────────────────────────── +echo +echo " -- Path 5: Anchor 'init' constraint protects re-init --" +post '{"contract":"staking","command":"Clear"}' >/dev/null +print_call "init" "$(init_pool)" +print_call "init (twice)" "$(init_pool)" +echo " verified safe" + +# ── Path 6 — non-admin add_rewards blocked ───────────────────────────────── +echo +echo " -- Path 6: non-admin add_rewards blocked by require!(pool.admin) --" +print_call "non-admin add_rewards" "$(post '{"contract":"staking","command":{"Call":{"ix":"add_rewards","args":{"amount":99},"accounts":{"pool":"pool","admin":"bob"},"signers":["bob"]}}}')" +echo " verified safe" + +# ── Path 7 — overflow on stake ───────────────────────────────────────────── +echo +echo " -- Path 7: overflow protection on stake amount=u64::MAX --" +post '{"contract":"staking","command":"Clear"}' >/dev/null +print_call "init" "$(init_pool)" +# u64::MAX = 18446744073709551615 +print_call "alice stake u64::MAX" "$(post '{"contract":"staking","command":{"Call":{"ix":"stake","args":{"amount":18446744073709551615},"accounts":{"pool":"pool","user_stake":"alice_stake","user":"alice"},"signers":["alice_stake","alice"]}}}')" +print_call "alice stake again, should overflow" "$(post '{"contract":"staking","command":{"Call":{"ix":"stake","args":{"amount":1},"accounts":{"pool":"pool","user_stake":"alice_stake","user":"alice"},"signers":["alice_stake","alice"]}}}')" +echo " hypothesis: checked_add catches overflow with custom error" + +# ── Path 8 — wrong user claiming someone else's user_stake ──────────────── +echo +echo " -- Path 8: bob tries to claim alice's user_stake --" +post '{"contract":"staking","command":"Clear"}' >/dev/null +print_call "init" "$(init_pool)" +print_call "alice stake 1000" "$(stake_as alice alice_stake 1000)" +print_call "add_rewards 100" "$(post '{"contract":"staking","command":{"Call":{"ix":"add_rewards","args":{"amount":100},"accounts":{"pool":"pool","admin":"admin"},"signers":["admin"]}}}')" +print_call "bob claims alice_stake" "$(post '{"contract":"staking","command":{"Call":{"ix":"claim_rewards","args":{},"accounts":{"pool":"pool","user_stake":"alice_stake","user":"bob"},"signers":["bob"]}}}')" +echo " verified safe (require! user == user_stake.user)" + +# ── Path 9 — claim with 0 stake amount ───────────────────────────────────── +echo +echo " -- Path 9: alice unstakes all then claims again --" +print_call "alice unstake 1000" "$(post '{"contract":"staking","command":{"Call":{"ix":"unstake","args":{"amount":1000},"accounts":{"pool":"pool","user_stake":"alice_stake","user":"alice"},"signers":["alice"]}}}')" +print_call "alice claim again" "$(post '{"contract":"staking","command":{"Call":{"ix":"claim_rewards","args":{},"accounts":{"pool":"pool","user_stake":"alice_stake","user":"alice"},"signers":["alice"]}}}')" +echo " hypothesis: EmptyPool error (pool.total_staked=0)" + +# ── Path 10 — multiple user_stake accounts for same alice (no PDA) ───────── +echo +echo " -- Path 10: same alice, two distinct user_stake keypairs --" +post '{"contract":"staking","command":"Clear"}' >/dev/null +post '{"contract":"staking","command":{"UsersNew":{"name":"alice_stake_2","lamports":2000000}}}' >/dev/null +print_call "init" "$(init_pool)" +print_call "alice stakes #1" "$(stake_as alice alice_stake 500)" +print_call "alice stakes #2 (different keypair)" "$(stake_as alice alice_stake_2 500)" +echo " finding: program does not derive user_stake PDA from (pool, user); allows multiple accounts" +echo " probably OK economically (each claims its own share) but worth a Medium note" + +# ── Path 11 — `tl <name>` resolves to pubkey (T-R47 fix) ─────────────────── +echo +echo " -- Path 11: timeline by name (T-R47 fix) --" +TL_BY_NAME=$(post '{"contract":"staking","command":{"Timeline":{"pubkey":"pool"}}}' | jq -c '.TimelineView | {label, entries_count: (.entries | length)}') +TL_BY_USERSTAKE=$(post '{"contract":"staking","command":{"Timeline":{"pubkey":"alice_stake"}}}' | jq -c '.TimelineView | {label, entries_count: (.entries | length)}') +echo " tl pool → $TL_BY_NAME" +echo " tl alice_stake → $TL_BY_USERSTAKE" +[ "$(echo "$TL_BY_NAME" | jq -r '.entries_count')" -gt 0 ] \ + && { echo " PASS tl <name> resolves to pubkey and finds diffs"; PASS=$((PASS+1)); } \ + || { echo " FAIL tl <name> still does not resolve"; FAIL=$((FAIL+1)); } + +# ── Step formatter regression: re-inspect step 1 with decoded diff ───────── +echo +echo " -- step 1 re-inspect (decoded diff regression) --" +STEP1=$(post '{"contract":"staking","command":{"Step":{"index":1}}}') +HAS_DECODED=$(echo "$STEP1" | jq -r '.StepDetail.diff_summary[]?.decoded_after | type' | grep -m1 object) +[ -n "$HAS_DECODED" ] \ + && { echo " PASS step diff carries decoded_after"; PASS=$((PASS+1)); } \ + || { echo " FAIL step diff missing decoded_after"; FAIL=$((FAIL+1)); } + +# ── Failed call returns CallFailed and does NOT pollute the timeline ─────── +echo +echo " -- failed Call returns CallFailed, no step appended (T-R48 fix) --" +post '{"contract":"staking","command":"Clear"}' >/dev/null +post '{"contract":"staking","command":{"Call":{"ix":"initialize_pool","args":{"reward_rate":10},"accounts":{"pool":"pool","admin":"admin"},"signers":["pool","admin"]}}}' >/dev/null +LEN_BEFORE=$(post '{"contract":"staking","command":"Session"}' | jq -r '.SessionView.steps | length') +FAIL_RESP=$(post '{"contract":"staking","command":{"Call":{"ix":"initialize_pool","args":{"reward_rate":999},"accounts":{"pool":"pool","admin":"admin"},"signers":["pool","admin"]}}}') +KEY=$(echo "$FAIL_RESP" | jq -r 'keys[0]') +LEN_AFTER=$(post '{"contract":"staking","command":"Session"}' | jq -r '.SessionView.steps | length') +[ "$KEY" = "CallFailed" ] \ + && { echo " PASS response variant is CallFailed"; PASS=$((PASS+1)); } \ + || { echo " FAIL expected CallFailed got $KEY"; FAIL=$((FAIL+1)); } +[ "$LEN_BEFORE" = "$LEN_AFTER" ] \ + && { echo " PASS session length unchanged ($LEN_BEFORE → $LEN_AFTER)"; PASS=$((PASS+1)); } \ + || { echo " FAIL failed call polluted the timeline ($LEN_BEFORE → $LEN_AFTER)"; FAIL=$((FAIL+1)); } +ERR=$(echo "$FAIL_RESP" | jq -r '.CallFailed.error // empty') +[ -n "$ERR" ] \ + && { echo " PASS error message present: $(echo "$ERR" | head -c 60)..."; PASS=$((PASS+1)); } \ + || { echo " FAIL CallFailed.error missing"; FAIL=$((FAIL+1)); } + +scenario_summary "$NAME" diff --git a/tests/scenarios/14-coverage-overlay.sh b/tests/scenarios/14-coverage-overlay.sh new file mode 100755 index 0000000..85b89e7 --- /dev/null +++ b/tests/scenarios/14-coverage-overlay.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# Scenario 14: validates RuntimeOverlay (T-R52) end-to-end via the Coverage +# command and the GET /api/program/{name}/overlay endpoint. +# +# Default fixture (tests/fixtures/solana/staking) exercises Anchor `init`, +# which CPIs into system_program (11111…) — so cpi_edges should be non-empty +# after initialize_pool / stake. To exercise a hand→lever CPI explicitly, +# rerun with `ILOLD_FIXTURE=tests/fixtures/solana/cpi tests/scenarios/run.sh`. +set -uo pipefail +DIR="$(cd "$(dirname "$0")" && pwd)" +. "$DIR/_lib.sh" + +echo "scenario 14: coverage overlay" + +setup_users +init_pool >/dev/null +stake_as alice alice_stake 1000 >/dev/null +stake_as alice alice_stake 500 >/dev/null +stake_as bob bob_stake 2000 >/dev/null + +# Trigger a real CallFailed via Anchor's init re-use guard. Pool was +# already initialized at the top, so a second initialize_pool runs through +# add_solana_step and gets rejected by the VM. failed_calls_per_ix must +# tick — that is exactly the persistence path T-R52b restored. +fail_resp=$(init_pool) +fail_kind=$(echo "$fail_resp" | jq -r 'keys[0] // empty') +expect_eq "re-initialize_pool rejected as CallFailed" "CallFailed" "$fail_kind" + +# Coverage via /api/cmd +cov_response=$(post '{"contract":"staking","command":"Coverage"}') +overlay_json=$(echo "$cov_response" | jq '.Coverage.overlay') + +prog=$(echo "$overlay_json" | jq -r '.program') +expect_eq "coverage.program is staking" "staking" "$prog" + +scn=$(echo "$overlay_json" | jq -r '.scenario') +if [ -n "$scn" ] && [ "$scn" != "null" ]; then has_scn=1; else has_scn=0; fi +expect_true "coverage.scenario is set" "$has_scn" + +stake_calls=$(echo "$overlay_json" | jq -r '.calls_per_ix.stake // 0') +expect_eq "stake called three times" "3" "$stake_calls" + +init_calls=$(echo "$overlay_json" | jq -r '.calls_per_ix.initialize_pool // 0') +expect_eq "initialize_pool called once" "1" "$init_calls" + +cu_samples=$(echo "$overlay_json" | jq -r '.cu_stats_per_ix.stake.samples // 0') +expect_eq "cu_stats.stake.samples == 3" "3" "$cu_samples" + +failed_init=$(echo "$overlay_json" | jq -r '.failed_per_ix.initialize_pool // 0') +expect_eq "failed_per_ix.initialize_pool == 1" "1" "$failed_init" + +# Anchor `init` (used by initialize_pool / stake's user_stake init) CPIs to +# system_program. The overlay must surface those edges. +sys_edges=$(echo "$overlay_json" \ + | jq -r '[.cpi_edges[] | select(.to_program=="11111111111111111111111111111111")] | length') +if [ "${sys_edges:-0}" -ge 1 ]; then has_sys=1; else has_sys=0; fi +expect_true "cpi_edges contains system_program" "$has_sys" + +# REST endpoint roundtrip — same shape, served via GET. +rest_overlay=$(curl -sf "$BASE/api/program/staking/overlay") +rest_prog=$(echo "$rest_overlay" | jq -r '.program') +expect_eq "REST /overlay program" "staking" "$rest_prog" + +rest_stake=$(echo "$rest_overlay" | jq -r '.calls_per_ix.stake // 0') +expect_eq "REST /overlay stake==3" "3" "$rest_stake" + +scenario_summary "14-coverage-overlay" diff --git a/tests/scenarios/_lib.sh b/tests/scenarios/_lib.sh new file mode 100755 index 0000000..1cc0359 --- /dev/null +++ b/tests/scenarios/_lib.sh @@ -0,0 +1,96 @@ +# Shared helpers for scenario scripts. Each scenario sources this and exposes +# a series of `expect_*` assertions; the runner counts them globally. +BASE="${BASE:-http://127.0.0.1:8081}" + +post() { + curl -s -X POST "$BASE/api/cmd" -H 'content-type: application/json' -d "$1" +} + +# Highest-numbered pool entry — State labels look like "pool#N" where N is +# the step index of the most recent mutation. +pool_total_staked() { + post '{"contract":"staking","command":"State"}' \ + | jq -r '[.StateView.accounts[]? | select(.label|startswith("pool"))][-1].decoded.total_staked' \ + 2>/dev/null +} + +pool_total_rewards() { + post '{"contract":"staking","command":"State"}' \ + | jq -r '[.StateView.accounts[]? | select(.label|startswith("pool"))][-1].decoded.total_rewards' \ + 2>/dev/null +} + +session_steps_len() { + post '{"contract":"staking","command":"Session"}' | jq -r '.SessionView.steps | length' +} + +active_scenario() { + post '{"contract":"staking","command":{"Scenario":{"sub":"List"}}}' \ + | jq -r '.ScenarioList.items[] | select(.active==true) | .name' +} + +step_failed() { + # Returns 0 if the step at $1 has an error in its runtime trace. + local idx="$1" + local err + err=$(post "{\"contract\":\"staking\",\"command\":{\"Step\":{\"index\":$idx}}}" \ + | jq -r '.StepDetail.runtime_trace.error // ""') + [ -n "$err" ] +} + +call_failed_in_logs() { + # $1 is the JSON response from a Call. Solana now distinguishes + # CallFailed (VM rejected, no step recorded) from StepAdded (success). + # We accept either signal. + local key + key=$(echo "$1" | jq -r 'keys[0] // empty') + case "$key" in + CallFailed) return 0 ;; + *) + echo "$1" | jq -r '.StepAdded.logs_excerpt[]? // empty' \ + | grep -qE 'AnchorError|failed:|panicked' + ;; + esac +} + +setup_users() { + # Convenience: create pool + admin + alice + alice_stake + bob + bob_stake. + for n in admin pool alice alice_stake bob bob_stake; do + local L=100000000 + case "$n" in alice_stake|bob_stake|pool) L=2000000 ;; esac + post "{\"contract\":\"staking\",\"command\":{\"UsersNew\":{\"name\":\"$n\",\"lamports\":$L}}}" >/dev/null + done +} + +init_pool() { + post '{"contract":"staking","command":{"Call":{"ix":"initialize_pool","args":{"reward_rate":10},"accounts":{"pool":"pool","admin":"admin"},"signers":["pool","admin"]}}}' +} + +stake_as() { + # stake_as <user> <user_stake> <amount> + post "{\"contract\":\"staking\",\"command\":{\"Call\":{\"ix\":\"stake\",\"args\":{\"amount\":$3},\"accounts\":{\"pool\":\"pool\",\"user_stake\":\"$2\",\"user\":\"$1\"},\"signers\":[\"$2\",\"$1\"]}}}" +} + +# ── Assertion helpers — each one increments PASS or FAIL globally ─────────── +PASS=${PASS:-0}; FAIL=${FAIL:-0} +expect_eq() { # name expected actual + if [ "$3" = "$2" ]; then echo " PASS $1"; PASS=$((PASS+1)) + else echo " FAIL $1 | expected:'$2' actual:'$3'"; FAIL=$((FAIL+1)); fi +} +expect_true() { # name boolean + if [ "$2" = "1" ] || [ "$2" = "true" ]; then echo " PASS $1"; PASS=$((PASS+1)) + else echo " FAIL $1 | expected truthy, got '$2'"; FAIL=$((FAIL+1)); fi +} +expect_failed_call() { # name response_json + if call_failed_in_logs "$2"; then echo " PASS $1 (call failed as expected)"; PASS=$((PASS+1)) + else echo " FAIL $1 — call did NOT fail; logs: $(echo "$2" | jq -r '.StepAdded.logs_excerpt[0]?' 2>/dev/null)"; FAIL=$((FAIL+1)); fi +} +expect_ok_call() { # name response_json + if call_failed_in_logs "$2"; then echo " FAIL $1 — call failed unexpectedly; logs: $(echo "$2" | jq -r '.StepAdded.logs_excerpt[]?' 2>/dev/null | head -3)"; FAIL=$((FAIL+1)) + else echo " PASS $1"; PASS=$((PASS+1)); fi +} + +scenario_summary() { + echo " ── $1: $PASS passed / $FAIL failed ──" + return $FAIL +} diff --git a/tests/scenarios/run.sh b/tests/scenarios/run.sh new file mode 100755 index 0000000..a37ff42 --- /dev/null +++ b/tests/scenarios/run.sh @@ -0,0 +1,98 @@ +#!/usr/bin/env bash +# Scenario runner: spins up a fresh `ilold serve` per scenario file so they +# don't pollute each other, runs the scenario, and aggregates pass/fail. +set -uo pipefail +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +PORT="${ILOLD_TEST_PORT:-8081}" +FIXTURE="${ILOLD_FIXTURE:-$ROOT/tests/fixtures/solana/staking}" +BIN="${ILOLD_BIN:-$ROOT/target/release/ilold}" +LOG="/tmp/ilold-scenarios-serve.log" + +if [ ! -x "$BIN" ]; then + echo "binary not found: $BIN — run 'cargo build --release --bin ilold -p ilold-cli'" >&2 + exit 2 +fi + +start_serve() { + pkill -f "ilold serve --port $PORT" 2>/dev/null || true + sleep 0.5 + "$BIN" serve --port "$PORT" "$FIXTURE" > "$LOG" 2>&1 & + for _ in 1 2 3 4 5 6 7 8 9 10; do + sleep 0.4 + if curl -sf "http://127.0.0.1:$PORT/api/project/map" >/dev/null; then return 0; fi + done + echo "serve failed to come up; tail of $LOG:" >&2 + tail -20 "$LOG" >&2 + return 1 +} + +stop_serve() { + pkill -f "ilold serve --port $PORT" 2>/dev/null || true +} + +TOTAL_PASS=0 +TOTAL_FAIL=0 +SCENARIOS_OK=() +SCENARIOS_FAIL=() + +DIR="$(dirname "$0")" +echo "================================================================" +echo " ilold scenario suite — fixture: $FIXTURE" +echo "================================================================" + +for f in "$DIR"/[0-9][0-9]-*.sh; do + [ -f "$f" ] || continue + name=$(basename "$f" .sh) + start_serve || { echo " skipped $name (serve down)"; continue; } + set +e + output=$(env BASE="http://127.0.0.1:$PORT" PASS=0 FAIL=0 bash "$f" 2>&1) + exit_code=$? + set -e + echo "$output" + counts=$(echo "$output" | tail -3 | grep -oE '[0-9]+ passed / [0-9]+ failed' | head -1) + p=$(echo "$counts" | grep -oE '^[0-9]+' | head -1) + fc=$(echo "$counts" | grep -oE '[0-9]+ failed' | grep -oE '[0-9]+') + TOTAL_PASS=$((TOTAL_PASS + ${p:-0})) + TOTAL_FAIL=$((TOTAL_FAIL + ${fc:-0})) + if [ "$exit_code" = "0" ] && [ "${fc:-0}" = "0" ]; then + SCENARIOS_OK+=("$name") + else + SCENARIOS_FAIL+=("$name") + fi + stop_serve +done + +echo +# Optional WebSocket broadcast test — requires python3 + websockets. +WS_PY="$DIR/ws-broadcast.py" +if [ -f "$WS_PY" ] && command -v python3 >/dev/null 2>&1 \ + && python3 -c 'import websockets' 2>/dev/null; then + echo "## ws-broadcast (python3 + websockets)" + start_serve || { echo " skipped ws-broadcast (serve down)"; } + set +e + python3 "$WS_PY" 2>&1 + ws_exit=$? + set -e + stop_serve + if [ "$ws_exit" = "0" ]; then + SCENARIOS_OK+=("ws-broadcast") + TOTAL_PASS=$((TOTAL_PASS + 1)) + else + SCENARIOS_FAIL+=("ws-broadcast") + TOTAL_FAIL=$((TOTAL_FAIL + 1)) + fi +else + echo "## ws-broadcast — skipped (need python3 + 'pip install websockets')" +fi + +echo +echo "================================================================" +echo " SUMMARY: ${#SCENARIOS_OK[@]} scenarios green, ${#SCENARIOS_FAIL[@]} scenarios red" +echo " $TOTAL_PASS assertions passed / $TOTAL_FAIL failed" +echo "================================================================" +if [ ${#SCENARIOS_FAIL[@]} -gt 0 ]; then + echo "FAILED scenarios:" + for s in "${SCENARIOS_FAIL[@]}"; do echo " - $s"; done + exit 1 +fi +exit 0 diff --git a/tests/scenarios/ws-broadcast.py b/tests/scenarios/ws-broadcast.py new file mode 100644 index 0000000..f2b2a09 --- /dev/null +++ b/tests/scenarios/ws-broadcast.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +"""Captura broadcasts WS durante una sesión REST y reporta lo que recibió.""" +import asyncio +import json +import sys +import time +import urllib.request +import websockets + +BASE = "http://127.0.0.1:8081" + +def post(body): + req = urllib.request.Request( + f"{BASE}/api/cmd", + data=json.dumps(body).encode(), + headers={"content-type": "application/json"}, + method="POST", + ) + with urllib.request.urlopen(req, timeout=5) as r: + return json.loads(r.read()) + +async def listen(received: list, ready: asyncio.Event): + async with websockets.connect("ws://127.0.0.1:8081/ws") as ws: + ready.set() + try: + while True: + msg = await asyncio.wait_for(ws.recv(), timeout=10.0) + received.append(json.loads(msg)) + except asyncio.TimeoutError: + return + +async def driver(): + received = [] + ready = asyncio.Event() + listener = asyncio.create_task(listen(received, ready)) + await ready.wait() + await asyncio.sleep(0.1) + + # Setup + for name, lam in [("admin", 100_000_000), ("pool", 2_000_000), + ("alice", 50_000_000), ("alice_stake", 2_000_000)]: + post({"contract":"staking","command":{"UsersNew":{"name":name,"lamports":lam}}}) + + post({"contract":"staking","command":{"Call":{"ix":"initialize_pool","args":{"reward_rate":10}, + "accounts":{"pool":"pool","admin":"admin"},"signers":["pool","admin"]}}}) + post({"contract":"staking","command":{"Call":{"ix":"stake","args":{"amount":1234}, + "accounts":{"pool":"pool","user_stake":"alice_stake","user":"alice"},"signers":["alice_stake","alice"]}}}) + + post({"contract":"staking","command":{"Scenario":{"sub":{"Fork":{"name":"branch_x","at_step":1}}}}}) + post({"contract":"staking","command":{"Scenario":{"sub":{"Switch":{"name":"branch_x"}}}}}) + post({"contract":"staking","command":"Back"}) + post({"contract":"staking","command":"Clear"}) + post({"contract":"staking","command":{"Scenario":{"sub":{"Switch":{"name":"main"}}}}}) + post({"contract":"staking","command":{"Scenario":{"sub":{"Delete":{"name":"branch_x"}}}}}) + + await asyncio.sleep(0.5) + listener.cancel() + try: await listener + except: pass + return received + +async def main(): + received = await driver() + counts = {} + runtime_with_data = 0 + for m in received: + t = m.get("type", "?") + counts[t] = counts.get(t, 0) + 1 + if t == "session_add_node" and m.get("runtime"): + r = m["runtime"] + if r.get("compute_units", 0) > 0 and r.get("logs_excerpt"): + runtime_with_data += 1 + + print("=== WS broadcast count by type ===") + for k, v in sorted(counts.items()): + print(f" {k}: {v}") + print() + print(f"=== session_add_node con runtime CU+logs: {runtime_with_data} ===") + print() + + expected_topics = { + "solana_users_changed": 4, # 4 UsersNew + "session_add_node": 2, # 2 Calls + "session_overlay_update": 2, # one per Call (StepAdded path) + "scenario_forked": 1, + "scenario_switched": 2, # main→branch, branch→main + "session_remove_node": 1, # Back + "session_clear": 1, + "scenario_deleted": 1, + } + issues = 0 + for topic, expected in expected_topics.items(): + actual = counts.get(topic, 0) + if actual != expected: + print(f" ISSUE {topic}: expected {expected}, got {actual}") + issues += 1 + else: + print(f" OK {topic}: {actual}") + + print() + print(f"=== {len(received)} frames total / {issues} issues ===") + sys.exit(0 if issues == 0 else 1) + +asyncio.run(main())