diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6453ce0..7a0edb3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,5 +48,5 @@ jobs: mix nif.stress.quick mix run --no-start scripts/cmerkle_deadlock_watchdog.exs -- \ --progress-timeout 120 --wall-timeout 900 \ - mix run --no-start scripts/cmerkle_parallel_stress.exs -- --waves 1 --tasks 24 --scenario P10 --scenario P11 + mix run --no-start scripts/cmerkle_parallel_stress.exs -- --waves 1 --tasks 24 --scenario P12 --scenario P14 mix nif.leak.quick diff --git a/.github/workflows/nif-nightly.yml b/.github/workflows/nif-nightly.yml index 5dda2a4..1ef47e6 100644 --- a/.github/workflows/nif-nightly.yml +++ b/.github/workflows/nif-nightly.yml @@ -76,4 +76,4 @@ jobs: mix run --no-start scripts/cmerkle_deadlock_watchdog.exs -- \ --progress-timeout 300 --wall-timeout 1200 \ mix run --no-start scripts/cmerkle_parallel_stress.exs -- \ - --waves 2 --tasks 24 --scenario P4 --scenario P5 --scenario P10 --scenario P12 + --waves 2 --tasks 24 --scenario P12 --scenario P13 --scenario P14 --scenario P16 diff --git a/.gitignore b/.gitignore index 6981b0a..f288d62 100644 --- a/.gitignore +++ b/.gitignore @@ -14,7 +14,6 @@ /priv/merkletree_nif.so /priv/merkletree_nif.asan.so /priv/merkletree_nif.so.bak* - # Profiling / measurement outputs (see scripts/profile_*.sh) /tmp/ /leak_state.bin diff --git a/AGENTS.md b/AGENTS.md index d626139..509f201 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,6 +28,10 @@ Ethereum-compatible JSON-RPC endpoint plus the Diode PEER/EDGE protocols. `make -C deps/libsecp256k1/` (see `.github/workflows/ci.yml`). Build artifacts are gitignored and persist across sessions, so this is only needed after a clean checkout of that dep. +- **CAccountMap / state NIF semantics** (clone, lock, storage APIs, get shape): + see [`docs/caccount-map-nif.md`](docs/caccount-map-nif.md). Difference/clone + performance (cached compact roots, COW, trie-driven `difference_full`): + [`docs/specs/change-state-diff-perf.md`](docs/specs/change-state-diff-perf.md). ### Lint - `mix lint` = `compile` + `mix format --check-formatted` + `mix credo --only warning` + `mix dialyzer`. @@ -39,12 +43,12 @@ Ethereum-compatible JSON-RPC endpoint plus the Diode PEER/EDGE protocols. - `make test` generates test PEM certs, then runs each `test/*_test.exs` file in a separate `mix test --max-failures 1` invocation (per-file isolation). Test env pins ports `RPC_PORT=18001`, `EDGE2_PORT=18003`, `PEER_PORT=18004`. -- `Chain.State` is a MUTABLE NIF-backed `CMerkleTree`: `Chain.Transaction.apply/3` - mutates the state passed to it in place. Use `Chain.State.clone/1` to get an - independent copy before reapplying. `test/evm_test.exs` "create contract" - currently fails for this reason (it reuses `state` across `apply` calls - without cloning). This is a pre-existing repo issue — CI has `mix test` - commented out and does not gate on it. +- For `Chain.State` / CAccountMap mutability and storage rules, see + [`docs/caccount-map-nif.md`](docs/caccount-map-nif.md). Perf contract tests: + `test/state_diff_perf_contract_test.exs`. Benches (no app start): + `scripts/state_diff_bench.exs` (`State.difference`), + `scripts/state_uncompact_bench.exs` (`state(uncompact:…)`), + `scripts/state_delta_apply_bench.exs` (`state(delta:…)`). ### Running the node (dev mode) - `./dev` runs `MIX_ENV=dev iex -S mix run` (wipes `data_dev/` first). For a diff --git a/Makefile b/Makefile index 404df23..a2eb7c7 100644 --- a/Makefile +++ b/Makefile @@ -8,7 +8,8 @@ TESTDATA := test/pems/device1_certificate.pem test/pems/device2_certificate.pem .PHONY: all all: evm/evm priv/merkletree_nif.so -priv/merkletree_nif.so: $(wildcard c_src/*.cpp c_src/*.hpp) +.PHONY: priv/merkletree_nif.so +priv/merkletree_nif.so: $(MAKE) -C c_src nif evm/evm: $(wildcard evm/*.cpp evm/*.hpp evm/*/*.cpp evm/*/*.hpp) diff --git a/c_src/LOCK_ORDER.md b/c_src/LOCK_ORDER.md index 5e6bd46..399aa0a 100644 --- a/c_src/LOCK_ORDER.md +++ b/c_src/LOCK_ORDER.md @@ -1,111 +1,87 @@ -# CMerkleTree NIF — lock order and deadlock scenario registry +# CAccountMap NIF — lock order and deadlock scenario registry -This document inventories mutex layers in [`nif.cpp`](nif.cpp), documents acquisition order, and maps every known deadlock / liveness scenario to regression tests. +This document inventories mutex layers in [`nif.cpp`](nif.cpp), documents acquisition order, and maps known deadlock / liveness scenarios to regression tests. -See also [`SECURITY_REVIEW.md`](SECURITY_REVIEW.md) (F-5 fix) and [`scripts/cmerkle_parallel_stress.exs`](../scripts/cmerkle_parallel_stress.exs). +See also [`SECURITY_REVIEW.md`](SECURITY_REVIEW.md) and [`scripts/cmerkle_parallel_stress.exs`](../scripts/cmerkle_parallel_stress.exs). ## Mutex layers | Mutex | Type | Scope | Used by | |-------|------|-------|---------| -| `LockedStates::mtx` | `ErlNifMutex` | Global | `enter_lock`, `leave_lock`, `destruct_merkletree_type` | -| `SharedState::mtx` | `ErlNifMutex` | Per trie | `Lock(mt)` — insert, get, difference, clone, etc. | +| `SharedState::mtx` | `ErlNifMutex` | Per storage / state trie | `Lock(mt)` during COW / storage reads / root hash; destructor `release_merkletree_shared` | | `SharedAccountMap::mtx` | `ErlNifMutex` | Per account map | `AccountMapLock` | | `GlobalStripePool::s_mtx` | `std::mutex` | Process-global | PreAllocator stripe reuse | | `ItemPool` internal | `std::recursive_mutex` | Per SharedState pool | COW / pair allocation | | `PreAllocator` internal | `std::recursive_mutex` | Per tree stripe | Pair slab mutation | -| `stats_mutex` | `ErlNifMutex` | Global | DEBUG stats only | +| `stats_mutex` | `ErlNifMutex` | Global | DEBUG stats + `nif_stats_raw` live counters | -**Independent domains:** `AccountMapLock` and `LockedStates::mtx` are never held together in current NIF code. +Bare-tree Elixir NIFs (`new` / `insert` / `difference_raw` / `lock` / …) are gone. Storage tries exist only as map-owned internals. The old global `LockedStates` / orphan queue was removed with them. ## Lock acquisition rules | Path | Order | Notes | |------|-------|-------| -| `difference_raw` | `locked_states_mutex` → snapshot pointers + `read_pins` → **release global** → `SharedState*` mutexes (address order) → unpin `read_pins` | `read_pins` prevents COW from consuming `difference` lifetime while global is dropped | -| `enter_lock` (dedup) | tree (`mt->locked`) → `locked_states_mutex` → pin `read_pins` on canonical → **release global** → bump `has_clone` / canonical switch | `read_pins` prevents reclaim until registration ref is taken | -| `leave_lock` / GC destructor | if `mt->locked`: `locked_states_mutex` → tree → decrement registration → erase map when `has_clone == 0`; else tree refcount only | Unlocked resources never touch `LockedStates` map | -| `merkletree_clone` | `Lock(parent)` | Dirty CPU scheduler; O(1) shallow resource alloc (`locked = false`) | -| `account_map_clone` | `AccountMapLock` → `Lock(parent_storage)` per trie (sequential) | Dirty scheduler; long hold | -| `account_map_lock` | `AccountMapLock` → `enter_lock` / `apply_canonical_lock` per unique `root_hash`; optional store trie after map lock released | Dirty scheduler; dedupes by root hash to avoid redundant canonical switches | -| `switch_local_to_canonical` | tree mutexes (address order) | Abandoned `SharedState` queued on `pending_orphans`; reclaimed via `try_reclaim_orphans` after `enter_lock` / `leave_lock` when mutex trylock succeeds and `has_clone == 0` | -| `account_map_uncompact_state` | `AccountMapLock(input)` → `materialize_storage` (brief tree lock) → `batch_insert` (state_store lock) | Dirty scheduler | -| `account_map_put/delete` | `AccountMapLock` only | May `release_resource` → async GC `leave_lock` | -| `account_map_list_difference_raw` | `SharedAccountMap*` mutexes (address order) → brief tree lock per entry for root read → release all before materialize | Dirty CPU; never hold map lock across `materialize_storage` | -| Insert / COW | Tree lock → ItemPool / PreAllocator / stripe pool | Same-thread nesting | +| Storage trie GC destructor | `release_merkletree_shared`: tree mutex → drop `has_clone` / delete when 0 | Immediate reclaim; no deferred orphan queue | +| `account_map_clone` | `AccountMapLock` → `fork_shared_accountmap` (`Lock` per parent storage / state_trie) | Dirty scheduler; writable fork even if parent `frozen` | +| `account_map_lock` | `AccountMapLock` → set `frozen=true` only (O(1); no per-trie seal) | Dirty scheduler; put/delete/storage_put_map reject via `frozen` | +| `account_map_storage_put_map` | `AccountMapLock` → reject if frozen → per-addr `write_storage_slot` → `update_state_trie_for_entry` | Dirty CPU; EVM `su` hot path | +| `account_map_storage` | `AccountMapLock` → read-only storage query (`:get`/`:range`/`:list`/`:size`) | Dirty CPU | +| `account_map_storage_roots` / `account_map_state_roots` | `AccountMapLock` → tree lock (live) **or** temp tree from compact slots (never materialize solely for roots) | No live trie export; compact may use cached root | +| `account_map_proof` | `AccountMapLock` → account or storage proof | Dirty CPU; arities 2 and 3 | +| `account_map_uncompact_state` | `AccountMapLock(input)` → parse/seed compact roots → `batch_insert` (state_store lock) | Dirty scheduler; compact entries stay lazy when `:root_hash` present | +| `account_map_compact` | `AccountMapLock` (read-only; OK frozen) → per-account storage list via live tree lock or compact_storage slots | Dirty CPU | +| `account_map_put/delete` | `AccountMapLock` only; reject if `frozen`; storage arg may be `:keep` / list / `nil` | `:keep` on compact updates `state_trie` via cached storage root (no materialize) | +| `account_map_difference_full` | `DualAccountMapLock` → if `state_trie` `SharedState*` equal return `[]` → else `SharedStateLock` on both tries → leaf symmetric difference → snapshot candidates → release map lock → per-candidate storage diffs + roots (6-tuple) | Dirty CPU; never hold map lock across `build_storage_diff_list` | +| `account_map_apply_difference` | `AccountMapLock` → reject if `frozen` → storage/field writes (`write_storage_slot` → `make_writeable_locked`) | Dirty CPU | +| Insert / COW (internal) | Tree lock → ItemPool / PreAllocator / stripe pool | Same-thread nesting | ## Deadlock scenario registry -Each scenario has an ID, hypothesis, and test coverage target. - -### A. Tree mutex + `LockedStates::mtx` - -| ID | Scenario | Hypothesis | Covered by | -|----|----------|------------|------------| -| D-A1 | `enter_lock` dedup + `difference` on overlapping SharedStates | Global held during second tree wait (pre-fix F-5) | P9, S9, ExUnit lock concurrency | -| D-A2 | `leave_lock` (GC) + `difference` on same tree | Liveness stall: leave waits tree; diff never waits global | P11, S14, ExUnit D-A2 | -| D-A3 | `leave_lock` + `enter_lock` phase1 on same tree | Serialized on global | P10 smoke | -| D-A4 | `leave_lock`(global→T) + `enter_lock` switch(T,U) + `difference`(T,U) | Three-way liveness under heavy GC | P10 | -| D-A5 | Many concurrent `leave_lock` on distinct trees | Global mutex convoy | P11 | -| D-A6 | `enter_lock` dedup from 50+ clones | Canonical switch / refcount edge | P6, P14, ExUnit D-F2 | -| D-A7 | `lock` on already-locked tree | Re-entrant / double enter | S8 | - -### B. Dual-tree ordering (`difference_raw`) - -| ID | Scenario | Hypothesis | Covered by | -|----|----------|------------|------------| -| D-B1 | `difference(A,B)` vs `difference(B,A)` | Same address order | P2, P3 | -| D-B2 | `difference(A,B)` vs `difference(A,C)` — shared first tree | Second lock order by B vs C address | P10, ExUnit D-B2 | -| D-B3 | Long `difference` + concurrent insert | Insert waits tree held by difference | P5 | -| D-B4 | `difference` on same SharedState (early return) | No dual lock | S9 smoke | -| D-B5 | `difference` while `switch_local_to_canonical` holds both trees | Post-F-5 residual | P10 | - ### C. Account map × tree mutex | ID | Scenario | Risk | Covered by | |----|----------|------|------------| -| D-C1 | `account_map_clone` + `difference` on shared storage | Tree mutex contention | P13 | -| D-C2 | `account_map_uncompact_state` + per-account `difference` | Independent domains unless storage shared | P12, S12, ExUnit D-C2 | -| D-C3 | `account_map_get` / `to_list` (materialize) + `difference` | Brief tree lock vs diff | S13 | -| D-C4 | `account_map_put` replacing storage + GC `leave_lock` | Async GC vs diff | P13 | -| D-C5 | `account_map_lock` / `account_map_clone` + concurrent `State.lock/1` | Correctness / writable fork | P14, P15, chain_state_uncompact_test, ExUnit D-C5, fuzz 16–18 | -| D-C6 | `cow_copy_accountmap` during concurrent `put` | Refcount race (F-4) | TSan on P4, P13 | -| D-C7 | `account_map_list_difference_raw` + `account_map_to_list` same map | Map mutex convoy / materialize stall | S20, ExUnit D-D7, D-C7 | -| D-C8 | Dual-map `list_difference` lock order (A,B) vs (B,A) | Ordering regression | S24, ExUnit D-C8 | +| D-C1 | `account_map_clone` + `difference_full` on shared storage | Tree mutex contention | P13 | +| D-C2 | `account_map_uncompact_state` + `difference_full` | Independent domains unless storage shared | P12, fuzz S3, ExUnit D-C2 | +| D-C3 | `account_map_get` / `to_list` (root hash export) + `difference_full` | No live storage export; brief hash compute | fuzz S4 | +| D-C4 | `account_map_put` replacing storage + GC `release_merkletree_shared` | Async GC vs diff | P13 | +| D-C5 | `account_map_lock` / `account_map_clone` + concurrent `State.lock/1` | Correctness / writable fork | P14, P15, chain_state_uncompact_test, ExUnit D-C5, fuzz 6–8 | +| D-C6 | Concurrent `put` / `apply_difference` on frozen map | Rejected via `make_writeable_accountmap` | TSan on P13 | +| D-C7 | `account_map_difference_full` + `account_map_to_list` same map | Map mutex convoy / materialize stall | fuzz S10, ExUnit D-D7, D-C7 | +| D-C8 | Dual-map `difference_full` lock order (A,B) vs (B,A) | Ordering regression | fuzz S14, ExUnit D-C8 | ### D. Production composite (block sync) | ID | Scenario | Covered by | |----|----------|------------| | D-D1 | `Chain.State.difference/2` (storage diffs per account) | P14 | -| D-D2 | `State.lock/1` on all account trees | P14 | +| D-D2 | `State.lock/1` sets map `frozen` only (no per-trie seal; get exports root hashes not live storage) | P14 | | D-D3 | D-D1 + D-D2 + uncompact concurrent | P14, P12, ExUnit D-D3 | -| D-D4 | compact → uncompact → clone → apply_difference | P14, S10, S11 | -| D-D5 | Storage get/insert + block import | P1, P5 | -| D-D6 | Dirty scheduler saturation | P15 | -| D-D7 | prepare_state composite (native diff + lock + legacy to_list) | S30, P17, ExUnit D-D7 | -| D-D8 | `list_difference` compact storage root compare (no full map materialize) | account_map_diff_test, S19 | +| D-D4 | compact → uncompact → clone → apply_difference | P14, fuzz S1–S2 | +| D-D6 | Dirty scheduler saturation | P15, P20 | +| D-D7 | prepare_state composite (native diff + lock + to_list) | fuzz S20, P17, ExUnit D-D7 | +| D-D8 | `difference_full` on compact storage | account_map_diff_test, fuzz S9 | +| D-L1 | `clone` after lock + storage_put_map + discard | `cmerkle_storage_map_test`, `cmerkle_lock_clone_regression_test`, P18L | +| D-L2 | `difference_full` + `apply_difference` round-trip | chain_state_merkle_test, account_map_diff_test | +| D-M1 | frozen map + eager clone writable fork | chain_state_merkle_test lock→clone | ### E. C++ internal mutexes | ID | Scenario | Covered by | |----|----------|------------| -| D-E1 | Concurrent clone+insert on COW SharedState | P4 + TSan | -| D-E2 | `difference` + insert allocating pairs | P5 | -| D-E3 | Parallel tree discard + stripe pool | P7, P11 | +| D-E1 | Concurrent clone+storage_put on COW SharedState | P13 + TSan | +| D-E3 | Parallel map discard + stripe pool | P12, P16 | | D-E4 | Nested ItemPool lock in `fork_for_write` | By design (recursive) | ### F. Refcount / UAF masquerading as deadlock | ID | Scenario | Covered by | |----|----------|------------| -| D-F1 | `has_clone` mismatch during canonical switch | P9, S9, ExUnit dedup | -| D-F2 | Canonical ref reserved vs concurrent `leave_lock` | P9, ExUnit D-F2 | | D-F3 | `release_storage_from_map` during delete vs diff | P13, caccount_map_lifetime_test | ## Remaining structural risk -`enter_lock` and `leave_lock` now both release the global mutex before blocking on tree mutexes. Monitor `nif_stats_raw/0` (`shared_states` vs `locked_states`) in production; sustained growth indicates a reclaim regression. `nif_stats_raw` is read-only (reclaim runs from lock/unlock paths only). +Monitor `nif_stats_raw/0` (`shared_states` / `merkletree_resources`) in production; sustained growth indicates a reclaim regression. Locked/orphan tuple slots stay zero (API shape preserved). ## CI / harness commands diff --git a/c_src/Makefile b/c_src/Makefile index c05d9dd..f881860 100644 --- a/c_src/Makefile +++ b/c_src/Makefile @@ -49,13 +49,15 @@ uncompact_harness.bin: uncompact_harness.cpp merkletree.hpp merkletree.cpp item_ uncompact_harness: uncompact_harness.bin # Optional: libFuzzer + ASan on SHA (requires Clang with fuzzer runtime) -.PHONY: nif nif-asan fuzz_sha +.PHONY: nif nif-asan fuzz_sha FORCE fuzz_sha: fuzz_sha.bin ./fuzz_sha.bin -runs=5000 fuzz_sha.bin: fuzz_sha.cpp sha.cpp Makefile clang++ -std=c++17 -I. -g -O1 -fsanitize=fuzzer,address -fno-omit-frame-pointer -o fuzz_sha.bin fuzz_sha.cpp sha.cpp -lstdc++ +FORCE: + nif: ../priv/merkletree_nif.so ../priv/merkletree_nif.so: nif.cpp merkletree.hpp merkletree.cpp item_pool.cpp sha.cpp preallocator.hpp Makefile diff --git a/c_src/SECURITY_REVIEW.md b/c_src/SECURITY_REVIEW.md index 6373a9a..dd1b69f 100644 --- a/c_src/SECURITY_REVIEW.md +++ b/c_src/SECURITY_REVIEW.md @@ -7,37 +7,33 @@ ## 1. NIF inventory (exports → Elixir) +All exports below are always registered (~21 entries). There is no separate bare-tree / test-only NIF mode. + | NIF name | Arity | Inputs | Callers (representative) | |----------|-------|--------|---------------------------| -| `new` | 0 | — | `CMerkleTree.new/0` | -| `insert_item_raw` | 3 | resource, key binary, value binary (must be 32 bytes) | `insert`, `insert_items` | -| `get_item` | 2 | resource, key binary | `get/2` | -| `get_range_raw` | 3 | resource, key binary (32 bytes), count (1..256) | `get_range/3`, `Evm` `gs` read-ahead | -| `get_proofs_raw` | 2 | resource, key binary | `get_proofs/2` → RPC, edge | -| `difference_raw` | 2 | two resources | `difference/2`, `Chain.State` | -| `lock` | 1 | resource | `CMerkleTree.lock/1`, scripts | -| `to_list` | 1 | resource | `to_list`, RPC | -| `import_map` | 2 | resource, map (bin→bin pairs, values 32 bytes) | `from_map` | -| `root_hash` | 1 | resource | Widespread | -| `hash` | 1 | binary | hashing helpers | -| `root_hashes_raw` | 1 | resource | `root_hashes/1`, edge | -| `bucket_count` | 1 | resource | tests | -| `size` | 1 | resource | Widespread | -| `clone` | 1 | resource | `clone`, account storage | | `count_zeros` | 1 | binary | `Evm` (tx payload) | -| `struct_sizes_raw` | 0 | — | tests, benches | -| `memory_stats_raw` | 1 | resource | tests, benches | -| `malloc_info_raw` | 0 | — | tests, `cmerkle_memory_bench.exs` | +| `nif_stats_raw` | 0 | — | `CMerkleTree.nif_stats/0`, `Network.Status`, leak/stress harnesses | | `account_map_new` | 0 | — | `CAccountMap.new/0`, `Chain.State` | | `account_map_clone` | 1 | account map resource | `CAccountMap.clone/1`, `Chain.State.clone/1` | -| `account_map_lock` | 2 | account map resource, optional state trie or `nil` | `CAccountMap.lock/2`, `Chain.State.lock/1` | -| `account_map_get` | 2 | resource, 20-byte address | `CAccountMap.get/2` | -| `account_map_put` | 6 | resource, address, nonce, balance, storage resource, code | `CAccountMap.put/5` | -| `account_map_delete` | 2 | resource, address | `CAccountMap.delete/2` | +| `account_map_lock` | 1 | account map resource | `CAccountMap.lock/1` — `frozen` only (O(1)) | +| `account_map_get` | 2 | resource, 20-byte address | `{nonce, balance, storage_root_hash_bin32, code}` — never a live storage resource | +| `account_map_put` | 6 | resource, addr, nonce, balance, storage, code | Storage arg: `:keep` \| `nil`/`[]` \| `[{k,v}]`; rejects frozen | +| `account_map_delete` | 2 | resource, address | Rejects frozen | +| `account_map_root_hash` | 1 | resource | `Chain.State.hash/1` | +| `account_map_state_roots` | 1 | resource | 544-byte `<>`; Edge `getstateroots` | | `account_map_size` | 1 | resource | `CAccountMap.size/1` | -| `account_map_to_list` | 1 | resource | `CAccountMap.to_list/1`, RPC export | -| `account_map_list_difference_raw` | 2 | two account map resources | `CAccountMap.list_difference/2`, `Chain.State.difference/2` | -| `account_map_uncompact_state` | 1 | compact account map or account map resource | `CAccountMap.uncompact_state/1`, `Chain.State.uncompact/1` | +| `account_map_to_list` | 1 | resource | `CAccountMap.to_list/1`, RPC account dumps | +| `account_map_difference_full` | 2 | two maps | `Chain.State.difference/2`; 6-tuple entries; trie-driven candidates | +| `account_map_apply_difference` | 2 | map, delta list | `Chain.State.apply_difference/2` | +| `account_map_compact` | 1 | account map | Dirty CPU; compact map for DB (OK frozen) | +| `account_map_uncompact_state` | 1 | compact or resource | Returns `{am, hash}` | +| `account_map_storage_put_map` | 2 | map, update list | EVM `su` hot path | +| `account_map_storage` | 3 | map, addr, spec | `{:get,k}` \| `{:range,k,n}` \| `:list` \| `:size` | +| `account_map_storage_roots` | 2 | map, addr | 544-byte `<>` | +| `account_map_proof` | 2 | map, addr | Account inclusion proof | +| `account_map_proof` | 3 | map, addr, key | Storage proof | + +**Frozen map:** `account_map_lock/1` sets map-level `frozen` only. Map mutations (`put`/`delete`/`apply_difference`/`storage_put_map`) fail while frozen. `get` / `to_list` export hashes only. Internal `state_trie` is never exported. `clone/1` forks writable wrappers for sync and speculative RPC/Edge/Shell. **Trust:** Erlang validates some shapes (e.g. `to_bytes32`), but the NIF must treat all binaries and terms as hostile (size, allocation, scheduler impact). @@ -49,8 +45,9 @@ | ID | Topic | Severity | CWE | Notes / mitigation | |----|--------|----------|-----|---------------------| -| F-1 | **`merkletree_import_map` map iterator leak** | High (resource leak / undefined behavior risk) | CWE-404 / CWE-775 | Early `return enif_make_badarg` inside `while` skipped `enif_map_iterator_destroy`. **Fixed:** `goto import_badarg` path destroys iterator. | -| F-2 | **`merkletree_difference` lock order** | High (deadlock) | CWE-833 | Concurrent `difference_raw(A,B)` vs `difference_raw(B,A)` could lock two trees in opposite order. **Fixed:** lock `first`/`second` by `SharedState*` address order, then use ordered locks. | +| F-1 | **Former `merkletree_import_map` map iterator leak** | High (historical) | CWE-404 / CWE-775 | Early `return enif_make_badarg` inside `while` skipped `enif_map_iterator_destroy`. **Fixed** before bare-tree export removal. | +| F-2 | **Former `merkletree_difference` lock order** | High (historical) | CWE-833 | Concurrent opposite-order dual-tree locks. **Fixed** with address-ordered locks. Account-map path uses `DualAccountMapLock` by map address. | +| F-2b | **`account_map_difference_full` dual-map order** | High (deadlock) | CWE-833 | Concurrent `(A,B)` vs `(B,A)` must lock maps in SharedAccountMap\* address order. Covered by fuzz S14 / ExUnit D-C8. | ### Medium @@ -58,24 +55,16 @@ |----|--------|----------|-----|--------| | F-3 | **`enif_binary_to_term` in `make_proof`** | Medium | CWE-502 / CWE-400 | Decodes Erlang term bytes embedded in proofs (`proof.type == 2`). Malicious or huge terms can stress atom table / allocation. Mitigations: trust only proofs from your own tree; consider max depth/size for `make_proof` recursion; optional caps via external format limits. | | F-4 | **Recursive `make_proof` / `do_get_proofs`** | Low–medium | CWE-674 | Depth follows trie height (bounded by key path; practical depth large for adversarial trie). Stack exhaustion theoretically possible on extreme trees; monitor if accepting untrusted trees. | -| F-5 | **Interaction `LockedStates::mtx` vs tree mutexes** | Medium | CWE-833 | **Fixed:** `enter_lock` pins `has_clone` on local/canonical under global+tree lock, drops global before `switch_local_to_canonical`, and map entries hold a `has_clone` ref. `difference_raw` snapshots pointers under global, bumps `read_pins` (not `has_clone`), releases global, then acquires dual tree locks. `leave_lock` erases map entries under global, releases global, then detaches under tree lock only. | +| F-5 | **Tree mutex vs map mutex** | Medium | CWE-833 | Historical bare-tree `enter_lock` / `difference_raw` paths removed. Map-owned storage destructors use `release_merkletree_shared` (tree mutex only). | | F-6 | **`make_writeable` COW under `Lock` RAII** | High | CWE-667 | **Fixed:** COW now transfers the held mutex (unlock old `SharedState`, lock new) instead of leaving `Lock` holding a destroyed mutex while mutating a forked tree. | -| F-7 | **`leave_lock` / canonical map UAF** | High | CWE-416 | **Fixed:** Map erase drops the map's `has_clone` ref; canonical pointers are re-validated before switch; `SharedState` is not deleted while referenced from the dedup map. | -| F-7b | **Abandoned `SharedState` after canonical switch** | High | CWE-404 | **Fixed:** `switch_local_to_canonical` enqueues unreferenced locals on `pending_orphans`; `try_reclaim_orphans` deletes when `has_clone == 0`; `difference_raw` pins `SharedState` during dual-lock; `account_map_lock` dedupes by `root_hash`. Monitor via `nif_stats_raw/0`. | -| F-6 | **Global `locked_states` / `stats_mutex` on upgrade** | Low | CWE-665 | `on_reload`/`on_upgrade` no-op; hot upgrade could leave stale globals. Acceptable if NIF not hot-reloaded. | - -### Information disclosure / introspection - -| ID | Topic | Severity | CWE | Notes | -|----|--------|----------|-----|--------| -| F-7 | **`malloc_info_raw`** | Low (info disclosure) | CWE-200 | Exposes glibc allocator XML; useful for debugging, aids heap fingerprinting. Restrict in production if threat model requires. | -| F-8 | **`struct_sizes_raw` / `memory_stats_raw`** | Low | CWE-200 | Exposes struct sizes and node/pair counts; aids exploit planning. Same as F-7. | +| F-7 | **SharedState reclaim on destructor** | High | CWE-416 / CWE-404 | **Fixed:** `release_merkletree_shared` drops `has_clone` under tree lock and deletes immediately when 0. `account_map_lock` is `frozen`-only. Monitor via `nif_stats_raw/0`. | +| F-6b | **`stats_mutex` on upgrade** | Low | CWE-665 | `on_reload`/`on_upgrade` no-op; hot upgrade could leave stale globals. Acceptable if NIF not hot-reloaded. | ### Denial of service | ID | Topic | Severity | CWE | Notes | |----|--------|----------|-----|--------| -| F-9 | **Unbounded work per NIF** | Medium | CWE-400 | Long-running exports use dirty schedulers: **CPU-bound** — `get_proofs_raw`, `difference_raw`, `to_list`, `import_map`, `count_zeros`, `memory_stats_raw`, `clone`, `account_map_clone`, `account_map_lock`, `account_map_to_list`, `account_map_list_difference_raw`, `account_map_uncompact_state`; **IO-bound** — `malloc_info_raw`. `account_map_put`/`delete` stay on normal schedulers; first COW copy uses `enif_consume_timeslice` every 1024 entries. Large dirty-NIF loops also call `enif_consume_timeslice` every 512 iterations. Ensure adequate dirty CPU schedulers at runtime (`+SDcpu` on heavy sync nodes). | +| F-9 | **Unbounded work per NIF** | Medium | CWE-400 | Long-running exports use dirty schedulers: **CPU-bound** — `account_map_clone`, `lock`, `to_list`, `difference_full`, `apply_difference`, `storage_put_map`, `storage`, `proof`, `compact`, `uncompact_state`, `count_zeros`. Large dirty-NIF loops call `enif_consume_timeslice` every 512 iterations. Ensure adequate dirty CPU schedulers (`+SDcpu`). | ### Memory safety (manual review) @@ -104,9 +93,9 @@ ## 3. Resource destructor / locking (summary) -- `destruct_merkletree_type` → `locked_states->leave_lock(mt)` → `Lock` on `mt->shared_state->mtx` → `destroy_shared_state` may `delete` `SharedState` when `has_clone == 0`. +- Map-owned storage `merkletree` resources: `destruct_merkletree_type` → `release_merkletree_shared(mt)` → tree mutex → delete when `has_clone == 0`. - Requires `mt->shared_state` non-null at destructor entry; normal paths maintain this until GC. -- **F-1** could have left iterators open; fixed to avoid VM resource leaks. +- Account maps use a separate resource type / destructor (SharedAccountMap refcount). --- @@ -118,32 +107,22 @@ | **cppcheck** | Not installed | Install `cppcheck` package for CI (`cppcheck --enable=all`). | | **scan-build** | Not installed | Install `clang-tools` / use `clang --analyze` as substitute. | | **clang-tidy** | Not in PATH | Add `run-clang-tidy` or IDE integration with `bugprone-*`, `cert-*`. | -| **ASan+UBSan+LSan NIF** | Built | `make nif CXXFLAGS='... -fsanitize=address,undefined,leak ...'` links; **loading under full `mix test` without `--no-start` failed** (app boot + ASan runtime interaction). Use targeted tests or `mix test --no-start` with sanitizer NIF if extended validation is needed. | -| **Valgrind** (`memcheck`) on `c_src/test` | Run | 0 invalid access errors; large “definitely lost” from harness not freeing final tree (test artifact). | -| **libFuzzer** | Run | `make fuzz_sha` — `fuzz_sha.cpp` + `sha.cpp`, 5000 runs, no crash. | -| **readelf** | Run | `GNU_RELRO` present; `BIND_NOW` not set — use `-Wl,-z,relro,-z,now` for full hardening if desired. | +| **ASan+UBSan+LSan NIF** | Built | Targeted harnesses preferred over full `mix test` under ASan. | +| **Valgrind** (`memcheck`) on `c_src/test` | Run | 0 invalid access errors; harness may leave intentional trees. | +| **libFuzzer** | Run | `make fuzz_sha` — no crash in 5000 runs. | +| **readelf** | Run | `GNU_RELRO` present; consider `-Wl,-z,relro,-z,now` for full hardening. | --- ## 5. Build / hardening recommendations - **Release flags:** Consider `-fstack-protector-strong`, `-D_FORTIFY_SOURCE=2`, `-Wl,-z,relro,-z,now` for `merkletree_nif.so`. -- **Reproducibility:** `-march=native` in `OPTS` ties binaries to CPU; use generic `-march=x86-64` (or equivalent) for release artifacts if needed. +- **Reproducibility:** `-march=native` in `OPTS` ties binaries to CPU; use generic `-march=x86-64` for release artifacts if needed. - **Debug:** `DEBUG` / `MERKLE_DEBUG_POOL` — avoid in production builds. -- **Sanitizer CI:** Job that builds NIF with sanitizers and runs `mix test test/cmerkletree_test.exs --no-start`. - ---- - -## 6. Code changes made during review - -1. **`merkletree_import_map`:** iterator destroyed on all error paths (`import_badarg`). -2. **`merkletree_difference`:** consistent lock ordering by `SharedState*` address. -3. **`fuzz_sha.cpp` + Makefile `fuzz_sha` target** for ongoing fuzzing of SHA. --- -## 7. Residual risk +## 6. Residual risk - No automated CodeQL/Semgrep rules in-repo; recommend adding CI. -- Full BEAM + ASan NIF requires runtime tuning (`ASAN_OPTIONS`, possibly `LD_PRELOAD`); use native harnesses for sanitizer depth. - Proof term decoding (`enif_binary_to_term`) remains a trust-boundary if proofs are ever deserialized from untrusted network bytes without verification. diff --git a/c_src/merkletree.cpp b/c_src/merkletree.cpp index 44c4a2f..8bca268 100644 --- a/c_src/merkletree.cpp +++ b/c_src/merkletree.cpp @@ -230,13 +230,6 @@ void Item::each(Tree &tree, std::function func) { } } -size_t Item::leaf_count(const Tree &tree) const { - if (this->is_leaf) { - return 1; - } - return tree.pool->get(left_id)->leaf_count(tree) + tree.pool->get(right_id)->leaf_count(tree); -} - Tree::Tree() : m_pair_allocator(std::make_shared>(*this)), pool(ItemPool::make()), @@ -329,12 +322,6 @@ void Tree::insert_item(pair_t &pair) { } } -void Tree::insert_items(pair_list_t &items) { - for (pair_t *pair : items) { - insert_item(*pair); - } -} - int hash_to_leafindex(pair_t &pair) { return pair.key_hash.last_byte() % LEAF_SIZE; } @@ -433,12 +420,6 @@ size_t Tree::size() { return count; } -size_t Tree::leaf_count() { - bin_t binary_buffer; - update_merkle_hash_count(root_id, binary_buffer); - return pool->get(root_id)->leaf_count(*this); -} - static size_t count_nodes(ItemPool *pool, ItemId item_id) { if (item_id == kItemNull) { return 0; diff --git a/c_src/merkletree.hpp b/c_src/merkletree.hpp index 1404b51..9fae332 100644 --- a/c_src/merkletree.hpp +++ b/c_src/merkletree.hpp @@ -260,7 +260,6 @@ struct Item { Item& operator=(const Item &other) = delete; void each(Tree &tree, std::function func); - size_t leaf_count(const Tree &tree) const; uint256_t *ensure_hashes(); const uint256_t *hashes_ro() const { return hash_values; } @@ -289,7 +288,6 @@ class Tree { void insert_item(pair_t &pair); void insert_item(bin_t &key, uint256_t &value); - void insert_items(pair_list_t &items); void delete_item(bin_t &key) { uint256_t null_value = {}; insert_item(key, null_value); @@ -300,7 +298,6 @@ class Tree { uint256_t root_hash(); uint256_t* root_hashes(); size_t size(); - size_t leaf_count(); size_t node_count() const; void each(std::function func) { if (root_id != kItemNull) { diff --git a/c_src/nif.cpp b/c_src/nif.cpp index dc1b10d..fd904e9 100644 --- a/c_src/nif.cpp +++ b/c_src/nif.cpp @@ -12,19 +12,14 @@ extern "C" { #include "merkletree.hpp" #include "rlp.hpp" #include -#include #include #include #include #include #include #include -#ifdef __GLIBC__ -#include -#endif static constexpr size_t kNifTimesliceInterval = 512; -static constexpr size_t kAccountMapCowProgressInterval = 1024; static void nif_loop_progress(ErlNifEnv *env, size_t i) { @@ -33,13 +28,6 @@ static void nif_loop_progress(ErlNifEnv *env, size_t i) } } -static void accountmap_cow_copy_progress(ErlNifEnv *env, size_t i) -{ - if (env && i > 0 && (i % kAccountMapCowProgressInterval) == 0) { - (void)enif_consume_timeslice(env, 1); - } -} - static void print(const char *msg); static ErlNifResourceType *merkletree_type = NULL; static ErlNifResourceType *accountmap_type = NULL; @@ -49,12 +37,6 @@ static ERL_NIF_TERM make_atom(ErlNifEnv *env, const char *atom_name); static ERL_NIF_TERM make_binary(ErlNifEnv *env, uint8_t *data, size_t size); static volatile int shared_states = 0; static volatile int resources = 0; -static int locked_states_cnt = 0; -static int orphan_shared_states = 0; - -class LockedStates; -static LockedStates* locked_states; - #ifdef DEBUG #define STAT(cmd) { enif_mutex_lock(stats_mutex); cmd; enif_mutex_unlock(stats_mutex); } @@ -62,7 +44,7 @@ static void print(const char *msg) { static int ops = 0; if (ops++ % 10000 == 0) { - fprintf(stderr, "%s [shared_states=%d] [locked_states=%d] [resources=%d]\n", msg, shared_states, locked_states_cnt, resources); fflush(stderr); + fprintf(stderr, "%s [shared_states=%d] [resources=%d]\n", msg, shared_states, resources); fflush(stderr); } } #else @@ -74,12 +56,10 @@ class SharedState { public: ErlNifMutex *mtx; int has_clone; - std::atomic read_pins; Tree tree; SharedState() : tree() { mtx = enif_mutex_create((char*)"merkletree_mutex"); has_clone = 0; - read_pins.store(0, std::memory_order_relaxed); enif_mutex_lock(stats_mutex); shared_states++; enif_mutex_unlock(stats_mutex); @@ -89,7 +69,6 @@ class SharedState { SharedState(SharedState &other) : tree(other.tree) { mtx = enif_mutex_create((char*)"merkletree_mutex"); has_clone = 0; - read_pins.store(0, std::memory_order_relaxed); enif_mutex_lock(stats_mutex); shared_states++; enif_mutex_unlock(stats_mutex); @@ -106,13 +85,16 @@ class SharedState { }; struct merkletree { - bool locked; SharedState *shared_state; }; static merkletree *empty_storage_tree = nullptr; static merkletree *alloc_merkletree_resource(); +static bool term_is_nil(ErlNifEnv *env, ERL_NIF_TERM term); +static bool term_is_atom_named(ErlNifEnv *env, ERL_NIF_TERM term, const char *name); +static ERL_NIF_TERM make_tree_roots_blob(ErlNifEnv *env, Tree &tree); +static merkletree *storage_from_kv_list(ErlNifEnv *env, ERL_NIF_TERM list); class Lock { ErlNifMutex *mtx; @@ -133,18 +115,13 @@ class Lock { } }; -/* Lock one or two SharedState mutexes in a fixed address order (matches difference_raw). */ +/* Lock one or two SharedState mutexes in a fixed address order. */ class SharedStateLock { SharedState *first; SharedState *second; bool dual; public: - explicit SharedStateLock(SharedState *state) - : first(state), second(nullptr), dual(false) { - enif_mutex_lock(first->mtx); - } - SharedStateLock(SharedState *s1, SharedState *s2) { if (s1 == s2) { first = s1; @@ -184,34 +161,6 @@ class SharedStateLock { } }; -static void switch_local_to_canonical(merkletree *mt, SharedState *local, SharedState *canonical); - -static bool shared_state_reclaimable(SharedState *state) { - return state != nullptr && - state->has_clone == 0 && - state->read_pins.load(std::memory_order_acquire) == 0; -} - -static void classify_shared_state_reclaim(SharedState *state, SharedState **dead, SharedState **orphan) { - if (shared_state_reclaimable(state)) { - *dead = state; - } else { - *orphan = state; - } -} - -static void pin_shared_state_read(SharedState *state) { - if (state != nullptr) { - state->read_pins.fetch_add(1, std::memory_order_acq_rel); - } -} - -static void unpin_shared_state_read(SharedState *state) { - if (state != nullptr) { - state->read_pins.fetch_sub(1, std::memory_order_acq_rel); - } -} - static void keep_storage_in_map(merkletree *mt); static void release_storage_from_map(merkletree *mt); static merkletree *clone_merkletree_locked(merkletree *mt); @@ -254,23 +203,18 @@ struct StorageSlot { struct CompactStorage { std::vector slots; + // Cached storage root; filled on uncompact seed or first hash. Writes materialize + // into a live trie and drop this shared_ptr (parent clones keep a valid cache). + mutable uint256_t root_hash; + mutable bool has_root = false; }; -static std::unique_ptr clone_compact_storage(const CompactStorage *src) -{ - if (src == nullptr) { - return nullptr; - } - auto dup = std::make_unique(); - dup->slots = src->slots; - return dup; -} - struct AccountEntry { uint64_t nonce; uint256_t balance; merkletree *storage; - std::unique_ptr compact_storage; + // Phase D: shared across forks until materialize / replace; no deep slot copy on clone. + std::shared_ptr compact_storage; bin_t code; AccountEntry() @@ -278,7 +222,7 @@ struct AccountEntry { AccountEntry(const AccountEntry &other) : nonce(other.nonce), balance(other.balance), storage(other.storage), - compact_storage(clone_compact_storage(other.compact_storage.get())), code(other.code) + compact_storage(other.compact_storage), code(other.code) { } @@ -296,7 +240,7 @@ struct AccountEntry { balance = other.balance; storage = other.storage; code = other.code; - compact_storage = clone_compact_storage(other.compact_storage.get()); + compact_storage = other.compact_storage; } return *this; } @@ -315,24 +259,55 @@ struct AccountEntry { } }; +// Phase D: deep-copy before in-place slot / has_root mutation when shared. +// Hot-path writes use materialize_storage + drop instead (read shared slots, then +// reset this entry's pointer so parents keep the shared CompactStorage). +static void __attribute__((unused)) ensure_unique_compact(AccountEntry &entry) +{ + if (!entry.compact_storage) { + return; + } + if (entry.compact_storage.use_count() > 1) { + entry.compact_storage = std::make_shared(*entry.compact_storage); + } +} + +static void __attribute__((unused)) invalidate_compact_root(AccountEntry &entry) +{ + if (!entry.compact_storage) { + return; + } + ensure_unique_compact(entry); + entry.compact_storage->has_root = false; +} + static void release_entry_storage(AccountEntry &entry); static merkletree *materialize_storage(AccountEntry &entry); +static bool storage_root_hash_for_entry(ErlNifEnv *env, const AccountEntry &entry, + uint256_t &out, size_t progress_base); static ERL_NIF_TERM account_entry_to_term(ErlNifEnv *env, AccountEntry &entry); class SharedAccountMap { public: ErlNifMutex *mtx; - int has_clone; + bool frozen; + merkletree *state_trie; std::unordered_map accounts; - SharedAccountMap() : has_clone(0) { + SharedAccountMap() : frozen(false), state_trie(nullptr) { mtx = enif_mutex_create((char*)"accountmap_mutex"); + // alloc_merkletree_resource starts at refcount 1 — that ref is map-owned. + state_trie = alloc_merkletree_resource(); } ~SharedAccountMap() { for (auto &entry : accounts) { release_entry_storage(entry.second); } + if (state_trie != nullptr) { + release_storage_from_map(state_trie); + state_trie = nullptr; + } enif_mutex_destroy(mtx); } }; @@ -341,6 +316,8 @@ struct accountmap { SharedAccountMap *shared; }; +static AccountEntry &ensure_account_entry(SharedAccountMap *shared, const uint160_t &addr); + class AccountMapLock { ErlNifMutex *mtx; public: @@ -413,43 +390,66 @@ static void release_storage_from_map(merkletree *mt) static void release_entry_storage(AccountEntry &entry) { - if (entry.storage != nullptr) { + if (entry.storage != nullptr && entry.storage != empty_storage_tree) { release_storage_from_map(entry.storage); - entry.storage = nullptr; } + entry.storage = nullptr; entry.compact_storage.reset(); } -static SharedAccountMap *cow_copy_accountmap(SharedAccountMap *other, ErlNifEnv *env) -{ - SharedAccountMap *copy = new SharedAccountMap(); - copy->accounts = other->accounts; - size_t i = 0; - for (auto &entry : copy->accounts) { - if (!entry.second.compact_storage && entry.second.storage != nullptr) { - keep_storage_in_map(entry.second.storage); - } - i++; - accountmap_cow_copy_progress(env, i); - } - return copy; -} - -// Allocates a new merkletree resource sharing mt's SharedState (has_clone += 1) with -// locked = false so a fork can COW-write. Returns a resource with refcount 1 (the -// caller's ownership): pair with enif_make_resource + enif_release_resource for an -// Elixir term, or enif_keep_resource + enif_release_resource for C-side ownership. +// Allocates a new merkletree resource sharing mt's SharedState (has_clone += 1). +// Returns a resource with refcount 1 for C-side ownership (keep/release as needed). static merkletree *clone_merkletree_locked(merkletree *mt) { Lock lock(mt); merkletree *clone = (merkletree*)enif_alloc_resource(merkletree_type, sizeof(merkletree)); STAT(resources++); clone->shared_state = mt->shared_state; - clone->locked = false; clone->shared_state->has_clone += 1; return clone; } +// New SharedAccountMap with cloned state_trie + distinct storage wrappers that share +// SharedState until first write. Caller owns the returned pointer. +static SharedAccountMap *fork_shared_accountmap(ErlNifEnv *env, SharedAccountMap *src) +{ + SharedAccountMap *new_shared = new SharedAccountMap(); + new_shared->accounts = src->accounts; + release_storage_from_map(new_shared->state_trie); + { + merkletree *st = clone_merkletree_locked(src->state_trie); + new_shared->state_trie = st; + keep_storage_in_map(st); + enif_release_resource(st); + } + + std::unordered_map storage_clones; + size_t i = 0; + for (auto &entry : new_shared->accounts) { + i++; + if (entry.second.compact_storage) { + nif_loop_progress(env, i); + continue; + } + merkletree *orig = entry.second.storage; + if (orig == nullptr) { + continue; + } + auto it = storage_clones.find(orig); + if (it == storage_clones.end()) { + merkletree *storage_clone = clone_merkletree_locked(orig); + it = storage_clones.insert({orig, storage_clone}).first; + } + enif_keep_resource(it->second); + entry.second.storage = it->second; + nif_loop_progress(env, i); + } + for (auto &kv : storage_clones) { + enif_release_resource(kv.second); + } + return new_shared; +} + static bool get_address(ErlNifEnv *env, ERL_NIF_TERM term, uint160_t &out) { ErlNifBinary bin; @@ -632,315 +632,43 @@ static ERL_NIF_TERM code_to_term(ErlNifEnv *env, const bin_t &code) return make_binary(env, (uint8_t*)code.data(), code.size()); } -static bool make_writeable_accountmap(ErlNifEnv *env, accountmap *am) +static bool make_writeable_accountmap(accountmap *am) { - if (am->shared->has_clone > 0) { - am->shared->has_clone -= 1; - am->shared = cow_copy_accountmap(am->shared, env); - } - return true; + return !am->shared->frozen; } static void destroy_shared_accountmap(accountmap *am, AccountMapLock &lock) { - if (am->shared->has_clone == 0) { - lock.unlock(); - delete am->shared; - } else { - am->shared->has_clone -= 1; - } + lock.unlock(); + delete am->shared; am->shared = NULL; } -class LockedStates { -public: - std::unordered_map states; - std::vector pending_orphans; - ErlNifMutex *mtx; - - LockedStates() { - mtx = enif_mutex_create((char*)"locked_states_mutex"); - } - - ~LockedStates() { - enif_mutex_destroy(mtx); - } - - void enqueue_orphan(SharedState *state) { - if (state == nullptr) { - return; - } - enif_mutex_lock(mtx); - if (std::find(pending_orphans.begin(), pending_orphans.end(), state) == - pending_orphans.end()) { - pending_orphans.push_back(state); - orphan_shared_states = (int)pending_orphans.size(); - } - enif_mutex_unlock(mtx); - } - - void remove_pending_orphan(SharedState *state) { - if (state == nullptr) { - return; - } - enif_mutex_lock(mtx); - auto it = std::find(pending_orphans.begin(), pending_orphans.end(), state); - if (it != pending_orphans.end()) { - pending_orphans.erase(it); - orphan_shared_states = (int)pending_orphans.size(); - } - enif_mutex_unlock(mtx); - } - - void try_reclaim_orphans() { - std::vector snapshot; - enif_mutex_lock(mtx); - snapshot = pending_orphans; - enif_mutex_unlock(mtx); - - for (SharedState *candidate : snapshot) { - enif_mutex_lock(mtx); - auto it = std::find(pending_orphans.begin(), pending_orphans.end(), candidate); - if (it == pending_orphans.end()) { - enif_mutex_unlock(mtx); - continue; - } - pending_orphans.erase(it); - orphan_shared_states = (int)pending_orphans.size(); - enif_mutex_unlock(mtx); - - if (enif_mutex_trylock(candidate->mtx) != 0) { - enqueue_orphan(candidate); - continue; - } - if (!shared_state_reclaimable(candidate)) { - enif_mutex_unlock(candidate->mtx); - enqueue_orphan(candidate); - continue; - } - enif_mutex_unlock(candidate->mtx); - delete candidate; - } - } - - void switch_to_canonical_locked(merkletree *mt, SharedState *local, SharedState *canonical) { - enif_mutex_lock(local->mtx); - local->has_clone += 1; - enif_mutex_unlock(local->mtx); - enif_mutex_lock(canonical->mtx); - canonical->has_clone += 1; - enif_mutex_unlock(canonical->mtx); - unpin_shared_state_read(canonical); - switch_local_to_canonical(mt, local, canonical); - enif_mutex_lock(mtx); - if (mt->shared_state != canonical) { - enif_mutex_lock(canonical->mtx); - if (canonical->has_clone > 0) { - canonical->has_clone -= 1; - } - enif_mutex_unlock(canonical->mtx); - } - enif_mutex_unlock(mtx); - try_reclaim_orphans(); - } - - void enter_lock(merkletree *mt) { - locked_states_cnt = (int)states.size(); - print("ENTER_LOCK"); - - SharedState *local = nullptr; - uint256_t root_hash; - - { - Lock lock(mt); - mt->locked = true; - local = mt->shared_state; - root_hash = local->tree.root_hash(); - } - - enif_mutex_lock(mtx); - SharedState *canonical = nullptr; - auto it = states.find(root_hash); - if (it != states.end()) { - if (it->second == local) { - enif_mutex_unlock(mtx); - enif_mutex_lock(local->mtx); - local->has_clone += 1; - enif_mutex_unlock(local->mtx); - return; - } - canonical = it->second; - pin_shared_state_read(canonical); - } else { - states[root_hash] = local; - enif_mutex_unlock(mtx); - enif_mutex_lock(local->mtx); - local->has_clone += 1; - enif_mutex_unlock(local->mtx); - return; - } - enif_mutex_unlock(mtx); - - if (canonical) { - switch_to_canonical_locked(mt, local, canonical); - } - } - - /* Lock a trie whose root_hash is already registered; repoint to canonical SharedState. */ - void apply_canonical_lock(merkletree *mt, const uint256_t &root_hash) { - enif_mutex_lock(mtx); - auto it = states.find(root_hash); - if (it == states.end()) { - enif_mutex_unlock(mtx); - enter_lock(mt); - return; - } - - SharedState *canonical = it->second; - pin_shared_state_read(canonical); - enif_mutex_unlock(mtx); - - SharedState *local = nullptr; - { - Lock lock(mt); - mt->locked = true; - local = mt->shared_state; - if (local == canonical) { - enif_mutex_lock(canonical->mtx); - canonical->has_clone += 1; - enif_mutex_unlock(canonical->mtx); - unpin_shared_state_read(canonical); - return; - } - } - - switch_to_canonical_locked(mt, local, canonical); - } - - void leave_lock(merkletree *mt) { - locked_states_cnt = (int)states.size(); - print("LEAVE_LOCK"); - - SharedState *state = mt->shared_state; - if (state == nullptr) { - return; - } - - bool was_locked = mt->locked; - mt->locked = false; - - uint256_t root_hash; - SharedState *dead = nullptr; - SharedState *orphan = nullptr; - bool erase_map_entry = false; - - if (was_locked) { - enif_mutex_lock(mtx); - enif_mutex_lock(state->mtx); - root_hash = state->tree.root_hash(); - - if (state->has_clone > 0) { - state->has_clone -= 1; - } - mt->shared_state = nullptr; - erase_map_entry = (state->has_clone == 0); - - if (state->has_clone == 0) { - classify_shared_state_reclaim(state, &dead, &orphan); - } - - if (erase_map_entry) { - auto it = states.find(root_hash); - if (it != states.end() && it->second == state) { - states.erase(it); - } - } - - enif_mutex_unlock(state->mtx); - enif_mutex_unlock(mtx); - } else { - enif_mutex_lock(state->mtx); - if (state->has_clone == 0) { - classify_shared_state_reclaim(state, &dead, &orphan); - mt->shared_state = nullptr; - } else { - state->has_clone -= 1; - mt->shared_state = nullptr; - } - enif_mutex_unlock(state->mtx); - } - - if (dead != nullptr) { - remove_pending_orphan(dead); - delete dead; - } - if (orphan != nullptr) { - enqueue_orphan(orphan); - } - - try_reclaim_orphans(); - } - - int locked_count() const { - return (int)states.size(); - } - - int orphan_count() const { - return orphan_shared_states; - } -}; - -static void switch_local_to_canonical(merkletree *mt, SharedState *local, SharedState *canonical) +/* Release map-owned merkletree resource SharedState (destructor path). */ +static void release_merkletree_shared(merkletree *mt) { - if (local == canonical) { - return; - } - - SharedState *lo = local < canonical ? local : canonical; - SharedState *hi = local < canonical ? canonical : local; - enif_mutex_lock(lo->mtx); - enif_mutex_lock(hi->mtx); - - if (mt->shared_state != local) { - enif_mutex_unlock(hi->mtx); - enif_mutex_unlock(lo->mtx); + SharedState *state = mt->shared_state; + if (state == nullptr) { return; } - SharedState *abandoned = nullptr; - if (local->has_clone == 0) { + SharedState *to_delete = nullptr; + enif_mutex_lock(state->mtx); + if (state->has_clone == 0) { + to_delete = state; mt->shared_state = nullptr; - enif_mutex_unlock(local->mtx); - if (shared_state_reclaimable(local)) { - abandoned = local; - } else { - locked_states->enqueue_orphan(local); - } } else { - local->has_clone -= 1; + state->has_clone -= 1; mt->shared_state = nullptr; - enif_mutex_unlock(local->mtx); - if (local->has_clone == 0) { - if (shared_state_reclaimable(local)) { - abandoned = local; - } else { - locked_states->enqueue_orphan(local); - } - } } - mt->shared_state = canonical; + enif_mutex_unlock(state->mtx); - if (local == lo) { - enif_mutex_unlock(hi->mtx); - } else { - enif_mutex_unlock(lo->mtx); - } - - if (abandoned != nullptr) { - locked_states->enqueue_orphan(abandoned); + if (to_delete != nullptr) { + delete to_delete; } } + static ERL_NIF_TERM make_atom(ErlNifEnv *env, const char *atom_name) { @@ -961,39 +689,10 @@ make_binary(ErlNifEnv *env, uint8_t *data, size_t size) return term; } - -static ERL_NIF_TERM -merkletree_new(ErlNifEnv *env, int argc, const ERL_NIF_TERM[] /*argv[]*/) -{ - if (argc != 0) return enif_make_badarg(env); - merkletree *mt = alloc_merkletree_resource(); - ERL_NIF_TERM res = enif_make_resource(env, mt); - enif_release_resource(mt); - return res; -} - -static ERL_NIF_TERM -merkletree_clone(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) -{ - merkletree *mt; - if (argc != 1) return enif_make_badarg(env); - if (!enif_get_resource(env, argv[0], merkletree_type, (void **) &mt)) return enif_make_badarg(env); - - merkletree *clone = clone_merkletree_locked(mt); - ERL_NIF_TERM res = enif_make_resource(env, clone); - enif_release_resource(clone); - return res; -} - - -/* Caller must hold mt->shared_state->mtx. On COW, releases the old mutex and - * acquires the new SharedState mutex before returning. */ -static bool make_writeable_locked(merkletree *mt) +/* In-place COW: when SharedState is shared (has_clone > 0), detach this resource onto a + * privately-owned SharedState copy. Caller must hold mt->shared_state->mtx. */ +static void make_writeable_locked(merkletree *mt) { - if (mt->locked) { - return false; - } - SharedState *state = mt->shared_state; if (state->has_clone > 0) { state->has_clone -= 1; @@ -1002,7 +701,6 @@ static bool make_writeable_locked(merkletree *mt) enif_mutex_lock(mt->shared_state->mtx); print("CREATING (UNCLONING)"); } - return true; } static bool decode_storage_slot(const ErlNifBinary &key_binary, @@ -1028,45 +726,6 @@ static bool insert_binary_pair(Tree &tree, const ErlNifBinary &key_binary, return true; } -static bool insert_binary_terms(ErlNifEnv *env, Tree &tree, ERL_NIF_TERM key_term, - ERL_NIF_TERM value_term, bin_t &key_scratch) -{ - ErlNifBinary key_binary, value_binary; - if (!enif_inspect_binary(env, key_term, &key_binary)) { - return false; - } - if (!enif_inspect_binary(env, value_term, &value_binary)) { - return false; - } - return insert_binary_pair(tree, key_binary, value_binary, key_scratch); -} - -static ERL_NIF_TERM -merkletree_insert_item(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) -{ - merkletree *mt; - ErlNifBinary key_binary, value_binary; - - if (argc != 3) return enif_make_badarg(env); - if (!enif_get_resource(env, argv[0], merkletree_type, (void **) &mt)) return enif_make_badarg(env); - - if (!enif_inspect_binary(env, argv[1], &key_binary)) return enif_make_badarg(env); - if (!enif_inspect_binary(env, argv[2], &value_binary)) return enif_make_badarg(env); - - enif_mutex_lock(mt->shared_state->mtx); - if (!make_writeable_locked(mt)) { - enif_mutex_unlock(mt->shared_state->mtx); - return enif_make_badarg(env); - } - bin_t key_scratch; - if (!insert_binary_pair(mt->shared_state->tree, key_binary, value_binary, key_scratch)) { - enif_mutex_unlock(mt->shared_state->mtx); - return enif_make_badarg(env); - } - enif_mutex_unlock(mt->shared_state->mtx); - return argv[0]; -} - namespace { struct RangeEntry { @@ -1116,67 +775,7 @@ static size_t get_range_entries(Tree &tree, const bin_t &base_key, size_t count, } // namespace static ERL_NIF_TERM -merkletree_get_range(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) -{ - merkletree *mt; - ErlNifBinary key_binary; - unsigned count; - - if (argc != 3) return enif_make_badarg(env); - if (!enif_get_resource(env, argv[0], merkletree_type, (void **) &mt)) return enif_make_badarg(env); - if (!enif_inspect_binary(env, argv[1], &key_binary)) return enif_make_badarg(env); - if (key_binary.size != 32) return enif_make_badarg(env); - if (!enif_get_uint(env, argv[2], &count)) return enif_make_badarg(env); - if (count < 1 || count > 256) return enif_make_badarg(env); - - Lock lock(mt); - - bin_t key; - key.insert(key.end(), key_binary.data, key_binary.data + key_binary.size); - - std::vector entries(count); - size_t n = get_range_entries(mt->shared_state->tree, key, count, entries.data()); - - ERL_NIF_TERM list = enif_make_list(env, 0); - for (size_t i = n; i > 0; i--) { - RangeEntry &entry = entries[i - 1]; - ERL_NIF_TERM key_term = make_binary(env, entry.key.data(), entry.key.size()); - ERL_NIF_TERM value_term = make_binary(env, entry.value.data(), 32); - ERL_NIF_TERM pair = enif_make_tuple2(env, key_term, value_term); - list = enif_make_list_cell(env, pair, list); - } - - return list; -} - -static ERL_NIF_TERM -merkletree_get_item(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) -{ - merkletree *mt; - ErlNifBinary key_binary; - - if (argc != 2) return enif_make_badarg(env); - if (!enif_get_resource(env, argv[0], merkletree_type, (void **) &mt)) return enif_make_badarg(env); - Lock lock(mt); - if (!enif_inspect_binary(env, argv[1], &key_binary)) return enif_make_badarg(env); - if (key_binary.size != 32) return enif_make_badarg(env); - - bin_t key; - key.insert(key.end(), key_binary.data, key_binary.data + key_binary.size); - pair_t *pair = mt->shared_state->tree.get_item(std::move(key)); - - if (pair == nullptr) { - return make_atom(env, "nil"); - } - - ERL_NIF_TERM key_term = argv[1]; - ERL_NIF_TERM value_term = make_binary(env, pair->value.data(), 32); - ERL_NIF_TERM hash_term = make_binary(env, pair->key_hash.data(), 32); - return enif_make_tuple3(env, key_term, value_term, hash_term); -} - -static ERL_NIF_TERM -make_proof(ErlNifEnv *env, proof_t& proof) +make_proof(ErlNifEnv *env, proof_t& proof) { switch (proof.type) { case 0: @@ -1196,249 +795,6 @@ make_proof(ErlNifEnv *env, proof_t& proof) } } -static ERL_NIF_TERM -merkletree_get_proofs(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) -{ - merkletree *mt; - ErlNifBinary key_binary; - - if (argc != 2) return enif_make_badarg(env); - if (!enif_get_resource(env, argv[0], merkletree_type, (void **) &mt)) return enif_make_badarg(env); - Lock lock(mt); - if (!enif_inspect_binary(env, argv[1], &key_binary)) return enif_make_badarg(env); - - bin_t key; - key.insert(key.end(), key_binary.data, key_binary.data + key_binary.size); - proof_t proof = mt->shared_state->tree.get_proofs(key); - return make_proof(env, proof); -} - -static ERL_NIF_TERM -merkletree_to_list(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) -{ - merkletree *mt; - - if (argc != 1) return enif_make_badarg(env); - if (!enif_get_resource(env, argv[0], merkletree_type, (void **) &mt)) return enif_make_badarg(env); - Lock lock(mt); - ERL_NIF_TERM list = enif_make_list(env, 0); - size_t i = 0; - mt->shared_state->tree.each([&](pair_t &pair) { - i++; - ERL_NIF_TERM key_term = make_binary(env, pair.key.data(), pair.key.size()); - ERL_NIF_TERM value_term = make_binary(env, pair.value.data(), 32); - ERL_NIF_TERM tuple = enif_make_tuple2(env, key_term, value_term); - list = enif_make_list_cell(env, tuple, list); - nif_loop_progress(env, i); - }); - return list; -} - -static ERL_NIF_TERM -merkletree_lock(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) -{ - merkletree *mt; - if (argc != 1) return enif_make_badarg(env); - if (!enif_get_resource(env, argv[0], merkletree_type, (void **) &mt)) return enif_make_badarg(env); - locked_states->enter_lock(mt); - return argv[0]; -} - -static ERL_NIF_TERM -merkletree_difference(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) -{ - merkletree *mt1; - merkletree *mt2; - - if (argc != 2) return enif_make_badarg(env); - if (!enif_get_resource(env, argv[0], merkletree_type, (void **) &mt1)) return enif_make_badarg(env); - if (!enif_get_resource(env, argv[1], merkletree_type, (void **) &mt2)) return enif_make_badarg(env); - - if (mt1 == mt2) { - return enif_make_list(env, 0); - } - - SharedState *s1; - SharedState *s2; - enif_mutex_lock(locked_states->mtx); - s1 = mt1->shared_state; - s2 = mt2->shared_state; - if (s1 == s2) { - enif_mutex_unlock(locked_states->mtx); - return enif_make_list(env, 0); - } - pin_shared_state_read(s1); - pin_shared_state_read(s2); - enif_mutex_unlock(locked_states->mtx); - - if (s1 == nullptr || s2 == nullptr) { - unpin_shared_state_read(s2); - unpin_shared_state_read(s1); - return enif_make_list(env, 0); - } - - SharedStateLock state_lock(s1, s2); - - Tree output; - s1->tree.difference(s2->tree, output); - s2->tree.difference(s1->tree, output); - - ERL_NIF_TERM list = enif_make_list(env, 0); - size_t i = 0; - output.each([&](pair_t &pair) { - i++; - ERL_NIF_TERM key_term = make_binary(env, pair.key.data(), pair.key.size()); - - auto pair1 = s1->tree.get_item(pair); - auto pair2 = s2->tree.get_item(pair); - - ERL_NIF_TERM value1_term = pair1 == nullptr ? make_atom(env, "nil") : make_binary(env, pair1->value.data(), 32); - ERL_NIF_TERM value2_term = pair2 == nullptr ? make_atom(env, "nil") : make_binary(env, pair2->value.data(), 32); - ERL_NIF_TERM tuple = enif_make_tuple2(env, value1_term, value2_term); - tuple = enif_make_tuple2(env, key_term, tuple); - list = enif_make_list_cell(env, tuple, list); - nif_loop_progress(env, i); - }); - - unpin_shared_state_read(s2); - unpin_shared_state_read(s1); - state_lock.unlock(); - locked_states->try_reclaim_orphans(); - return list; -} - -static ERL_NIF_TERM -merkletree_import_map(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) -{ - merkletree *mt; - - if (argc != 2) return enif_make_badarg(env); - if (!enif_get_resource(env, argv[0], merkletree_type, (void **) &mt)) return enif_make_badarg(env); - if (!enif_is_map(env, argv[1])) return enif_make_badarg(env); - - enif_mutex_lock(mt->shared_state->mtx); - if (!make_writeable_locked(mt)) { - enif_mutex_unlock(mt->shared_state->mtx); - return enif_make_badarg(env); - } - - ERL_NIF_TERM key, value; - ErlNifMapIterator iter; - enif_map_iterator_create(env, argv[1], &iter, ERL_NIF_MAP_ITERATOR_FIRST); - - size_t i = 0; - bin_t key_scratch; - while (enif_map_iterator_get_pair(env, &iter, &key, &value)) { - i++; - if (!insert_binary_terms(env, mt->shared_state->tree, key, value, key_scratch)) { - enif_mutex_unlock(mt->shared_state->mtx); - goto import_badarg; - } - enif_map_iterator_next(env, &iter); - nif_loop_progress(env, i); - } - enif_mutex_unlock(mt->shared_state->mtx); - enif_map_iterator_destroy(env, &iter); - return argv[0]; - -import_badarg: - enif_map_iterator_destroy(env, &iter); - return enif_make_badarg(env); -} - -static ERL_NIF_TERM -merkletree_root_hash(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) -{ - merkletree *mt; - if (argc != 1) return enif_make_badarg(env); - if (!enif_get_resource(env, argv[0], merkletree_type, (void **) &mt)) return enif_make_badarg(env); - Lock lock(mt); - auto root_hash = mt->shared_state->tree.root_hash(); - return make_binary(env, root_hash.data(), 32); -} - -static ERL_NIF_TERM -merkletree_hash(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) -{ - ErlNifBinary key_binary; - - if (argc != 1) return enif_make_badarg(env); - if (!enif_inspect_binary(env, argv[0], &key_binary)) return enif_make_badarg(env); - - uint256_t hash = {}; - sha((const uint8_t*)key_binary.data, key_binary.size, hash.data()); - return make_binary(env, hash.data(), 32); -} - -static ERL_NIF_TERM -merkletree_root_hashes(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) -{ - merkletree *mt; - if (argc != 1) return enif_make_badarg(env); - if (!enif_get_resource(env, argv[0], merkletree_type, (void **) &mt)) return enif_make_badarg(env); - Lock lock(mt); - auto root_hashes = mt->shared_state->tree.root_hashes(); - return make_binary(env, (uint8_t*)root_hashes, 32*16); -} - -static ERL_NIF_TERM -merkletree_size(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) -{ - merkletree *mt; - if (argc != 1) return enif_make_badarg(env); - if (!enif_get_resource(env, argv[0], merkletree_type, (void **) &mt)) return enif_make_badarg(env); - Lock lock(mt); - auto size = mt->shared_state->tree.size(); - return enif_make_uint(env, size); -} - -static ERL_NIF_TERM -merkletree_bucket_count(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) -{ - merkletree *mt; - if (argc != 1) return enif_make_badarg(env); - if (!enif_get_resource(env, argv[0], merkletree_type, (void **) &mt)) return enif_make_badarg(env); - Lock lock(mt); - auto size = mt->shared_state->tree.leaf_count(); - return enif_make_uint(env, size); -} - -static ERL_NIF_TERM -merkletree_struct_sizes(ErlNifEnv *env, int argc, const ERL_NIF_TERM /*argv*/[]) -{ - if (argc != 0) { - return enif_make_badarg(env); - } - return enif_make_tuple5(env, - enif_make_uint64(env, sizeof(Item)), - enif_make_uint64(env, sizeof(pair_t)), - enif_make_uint64(env, sizeof(pair_list_t)), - enif_make_uint64(env, sizeof(Tree)), - enif_make_uint64(env, MERKLE_STRIPE_SIZE)); -} - -static ERL_NIF_TERM -merkletree_memory_stats(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) -{ - merkletree *mt; - if (argc != 1) { - return enif_make_badarg(env); - } - if (!enif_get_resource(env, argv[0], merkletree_type, (void **) &mt)) { - return enif_make_badarg(env); - } - Lock lock(mt); - Tree &t = mt->shared_state->tree; - size_t nodes = t.node_count(); - size_t pairs = t.size(); - uint64_t approx = - (uint64_t)nodes * (uint64_t)sizeof(Item) + (uint64_t)pairs * (uint64_t)sizeof(pair_t); - return enif_make_tuple3(env, - enif_make_uint64(env, nodes), - enif_make_uint64(env, pairs), - enif_make_uint64(env, approx)); -} - static ERL_NIF_TERM merkletree_nif_stats(ErlNifEnv *env, int argc, const ERL_NIF_TERM /*argv*/[]) { @@ -1446,8 +802,6 @@ merkletree_nif_stats(ErlNifEnv *env, int argc, const ERL_NIF_TERM /*argv*/[]) return enif_make_badarg(env); } - int locked = 0; - int orphans = 0; int shared = 0; int res = 0; @@ -1456,43 +810,14 @@ merkletree_nif_stats(ErlNifEnv *env, int argc, const ERL_NIF_TERM /*argv*/[]) res = resources; enif_mutex_unlock(stats_mutex); - if (locked_states != nullptr) { - enif_mutex_lock(locked_states->mtx); - locked = locked_states->locked_count(); - orphans = locked_states->orphan_count(); - enif_mutex_unlock(locked_states->mtx); - } - - ERL_NIF_TERM locked_term = enif_make_int(env, locked); - ERL_NIF_TERM orphans_term = enif_make_int(env, orphans); + /* locked/orphan counters retired with bare-tree lock NIFs; keep tuple shape. */ + ERL_NIF_TERM locked_term = enif_make_int(env, 0); + ERL_NIF_TERM orphans_term = enif_make_int(env, 0); ERL_NIF_TERM shared_term = enif_make_int(env, shared); ERL_NIF_TERM resources_term = enif_make_int(env, res); return enif_make_tuple4(env, locked_term, orphans_term, shared_term, resources_term); } -static ERL_NIF_TERM -merkletree_malloc_info(ErlNifEnv *env, int argc, const ERL_NIF_TERM /*argv*/[]) -{ - if (argc != 0) { - return enif_make_badarg(env); - } -#ifdef __GLIBC__ - char *buf = NULL; - size_t sz = 0; - FILE *fp = open_memstream(&buf, &sz); - if (!fp) { - return make_atom(env, "error"); - } - malloc_info(0, fp); - fclose(fp); - ERL_NIF_TERM term = make_binary(env, (uint8_t *)buf, sz); - free(buf); - return term; -#else - return make_atom(env, "unsupported"); -#endif -} - static ERL_NIF_TERM merkletree_count_zeros(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { @@ -1528,43 +853,9 @@ account_map_clone(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) AccountMapLock lock(am); - // Eagerly COW the SharedAccountMap and clone every storage trie so the fork is - // writable (locked = false) while the cached parent stays frozen. Sharing the - // parent's merkletree* resources would leave the fork's storage tries with - // mt->locked == true, and make_writeable() would reject every storage write - // (merkletree_insert_item returns badarg), breaking block sync. - SharedAccountMap *new_shared = new SharedAccountMap(); - new_shared->accounts = am->shared->accounts; - - // Clone each unique parent storage trie once (accounts that shared a storage - // trie in the parent keep sharing the single clone). ~SharedAccountMap releases - // one resource ref per entry, so we keep one ref per entry here to match. - std::unordered_map storage_clones; - size_t i = 0; - for (auto &entry : new_shared->accounts) { - i++; - if (entry.second.compact_storage) { - nif_loop_progress(env, i); - continue; - } - merkletree *orig = entry.second.storage; - if (orig == nullptr) { - continue; - } - auto it = storage_clones.find(orig); - if (it == storage_clones.end()) { - merkletree *storage_clone = clone_merkletree_locked(orig); - it = storage_clones.insert({orig, storage_clone}).first; - } - enif_keep_resource(it->second); - entry.second.storage = it->second; - nif_loop_progress(env, i); - } - // Drop the creator refs (one per unique clone); the per-entry keeps above own - // the resources now. - for (auto &kv : storage_clones) { - enif_release_resource(kv.second); - } + // Eagerly fork so the clone is writable while a frozen parent stays immutable. + // Distinct merkletree wrappers share SharedState until first write. + SharedAccountMap *new_shared = fork_shared_accountmap(env, am->shared); accountmap *clone = (accountmap*)enif_alloc_resource(accountmap_type, sizeof(accountmap)); clone->shared = new_shared; @@ -1573,83 +864,194 @@ account_map_clone(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) return res; } -static bool get_optional_merkletree(ErlNifEnv *env, ERL_NIF_TERM term, merkletree **out) -{ - *out = nullptr; - if (enif_is_atom(env, term)) { - char atom[16]; - if (enif_get_atom(env, term, atom, sizeof(atom), ERL_NIF_LATIN1) && - strcmp(atom, "nil") == 0) { - return true; - } - return false; - } - return enif_get_resource(env, term, merkletree_type, (void **)out); -} - static ERL_NIF_TERM account_map_lock(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { accountmap *am; - merkletree *store = nullptr; - if (argc != 2) return enif_make_badarg(env); + if (argc != 1) return enif_make_badarg(env); if (!enif_get_resource(env, argv[0], accountmap_type, (void **)&am)) return enif_make_badarg(env); - if (!get_optional_merkletree(env, argv[1], &store)) return enif_make_badarg(env); { AccountMapLock lock(am); - std::unordered_set locked_roots; - size_t i = 0; - for (auto &entry : am->shared->accounts) { - i++; - merkletree *storage = materialize_storage(entry.second); - if (storage == nullptr) { - nif_loop_progress(env, i); - continue; - } - - uint256_t root_hash; - { - Lock tree_lock(storage); - root_hash = storage->shared_state->tree.root_hash(); - } - - if (!locked_roots.insert(root_hash).second) { - locked_states->apply_canonical_lock(storage, root_hash); - } else { - locked_states->enter_lock(storage); - } - nif_loop_progress(env, i); - } + // Map-level freeze only: get no longer exports live storage resources. + am->shared->frozen = true; } - if (store != nullptr) { - locked_states->enter_lock(store); - } - - locked_states->try_reclaim_orphans(); return argv[0]; } static ERL_NIF_TERM account_entry_to_term(ErlNifEnv *env, AccountEntry &entry) { - merkletree *storage = materialize_storage(entry); + uint256_t storage_root; + // Prefer hash without materializing compact slots into a live trie. + if (!storage_root_hash_for_entry(env, entry, storage_root, 0)) { + return enif_make_badarg(env); + } ERL_NIF_TERM nonce = enif_make_uint64(env, entry.nonce); ERL_NIF_TERM balance = balance_to_term(env, entry.balance); - ERL_NIF_TERM storage_term = enif_make_resource(env, storage); + ERL_NIF_TERM storage_hash = make_binary(env, storage_root.data(), 32); ERL_NIF_TERM code = code_to_term(env, entry.code); - return enif_make_tuple4(env, nonce, balance, storage_term, code); + return enif_make_tuple4(env, nonce, balance, storage_hash, code); } -static ERL_NIF_TERM -account_map_get(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) -{ - accountmap *am; - uint160_t addr; - - if (argc != 2) return enif_make_badarg(env); - if (!enif_get_resource(env, argv[0], accountmap_type, (void **)&am)) return enif_make_badarg(env); +struct AccountHashCtx { + std::vector nonce_rlp; + std::vector balance_rlp; + std::vector root_rlp; + std::vector code_rlp; + std::vector list_rlp; + std::vector list_payload; + + bool compute(const AccountEntry &entry, const uint256_t *storage_root_override, + const uint256_t *code_hash_override, uint256_t &out) + { + uint256_t storage_root; + if (storage_root_override != nullptr) { + storage_root = *storage_root_override; + } else { + if (entry.storage == nullptr) { + return false; + } + Lock lock(entry.storage); + storage_root = entry.storage->shared_state->tree.root_hash(); + } + + uint256_t code_hash; + if (code_hash_override != nullptr) { + code_hash = *code_hash_override; + } else if (entry.code.empty()) { + code_hash = empty_code_hash; + } else { + sha(entry.code.data(), entry.code.size(), code_hash.data()); + } + + nonce_rlp.clear(); + balance_rlp.clear(); + root_rlp.clear(); + code_rlp.clear(); + list_rlp.clear(); + + rlp_encode_uint64(entry.nonce, nonce_rlp); + rlp_encode_uint256(entry.balance.value, balance_rlp); + rlp_encode_bytes(storage_root.data(), 32, root_rlp); + rlp_encode_bytes(code_hash.data(), 32, code_rlp); + + rlp_encode_list(nonce_rlp, balance_rlp, root_rlp, code_rlp, list_payload, list_rlp); + sha(list_rlp.data(), list_rlp.size(), out.data()); + return true; + } +}; + +static void insert_state_trie_hash(SharedAccountMap *shared, const uint160_t &addr, const uint256_t &hash) +{ + bin_t key(addr.value, addr.value + 20); + merkletree *mt = shared->state_trie; + enif_mutex_lock(mt->shared_state->mtx); + make_writeable_locked(mt); + uint256_t hash_value = hash; + mt->shared_state->tree.insert_item(key, hash_value); + enif_mutex_unlock(mt->shared_state->mtx); +} + +static void update_state_trie_for_entry(SharedAccountMap *shared, const uint160_t &addr, + AccountEntry &entry, AccountHashCtx &ctx) +{ + uint256_t storage_root; + const uint256_t *root_override = nullptr; + if (entry.storage == nullptr) { + if (entry.compact_storage) { + if (!storage_root_hash_for_entry(nullptr, entry, storage_root, 0)) { + return; + } + root_override = &storage_root; + } else { + materialize_storage(entry); + } + } + uint256_t account_hash; + if (!ctx.compute(entry, root_override, nullptr, account_hash)) { + return; + } + insert_state_trie_hash(shared, addr, account_hash); +} + +static void remove_state_trie_entry(SharedAccountMap *shared, const uint160_t &addr) +{ + uint256_t zero; + insert_state_trie_hash(shared, addr, zero); +} + +static ERL_NIF_TERM +make_tree_roots_blob(ErlNifEnv *env, Tree &tree) +{ + uint8_t buf[32 + 32 * 16]; + // root_hashes updates the tree once; root_hash then reuses clean hashes. + uint256_t *hashes = tree.root_hashes(); + uint256_t root = tree.root_hash(); + memcpy(buf, root.data(), 32); + memcpy(buf + 32, hashes, 32 * 16); + return make_binary(env, buf, sizeof(buf)); +} + +static merkletree * +storage_from_kv_list(ErlNifEnv *env, ERL_NIF_TERM list) +{ + merkletree *mt = alloc_merkletree_resource(); + bin_t key_scratch; + size_t i = 0; + ERL_NIF_TERM head, tail = list; + bool ok = true; + + { + Lock lock(mt); + while (ok && enif_get_list_cell(env, tail, &head, &tail)) { + i++; + const ERL_NIF_TERM *elems; + int arity; + if (!enif_get_tuple(env, head, &arity, &elems) || arity != 2) { + ok = false; + break; + } + + ErlNifBinary key_bin, value_bin; + if (!enif_inspect_binary(env, elems[0], &key_bin) || key_bin.size != 32 || + !enif_inspect_binary(env, elems[1], &value_bin) || value_bin.size != 32 || + !insert_binary_pair(mt->shared_state->tree, key_bin, value_bin, key_scratch)) { + ok = false; + break; + } + nif_loop_progress(env, i); + } + } + + if (!ok) { + enif_release_resource(mt); + return nullptr; + } + return mt; +} + +static ERL_NIF_TERM +account_map_state_roots(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) +{ + accountmap *am; + if (argc != 1) return enif_make_badarg(env); + if (!enif_get_resource(env, argv[0], accountmap_type, (void **)&am)) return enif_make_badarg(env); + + AccountMapLock lock(am); + Lock tree_lock(am->shared->state_trie); + return make_tree_roots_blob(env, am->shared->state_trie->shared_state->tree); +} + +static ERL_NIF_TERM +account_map_get(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) +{ + accountmap *am; + uint160_t addr; + + if (argc != 2) return enif_make_badarg(env); + if (!enif_get_resource(env, argv[0], accountmap_type, (void **)&am)) return enif_make_badarg(env); if (!get_address(env, argv[1], addr)) return enif_make_badarg(env); AccountMapLock lock(am); @@ -1669,7 +1071,6 @@ account_map_put(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) uint160_t addr; ErlNifUInt64 nonce; uint256_t balance; - merkletree *storage; bin_t code; if (argc != 6) return enif_make_badarg(env); @@ -1677,22 +1078,56 @@ account_map_put(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) if (!get_address(env, argv[1], addr)) return enif_make_badarg(env); if (!enif_get_uint64(env, argv[2], &nonce)) return enif_make_badarg(env); if (!get_balance_uint256(env, argv[3], balance)) return enif_make_badarg(env); - if (!enif_get_resource(env, argv[4], merkletree_type, (void **)&storage)) return enif_make_badarg(env); if (!get_code(env, argv[5], code)) return enif_make_badarg(env); + bool keep_meta = false; + bool allocated = false; + merkletree *storage = nullptr; + ERL_NIF_TERM storage_term = argv[4]; + + if (term_is_atom_named(env, storage_term, "keep")) { + keep_meta = true; + } else if (term_is_nil(env, storage_term) || + (enif_is_list(env, storage_term) && enif_is_empty_list(env, storage_term))) { + storage = alloc_merkletree_resource(); + allocated = true; + } else if (enif_is_list(env, storage_term)) { + storage = storage_from_kv_list(env, storage_term); + if (storage == nullptr) return enif_make_badarg(env); + allocated = true; + } else { + return enif_make_badarg(env); + } + AccountMapLock lock(am); - if (!make_writeable_accountmap(env, am)) return enif_make_badarg(env); + if (!make_writeable_accountmap(am)) { + if (allocated) enif_release_resource(storage); + return enif_make_badarg(env); + } + + if (keep_meta) { + AccountEntry &entry = ensure_account_entry(am->shared, addr); + entry.nonce = (uint64_t)nonce; + entry.balance = balance; + entry.code = code; + + AccountHashCtx hash_ctx; + update_state_trie_for_entry(am->shared, addr, entry, hash_ctx); + return argv[0]; + } auto it = am->shared->accounts.find(addr); if (it != am->shared->accounts.end()) { release_entry_storage(it->second); keep_storage_in_map(storage); + if (allocated) enif_release_resource(storage); it->second.nonce = (uint64_t)nonce; it->second.balance = balance; it->second.storage = storage; it->second.code = code; } else { keep_storage_in_map(storage); + if (allocated) enif_release_resource(storage); AccountEntry entry; entry.nonce = (uint64_t)nonce; entry.balance = balance; @@ -1700,6 +1135,9 @@ account_map_put(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) entry.code = code; am->shared->accounts[addr] = std::move(entry); } + + AccountHashCtx hash_ctx; + update_state_trie_for_entry(am->shared, addr, am->shared->accounts[addr], hash_ctx); return argv[0]; } @@ -1714,16 +1152,30 @@ account_map_delete(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) if (!get_address(env, argv[1], addr)) return enif_make_badarg(env); AccountMapLock lock(am); - if (!make_writeable_accountmap(env, am)) return enif_make_badarg(env); + if (!make_writeable_accountmap(am)) return enif_make_badarg(env); auto it = am->shared->accounts.find(addr); if (it != am->shared->accounts.end()) { release_entry_storage(it->second); am->shared->accounts.erase(it); + remove_state_trie_entry(am->shared, addr); } return argv[0]; } +static ERL_NIF_TERM +account_map_root_hash(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) +{ + accountmap *am; + if (argc != 1) return enif_make_badarg(env); + if (!enif_get_resource(env, argv[0], accountmap_type, (void **)&am)) return enif_make_badarg(env); + + AccountMapLock lock(am); + Lock tree_lock(am->shared->state_trie); + uint256_t root = am->shared->state_trie->shared_state->tree.root_hash(); + return make_binary(env, root.data(), 32); +} + static ERL_NIF_TERM account_map_size(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { @@ -1761,7 +1213,6 @@ static merkletree *alloc_merkletree_resource() merkletree *mt = (merkletree*)enif_alloc_resource(merkletree_type, sizeof(merkletree)); STAT(resources++); mt->shared_state = new SharedState(); - mt->locked = false; return mt; } @@ -1771,8 +1222,11 @@ static merkletree *materialize_storage(AccountEntry &entry) return entry.storage; } if (!entry.compact_storage || entry.compact_storage->slots.empty()) { - entry.storage = empty_storage_tree; - keep_storage_in_map(empty_storage_tree); + // Fresh empty tree — never share empty_storage_tree as a writable map entry + // (in-place COW would mutate the singleton for every account). + // alloc refcount 1 is owned by the map entry (same as ensure_account_entry). + merkletree *mt = alloc_merkletree_resource(); + entry.storage = mt; entry.compact_storage.reset(); return entry.storage; } @@ -1783,7 +1237,6 @@ static merkletree *materialize_storage(AccountEntry &entry) mt->shared_state->tree.insert_item(slot.key, slot.value); } } - keep_storage_in_map(mt); entry.storage = mt; entry.compact_storage.reset(); return entry.storage; @@ -1800,6 +1253,14 @@ static bool storage_root_hash_for_entry(ErlNifEnv *env, const AccountEntry &entr if (!entry.compact_storage || entry.compact_storage->slots.empty()) { Lock lock(empty_storage_tree); out = empty_storage_tree->shared_state->tree.root_hash(); + if (entry.compact_storage) { + entry.compact_storage->root_hash = out; + entry.compact_storage->has_root = true; + } + return true; + } + if (entry.compact_storage->has_root) { + out = entry.compact_storage->root_hash; return true; } Tree temp; @@ -1810,6 +1271,8 @@ static bool storage_root_hash_for_entry(ErlNifEnv *env, const AccountEntry &entr nif_loop_progress(env, progress_base + i); } out = temp.root_hash(); + entry.compact_storage->root_hash = out; + entry.compact_storage->has_root = true; return true; } @@ -1819,7 +1282,7 @@ struct DiffAccountSide { uint256_t balance; bin_t code; merkletree *storage; - std::unique_ptr compact_storage; + std::shared_ptr compact_storage; DiffAccountSide() : present(false), nonce(0), balance(), storage(nullptr), compact_storage(nullptr) {} @@ -1842,176 +1305,772 @@ static void snapshot_side(const AccountEntry &src, DiffAccountSide &out) out.balance = src.balance; out.code = src.code; out.storage = src.storage; - out.compact_storage = clone_compact_storage(src.compact_storage.get()); + out.compact_storage = src.compact_storage; if (src.storage != nullptr) { enif_keep_resource(src.storage); } } -static AccountEntry side_to_entry(DiffAccountSide &side) +static bool entries_equal(ErlNifEnv *env, const AccountEntry &a, const AccountEntry &b, size_t progress_base) +{ + if (a.nonce != b.nonce || a.balance != b.balance || a.code != b.code) { + return false; + } + if (a.storage != nullptr && b.storage != nullptr && + a.storage->shared_state == b.storage->shared_state) { + return true; + } + if (a.storage != nullptr && a.storage == b.storage) { + return true; + } + if (a.compact_storage && a.compact_storage == b.compact_storage) { + return true; + } + uint256_t root_a, root_b; + storage_root_hash_for_entry(env, a, root_a, progress_base); + storage_root_hash_for_entry(env, b, root_b, progress_base + 1); + return root_a == root_b; +} + +struct DiffItem { + uint160_t addr; + DiffAccountSide a; + DiffAccountSide b; +}; + +static ERL_NIF_TERM diff_side_fields_to_term(ErlNifEnv *env, DiffAccountSide &side) +{ + if (!side.present) { + return make_atom(env, "nil"); + } + ERL_NIF_TERM nonce = enif_make_uint64(env, side.nonce); + ERL_NIF_TERM balance = balance_to_term(env, side.balance); + ERL_NIF_TERM code = code_to_term(env, side.code); + return enif_make_tuple3(env, nonce, balance, code); +} + +static merkletree *storage_for_diff_side(DiffAccountSide &side, merkletree **temp_out) +{ + *temp_out = nullptr; + if (!side.present) { + return empty_storage_tree; + } + if (side.storage != nullptr) { + return side.storage; + } + if (!side.compact_storage || side.compact_storage->slots.empty()) { + return empty_storage_tree; + } + merkletree *mt = alloc_merkletree_resource(); + *temp_out = mt; + { + Lock lock(mt); + for (auto &slot : side.compact_storage->slots) { + mt->shared_state->tree.insert_item(slot.key, slot.value); + } + } + return mt; +} + +static void release_temp_storage(merkletree *temp) +{ + if (temp != nullptr) { + enif_release_resource(temp); + } +} + +static ERL_NIF_TERM build_storage_diff_list(ErlNifEnv *env, DiffAccountSide &side_a, + DiffAccountSide &side_b, size_t progress_base) +{ + merkletree *temp_a = nullptr; + merkletree *temp_b = nullptr; + merkletree *mt_a = storage_for_diff_side(side_a, &temp_a); + merkletree *mt_b = storage_for_diff_side(side_b, &temp_b); + + if (mt_a == mt_b) { + release_temp_storage(temp_a); + release_temp_storage(temp_b); + return enif_make_list(env, 0); + } + + SharedState *s1 = mt_a->shared_state; + SharedState *s2 = mt_b->shared_state; + if (s1 == s2) { + release_temp_storage(temp_a); + release_temp_storage(temp_b); + return enif_make_list(env, 0); + } + + SharedStateLock state_lock(s1, s2); + + Tree output; + s1->tree.difference(s2->tree, output); + s2->tree.difference(s1->tree, output); + + ERL_NIF_TERM list = enif_make_list(env, 0); + size_t i = 0; + output.each([&](pair_t &pair) { + i++; + ERL_NIF_TERM key_term = make_binary(env, pair.key.data(), pair.key.size()); + + auto pair1 = s1->tree.get_item(pair); + auto pair2 = s2->tree.get_item(pair); + + ERL_NIF_TERM value1_term = pair1 == nullptr ? make_atom(env, "nil") : + make_binary(env, pair1->value.data(), 32); + ERL_NIF_TERM value2_term = pair2 == nullptr ? make_atom(env, "nil") : + make_binary(env, pair2->value.data(), 32); + ERL_NIF_TERM values = enif_make_tuple2(env, value1_term, value2_term); + ERL_NIF_TERM item = enif_make_tuple2(env, key_term, values); + list = enif_make_list_cell(env, item, list); + nif_loop_progress(env, progress_base + i); + }); + + release_temp_storage(temp_a); + release_temp_storage(temp_b); + return list; +} + +static ERL_NIF_TERM root_term_for_diff_side(ErlNifEnv *env, DiffAccountSide &side, + size_t progress_base) +{ + if (!side.present) { + return make_atom(env, "nil"); + } + AccountEntry tmp; + tmp.storage = side.storage; + tmp.compact_storage = side.compact_storage; + uint256_t root; + if (!storage_root_hash_for_entry(env, tmp, root, progress_base)) { + return make_atom(env, "nil"); + } + return make_binary(env, root.data(), 32); +} + +static ERL_NIF_TERM +account_map_difference_full(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) +{ + accountmap *am_a; + accountmap *am_b; + + if (argc != 2) return enif_make_badarg(env); + if (!enif_get_resource(env, argv[0], accountmap_type, (void **)&am_a)) return enif_make_badarg(env); + if (!enif_get_resource(env, argv[1], accountmap_type, (void **)&am_b)) return enif_make_badarg(env); + + if (am_a->shared == am_b->shared) { + return enif_make_list(env, 0); + } + + std::vector diffs; + + { + DualAccountMapLock map_lock(am_a->shared, am_b->shared); + + SharedState *trie_a = am_a->shared->state_trie->shared_state; + SharedState *trie_b = am_b->shared->state_trie->shared_state; + if (trie_a == trie_b) { + return enif_make_list(env, 0); + } + + std::vector keys; + { + SharedStateLock trie_lock(trie_a, trie_b); + Tree output; + trie_a->tree.difference(trie_b->tree, output); + trie_b->tree.difference(trie_a->tree, output); + output.each([&](pair_t &pair) { + if (pair.key.size() != 20) { + return; + } + keys.push_back(uint160_t(pair.key.data())); + }); + } + std::sort(keys.begin(), keys.end()); + keys.erase(std::unique(keys.begin(), keys.end()), keys.end()); + + size_t i = 0; + for (auto &addr : keys) { + i++; + auto it_a = am_a->shared->accounts.find(addr); + auto it_b = am_b->shared->accounts.find(addr); + bool in_a = it_a != am_a->shared->accounts.end(); + bool in_b = it_b != am_b->shared->accounts.end(); + + if (!in_a && !in_b) { + nif_loop_progress(env, i); + continue; + } + + if (in_a && in_b && entries_equal(env, it_a->second, it_b->second, i)) { + nif_loop_progress(env, i); + continue; + } + + DiffItem item; + item.addr = addr; + if (in_a) { + snapshot_side(it_a->second, item.a); + } + if (in_b) { + snapshot_side(it_b->second, item.b); + } + diffs.push_back(std::move(item)); + nif_loop_progress(env, i); + } + } + + ERL_NIF_TERM list = enif_make_list(env, 0); + size_t j = 0; + for (auto &item : diffs) { + j++; + ERL_NIF_TERM addr_term = make_binary(env, (uint8_t *)item.addr.value, 20); + ERL_NIF_TERM side_a = diff_side_fields_to_term(env, item.a); + ERL_NIF_TERM side_b = diff_side_fields_to_term(env, item.b); + ERL_NIF_TERM storage_diff = build_storage_diff_list(env, item.a, item.b, j * 1000); + ERL_NIF_TERM root_a = root_term_for_diff_side(env, item.a, j * 1000 + 500); + ERL_NIF_TERM root_b = root_term_for_diff_side(env, item.b, j * 1000 + 750); + ERL_NIF_TERM sextuple = + enif_make_tuple6(env, addr_term, side_a, side_b, storage_diff, root_a, root_b); + list = enif_make_list_cell(env, sextuple, list); + release_snapshot_side(item.a); + release_snapshot_side(item.b); + nif_loop_progress(env, j); + } + + return list; +} + +static bool map_get_atom(ErlNifEnv *env, ERL_NIF_TERM map, const char *key, ERL_NIF_TERM &out); + +static bool term_is_atom_named(ErlNifEnv *env, ERL_NIF_TERM term, const char *name) +{ + if (!enif_is_atom(env, term)) { + return false; + } + char atom[64]; + return enif_get_atom(env, term, atom, sizeof(atom), ERL_NIF_LATIN1) && + strcmp(atom, name) == 0; +} + +static bool term_is_nil(ErlNifEnv *env, ERL_NIF_TERM term) +{ + return term_is_atom_named(env, term, "nil"); +} + +static bool balance_equals_term(ErlNifEnv *env, const uint256_t &actual, ERL_NIF_TERM term) +{ + uint256_t expected; + if (!get_balance_uint256(env, term, expected)) { + return false; + } + return expected == actual; +} + +static bool code_equals_term(ErlNifEnv *env, const bin_t &actual, ERL_NIF_TERM term) +{ + bin_t expected; + if (!get_code(env, term, expected)) { + return false; + } + return expected == actual; +} + +static bool get_storage_value_from_term(ErlNifEnv *env, ERL_NIF_TERM term, uint256_t &out) +{ + memset(out.value, 0, sizeof(out.value)); + if (term_is_nil(env, term)) { + return true; + } + ErlNifBinary bin; + if (!enif_inspect_binary(env, term, &bin) || bin.size != 32) { + return false; + } + memcpy(out.value, bin.data, 32); + return true; +} + +static bool storage_values_equal(ErlNifEnv *env, ERL_NIF_TERM expected_term, const uint256_t &actual) +{ + uint256_t expected; + if (!get_storage_value_from_term(env, expected_term, expected)) { + return false; + } + return expected == actual; +} + +static uint256_t read_storage_slot(merkletree *mt, const bin_t &key) +{ + Lock lock(mt); + bin_t lookup = key; + pair_t *pair = mt->shared_state->tree.get_item(std::move(lookup)); + return pair == nullptr ? uint256_t() : pair->value; +} + +static merkletree *write_storage_slot(AccountEntry &entry, const bin_t &key, const uint256_t &value) +{ + merkletree *mt = materialize_storage(entry); + enif_mutex_lock(mt->shared_state->mtx); + make_writeable_locked(mt); + bin_t key_copy = key; + uint256_t value_copy = value; + mt->shared_state->tree.insert_item(key_copy, value_copy); + enif_mutex_unlock(mt->shared_state->mtx); + entry.compact_storage.reset(); + return mt; +} + +static ERL_NIF_TERM make_apply_error(ErlNifEnv *env, const char *reason) +{ + return enif_make_tuple2(env, make_atom(env, "error"), make_atom(env, reason)); +} + +static AccountEntry &ensure_account_entry(SharedAccountMap *shared, const uint160_t &addr) +{ + auto it = shared->accounts.find(addr); + if (it == shared->accounts.end()) { + AccountEntry entry; + entry.storage = alloc_merkletree_resource(); + keep_storage_in_map(entry.storage); + enif_release_resource(entry.storage); + shared->accounts[addr] = std::move(entry); + return shared->accounts[addr]; + } + return it->second; +} + +static bool apply_field_delta(ErlNifEnv *env, AccountEntry &entry, const char *field, + ERL_NIF_TERM delta_term) +{ + const ERL_NIF_TERM *elems; + int arity; + if (!enif_get_tuple(env, delta_term, &arity, &elems) || arity != 2) { + return false; + } + + if (strcmp(field, "nonce") == 0) { + ErlNifUInt64 expected, new_val; + if (!enif_get_uint64(env, elems[0], &expected) || + !enif_get_uint64(env, elems[1], &new_val)) { + return false; + } + if (entry.nonce != (uint64_t)expected) { + return false; + } + entry.nonce = (uint64_t)new_val; + return true; + } + + if (strcmp(field, "balance") == 0) { + if (!balance_equals_term(env, entry.balance, elems[0])) { + return false; + } + if (!get_balance_uint256(env, elems[1], entry.balance)) { + return false; + } + return true; + } + + if (strcmp(field, "code") == 0) { + if (!code_equals_term(env, entry.code, elems[0])) { + return false; + } + if (!get_code(env, elems[1], entry.code)) { + return false; + } + return true; + } + + return true; +} + +static bool apply_storage_delta(ErlNifEnv *env, AccountEntry &entry, ERL_NIF_TERM state_map) +{ + if (!enif_is_map(env, state_map)) { + return false; + } + + ErlNifMapIterator iter; + enif_map_iterator_create(env, state_map, &iter, ERL_NIF_MAP_ITERATOR_FIRST); + + ERL_NIF_TERM slot_key, slot_delta; + while (enif_map_iterator_get_pair(env, &iter, &slot_key, &slot_delta)) { + ErlNifBinary key_bin; + if (!enif_inspect_binary(env, slot_key, &key_bin)) { + enif_map_iterator_destroy(env, &iter); + return false; + } + + const ERL_NIF_TERM *delta_elems; + int delta_arity; + if (!enif_get_tuple(env, slot_delta, &delta_arity, &delta_elems) || delta_arity != 2) { + enif_map_iterator_destroy(env, &iter); + return false; + } + + merkletree *mt = materialize_storage(entry); + bin_t key(key_bin.data, key_bin.data + key_bin.size); + uint256_t current = read_storage_slot(mt, key); + if (!storage_values_equal(env, delta_elems[0], current)) { + enif_map_iterator_destroy(env, &iter); + return false; + } + + uint256_t new_value; + if (!get_storage_value_from_term(env, delta_elems[1], new_value)) { + enif_map_iterator_destroy(env, &iter); + return false; + } + + write_storage_slot(entry, key, new_value); + + enif_map_iterator_next(env, &iter); + } + + enif_map_iterator_destroy(env, &iter); + return true; +} + +static ERL_NIF_TERM +account_map_apply_difference(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) +{ + accountmap *am; + if (argc != 2) return enif_make_badarg(env); + if (!enif_get_resource(env, argv[0], accountmap_type, (void **)&am)) return enif_make_badarg(env); + if (!enif_is_list(env, argv[1])) return enif_make_badarg(env); + + AccountMapLock lock(am); + if (!make_writeable_accountmap(am)) return enif_make_badarg(env); + + AccountHashCtx hash_ctx; + size_t i = 0; + ERL_NIF_TERM head, tail = argv[1]; + + while (enif_get_list_cell(env, tail, &head, &tail)) { + i++; + const ERL_NIF_TERM *elems; + int arity; + if (!enif_get_tuple(env, head, &arity, &elems) || arity != 2) { + return enif_make_badarg(env); + } + + uint160_t addr; + if (!get_address(env, elems[0], addr)) { + return enif_make_badarg(env); + } + if (!enif_is_map(env, elems[1])) { + return enif_make_badarg(env); + } + + AccountEntry &entry = ensure_account_entry(am->shared, addr); + ERL_NIF_TERM report = elems[1]; + + ERL_NIF_TERM state_term; + if (map_get_atom(env, report, "state", state_term)) { + if (!apply_storage_delta(env, entry, state_term)) { + return make_apply_error(env, "mismatch"); + } + } + + static const char *fields[] = {"nonce", "balance", "code", nullptr}; + for (int f = 0; fields[f] != nullptr; f++) { + ERL_NIF_TERM delta_term; + if (map_get_atom(env, report, fields[f], delta_term)) { + if (!apply_field_delta(env, entry, fields[f], delta_term)) { + return make_apply_error(env, "mismatch"); + } + } + } + + update_state_trie_for_entry(am->shared, addr, entry, hash_ctx); + nif_loop_progress(env, i); + } + + return argv[0]; +} + +static ERL_NIF_TERM +account_map_storage_put_map(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) +{ + accountmap *am; + + if (argc != 2) return enif_make_badarg(env); + if (!enif_get_resource(env, argv[0], accountmap_type, (void **)&am)) return enif_make_badarg(env); + if (!enif_is_list(env, argv[1])) return enif_make_badarg(env); + + AccountMapLock lock(am); + if (!make_writeable_accountmap(am)) return enif_make_badarg(env); + + AccountHashCtx hash_ctx; + size_t i = 0; + ERL_NIF_TERM head, tail = argv[1]; + + while (enif_get_list_cell(env, tail, &head, &tail)) { + i++; + const ERL_NIF_TERM *elems; + int arity; + if (!enif_get_tuple(env, head, &arity, &elems) || arity != 2) { + return enif_make_badarg(env); + } + + uint160_t addr; + if (!get_address(env, elems[0], addr)) { + return enif_make_badarg(env); + } + if (!enif_is_list(env, elems[1])) { + return enif_make_badarg(env); + } + + AccountEntry &entry = ensure_account_entry(am->shared, addr); + ERL_NIF_TERM kv_head, kv_tail = elems[1]; + size_t j = 0; + while (enif_get_list_cell(env, kv_tail, &kv_head, &kv_tail)) { + j++; + const ERL_NIF_TERM *kv_elems; + int kv_arity; + if (!enif_get_tuple(env, kv_head, &kv_arity, &kv_elems) || kv_arity != 2) { + return enif_make_badarg(env); + } + + ErlNifBinary key_bin, value_bin; + if (!enif_inspect_binary(env, kv_elems[0], &key_bin) || key_bin.size != 32) { + return enif_make_badarg(env); + } + if (!enif_inspect_binary(env, kv_elems[1], &value_bin) || value_bin.size != 32) { + return enif_make_badarg(env); + } + + bin_t key(key_bin.data, key_bin.data + key_bin.size); + uint256_t value((const char *)value_bin.data); + write_storage_slot(entry, key, value); + nif_loop_progress(env, j); + } + + update_state_trie_for_entry(am->shared, addr, entry, hash_ctx); + nif_loop_progress(env, i); + } + + return argv[0]; +} + +static ERL_NIF_TERM +account_map_storage_get_helper(ErlNifEnv *env, accountmap *am, const uint160_t &addr, + const ErlNifBinary &key_binary) +{ + auto it = am->shared->accounts.find(addr); + if (it == am->shared->accounts.end()) { + return make_atom(env, "nil"); + } + + merkletree *mt = materialize_storage(it->second); + bin_t key(key_binary.data, key_binary.data + key_binary.size); + uint256_t value = read_storage_slot(mt, key); + if (value.is_null()) { + return make_atom(env, "nil"); + } + return make_binary(env, value.data(), 32); +} + +static ERL_NIF_TERM +account_map_storage_get_range_helper(ErlNifEnv *env, accountmap *am, const uint160_t &addr, + const ErlNifBinary &key_binary, unsigned count) +{ + auto it = am->shared->accounts.find(addr); + if (it == am->shared->accounts.end()) { + return enif_make_list(env, 0); + } + + merkletree *mt = materialize_storage(it->second); + bin_t key(key_binary.data, key_binary.data + key_binary.size); + + std::vector entries(count); + size_t n; + { + Lock tree_lock(mt); + n = get_range_entries(mt->shared_state->tree, key, count, entries.data()); + } + + ERL_NIF_TERM list = enif_make_list(env, 0); + for (size_t i = n; i > 0; i--) { + RangeEntry &entry = entries[i - 1]; + ERL_NIF_TERM key_term = make_binary(env, entry.key.data(), entry.key.size()); + ERL_NIF_TERM value_term = make_binary(env, entry.value.data(), 32); + ERL_NIF_TERM pair = enif_make_tuple2(env, key_term, value_term); + list = enif_make_list_cell(env, pair, list); + } + return list; +} + +static ERL_NIF_TERM +account_map_storage_to_list_helper(ErlNifEnv *env, accountmap *am, const uint160_t &addr) { - AccountEntry entry; - entry.nonce = side.nonce; - entry.balance = side.balance; - entry.code = side.code; - entry.storage = side.storage; - entry.compact_storage = std::move(side.compact_storage); - return entry; -} + auto it = am->shared->accounts.find(addr); + if (it == am->shared->accounts.end()) { + return enif_make_list(env, 0); + } -static ERL_NIF_TERM diff_side_to_term(ErlNifEnv *env, DiffAccountSide &side) -{ - if (!side.present) { - return make_atom(env, "nil"); + merkletree *mt = materialize_storage(it->second); + ERL_NIF_TERM list = enif_make_list(env, 0); + size_t i = 0; + { + Lock tree_lock(mt); + mt->shared_state->tree.each([&](pair_t &pair) { + i++; + ERL_NIF_TERM key_term = make_binary(env, pair.key.data(), pair.key.size()); + ERL_NIF_TERM value_term = make_binary(env, pair.value.data(), 32); + ERL_NIF_TERM tuple = enif_make_tuple2(env, key_term, value_term); + list = enif_make_list_cell(env, tuple, list); + nif_loop_progress(env, i); + }); } - AccountEntry entry = side_to_entry(side); - ERL_NIF_TERM term = account_entry_to_term(env, entry); - release_entry_storage(entry); - side.storage = nullptr; - return term; + return list; } -static bool entries_equal(ErlNifEnv *env, const AccountEntry &a, const AccountEntry &b, size_t progress_base) +static ERL_NIF_TERM +account_map_storage_size_helper(ErlNifEnv *env, accountmap *am, const uint160_t &addr) { - if (a.nonce != b.nonce || a.balance != b.balance || a.code != b.code) { - return false; - } - if (a.storage != nullptr && a.storage == b.storage) { - return true; + auto it = am->shared->accounts.find(addr); + if (it == am->shared->accounts.end()) { + return enif_make_uint(env, 0); } - uint256_t root_a, root_b; - storage_root_hash_for_entry(env, a, root_a, progress_base); - storage_root_hash_for_entry(env, b, root_b, progress_base + 1); - return root_a == root_b; -} -struct DiffItem { - uint160_t addr; - DiffAccountSide a; - DiffAccountSide b; -}; + merkletree *mt = materialize_storage(it->second); + Lock tree_lock(mt); + return enif_make_uint(env, (unsigned)mt->shared_state->tree.size()); +} static ERL_NIF_TERM -account_map_list_difference_raw(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) +account_map_storage(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { - accountmap *am_a; - accountmap *am_b; + accountmap *am; + uint160_t addr; - if (argc != 2) return enif_make_badarg(env); - if (!enif_get_resource(env, argv[0], accountmap_type, (void **)&am_a)) return enif_make_badarg(env); - if (!enif_get_resource(env, argv[1], accountmap_type, (void **)&am_b)) return enif_make_badarg(env); + if (argc != 3) return enif_make_badarg(env); + if (!enif_get_resource(env, argv[0], accountmap_type, (void **)&am)) return enif_make_badarg(env); + if (!get_address(env, argv[1], addr)) return enif_make_badarg(env); - if (am_a->shared == am_b->shared) { - return enif_make_list(env, 0); - } + ERL_NIF_TERM spec = argv[2]; + AccountMapLock lock(am); - std::vector diffs; - std::unordered_set key_set; + if (term_is_atom_named(env, spec, "list")) { + return account_map_storage_to_list_helper(env, am, addr); + } + if (term_is_atom_named(env, spec, "size")) { + return account_map_storage_size_helper(env, am, addr); + } - { - DualAccountMapLock map_lock(am_a->shared, am_b->shared); + const ERL_NIF_TERM *elems; + int arity; + if (!enif_get_tuple(env, spec, &arity, &elems)) { + return enif_make_badarg(env); + } - for (auto &entry : am_a->shared->accounts) { - key_set.insert(entry.first); + if (arity == 2 && term_is_atom_named(env, elems[0], "get")) { + ErlNifBinary key_binary; + if (!enif_inspect_binary(env, elems[1], &key_binary) || key_binary.size != 32) { + return enif_make_badarg(env); } - for (auto &entry : am_b->shared->accounts) { - key_set.insert(entry.first); + return account_map_storage_get_helper(env, am, addr, key_binary); + } + + if (arity == 3 && term_is_atom_named(env, elems[0], "range")) { + ErlNifBinary key_binary; + unsigned count; + if (!enif_inspect_binary(env, elems[1], &key_binary) || key_binary.size != 32) { + return enif_make_badarg(env); } + if (!enif_get_uint(env, elems[2], &count)) return enif_make_badarg(env); + if (count < 1 || count > 256) return enif_make_badarg(env); + return account_map_storage_get_range_helper(env, am, addr, key_binary, count); + } - std::vector keys(key_set.begin(), key_set.end()); - std::sort(keys.begin(), keys.end()); + return enif_make_badarg(env); +} - size_t i = 0; - for (auto &addr : keys) { - i++; - auto it_a = am_a->shared->accounts.find(addr); - auto it_b = am_b->shared->accounts.find(addr); - bool in_a = it_a != am_a->shared->accounts.end(); - bool in_b = it_b != am_b->shared->accounts.end(); +static ERL_NIF_TERM +account_map_storage_roots(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) +{ + accountmap *am; + uint160_t addr; - if (in_a && in_b && entries_equal(env, it_a->second, it_b->second, i)) { - nif_loop_progress(env, i); - continue; - } + if (argc != 2) return enif_make_badarg(env); + if (!enif_get_resource(env, argv[0], accountmap_type, (void **)&am)) return enif_make_badarg(env); + if (!get_address(env, argv[1], addr)) return enif_make_badarg(env); - DiffItem item; - item.addr = addr; - if (in_a) { - snapshot_side(it_a->second, item.a); - } - if (in_b) { - snapshot_side(it_b->second, item.b); - } - diffs.push_back(std::move(item)); - nif_loop_progress(env, i); - } + AccountMapLock lock(am); + auto it = am->shared->accounts.find(addr); + if (it == am->shared->accounts.end()) { + Lock tree_lock(empty_storage_tree); + return make_tree_roots_blob(env, empty_storage_tree->shared_state->tree); } - ERL_NIF_TERM list = enif_make_list(env, 0); - size_t j = 0; - for (auto &item : diffs) { - j++; - ERL_NIF_TERM addr_term = make_binary(env, (uint8_t *)item.addr.value, 20); - ERL_NIF_TERM side_a = diff_side_to_term(env, item.a); - ERL_NIF_TERM side_b = diff_side_to_term(env, item.b); - ERL_NIF_TERM pair = enif_make_tuple2(env, side_a, side_b); - ERL_NIF_TERM triple = enif_make_tuple2(env, addr_term, pair); - list = enif_make_list_cell(env, triple, list); - release_snapshot_side(item.a); - release_snapshot_side(item.b); - nif_loop_progress(env, j); + AccountEntry &entry = it->second; + if (entry.storage != nullptr) { + Lock tree_lock(entry.storage); + return make_tree_roots_blob(env, entry.storage->shared_state->tree); } - return list; + // Compact: never materialize solely to read roots; build a temp tree when needed. + if (entry.compact_storage && entry.compact_storage->slots.empty()) { + uint256_t root; + storage_root_hash_for_entry(env, entry, root, 0); + Lock tree_lock(empty_storage_tree); + return make_tree_roots_blob(env, empty_storage_tree->shared_state->tree); + } + if (entry.compact_storage) { + Tree temp; + size_t i = 0; + for (auto &slot : entry.compact_storage->slots) { + i++; + temp.insert_item(slot.key, slot.value); + nif_loop_progress(env, i); + } + if (!entry.compact_storage->has_root) { + entry.compact_storage->root_hash = temp.root_hash(); + entry.compact_storage->has_root = true; + } + return make_tree_roots_blob(env, temp); + } + + merkletree *mt = materialize_storage(entry); + Lock tree_lock(mt); + return make_tree_roots_blob(env, mt->shared_state->tree); } -struct AccountHashCtx { - std::vector nonce_rlp; - std::vector balance_rlp; - std::vector root_rlp; - std::vector code_rlp; - std::vector list_rlp; - std::vector list_payload; +static ERL_NIF_TERM +account_map_proof(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) +{ + accountmap *am; + uint160_t addr; - bool compute(const AccountEntry &entry, const uint256_t *storage_root_override, - const uint256_t *code_hash_override, uint256_t &out) - { - uint256_t storage_root; - if (storage_root_override != nullptr) { - storage_root = *storage_root_override; - } else { - if (entry.storage == nullptr) { - return false; - } - Lock lock(entry.storage); - storage_root = entry.storage->shared_state->tree.root_hash(); - } + if (argc != 2 && argc != 3) return enif_make_badarg(env); + if (!enif_get_resource(env, argv[0], accountmap_type, (void **)&am)) return enif_make_badarg(env); + if (!get_address(env, argv[1], addr)) return enif_make_badarg(env); - uint256_t code_hash; - if (code_hash_override != nullptr) { - code_hash = *code_hash_override; - } else if (entry.code.empty()) { - code_hash = empty_code_hash; - } else { - sha(entry.code.data(), entry.code.size(), code_hash.data()); - } + AccountMapLock lock(am); - nonce_rlp.clear(); - balance_rlp.clear(); - root_rlp.clear(); - code_rlp.clear(); - list_rlp.clear(); + if (argc == 2) { + bin_t key(addr.value, addr.value + 20); + Lock tree_lock(am->shared->state_trie); + proof_t proof = am->shared->state_trie->shared_state->tree.get_proofs(key); + return make_proof(env, proof); + } - rlp_encode_uint64(entry.nonce, nonce_rlp); - rlp_encode_uint256(entry.balance.value, balance_rlp); - rlp_encode_bytes(storage_root.data(), 32, root_rlp); - rlp_encode_bytes(code_hash.data(), 32, code_rlp); + ErlNifBinary key_binary; + if (!enif_inspect_binary(env, argv[2], &key_binary)) return enif_make_badarg(env); - rlp_encode_list(nonce_rlp, balance_rlp, root_rlp, code_rlp, list_payload, list_rlp); - sha(list_rlp.data(), list_rlp.size(), out.data()); - return true; + merkletree *mt; + auto it = am->shared->accounts.find(addr); + if (it == am->shared->accounts.end()) { + mt = empty_storage_tree; + } else { + mt = materialize_storage(it->second); } -}; + + bin_t key(key_binary.data, key_binary.data + key_binary.size); + Lock tree_lock(mt); + proof_t proof = mt->shared_state->tree.get_proofs(key); + return make_proof(env, proof); +} struct UncompactLoopScratch { AccountHashCtx hash_ctx; @@ -2046,12 +2105,6 @@ static bool parse_compact_storage(ErlNifEnv *env, ERL_NIF_TERM storage_term, entry.storage = nullptr; entry.compact_storage.reset(); - merkletree *existing; - if (enif_get_resource(env, storage_term, merkletree_type, (void **)&existing)) { - entry.storage = existing; - return true; - } - if (enif_is_atom(env, storage_term)) { char atom[16]; if (enif_get_atom(env, storage_term, atom, sizeof(atom), ERL_NIF_LATIN1) && @@ -2061,7 +2114,7 @@ static bool parse_compact_storage(ErlNifEnv *env, ERL_NIF_TERM storage_term, return false; } - entry.compact_storage = std::make_unique(); + entry.compact_storage = std::make_shared(); const ERL_NIF_TERM *elems; int arity; @@ -2139,22 +2192,27 @@ static bool parse_compact_account(ErlNifEnv *env, ERL_NIF_TERM account_term, ERL_NIF_TERM root_hash_term; if (map_get_atom(env, account_term, "root_hash", root_hash_term)) { - ErlNifBinary root_bin; - if (!enif_inspect_binary(env, root_hash_term, &root_bin) || root_bin.size != 32) { - return false; + if (!enif_is_atom(env, root_hash_term)) { + ErlNifBinary root_bin; + if (!enif_inspect_binary(env, root_hash_term, &root_bin) || root_bin.size != 32) { + return false; + } + out.compact_root_hash = (char*)root_bin.data; + out.has_compact_root_hash = true; } - out.compact_root_hash = (char*)root_bin.data; - out.has_compact_root_hash = true; } ERL_NIF_TERM code_hash_term; if (map_get_atom(env, account_term, "code_hash", code_hash_term)) { - ErlNifBinary code_hash_bin; - if (!enif_inspect_binary(env, code_hash_term, &code_hash_bin) || code_hash_bin.size != 32) { - return false; + if (!enif_is_atom(env, code_hash_term)) { + ErlNifBinary code_hash_bin; + if (!enif_inspect_binary(env, code_hash_term, &code_hash_bin) || + code_hash_bin.size != 32) { + return false; + } + out.compact_code_hash = (char*)code_hash_bin.data; + out.has_compact_code_hash = true; } - out.compact_code_hash = (char*)code_hash_bin.data; - out.has_compact_code_hash = true; } ErlNifUInt64 nonce; @@ -2173,8 +2231,7 @@ static bool parse_compact_account(ErlNifEnv *env, ERL_NIF_TERM account_term, return parse_compact_storage(env, storage_term, out.entry); } -static ERL_NIF_TERM uncompact_state_fail(ErlNifEnv *env, ErlNifMapIterator *iter, - accountmap *am, merkletree *state_store) +static ERL_NIF_TERM uncompact_state_fail(ErlNifEnv *env, ErlNifMapIterator *iter, accountmap *am) { if (iter) { enif_map_iterator_destroy(env, iter); @@ -2182,9 +2239,6 @@ static ERL_NIF_TERM uncompact_state_fail(ErlNifEnv *env, ErlNifMapIterator *iter if (am) { enif_release_resource(am); } - if (state_store) { - enif_release_resource(state_store); - } return enif_make_badarg(env); } @@ -2208,22 +2262,169 @@ static bool append_uncompacted_account(ErlNifEnv *env, accountmap *am, AccountHa std::vector &pending_state, const uint160_t &addr, AccountEntry &entry, const uint256_t *storage_root_override, const uint256_t *code_hash_override, size_t i) { - if (storage_root_override == nullptr && entry.storage == nullptr) { - materialize_storage(entry); + if (entry.storage == nullptr) { + if (storage_root_override == nullptr) { + // Fresh materialize: alloc ownership transfers with the entry. + materialize_storage(entry); + } + } else if (entry.compact_storage == nullptr) { + // Shared/copied pointer from another map entry: take a map ref. + keep_storage_in_map(entry.storage); } uint256_t account_hash; if (!hash_ctx.compute(entry, storage_root_override, code_hash_override, account_hash)) { return false; } - if (entry.compact_storage == nullptr && entry.storage != nullptr) { - keep_storage_in_map(entry.storage); - } am->shared->accounts[addr] = std::move(entry); pending_state.push_back({addr, account_hash}); nif_loop_progress(env, i); return true; } +// Build storage items map for compact export without materializing compact_storage +// into a live trie when possible. +static bool build_compact_storage_items(ErlNifEnv *env, const AccountEntry &entry, + ERL_NIF_TERM &out_map, size_t &out_size, size_t progress_base) +{ + out_size = 0; + out_map = enif_make_new_map(env); + + if (entry.storage != nullptr) { + Lock lock(entry.storage); + size_t i = 0; + bool ok = true; + entry.storage->shared_state->tree.each([&](pair_t &pair) { + if (!ok) { + return; + } + i++; + ERL_NIF_TERM key_term = make_binary(env, pair.key.data(), pair.key.size()); + ERL_NIF_TERM value_term = make_binary(env, pair.value.data(), 32); + ERL_NIF_TERM new_map; + if (!enif_make_map_put(env, out_map, key_term, value_term, &new_map)) { + ok = false; + return; + } + out_map = new_map; + nif_loop_progress(env, progress_base + i); + }); + out_size = i; + return ok; + } + + if (entry.compact_storage && !entry.compact_storage->slots.empty()) { + size_t i = 0; + for (auto &slot : entry.compact_storage->slots) { + i++; + ERL_NIF_TERM key_term = make_binary(env, (uint8_t *)slot.key.data(), slot.key.size()); + ERL_NIF_TERM value_term = make_binary(env, slot.value.data(), 32); + ERL_NIF_TERM new_map; + if (!enif_make_map_put(env, out_map, key_term, value_term, &new_map)) { + return false; + } + out_map = new_map; + nif_loop_progress(env, progress_base + i); + } + out_size = i; + return true; + } + + return true; +} + +static bool make_compact_account_term(ErlNifEnv *env, AccountEntry &entry, + size_t progress_base, ERL_NIF_TERM &out) +{ + uint256_t storage_root; + if (!storage_root_hash_for_entry(env, entry, storage_root, progress_base)) { + return false; + } + + ERL_NIF_TERM items_map; + size_t item_count = 0; + if (!build_compact_storage_items(env, entry, items_map, item_count, progress_base)) { + return false; + } + + ERL_NIF_TERM storage_root_term; + if (item_count == 0) { + storage_root_term = make_atom(env, "nil"); + } else { + // Match Elixir `{MapMerkleTree, [], items}` (module atom Elixir.MapMerkleTree). + storage_root_term = enif_make_tuple3(env, + make_atom(env, "Elixir.MapMerkleTree"), + enif_make_list(env, 0), + items_map); + } + + ERL_NIF_TERM code_term = + entry.code.empty() ? make_atom(env, "nil") + : make_binary(env, (uint8_t *)entry.code.data(), entry.code.size()); + + uint256_t code_hash; + if (entry.code.empty()) { + code_hash = empty_code_hash; + } else { + sha(entry.code.data(), entry.code.size(), code_hash.data()); + } + + // Shape matches Chain.State.compact / CAccountMap.compact and parse_compact_account: + // %Chain.Account{nonce, balance, storage_root, code, map_backed: false} + // plus :root_hash and :code_hash. + ERL_NIF_TERM keys[8]; + ERL_NIF_TERM values[8]; + keys[0] = make_atom(env, "__struct__"); + values[0] = make_atom(env, "Elixir.Chain.Account"); + keys[1] = make_atom(env, "nonce"); + values[1] = enif_make_uint64(env, entry.nonce); + keys[2] = make_atom(env, "balance"); + values[2] = balance_to_term(env, entry.balance); + keys[3] = make_atom(env, "storage_root"); + values[3] = storage_root_term; + keys[4] = make_atom(env, "code"); + values[4] = code_term; + keys[5] = make_atom(env, "map_backed"); + values[5] = make_atom(env, "false"); + keys[6] = make_atom(env, "root_hash"); + values[6] = make_binary(env, storage_root.data(), 32); + keys[7] = make_atom(env, "code_hash"); + values[7] = make_binary(env, code_hash.data(), 32); + + return enif_make_map_from_arrays(env, keys, values, 8, &out); +} + +static ERL_NIF_TERM +account_map_compact(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) +{ + accountmap *am; + if (argc != 1) return enif_make_badarg(env); + if (!enif_get_resource(env, argv[0], accountmap_type, (void **)&am)) { + return enif_make_badarg(env); + } + + // Read-only: OK on frozen maps; do not require writable. + AccountMapLock lock(am); + + ERL_NIF_TERM result = enif_make_new_map(env); + + size_t i = 0; + for (auto &kv : am->shared->accounts) { + i++; + ERL_NIF_TERM addr = make_binary(env, (uint8_t *)kv.first.value, 20); + ERL_NIF_TERM account; + if (!make_compact_account_term(env, kv.second, i * kNifTimesliceInterval, account)) { + return enif_make_badarg(env); + } + ERL_NIF_TERM new_map; + if (!enif_make_map_put(env, result, addr, account, &new_map)) { + return enif_make_badarg(env); + } + result = new_map; + nif_loop_progress(env, i); + } + return result; +} + static ERL_NIF_TERM account_map_uncompact_state(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { @@ -2235,8 +2436,6 @@ account_map_uncompact_state(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) accountmap *am = (accountmap*)enif_alloc_resource(accountmap_type, sizeof(accountmap)); am->shared = new SharedAccountMap(); - merkletree *state_store = alloc_merkletree_resource(); - size_t expected = 0; if (from_resource) { AccountMapLock lock(input_am); @@ -2244,12 +2443,10 @@ account_map_uncompact_state(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) } else if (enif_is_map(env, argv[0])) { if (!enif_get_map_size(env, argv[0], &expected)) { enif_release_resource(am); - enif_release_resource(state_store); return enif_make_badarg(env); } } else { enif_release_resource(am); - enif_release_resource(state_store); return enif_make_badarg(env); } @@ -2267,7 +2464,7 @@ account_map_uncompact_state(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) AccountEntry entry = kv.second; if (!append_uncompacted_account(env, am, scratch.hash_ctx, pending_state, kv.first, entry, nullptr, nullptr, i)) { - return uncompact_state_fail(env, nullptr, am, state_store); + return uncompact_state_fail(env, nullptr, am); } } } else { @@ -2279,12 +2476,17 @@ account_map_uncompact_state(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) i++; uint160_t addr; if (!get_address(env, key, addr)) { - return uncompact_state_fail(env, &iter, am, state_store); + return uncompact_state_fail(env, &iter, am); } ParsedCompactAccount parsed; if (!parse_compact_account(env, value, parsed, scratch.code_buf)) { - return uncompact_state_fail(env, &iter, am, state_store); + return uncompact_state_fail(env, &iter, am); + } + + if (parsed.has_compact_root_hash && parsed.entry.compact_storage) { + parsed.entry.compact_storage->root_hash = parsed.compact_root_hash; + parsed.entry.compact_storage->has_root = true; } const uint256_t *storage_root_override = @@ -2293,7 +2495,7 @@ account_map_uncompact_state(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) parsed.has_compact_code_hash ? &parsed.compact_code_hash : nullptr; if (!append_uncompacted_account(env, am, scratch.hash_ctx, pending_state, addr, parsed.entry, storage_root_override, code_hash_override, i)) { - return uncompact_state_fail(env, &iter, am, state_store); + return uncompact_state_fail(env, &iter, am); } enif_map_iterator_next(env, &iter); @@ -2301,20 +2503,18 @@ account_map_uncompact_state(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) enif_map_iterator_destroy(env, &iter); } - batch_insert_state_items(state_store, pending_state); + batch_insert_state_items(am->shared->state_trie, pending_state); uint256_t state_root; { - Lock lock(state_store); - state_root = state_store->shared_state->tree.root_hash(); + Lock lock(am->shared->state_trie); + state_root = am->shared->state_trie->shared_state->tree.root_hash(); } ERL_NIF_TERM am_term = enif_make_resource(env, am); enif_release_resource(am); - ERL_NIF_TERM store_term = enif_make_resource(env, state_store); - enif_release_resource(state_store); ERL_NIF_TERM hash_term = make_binary(env, state_root.data(), 32); - return enif_make_tuple3(env, am_term, store_term, hash_term); + return enif_make_tuple2(env, am_term, hash_term); } static void @@ -2332,7 +2532,7 @@ destruct_merkletree_type(ErlNifEnv* /*env*/, void *arg) { merkletree *mt = (merkletree *) arg; STAT(resources--); - locked_states->leave_lock(mt); + release_merkletree_shared(mt); } @@ -2351,7 +2551,6 @@ on_load(ErlNifEnv* env, void** /*priv*/, ERL_NIF_TERM /*info*/) if(!rt) return -1; accountmap_type = rt; - locked_states = new LockedStates(); stats_mutex = enif_mutex_create((char*)"stats_mutex"); sha((const uint8_t*)"", 0, empty_code_hash.value); empty_storage_tree = alloc_merkletree_resource(); @@ -2370,37 +2569,27 @@ static int on_upgrade(ErlNifEnv* /*env*/, void** /*priv*/, void** /*old_priv_dat } static ErlNifFunc nif_funcs[] = { - {"new", 0, merkletree_new, 0}, - {"insert_item_raw", 3, merkletree_insert_item, 0}, - {"get_item", 2, merkletree_get_item, 0}, - {"get_range_raw", 3, merkletree_get_range, 0}, - {"get_proofs_raw", 2, merkletree_get_proofs, ERL_NIF_DIRTY_JOB_CPU_BOUND}, - {"difference_raw", 2, merkletree_difference, ERL_NIF_DIRTY_JOB_CPU_BOUND}, - {"lock", 1, merkletree_lock, ERL_NIF_DIRTY_JOB_CPU_BOUND}, - {"to_list", 1, merkletree_to_list, ERL_NIF_DIRTY_JOB_CPU_BOUND}, - {"import_map", 2, merkletree_import_map, ERL_NIF_DIRTY_JOB_CPU_BOUND}, - {"root_hash", 1, merkletree_root_hash, 0}, - {"hash", 1, merkletree_hash, 0}, - {"root_hashes_raw", 1, merkletree_root_hashes, 0}, - {"bucket_count", 1, merkletree_bucket_count, 0}, - {"size", 1, merkletree_size, 0}, - {"clone", 1, merkletree_clone, ERL_NIF_DIRTY_JOB_CPU_BOUND}, {"count_zeros", 1, merkletree_count_zeros, ERL_NIF_DIRTY_JOB_CPU_BOUND}, - {"struct_sizes_raw", 0, merkletree_struct_sizes, 0}, - {"memory_stats_raw", 1, merkletree_memory_stats, ERL_NIF_DIRTY_JOB_CPU_BOUND}, - {"malloc_info_raw", 0, merkletree_malloc_info, ERL_NIF_DIRTY_JOB_IO_BOUND}, {"nif_stats_raw", 0, merkletree_nif_stats, 0}, {"account_map_new", 0, account_map_new, 0}, {"account_map_clone", 1, account_map_clone, ERL_NIF_DIRTY_JOB_CPU_BOUND}, - {"account_map_lock", 2, account_map_lock, ERL_NIF_DIRTY_JOB_CPU_BOUND}, + {"account_map_lock", 1, account_map_lock, ERL_NIF_DIRTY_JOB_CPU_BOUND}, {"account_map_get", 2, account_map_get, 0}, {"account_map_put", 6, account_map_put, 0}, {"account_map_delete", 2, account_map_delete, 0}, + {"account_map_root_hash", 1, account_map_root_hash, 0}, + {"account_map_state_roots", 1, account_map_state_roots, 0}, {"account_map_size", 1, account_map_size, 0}, {"account_map_to_list", 1, account_map_to_list, ERL_NIF_DIRTY_JOB_CPU_BOUND}, - {"account_map_list_difference_raw", 2, account_map_list_difference_raw, ERL_NIF_DIRTY_JOB_CPU_BOUND}, + {"account_map_difference_full", 2, account_map_difference_full, ERL_NIF_DIRTY_JOB_CPU_BOUND}, + {"account_map_apply_difference", 2, account_map_apply_difference, ERL_NIF_DIRTY_JOB_CPU_BOUND}, + {"account_map_compact", 1, account_map_compact, ERL_NIF_DIRTY_JOB_CPU_BOUND}, {"account_map_uncompact_state", 1, account_map_uncompact_state, ERL_NIF_DIRTY_JOB_CPU_BOUND}, + {"account_map_storage_put_map", 2, account_map_storage_put_map, ERL_NIF_DIRTY_JOB_CPU_BOUND}, + {"account_map_storage", 3, account_map_storage, 0}, + {"account_map_storage_roots", 2, account_map_storage_roots, 0}, + {"account_map_proof", 2, account_map_proof, ERL_NIF_DIRTY_JOB_CPU_BOUND}, + {"account_map_proof", 3, account_map_proof, ERL_NIF_DIRTY_JOB_CPU_BOUND}, }; -// ERL_NIF_INIT(merkletree_nif, nif_funcs, on_load, on_reload, on_upgrade, NULL); ERL_NIF_INIT(Elixir.CMerkleTree, nif_funcs, on_load, on_reload, on_upgrade, NULL) diff --git a/docs/caccount-map-nif.md b/docs/caccount-map-nif.md new file mode 100644 index 0000000..e41451d --- /dev/null +++ b/docs/caccount-map-nif.md @@ -0,0 +1,87 @@ +# CAccountMap / CMerkleTree NIF + +Agent-facing notes for the merkle account-map NIF (`priv/merkletree_nif.so`, +built from `c_src/` via `mix compile` / top-level `Makefile`). + +See also [`c_src/LOCK_ORDER.md`](../c_src/LOCK_ORDER.md), +[`c_src/SECURITY_REVIEW.md`](../c_src/SECURITY_REVIEW.md), and the +implementation spec for difference/clone performance work: +[`docs/specs/change-state-diff-perf.md`](specs/change-state-diff-perf.md) +(cached compact storage roots, CompactStorage COW, state_trie-driven +`difference_full`). + +## Ownership model + +- `Chain.State` is backed by `CAccountMap`. Account storage tries and the state + root trie live in C++. Elixir does **not** carry a separate `:store` field. +- Prefer map-owned storage APIs — never bare `merkletree` resources in production: + - `Chain.State.storage_value/3`, `storage_put_map/2`, `storage_to_list/2`, + `storage_get_proofs/3`, `storage_root_hash/2`, `state_root_hashes/1` + - `CAccountMap` mirrors of the same (thin wrappers over merged NIFs) +- `Chain.State.hash/1` or `CAccountMap.root_hash/1` for the state root. + Internal `state_trie` is never exported; Edge uses `state_root_hashes/1`. +- `account_map_get` / `to_list` return `{nonce, balance, storage_root_hash_bin32, code}` + — the third element is a **32-byte hash**, never a live storage resource. +- `account_map_put/6` storage arg: `:keep` (meta-only) | `nil`/`[]` | + `[{key32, value32}]` (genesis / hardfork / tests). Live bare-tree resources are + not part of the public NIF surface. +- `account_map_storage/3` covers get / range / list / size via one NIF. +- `account_map_storage_roots/2` and `account_map_state_roots/1` return + `<>`. +- `account_map_proof/2` (account) and `/3` (storage key). +- `account_map_compact/1` — one NIF for `Chain.State.compact/1`. +- Map-backed `%Chain.Account{}` values set `map_backed: true` and `root_hash` + (struct field) via `Account.from_parts/4`. Storage is accessed only through + `State.storage_*` / `CAccountMap.storage_*`. Edge `getaccount` / + `getaccountroot` use `Account.root_hash/1` (no extra storage-roots NIF). +- Every chain definition exports `genesis_storage/0` (Devnet has slots; + others return `%{}`). Genesis applies accounts then `State.storage_put_map/2`. + +## Clone and lock + +- `Chain.State.clone/1` forks a writable map. Use it after `Chain.State.lock/1` + (cached peak / block sync) and for RPC / EdgeV2 / Shell speculative execution. +- Compact account storage uses `shared_ptr` COW: clone shares slot vectors until a + write materializes unique live storage (parent keeps the shared compact object). + Storage roots are cached on `CompactStorage` (seeded from Elixir `:root_hash` on + uncompact). Meta-only `:keep` puts on compact accounts update `state_trie` via + that cached root without materializing. +- `account_map_difference_full/2` is driven by the symmetric difference of + `state_trie` leaves (not a full accounts scan), returns 6-tuples + `{addr, side_a, side_b, storage_diff, root_a, root_b}`, and compares live + storage via shared `SharedState*` when possible. `Chain.State.difference/2` + consumes those roots (no second `storage_root_hash` pass). +- `account_map_storage_roots/2` does **not** materialize compact entries solely to + read roots; it may build a temporary trie for the intermediate-hash blob. +- `lock/1` sets map-level `frozen` only. Mutations + (`put` / `delete` / `apply_difference` / `storage_put_map`) reject frozen maps. +- `Chain.Transaction.apply/3` mutates state in place on an unlocked candidate. +- `Chain.State` is **mutable**: always `clone/1` before applying transactions on + a shared cached state. + +## Build + +- NIF: `mix compile` → `elixir_make` → `c_src/` → `priv/merkletree_nif.so` +- Exports: `account_map_*` plus `count_zeros/1` and `nif_stats_raw/0` + (`CMerkleTree.nif_stats/0`). No bare-tree test NIF mode. +- EVM binary: `evm/evm` (needs `libboost-dev`) +- `deps/libsecp256k1`: build once with `make -C deps/libsecp256k1/` (not via `mix`) + +## Performance benches + +Run without starting the full node (`mix run --no-start …`). Defaults target ~14k +accounts (prod jump-block scale). + +| Script | Prod warning / path | +|--------|---------------------| +| `scripts/state_diff_bench.exs` | `State diff took longer than 1s…` / `State.difference` | +| `scripts/state_uncompact_bench.exs` | `state(uncompact:0x…) took Nms` / jump-block `State.uncompact` | +| `scripts/state_delta_apply_bench.exs` | `state(delta:0x…) took Nms` / clone + `apply_difference` + `normalize` | + +Examples: + +```bash +mix run --no-start scripts/state_uncompact_bench.exs -- --scenario sparse_jump +mix run --no-start scripts/state_delta_apply_bench.exs -- --scenario all --changed 20 +mix run --no-start scripts/state_diff_bench.exs -- --scenario compact_small_delta +``` diff --git a/docs/specs/change-state-diff-perf.md b/docs/specs/change-state-diff-perf.md new file mode 100644 index 0000000..3216cce --- /dev/null +++ b/docs/specs/change-state-diff-perf.md @@ -0,0 +1,399 @@ +# State Diff Performance Specification v0.1.2 + +> **Spec type:** Change +> **Path:** `docs/specs/change-state-diff-perf.md` +> **Status:** Implemented (Phases A–E) + +## Overview + +This change eliminates multi-second `Chain.State.difference/2` walls on jump-shaped +(compact) peaks when only a few accounts change. + +**Before:** The NIF rebuilt temporary Merkle trees from every `CompactStorage` slot +vector solely to compare storage roots, deep-copied those slot vectors on every +clone, scanned all accounts for equality, and had Elixir re-fetch roots +(materializing compact storage) after the NIF already did the work. + +**After (shipping):** Compact storage roots are cached; live equality uses +`SharedState*`; `difference_full` is driven by `state_trie` leaf differences and +returns 6-tuples with roots; compact storage is `shared_ptr` COW on clone; +Elixir consumes NIF roots without a second `storage_root_hash` pass. + +**Integration context:** `c_src/nif.cpp` (`CompactStorage`, `entries_equal`, +`storage_root_hash_for_entry`, `fork_shared_accountmap`, `account_map_difference_full`, +`account_map_storage_roots`, uncompact/compact), `lib/chain/state.ex`, +`lib/caccount_map.ex`, `lib/cmerkletree.ex`, `Model.ChainSql.prepare_state/2`, +and `scripts/state_diff_bench.exs`. On-disk BertInt block encodings MUST NOT change. + +## Design Principles + +1. **Never rebuild a trie only to compare roots.** Storage roots are cached data; + deriving them on every equality check is forbidden once a root is known. +2. **O(changed) for common `prepare_state` deltas.** Full-map account scans for + equality are unacceptable when few accounts differ. +3. **Compact storage is COW.** `State.clone/1` / `account_map_clone` MUST NOT + deep-copy slot vectors until a write requires uniqueness. +4. **Same observable deltas.** `State.difference` / `apply_difference` round-trips + and BertInt encodings remain bit-compatible with pre-change behavior for the + Elixir report map shape (`{id, report}` with optional `:state` / `:root_hash`). +5. **Measure before claiming.** Each phase has a `state_diff_bench` acceptance + gate; phases ship in order A→E and are each mergeable alone with tests green. + +--- + +## Output Structure + +**Do generate:** + +- Updates under `c_src/nif.cpp` (and helpers if extracted) +- Elixir wrapper / `State.difference` updates for the NIF tuple shape +- Unit and regression tests listed in Testing +- Updates to `docs/caccount-map-nif.md` describing cache, COW, and trie-driven diff + +**Do not generate:** + +- Changes to BertInt / on-disk block state encoding +- Rework of `Tree::difference` internals +- Slot-vector-only storage diffs as a substitute for Phase E (deferred) +- Standalone packages or unrelated refactors + +--- + +## Type Conventions + +| Spec type | Meaning | Examples | +|-----------|---------|----------| +| `uint256_t` | 32-byte big-endian hash / root | storage root | +| `CompactStorage` | Lazy account storage: slots + optional cached root | see Phase A | +| `AccountEntry` | nonce, balance, live `merkletree*`, compact ptr, code | `c_src/nif.cpp` | +| `difference_full` entry | Erlang term from NIF | Phase C 6-tuple | +| `State.difference` report | `%{optional fields}` per address | `:nonce`, `:balance`, `:code`, `:state`, `:root_hash` | +| `nif_ms` | Wall ms inside `account_map_difference_full` | `state_diff_bench` | + +### Normalization + +- Elixir compact account `:root_hash` is the **storage** root (32 bytes), not the + account RLP hash. Uncompact MUST treat it as the storage root cache seed. +- Atom `nil` in NIF root fields means “side absent”; never a zero hash. + +--- + +## Error Handling + +| Language | Error style | +|----------|-------------| +| C++ NIF | `enif_make_badarg` / existing apply error tuples | +| Elixir | Raise / existing `ArgumentError` on `apply_difference` mismatch | + +| Behavior | Error when | +|----------|------------| +| `storage_root_hash_for_entry` | Unchanged: still returns false only on hard failure; cache miss MUST compute and store, not fail | +| `difference_full` | Bad resources / arity → `badarg` (unchanged) | +| `State.difference` | Malformed NIF tuple after Phase C → raise (tests catch wrapper bugs) | +| COW write | Allocation failure → existing NIF OOM / badarg paths | + +Liberal inputs (legacy compact without `:root_hash`): compute root once, cache it. +Strict outputs: cached root MUST match a fresh trie hash of the same slots. + +--- + +## Domain Rules + +### Cache invalidation (Phase A) + +`CompactStorage.has_root` MUST be set `false` (and root ignored) before or when +mutating slots **in place** on a compact entry. The shipping write path instead: + +- Materializes compact slots into a live `merkletree`, then +- Resets `compact_storage` on that entry (refcount drop; siblings keep the shared + compact object and its cached root) + +So invalidation is ownership drop, not an in-place `has_root=false` clear. + +Helpers `ensure_unique_compact` / `invalidate_compact_root` MUST exist for any +future in-place compact mutation (COW when `use_count() > 1`, then clear +`has_root`). After a true in-place invalidation, the next +`storage_root_hash_for_entry` MUST recompute once and set `has_root` again if the +entry remains compact. + +### COW (Phase D) + +- `shared_ptr` (or equivalent intrusive shared ownership). +- Copy/assign/fork: share the pointer; do **not** copy `slots`. +- Write path (shipping): `materialize_storage` reads shared slots into a new live + trie, then `compact_storage.reset()` on the writing entry only. +- In-place compact mutation (if added): if `use_count() > 1`, allocate a unique + copy (deep-copy slots + root flags), then mutate / clear `has_root`. +- `snapshot_side` MAY share the compact pointer (refcount bump); MUST NOT force a + deep copy solely for snapshotting. + +### State trie invariant (Phase E) + +Every NIF that changes account RLP content (nonce, balance, code, or storage root) +MUST update `state_trie` for that address. Paths that MUST keep this invariant: + +- `account_map_put` / `put` meta +- `account_map_delete` +- `account_map_storage_put_map` +- `account_map_apply_difference` +- Uncompact batch insert into `state_trie` + +If a future NIF mutates account content without updating `state_trie`, Phase E +equality is wrong — such a NIF is a spec violation. + +### Observable compatibility + +- `State.difference/2` return type remains a list of `{addr, report}` maps. +- Report keys and semantics unchanged; only the *source* of `:root_hash` values + changes (NIF-provided vs Elixir re-fetch). +- `apply_difference` input shape unchanged. + +--- + +## Behaviors (Phases A–E) + +Phases MUST be implemented in order. Each phase MUST leave correctness suites green +and meet its acceptance gate before the next phase merges. + +```mermaid +flowchart TD + diff[State.difference] --> nif[difference_full] + nif --> eq[entries_equal] + eq -->|cached root| o1[O1 memcmp] + eq -->|live SharedState| share[pointer equal] + nif --> trie[Phase E: state_trie leaf diff] + trie --> deep[deep storage diff only for changed addrs] + clone[State.clone] --> cow[shared_ptr CompactStorage] +``` + +### Phase A — Cache storage root on `CompactStorage` + +**Data model** (`c_src/nif.cpp`): + +```cpp +struct CompactStorage { + std::vector slots; + uint256_t root_hash; // valid iff has_root + bool has_root = false; +}; +``` + +**MUST:** + +| Condition | Behavior | +|-----------|----------| +| Uncompact Elixir map with `parsed.has_compact_root_hash` | Set `has_root=true` and copy root onto the new `CompactStorage` (not only pass override into `AccountHashCtx::compute`) | +| `storage_root_hash_for_entry` + compact + `has_root` | Return cached root; **no** temporary `Tree` | +| `storage_root_hash_for_entry` + compact + `!has_root` | Build tree **once**, store root on compact, return it | +| `make_compact_account_term` | Emit Elixir `:root_hash` from cache or one-time compute | +| Storage mutation | Invalidate `has_root` per Domain Rules | +| Live `storage != nullptr` | Unchanged: use tree `root_hash()` | + +**MUST NOT:** Discard a known root and rebuild on the next compare. + +**Acceptance:** +`mix run --no-start scripts/state_diff_bench.exs -- --scenario compact_small_delta --accounts 14000 --changed 20 --slots 32 --warmup 1 --iters 2` +→ avg `nif_ms` **< 50** (baseline ~700+). + +**Rationale:** Uncompact already parses `:root_hash`; caching it removes the +prod “accounts=20 / multi-second” hotspot without changing algorithms. + +--- + +### Phase B — Live `SharedState*` equality + +**`entries_equal` order MUST be:** + +1. Meta mismatch (nonce / balance / code) → not equal. +2. Both `storage` non-null and `a.storage->shared_state == b.storage->shared_state` → equal. +3. Else both wrappers identical (`a.storage == b.storage`) → equal. +4. Else compare storage roots via `storage_root_hash_for_entry` (Phase A cache / live). + +**Acceptance:** `locked_peak_delta` / `live_small_delta` latency no worse than +pre-change; `test/cmerkle_account_map_diff_test.exs` and +`test/chain_state_merkle_test.exs` pass. + +**Rationale:** Fork installs distinct wrappers sharing `SharedState` until write; +wrapper-only equality missed the cheap path. + +--- + +### Phase C — NIF returns storage roots; Elixir stops double fetch + +**NIF entry shape for `account_map_difference_full`:** + +| Version | Term | +|---------|------| +| Before | `{addr, side_a, side_b, storage_diff}` | +| After (MUST) | `{addr, side_a, side_b, storage_diff, root_a, root_b}` | + +- `root_*`: 32-byte binary, or atom `nil` if that side is absent. +- Roots MUST be the storage roots for each present side (from cache / live tree), + computed during the diff without requiring Elixir callbacks. + +**Elixir `Chain.State.difference/2` MUST:** + +- Pattern-match the 6-tuple. +- Set `:root_hash` from `{root_a, root_b}` when storage diff is non-empty (decode + `nil` sides consistently with absent accounts). +- **MUST NOT** call `CAccountMap.storage_root_hash/2` for those roots. + +**`account_map_storage_roots` / storage root reads MUST:** + +- If compact + `has_root`, return the cached root **without** `materialize_storage`. +- Otherwise existing behavior (materialize or compute-and-cache). + +Update: `lib/caccount_map.ex`, `lib/cmerkletree.ex`, and every consumer that +pattern-matches `difference_full` tuples (tests, fuzz, stress). + +**Acceptance:** Identical `State.difference` report maps vs pre-change on existing +suites; `elixir_ms` ≈ 0 on storage-touching bench deltas; root-only reads do not +force materialize when cache hit. + +--- + +### Phase D — `shared_ptr` COW for `CompactStorage` + +**MUST:** + +- Replace `unique_ptr` with `shared_ptr` on + `AccountEntry` and `DiffAccountSide`. +- Copy / assign / `fork_shared_accountmap` (via `accounts = src->accounts`): share + pointer only — no `slots = src->slots` deep copy in the default copy path. +- COW before mutating slots or invalidating root when `use_count() > 1`. +- `snapshot_side`: share compact pointer; keep live storage `enif_keep_resource` + as today. + +**Acceptance:** Clone of compact peak (14k accounts × 32 slots): RSS growth +order-of-magnitude below today’s full slot duplication (target: well under ~2× +full slot payload duplication). `test/cmerkle_nif_leak_test.exs` passes. + +**Rationale:** Live storage already COWs via `SharedState::has_clone`; compact was +the outlier that doubled RAM on every `State.clone/1`. + +--- + +### Phase E — `state_trie`-driven `difference_full` + +**Algorithm MUST:** + +1. If `am_a->shared == am_b->shared` → return `[]` (existing). +2. If both maps’ `state_trie->shared_state` pointers are identical → return `[]`. +3. Else build the candidate address set from the **symmetric difference of + state_trie leaves** (hash differs, or key only on one side). MUST NOT scan all + `accounts` entries solely to run `entries_equal` on unchanged hashes. +4. For each candidate only: snapshot sides + `build_storage_diff_list` as today; + emit Phase C 6-tuples. +5. Accounts present in one map but missing from the other MUST still appear + (trie miss / accounts lookup as needed for that address). + +**Acceptance:** +`compact_small_delta` and `live_small_delta` with `--accounts 14000 --changed 20`: +avg `nif_ms` **< 20**. Full-map equality walk absent from profiles. + +**Rationale:** Account RLP hash embeds storage root; trie leaf inequality is a +sound filter for “this address needs a deep diff” under the state_trie invariant. + +--- + +## Out of Scope + +- BertInt / on-disk block state encoding changes +- Replacing `Tree::difference` with a new algorithm +- Pure slot-vector diff without trees for fat-storage accounts (may follow later) +- Changing the public Elixir `State.difference/2` report map shape + +--- + +## Testing + +### Perf gates (`scripts/state_diff_bench.exs`) + +| Phase | Scenario / flags | Gate | +|-------|------------------|------| +| A | `compact_small_delta` 14k / 20 / 32 | avg `nif_ms` < 50 | +| E | `compact_small_delta` and `live_small_delta` 14k / 20 | avg `nif_ms` < 20 | +| C | storage-touching scenarios | `elixir_ms` ≈ 0 | + +### Correctness suites + +```text +mix test test/cmerkle_account_map_diff_test.exs \ + test/chain_state_merkle_test.exs \ + test/cmerkle_lock_clone_regression_test.exs +``` + +### Memory + +- `mix test test/cmerkle_nif_leak_test.exs` +- Phase D: `cow_unique_after_write` includes an RSS smoke check on compact clone + (`test/state_diff_perf_contract_test.exs`) + +### Required unit cases + +Covered by `test/state_diff_perf_contract_test.exs` (must stay green): + +| Name | Assert | +|------|--------| +| `cached_root_after_uncompact` | Compact→uncompact preserves storage roots; compact Elixir structs carry `:root_hash` | +| `root_invalidated_after_storage_put` | After `storage_put_map`, root changes and `State.difference` reports `{before, after}` | +| `cow_unique_after_write` | Clone + write does not mutate parent; compact clone RSS growth stays well below full slot duplication | +| `difference_full tuple shape` | 6-tuple `{addr, side_a, side_b, storage_diff, root_a, root_b}` | +| `compact_small_delta prepare_state shape` | Few changed accounts on compact peak round-trip via difference/apply | + +Production NIF surface: `test/count_zeros_test.exs`, `test/caccount_map_test.exs`, +`test/cmerkle_nif_leak_test.exs` (nif_stats). + +### Integration + +- Existing chain state / `prepare_state` round-trips remain green. +- Implementations MAY add tests; suites above MUST pass unchanged in intent. + +--- + +## Integration Docs + +**Where it lives:** NIF hot path in `c_src/nif.cpp`; Elixir orchestration in +`lib/chain/state.ex` (`difference/2`); SQL writer path +`Model.ChainSql.prepare_state/2` calls `State.difference/2` for non-jump blocks. + +**How to call:** Unchanged for product code — `Chain.State.difference/2` / +`apply_difference/2`. Benchmark: + +```bash +mix run --no-start scripts/state_diff_bench.exs -- \ + --scenario compact_small_delta --accounts 14000 --changed 20 --slots 32 +``` + +**Migration:** No on-disk migration. Deploy is a rolling binary upgrade. Phase C +changes only the internal NIF tuple; keep wrappers and tests in the same commit +as the NIF. + +**Agent notes:** See also `docs/caccount-map-nif.md` (ownership, clone/lock). + +--- + +## Implementation Checklist + +- [x] Contract tests: `test/state_diff_perf_contract_test.exs` +- [x] Phase A: `CompactStorage` root cache + invalidation + uncompact seed +- [x] Phase A bench gate (`nif_ms` < 50) +- [x] Phase B: `SharedState*` equality in `entries_equal` +- [x] Phase C: 6-tuple NIF + Elixir stops double `storage_root_hash` (update tuple-shape test) +- [x] Phase C: `storage_roots` uses compact cache (no materialize-for-root) +- [x] Phase D: `shared_ptr` COW; fork no longer deep-copies slots +- [x] Phase D leak / RSS acceptance +- [x] Phase E: state_trie-driven candidate set; `nif_ms` < 20 +- [x] All listed correctness tests green +- [x] `docs/caccount-map-nif.md` updated for cache, COW, trie-driven diff +- [x] Each phase mergeable alone with tests green + +--- + +## Version History + +- **v0.1.2** — Docs/spec aligned with shipping behavior: status, COW write path + (materialize+drop), Phase D RSS contract, LOCK_ORDER / bench commentary. +- **v0.1.1** — Phases A–E implemented (cached compact roots, SharedState* equality, + 6-tuple roots, CompactStorage `shared_ptr` COW, state_trie-driven `difference_full`). +- **v0.1.0** — Initial specification (Phases A–E locked). diff --git a/lib/bench.ex b/lib/bench.ex index 24d95a9..79e9d63 100644 --- a/lib/bench.ex +++ b/lib/bench.ex @@ -55,11 +55,5 @@ defmodule Bench do {:ok, state, _rcpt} = Transaction.apply(tx, block, state) state end) - - # # Checking value of i at position 0 - # acc = Chain.State.account(state, addr) - # len = Chain.Account.storageInteger(acc, 0) - # IO.puts("#{length(txlist)} == #{len}") - # ^len = length(txlist) end end diff --git a/lib/block_process.ex b/lib/block_process.ex index bee4c19..ee75ee2 100644 --- a/lib/block_process.ex +++ b/lib/block_process.ex @@ -28,13 +28,6 @@ defmodule BlockProcess do EtsLru.put(__MODULE__.State, Block.hash(block), Chain.State.lock(Block.state(block))) end - def with_account_tree(block_ref, account_id, fun) do - with_block(block_ref, fn - nil -> fun.(nil) - block -> fun.(Block.account_tree(block, account_id)) - end) - end - def with_account(block_ref, account_id, fun) do with_state(block_ref, fn nil -> fun.(nil) diff --git a/lib/caccount_map.ex b/lib/caccount_map.ex index b0a22a3..2c77ee6 100644 --- a/lib/caccount_map.ex +++ b/lib/caccount_map.ex @@ -6,12 +6,22 @@ defmodule CAccountMap do alias Chain.Account @type t :: reference() + @null <<0::unsigned-size(256)>> def new, do: CMerkleTree.account_map_new() def clone(map), do: CMerkleTree.account_map_clone(map) - def lock(map, store \\ nil), do: CMerkleTree.account_map_lock(map, store) + def lock(map), do: CMerkleTree.account_map_lock(map) + + def root_hash(map), do: CMerkleTree.account_map_root_hash(map) + + def state_root_hashes(map) do + {_root, hashes} = split_roots(CMerkleTree.account_map_state_roots(map)) + hashes + end + + def get_proofs(map, <<_::160>> = addr), do: CMerkleTree.account_map_proof(map, addr) def get(map, <<_::160>> = addr) do case CMerkleTree.account_map_get(map, addr) do @@ -23,7 +33,7 @@ defmodule CAccountMap do def get_account(map, addr) do case get(map, addr) do :undefined -> nil - entry -> account_from_parts(entry) + {nonce, balance, root_hash, code} -> Account.from_parts(nonce, balance, root_hash, code) end end @@ -31,8 +41,18 @@ defmodule CAccountMap do CMerkleTree.account_map_put(map, addr, nonce, encode_balance(balance), storage, code) end + def put_meta(map, <<_::160>> = addr, nonce, balance, code) do + put(map, addr, nonce, balance, :keep, code) + end + def put_account(map, <<_::160>> = addr, %Account{} = account) do - put(map, addr, account.nonce, account.balance, Account.tree(account), Account.code(account)) + case account.storage_root do + nil -> + put_meta(map, addr, account.nonce, account.balance, Account.code(account)) + + storage -> + put(map, addr, account.nonce, account.balance, storage, Account.code(account)) + end end def delete(map, <<_::160>> = addr), do: CMerkleTree.account_map_delete(map, addr) @@ -46,28 +66,83 @@ defmodule CAccountMap do end def to_account_list(map) do - Enum.map(to_list(map), fn {addr, {nonce, balance, storage, code}} -> - {addr, Account.from_parts(nonce, balance, storage, code)} + Enum.map(to_list(map), fn {addr, {nonce, balance, root_hash, code}} -> + {addr, Account.from_parts(nonce, balance, root_hash, code)} end) end - def list_difference(map_a, map_b) do - Map.new(CMerkleTree.account_map_list_difference_raw(map_a, map_b), fn {addr, {side_a, side_b}} -> - {addr, {decode_account_side(side_a), decode_account_side(side_b)}} - end) + @doc """ + Apply EVM-style storage updates in one NIF call. + `updates` is `%{addr => %{slot => value}}` or a list of `{addr, [{slot, value}]}`. + """ + def storage_put_map(map, updates) when is_map(updates) do + list = + Enum.map(updates, fn {addr, kvs} -> + {addr, Map.to_list(kvs)} + end) + + storage_put_map(map, list) end - defp decode_account_side(nil), do: nil - defp decode_account_side(entry), do: entry |> decode_entry() |> account_from_parts() + def storage_put_map(map, updates) when is_list(updates) do + CMerkleTree.account_map_storage_put_map(map, updates) + end - def uncompact_state(accounts), do: CMerkleTree.account_map_uncompact_state(accounts) + def storage_get(map, <<_::160>> = addr, key) do + case CMerkleTree.account_map_storage(map, addr, {:get, to_bytes32(key)}) do + nil -> nil + @null -> nil + value -> value + end + end + + def storage_get_range(map, <<_::160>> = addr, key, count) + when is_integer(count) and count >= 1 and count <= 256 do + CMerkleTree.account_map_storage(map, addr, {:range, to_bytes32(key), count}) + |> Enum.map(fn {k, v} -> {k, if(v == @null, do: nil, else: v)} end) + end + + def storage_to_list(map, <<_::160>> = addr), + do: CMerkleTree.account_map_storage(map, addr, :list) + + def storage_size(map, <<_::160>> = addr), + do: CMerkleTree.account_map_storage(map, addr, :size) + + def storage_root_hash(map, <<_::160>> = addr) do + {root, _hashes} = split_roots(CMerkleTree.account_map_storage_roots(map, addr)) + root + end - defp decode_entry({nonce, balance, storage, code}) do - {nonce, decode_balance(balance), storage, code} + def storage_root_hashes(map, <<_::160>> = addr) do + {_root, hashes} = split_roots(CMerkleTree.account_map_storage_roots(map, addr)) + hashes end - defp account_from_parts({nonce, balance, storage, code}) do - Account.from_parts(nonce, balance, storage, code) + def storage_get_proofs(map, <<_::160>> = addr, key), + do: CMerkleTree.account_map_proof(map, addr, to_bytes(key)) + + def difference_full(map_a, map_b) do + CMerkleTree.account_map_difference_full(map_a, map_b) + end + + def apply_difference(map, delta), do: CMerkleTree.account_map_apply_difference(map, delta) + + def decode_storage_diff(storage_diff) do + Map.new(storage_diff, fn {key, {val_a, val_b}} -> + {key, {decode_storage_value(val_a), decode_storage_value(val_b)}} + end) + end + + defp decode_storage_value(nil), do: nil + defp decode_storage_value(val) when is_binary(val), do: val + + def compact(map), do: CMerkleTree.account_map_compact(map) + + def uncompact_state(accounts), do: CMerkleTree.account_map_uncompact_state(accounts) + + # Third element is always a 32-byte storage root hash (never a live resource). + defp decode_entry({nonce, balance, <<_::binary-size(32)>> = root_hash, code}) do + {nonce, decode_balance(balance), root_hash, code} end defp encode_balance(balance) when is_integer(balance) and balance >= 0 do @@ -83,4 +158,22 @@ defmodule CAccountMap do defp decode_balance(balance) when is_binary(balance) do :binary.decode_unsigned(balance) end + + defp to_bytes32(value), do: Hash.to_bytes32(value) + + defp to_bytes(string) when is_binary(string), do: string + defp to_bytes(int) when is_integer(int), do: to_bytes32(int) + + defp split_roots(<>) do + {root, decode_root_hashes(hashes)} + end + + defp decode_root_hashes( + <> + ) do + [a, b, c, d, e, f, g, h, i, j, k, l, m, n, o, p] + end end diff --git a/lib/chain/account.ex b/lib/chain/account.ex index 7e708ca..62dc71c 100644 --- a/lib/chain/account.ex +++ b/lib/chain/account.ex @@ -2,13 +2,23 @@ # Copyright 2021-2024 Diode # Licensed under the Diode License, Version 1.1 defmodule Chain.Account do - defstruct nonce: 0, balance: 0, storage_root: nil, code: nil + defstruct nonce: 0, balance: 0, storage_root: nil, code: nil, map_backed: false, root_hash: nil + + # Matches C++ empty storage trie root (root_hash of an empty storage trie). + @empty_storage_root Base.decode16!( + "438A90405DAA876539082CD0BAF6CDDAA3BF880F1C8AF0C0381F0042DB93088A" + ) @type t :: %Chain.Account{ nonce: non_neg_integer(), balance: non_neg_integer(), - storage_root: CMerkleTree.t(), - code: binary() | nil + storage_root: + nil + | [{binary(), binary()}] + | {atom(), list(), map()}, + code: binary() | nil, + map_backed: boolean(), + root_hash: binary() | nil } def new(props \\ []) do @@ -24,87 +34,40 @@ defmodule Chain.Account do def nonce(%Chain.Account{nonce: nonce}), do: nonce def balance(%Chain.Account{balance: balance}), do: balance - @spec tree(Chain.Account.t()) :: CMerkleTree.t() - def tree(%Chain.Account{storage_root: nil}), do: CMerkleTree.new() - def tree(%Chain.Account{storage_root: root}), do: root + @doc """ + Build an account from `CAccountMap.get/2` parts. + A 32-byte `storage` root marks the account map-backed (`map_backed: true`). + Otherwise `storage` is a put payload (`nil` / slot list / compact MapMerkleTree tuple). + """ + def from_parts(nonce, balance, <>, code) do + %Chain.Account{ + nonce: nonce, + balance: balance, + storage_root: nil, + code: if(code == "", do: nil, else: code), + map_backed: true, + root_hash: root_hash + } + end def from_parts(nonce, balance, storage, code) do %Chain.Account{ nonce: nonce, balance: balance, storage_root: storage, - code: if(code == "", do: nil, else: code) + code: if(code == "", do: nil, else: code), + map_backed: false, + root_hash: nil } end - def clone(%Chain.Account{} = acc) do - %Chain.Account{acc | storage_root: CMerkleTree.clone(tree(acc))} - end - - def put_tree(%Chain.Account{} = acc, root) do - %Chain.Account{acc | storage_root: root} - end - - def root_hash(%Chain.Account{} = acc) do - CMerkleTree.root_hash(tree(acc)) - end - - def uncompact(%Chain.Account{storage_root: nil} = acc) do - %Chain.Account{acc | storage_root: CMerkleTree.new()} - end - - def uncompact(%Chain.Account{storage_root: {MapMerkleTree, _opts, items}} = acc) - when is_map(items) do - # old_root = Map.get(acc, :root_hash) - storage_root = CMerkleTree.from_map(items) - - # if old_root != CMerkleTree.root_hash(storage_root) do - # IO.inspect({old_root, CMerkleTree.root_hash(storage_root)}, label: "root_hash mismatch") - # end + def root_hash(%Chain.Account{root_hash: <<_::binary-size(32)>> = hash}), do: hash - %Chain.Account{acc | storage_root: storage_root} - end - - def uncompact(%Chain.Account{storage_root: items} = acc) when is_list(items) do - %Chain.Account{acc | storage_root: CMerkleTree.from_list(items)} - end - - def compact(%Chain.Account{} = acc) do - tree = tree(acc) - - if CMerkleTree.size(tree) == 0 do - %Chain.Account{acc | storage_root: nil} - else - %Chain.Account{acc | storage_root: {MapMerkleTree, [], Map.new(CMerkleTree.to_list(tree))}} - end - |> Map.put(:root_hash, CMerkleTree.root_hash(tree)) - |> Map.put(:code_hash, codehash(acc)) - end - - def storage_set_value(acc, key = <<_k::256>>, value = <<_v::256>>) do - %Chain.Account{} = acc - store = CMerkleTree.insert(tree(acc), key, value) - %{acc | storage_root: store} - end - - def storage_set_value(acc, key, value) when is_integer(key) do - storage_set_value(acc, <>, value) - end - - def storage_set_value(acc, key, value) when is_integer(value) do - storage_set_value(acc, key, <>) - end - - @spec storage_value(Chain.Account.t(), binary() | integer()) :: binary() | nil - def storage_value(acc, key) when is_integer(key) do - storage_value(acc, <>) - end + def root_hash(%Chain.Account{storage_root: nil}), do: @empty_storage_root - def storage_value(%Chain.Account{} = acc, key) when is_binary(key) do - case CMerkleTree.get(tree(acc), key) do - nil -> <<0::unsigned-size(256)>> - bin -> bin - end + def root_hash(%Chain.Account{}) do + raise ArgumentError, + "account missing :root_hash; use Chain.State.storage_root_hash/2" end @spec to_rlp(Chain.Account.t()) :: [...] diff --git a/lib/chain/block.ex b/lib/chain/block.ex index b1ae2cd..1a26dd1 100644 --- a/lib/chain/block.ex +++ b/lib/chain/block.ex @@ -76,15 +76,24 @@ defmodule Chain.Block do end end - def account_tree(%Block{} = block, account_id) do - case Chain.State.account(state(block), account_id) do - nil -> nil - acc -> acc |> Chain.Account.tree() - end + def account_storage_root_hash(%Block{} = block, account_id) do + Chain.State.storage_root_hash(state(block), account_id) + end + + def account_storage_root_hashes(%Block{} = block, account_id) do + Chain.State.storage_root_hashes(state(block), account_id) + end + + def account_storage_get_proofs(%Block{} = block, account_id, key) do + Chain.State.storage_get_proofs(state(block), account_id, key) + end + + def state_root_hashes(%Block{} = block) do + Chain.State.state_root_hashes(state(block)) end - def state_tree(%Block{} = block) do - state(block) |> Chain.State.tree() + def account_proof(%Block{} = block, account_id) do + Chain.State.get_proofs(state(block), account_id) end @doc "For snapshot exporting ensure the block has a full state object" @@ -134,7 +143,7 @@ defmodule Chain.Block do try do hash = state_hash(block) hash2 = if is_binary(block.header.state_hash), do: block.header.state_hash, else: hash - hash3 = CMerkleTree.root_hash(Chain.State.tree(state(block))) + hash3 = Chain.State.hash(state(block)) consistent = hash2 == hash3 and (not is_binary(hash) or hash == hash2) diff --git a/lib/chain/block_cache.ex b/lib/chain/block_cache.ex index a736218..2c1f9a0 100644 --- a/lib/chain/block_cache.ex +++ b/lib/chain/block_cache.ex @@ -266,7 +266,7 @@ defmodule Chain.BlockCache do defdelegate size(block), to: Block defdelegate state(block), to: Block defdelegate state_hash(block), to: Block - defdelegate state_tree(block), to: Block + defdelegate state_root_hashes(block), to: Block defdelegate strip_state(block), to: Block defdelegate timestamp(block), to: Block defdelegate transaction(block, hash), to: Block diff --git a/lib/chain/genesis_factory.ex b/lib/chain/genesis_factory.ex index 0476462..e2508ad 100644 --- a/lib/chain/genesis_factory.ex +++ b/lib/chain/genesis_factory.ex @@ -21,9 +21,15 @@ defmodule Chain.GenesisFactory do end def genesis_state(accounts) do - Enum.reduce(accounts, Chain.State.new(), fn {address, user_account}, state -> - Chain.State.set_account(state, address, user_account) - end) + state = + Enum.reduce(accounts, Chain.State.new(), fn {address, user_account}, state -> + Chain.State.set_account(state, address, user_account) + end) + + case ChainDefinition.genesis_storage() do + storage when map_size(storage) == 0 -> state + storage -> Chain.State.storage_put_map(state, storage) + end end @spec genesis_accounts() :: [{binary(), Account.t()}] diff --git a/lib/chain/state.ex b/lib/chain/state.ex index 5364697..2eba8bf 100644 --- a/lib/chain/state.ex +++ b/lib/chain/state.ex @@ -3,7 +3,6 @@ # Licensed under the Diode License, Version 1.1 defmodule Chain.State do require Logger - alias Chain.Account @dialyzer [ {:nowarn_function, new: 0}, @@ -13,267 +12,224 @@ defmodule Chain.State do ] @enforce_keys [:accounts] - defstruct accounts: nil, hash: nil, store: nil - @type t :: %Chain.State{accounts: CAccountMap.t(), hash: any(), store: any()} + defstruct accounts: nil, hash: nil + @type t :: %Chain.State{accounts: CAccountMap.t(), hash: binary() | nil} def new() do %Chain.State{accounts: CAccountMap.new()} end - def compact(%Chain.State{accounts: accounts} = state) when is_map(accounts) do - accounts = - Enum.map(accounts, fn {id, acc} -> {id, Account.compact(acc)} end) - |> Map.new() - - %Chain.State{state | accounts: accounts} - |> Map.delete(:store) - end - def compact(%Chain.State{accounts: accounts} = state) do - accounts = - accounts - |> CAccountMap.to_account_list() - |> Enum.map(fn {id, acc} -> {id, Account.compact(acc)} end) - |> Map.new() - - %Chain.State{state | accounts: accounts} - |> Map.delete(:store) + %{state | accounts: CAccountMap.compact(accounts)} end def uncompact(%Chain.State{accounts: accounts} = state) do - {accounts, store, hash} = CAccountMap.uncompact_state(accounts) - + {accounts, hash} = CAccountMap.uncompact_state(accounts) %Chain.State{state | accounts: accounts, hash: hash} - |> Map.put(:store, store) end - def normalize(%Chain.State{} = state) do - tree = tree(state) - hash = CMerkleTree.root_hash(tree) - state = Map.put(state, :store, tree) - %Chain.State{} = state - %{state | hash: hash} + def normalize(%Chain.State{accounts: accounts} = state) do + %{state | hash: CAccountMap.root_hash(accounts)} end - def tree(%Chain.State{store: store}) when store != nil do - store + def state_root_hashes(%Chain.State{accounts: accounts}) do + CAccountMap.state_root_hashes(accounts) end - def tree(%Chain.State{accounts: accounts}) do - accounts - |> account_list() - |> Enum.reduce(%{}, fn {id, acc}, map -> - Map.put(map, id, Account.hash(acc)) - end) - |> CMerkleTree.from_map() + def get_proofs(%Chain.State{accounts: accounts}, addr) do + CAccountMap.get_proofs(accounts, normalize_address(addr)) + end + + def storage_value(%Chain.State{accounts: accounts}, addr, key) do + case CAccountMap.storage_get(accounts, normalize_address(addr), key) do + nil -> <<0::unsigned-size(256)>> + bin -> bin + end + end + + def storage_put_map(%Chain.State{accounts: accounts} = state, updates) do + %{state | accounts: CAccountMap.storage_put_map(accounts, updates), hash: nil} end + def storage_to_list(%Chain.State{accounts: accounts}, addr), + do: CAccountMap.storage_to_list(accounts, normalize_address(addr)) + + def storage_size(%Chain.State{accounts: accounts}, addr), + do: CAccountMap.storage_size(accounts, normalize_address(addr)) + + def storage_get_range(%Chain.State{accounts: accounts}, addr, key, count), + do: CAccountMap.storage_get_range(accounts, normalize_address(addr), key, count) + + def storage_root_hash(%Chain.State{accounts: accounts}, addr), + do: CAccountMap.storage_root_hash(accounts, normalize_address(addr)) + + def storage_root_hashes(%Chain.State{accounts: accounts}, addr), + do: CAccountMap.storage_root_hashes(accounts, normalize_address(addr)) + + def storage_get_proofs(%Chain.State{accounts: accounts}, addr, key), + do: CAccountMap.storage_get_proofs(accounts, normalize_address(addr), key) + def hash(%Chain.State{hash: nil} = state) do - CMerkleTree.root_hash(tree(state)) + CAccountMap.root_hash(state.accounts) end def hash(%Chain.State{hash: hash}) do hash end - def accounts(%Chain.State{accounts: accounts}) when is_map(accounts) do - accounts - end - def accounts(%Chain.State{accounts: accounts}) do CAccountMap.to_account_list(accounts) end @spec account(Chain.State.t(), <<_::160>>) :: Chain.Account.t() | nil - def account(%Chain.State{accounts: accounts}, id = <<_::160>>) when is_map(accounts) do - Map.get(accounts, id) - end - def account(%Chain.State{accounts: accounts}, id = <<_::160>>) do CAccountMap.get_account(accounts, id) end + def normalize_address(<<_::160>> = id), do: id + def normalize_address(id) when is_integer(id), do: <> + def normalize_address(id), do: Wallet.address!(id) + @spec ensure_account(Chain.State.t(), <<_::160>> | Wallet.t() | non_neg_integer()) :: Chain.Account.t() - def ensure_account(state = %Chain.State{}, id = <<_::160>>) do - case account(state, id) do + def ensure_account(state = %Chain.State{}, id) do + case account(state, normalize_address(id)) do nil -> Chain.Account.new(nonce: 0) acc -> acc end end - def ensure_account(state = %Chain.State{}, id) when is_integer(id) do - ensure_account(state, <>) - end - - def ensure_account(state = %Chain.State{}, id) do - ensure_account(state, Wallet.address!(id)) - end - @spec set_account(Chain.State.t(), binary(), Chain.Account.t()) :: Chain.State.t() def set_account(state, id = <<_::160>>, account) do - tree = CMerkleTree.insert(tree(state), id, Account.hash(account)) - accounts = put_account_in(state.accounts, id, account) - %{state | accounts: accounts, hash: nil, store: tree} + accounts = CAccountMap.put_account(state.accounts, id, account) + %{state | accounts: accounts, hash: nil} end @spec delete_account(Chain.State.t(), binary()) :: Chain.State.t() - def delete_account(state = %Chain.State{accounts: accounts}, id = <<_::160>>) - when is_map(accounts) do - %{state | accounts: Map.delete(accounts, id), hash: nil, store: nil} - end - def delete_account(state = %Chain.State{accounts: accounts}, id = <<_::160>>) do - %{state | accounts: CAccountMap.delete(accounts, id), hash: nil, store: nil} + %{state | accounts: CAccountMap.delete(accounts, id), hash: nil} end def difference( - %Chain.State{accounts: accounts_a} = state_a, - %Chain.State{accounts: accounts_b} = state_b + %Chain.State{accounts: accounts_a} = _state_a, + %Chain.State{accounts: accounts_b} = _state_b ) do - diff = account_maps_diff(accounts_a, accounts_b) - - Enum.map(diff, fn {id, {acc_a, acc_b}} -> - acc_a = acc_a || ensure_account(state_a, id) - acc_b = acc_b || ensure_account(state_b, id) - - {time, report} = - :timer.tc(fn -> - delta = %{ - nonce: {Account.nonce(acc_a), Account.nonce(acc_b)}, - balance: {Account.balance(acc_a), Account.balance(acc_b)}, - code: {Account.code(acc_a), Account.code(acc_b)} - } - - report = - Enum.reduce(delta, %{}, fn {key, {a, b}}, report -> - if a == b do - report + {time, result} = + :timer.tc(fn -> + Enum.map(CAccountMap.difference_full(accounts_a, accounts_b), fn + {id, side_a, side_b, state_diff, root_a, root_b} -> + report = + %{} + |> put_side_field_diff(:nonce, side_a, side_b) + |> put_side_field_diff(:balance, side_a, side_b) + |> put_side_field_diff(:code, side_a, side_b) + + storage_map = CAccountMap.decode_storage_diff(state_diff) + + report = + if map_size(storage_map) > 0 do + Map.merge(report, %{ + state: storage_map, + root_hash: {decode_diff_root(root_a), decode_diff_root(root_b)} + }) else - Map.put(report, key, {a, b}) + report end - end) - - state_diff = CMerkleTree.difference(Account.tree(acc_a), Account.tree(acc_b)) - - if map_size(state_diff) > 0 do - Map.merge(report, %{ - state: state_diff, - root_hash: {Account.root_hash(acc_a), Account.root_hash(acc_b)} - }) - else - report - end + + {id, report} end) + end) - if div(time, 1000) > 1000 do - Logger.warning( - "State diff took longer than 1s #{inspect({Base16.encode(id), div(time, 1000), map_size(report)})}" - ) - end + if div(time, 1000) > 1000 do + Logger.warning( + "State diff took longer than 1s total_ms=#{div(time, 1000)} accounts=#{length(result)}" + ) + end - {id, report} - end) + result end - def clone(%Chain.State{accounts: accounts} = state) do - state - |> Map.put(:accounts, clone_accounts(accounts)) - |> clone_store() + defp decode_diff_root(nil), do: empty_storage_root() + defp decode_diff_root(<<_::binary-size(32)>> = root), do: root + + defp empty_storage_root do + key = {__MODULE__, :empty_storage_root} + + case :persistent_term.get(key, :undefined) do + :undefined -> + root = CAccountMap.storage_root_hash(CAccountMap.new(), <<0::unsigned-size(160)>>) + :persistent_term.put(key, root) + root + + root -> + root + end end - def lock(%Chain.State{accounts: accounts} = state) when is_map(accounts) do - for {_id, acc} <- account_list(accounts) do - do_lock(Account.tree(acc)) + defp put_side_field_diff(report, field, side_a, side_b) do + a = side_field(side_a, field) + b = side_field(side_b, field) + + if a == b do + report + else + Map.put(report, field, {a, b}) end + end - do_lock(Map.get(state, :store)) - state + defp side_field(nil, :nonce), do: 0 + defp side_field(nil, :balance), do: 0 + defp side_field(nil, :code), do: "" + defp side_field({nonce, _balance, _code}, :nonce), do: nonce + defp side_field({_nonce, balance, _code}, :balance) when is_integer(balance), do: balance + + defp side_field({_nonce, balance, _code}, :balance) when is_binary(balance), + do: :binary.decode_unsigned(balance) + + defp side_field({_nonce, _balance, code}, :code), do: code + + # Writable fork for block sync and speculative paths on locked/cached peak state. + def clone(%Chain.State{accounts: accounts} = state) do + %{state | accounts: CAccountMap.clone(accounts), hash: nil} end + # Freeze map (`frozen` only). Map storage writes reject while frozen. def lock(%Chain.State{accounts: accounts} = state) do - CAccountMap.lock(accounts, Map.get(state, :store)) + CAccountMap.lock(accounts) state end - defp do_lock(nil), do: nil - defp do_lock(root), do: CMerkleTree.lock(root) - def apply_difference(%Chain.State{} = state, difference) do - Enum.reduce(difference, state, fn {id, report}, state -> - oacc = acc = ensure_account(state, id) + case CAccountMap.apply_difference(state.accounts, difference) do + {:error, reason} -> + raise ArgumentError, "apply_difference mismatch: #{inspect(reason)}" - {state_update, report} = Map.pop(report, :state, %{}) - - acc = - Enum.reduce(state_update, acc, fn {key, {a, b}}, acc -> - tree = Account.tree(acc) - ^a = CMerkleTree.get(tree, key) - tree = CMerkleTree.insert(tree, key, b) - Account.put_tree(acc, tree) - end) - - acc = - report - |> Enum.reject(fn {key, _delta} -> key == :root_hash end) - |> Enum.reduce(acc, fn {key, delta}, acc -> - {a, b} = delta - ret = apply(Account, key, [oacc]) - ^a = ret - %{acc | key => b} - end) - - set_account(state, id, acc) - end) + accounts -> + %{state | accounts: accounts, hash: nil} + end end def from_binary(bin) do map = BertInt.decode!(bin) Enum.reduce(map, new(), fn {id, acc}, state -> + data = Map.get(acc, :data) + + storage = + cond do + data in [nil, [], %{}] -> nil + is_map(data) -> Map.to_list(data) + is_list(data) -> data + true -> raise ArgumentError, "unsupported account data: #{inspect(data)}" + end + set_account(state, id, %Chain.Account{ nonce: acc.nonce, balance: acc.balance, - storage_root: CMerkleTree.new() |> CMerkleTree.insert_items(acc.data), - code: acc.code + storage_root: storage, + code: acc.code, + map_backed: false }) end) end - - defp account_list(accounts) when is_map(accounts) do - Map.to_list(accounts) - end - - defp account_list(accounts) do - CAccountMap.to_account_list(accounts) - end - - defp account_maps_diff(accounts_a, accounts_b) when is_map(accounts_a) or is_map(accounts_b) do - CMerkleTree.list_difference(account_list(accounts_a), account_list(accounts_b)) - end - - defp account_maps_diff(accounts_a, accounts_b) do - CAccountMap.list_difference(accounts_a, accounts_b) - end - - defp put_account_in(accounts, id, account) when is_map(accounts) do - Map.put(accounts, id, account) - end - - defp put_account_in(accounts, id, account) do - CAccountMap.put_account(accounts, id, account) - end - - defp clone_accounts(accounts) when is_map(accounts) do - Map.new(accounts, fn {id, acc} -> {id, Account.clone(acc)} end) - end - - defp clone_accounts(accounts), do: CAccountMap.clone(accounts) - - defp clone_store(%Chain.State{} = state) do - case Map.get(state, :store) do - nil -> state - store -> %{state | store: CMerkleTree.clone(store)} - end - end end diff --git a/lib/chaindefinition.ex b/lib/chaindefinition.ex index f0fe7a7..7b9d83a 100644 --- a/lib/chaindefinition.ex +++ b/lib/chaindefinition.ex @@ -70,6 +70,11 @@ defmodule ChainDefinition do chain_definition().genesis_accounts() end + @spec genesis_storage() :: %{binary() => %{binary() => binary()}} + def genesis_storage() do + chain_definition().genesis_storage() + end + @spec genesis_transactions(Wallet.t()) :: [Chain.Transaction.t()] def genesis_transactions(miner) do chain_definition().genesis_transactions(miner) diff --git a/lib/chaindefinition/devnet.ex b/lib/chaindefinition/devnet.ex index 34c566a..e1da2ee 100644 --- a/lib/chaindefinition/devnet.ex +++ b/lib/chaindefinition/devnet.ex @@ -36,25 +36,22 @@ defmodule ChainDefinition.Devnet do addr_balance(0xCECA2F8CF1983B4CF0C1BA51FD382C2BC37ABA58, ether(50_000)), addr_balance(0x68E0BAFDDA9EF323F692FC080D612718C941D120, ether(50_000)), - # The Registry with the accountant placed + # The Registry (storage applied via genesis_storage/0) addr_account( Diode.registry_address(), Account.new( balance: ether(100_000_000), code: Contract.Registry.test_code() ) - |> Account.storage_set_value(1, accountant) ), - # The Fleet with the operator and accountant placed + # The Fleet (storage applied via genesis_storage/0) addr_account( Diode.fleet_address(), Account.new( balance: 0, code: Contract.Fleet.code() ) - |> Account.storage_set_value(0, Diode.registry_address() |> :binary.decode_unsigned()) - |> Account.storage_set_value(2, accountant) ) ] @@ -64,6 +61,27 @@ defmodule ChainDefinition.Devnet do end) end + @doc """ + Initial contract storage slots for genesis accounts. + Keys and values are 32-byte binaries for `Chain.State.storage_put_map/2`. + """ + @spec genesis_storage() :: %{binary() => %{binary() => binary()}} + def genesis_storage() do + accountant = Hash.to_bytes32(0x96CDE043E986040CB13FFAFD80EB8CEAC196FB84) + registry = Diode.registry_address() + fleet = Diode.fleet_address() + + %{ + registry => %{ + Hash.to_bytes32(1) => accountant + }, + fleet => %{ + Hash.to_bytes32(0) => Hash.to_bytes32(registry), + Hash.to_bytes32(2) => accountant + } + } + end + @spec genesis_transactions(Wallet.t()) :: [Chain.Transaction.t()] def genesis_transactions(miner) do # Call "blockReward()": diff --git a/lib/chaindefinition/galileo.ex b/lib/chaindefinition/galileo.ex index 6263212..50926f4 100644 --- a/lib/chaindefinition/galileo.ex +++ b/lib/chaindefinition/galileo.ex @@ -1,6 +1,8 @@ defmodule ChainDefinition.Galileo do alias Chain.{State, Account} + def genesis_storage(), do: %{} + def apply(%State{} = state) do Enum.reduce(patch(), state, fn account, state -> id = Base16.decode(account["addr"]) diff --git a/lib/chaindefinition/mainnet.ex b/lib/chaindefinition/mainnet.ex index 0697b39..eb6fcb9 100644 --- a/lib/chaindefinition/mainnet.ex +++ b/lib/chaindefinition/mainnet.ex @@ -135,6 +135,8 @@ defmodule ChainDefinition.Mainnet do File.read!("data/genesis.bin") |> Chain.State.from_binary() |> Chain.State.accounts() end + def genesis_storage(), do: %{} + @spec genesis_transactions(Wallet.t()) :: [Chain.Transaction.t()] def genesis_transactions(_miner) do [] diff --git a/lib/chaindefinition/pioneer.ex b/lib/chaindefinition/pioneer.ex index 22c07c6..ab429fc 100644 --- a/lib/chaindefinition/pioneer.ex +++ b/lib/chaindefinition/pioneer.ex @@ -1,6 +1,8 @@ defmodule ChainDefinition.Pioneer do alias Chain.{State, Account} + def genesis_storage(), do: %{} + def apply(%State{} = state) do id = Base16.decode("0x5000000000000000000000000000000000000000") acc = State.ensure_account(state, id) diff --git a/lib/chaindefinition/stagenet.ex b/lib/chaindefinition/stagenet.ex index a8bc2a6..8faaa06 100644 --- a/lib/chaindefinition/stagenet.ex +++ b/lib/chaindefinition/stagenet.ex @@ -83,6 +83,8 @@ defmodule ChainDefinition.Stagenet do File.read!("data/genesis.bin") |> Chain.State.from_binary() |> Chain.State.accounts() end + def genesis_storage(), do: %{} + @spec genesis_transactions(Wallet.t()) :: [Chain.Transaction.t()] def genesis_transactions(_miner) do [] diff --git a/lib/chaindefinition/ulysses.ex b/lib/chaindefinition/ulysses.ex index acf7b41..d320170 100644 --- a/lib/chaindefinition/ulysses.ex +++ b/lib/chaindefinition/ulysses.ex @@ -1,6 +1,8 @@ defmodule ChainDefinition.Ulysses do alias Chain.{State, Account} + def genesis_storage(), do: %{} + def apply(%State{} = state) do Enum.reduce(patch(), state, fn account, state -> id = Base16.decode(account["addr"]) diff --git a/lib/chaindefinition/voyager.ex b/lib/chaindefinition/voyager.ex index 0487651..3040e07 100644 --- a/lib/chaindefinition/voyager.ex +++ b/lib/chaindefinition/voyager.ex @@ -1,29 +1,43 @@ defmodule ChainDefinition.Voyager do - alias Chain.{State, Account} + alias Chain.State + + def genesis_storage(), do: %{} def apply(%State{} = state) do Enum.reduce(patch(), state, fn account, state -> id = Base16.decode(account["addr"]) code = Base16.decode(account["code"]) {balance, ""} = Integer.parse(account["balance"]) - acc = State.ensure_account(state, id) - %Account{} = acc - acc = %{acc | balance: balance, code: code} - acc = - if Map.has_key?(account, "state_patch") do - Enum.reduce(account["state_patch"], acc, fn [key, value], acc -> - Account.storage_set_value(acc, Base16.decode(key), Base16.decode(value)) - end) - else - acc = %{acc | storage_root: nil} + acc0 = + state + |> State.ensure_account(id) + |> Map.put(:balance, balance) + |> Map.put(:code, code) + |> Map.put(:map_backed, false) + |> Map.put(:root_hash, nil) + + if Map.has_key?(account, "state_patch") do + state = State.set_account(state, id, %{acc0 | storage_root: nil}) - Enum.reduce(account["state"], acc, fn [key, value], acc -> - Account.storage_set_value(acc, Base16.decode(key), Base16.decode(value)) + updates = + Map.new(account["state_patch"], fn [key, value] -> + {Base16.decode(key), Base16.decode(value)} end) + + if map_size(updates) == 0 do + state + else + State.storage_put_map(state, %{id => updates}) end + else + slots = + Enum.map(account["state"] || [], fn [key, value] -> + {Base16.decode(key), Base16.decode(value)} + end) - State.set_account(state, id, acc) + State.set_account(state, id, %{acc0 | storage_root: slots}) + end end) end diff --git a/lib/cmerkletree.ex b/lib/cmerkletree.ex index 55e111a..b98c050 100644 --- a/lib/cmerkletree.ex +++ b/lib/cmerkletree.ex @@ -3,176 +3,39 @@ # Licensed under the Diode License, Version 1.1 defmodule CMerkleTree do @on_load :load_nifs - @null <<0::unsigned-size(256)>> - - @type t :: reference() def load_nifs do :erlang.load_nif(~c"./priv/merkletree_nif", 0) end - def from_map(map) do - import_map(new(), map) - end - - def from_list(list) do - insert_items(new(), list) - end - - def list_difference(a, b) do - a_diffmap = - Enum.reduce(a, %{}, fn {key, value}, acc -> - Map.put(acc, key, {value, nil}) - end) - - Enum.reduce(b, a_diffmap, fn {key, bvalue}, acc -> - case Map.get(acc, key) do - nil -> - Map.put(acc, key, {nil, bvalue}) - - {avalue, nil} -> - if avalue.nonce == bvalue.nonce && avalue.balance == bvalue.balance && - avalue.code == bvalue.code && - Chain.Account.root_hash(avalue) == Chain.Account.root_hash(bvalue) do - Map.delete(acc, key) - else - Map.put(acc, key, {avalue, bvalue}) - end - end - end) - end - - # def difference(a, b) do - # list_difference(to_list(a), to_list(b)) - # end - def difference(a, b) do - difference_raw(a, b) - |> Enum.map(fn - {key, {@null, value2}} -> {key, {nil, value2}} - {key, {value1, @null}} -> {key, {value1, nil}} - {key, {value1, value2}} -> {key, {value1, value2}} - end) - |> Map.new() - end - - def insert(tree, key, value) do - insert_item_raw(tree, to_bytes(key), to_bytes32(value)) - end - - def insert_items(tree, items) do - Enum.reduce(items, tree, fn {key, value}, acc -> - insert(acc, key, value) - end) - end - - def get_proofs(tree, key) do - get_proofs_raw(tree, to_bytes(key)) - end - - def insert_item(tree, {key, value}) do - insert(tree, key, value) - end - - def delete(tree, key) do - insert(tree, key, @null) - end - - def get(tree, key) do - case get_item(tree, to_bytes32(key)) do - nil -> nil - {_key, @null, _hash} -> nil - {_key, value, _hash} -> value - end - end - - def get_range(tree, key, count) when is_integer(count) and count >= 1 and count <= 256 do - get_range_raw(tree, to_bytes32(key), count) - |> Enum.map(fn {k, v} -> {k, if(v == @null, do: nil, else: v)} end) - end - - def root_hashes(tree) do - <> = - root_hashes_raw(tree) - - [a, b, c, d, e, f, g, h, i, j, k, l, m, n, o, p] - end - - def new, do: error() - def insert_item_raw(_tree, _key, _value), do: error() - def root_hash(_tree), do: error() - def root_hashes_raw(_tree), do: error() - def clone(_tree), do: error() - def size(_tree), do: error() - def hash(_binary), do: error() - def bucket_count(_tree), do: error() - def get_item(_tree, _key), do: error() - def get_range_raw(_tree, _key, _count), do: error() - def to_list(_tree), do: error() - def import_map(_tree, _map), do: error() - def difference_raw(_tree, _map), do: error() - def lock(_tree), do: error() - def get_proofs_raw(_tree, _key), do: error() def count_zeros(binary) when is_binary(binary), do: count_zeros_raw(binary) defp count_zeros_raw(_binary), do: error() - @doc """ - Returns `{item_bytes, pair_bytes, pair_list_bytes, tree_bytes, stripe_size}` for C++ layout. - """ - def struct_sizes, do: struct_sizes_raw() - - @doc """ - Returns `{node_count, pair_count, approx_bytes}` for the tree. `approx_bytes` is - `nodes * sizeof(Item) + pairs * sizeof(pair_t)` (heap vectors in keys not included). - """ - def memory_stats(tree), do: memory_stats_raw(tree) - - @doc """ - GNU libc `malloc_info(3)` XML as a binary, or `:unsupported` on other platforms. - """ - def malloc_info, do: malloc_info_raw() - @doc """ Returns `{locked_states_count, pending_orphan_count, shared_states_live, merkletree_resources}`. """ def nif_stats, do: nif_stats_raw() + defp nif_stats_raw, do: error() def account_map_new(), do: error() def account_map_clone(_map), do: error() - def account_map_lock(_map, _store), do: error() + def account_map_root_hash(_map), do: error() + def account_map_state_roots(_map), do: error() + def account_map_proof(_map, _addr), do: error() + def account_map_proof(_map, _addr, _key), do: error() + def account_map_lock(_map), do: error() def account_map_get(_map, _addr), do: error() def account_map_put(_map, _addr, _nonce, _balance, _storage, _code), do: error() def account_map_delete(_map, _addr), do: error() def account_map_size(_map), do: error() def account_map_to_list(_map), do: error() - def account_map_list_difference_raw(_map_a, _map_b), do: error() + def account_map_difference_full(_map_a, _map_b), do: error() + def account_map_apply_difference(_map, _delta), do: error() + def account_map_compact(_map), do: error() def account_map_uncompact_state(_map), do: error() + def account_map_storage_put_map(_map, _updates), do: error() + def account_map_storage(_map, _addr, _spec), do: error() + def account_map_storage_roots(_map, _addr), do: error() - defp struct_sizes_raw, do: error() - defp memory_stats_raw(_tree), do: error() - defp malloc_info_raw, do: error() - defp nif_stats_raw, do: error() defp error, do: :erlang.nif_error(:nif_not_loaded) - - defp to_bytes32(nil) do - <<0::unsigned-size(256)>> - end - - defp to_bytes32(int) when is_integer(int) do - <> - end - - defp to_bytes32(string) when byte_size(string) < 32 do - missing = (32 - byte_size(string)) * 8 - <<0::unsigned-size(missing), string::binary>> - end - - defp to_bytes32(string) when byte_size(string) == 32 do - string - end - - defp to_bytes(string) when is_binary(string), do: string - defp to_bytes(int) when is_integer(int), do: to_bytes32(int) end diff --git a/lib/contract/registry.ex b/lib/contract/registry.ex index 6e3667b..08828e9 100644 --- a/lib/contract/registry.ex +++ b/lib/contract/registry.ex @@ -7,52 +7,34 @@ defmodule Contract.Registry do as needed by the inner workings of the chain """ - def miner_value_slot(key, blockRef) do - BlockProcess.with_account(blockRef, Diode.registry_address(), fn account -> - staked_a = - account - |> Chain.Account.storage_value(hash_slot_binary(5, key)) - |> :binary.decode_unsigned() - - staked_b = - account - |> Chain.Account.storage_value(hash_slot_binary(5, key) |> badd(2)) - |> :binary.decode_unsigned() + def miner_value_slot(key, blockRef), do: value_slot(5, key, blockRef) - unstaked_a = - account - |> Chain.Account.storage_value(hash_slot_binary(5, key) |> badd(3)) - |> :binary.decode_unsigned() + def contract_value_slot(key, blockRef), do: value_slot(6, key, blockRef) - unstaked_b = - account - |> Chain.Account.storage_value(hash_slot_binary(5, key) |> badd(5)) - |> :binary.decode_unsigned() + defp value_slot(base, key, blockRef) do + registry = Diode.registry_address() - {staked_a + staked_b, unstaked_a + unstaked_b} - end) - end + BlockProcess.with_state(blockRef, fn state -> + slot = hash_slot_binary(base, key) - def contract_value_slot(key, blockRef) do - BlockProcess.with_account(blockRef, Diode.registry_address(), fn account -> staked_a = - account - |> Chain.Account.storage_value(hash_slot_binary(6, key)) + state + |> Chain.State.storage_value(registry, slot) |> :binary.decode_unsigned() staked_b = - account - |> Chain.Account.storage_value(hash_slot_binary(6, key) |> badd(2)) + state + |> Chain.State.storage_value(registry, slot |> badd(2)) |> :binary.decode_unsigned() unstaked_a = - account - |> Chain.Account.storage_value(hash_slot_binary(6, key) |> badd(3)) + state + |> Chain.State.storage_value(registry, slot |> badd(3)) |> :binary.decode_unsigned() unstaked_b = - account - |> Chain.Account.storage_value(hash_slot_binary(6, key) |> badd(5)) + state + |> Chain.State.storage_value(registry, slot |> badd(5)) |> :binary.decode_unsigned() {staked_a + staked_b, unstaked_a + unstaked_b} diff --git a/lib/evm.ex b/lib/evm.ex index 51bc6ae..b9673d4 100644 --- a/lib/evm.ex +++ b/lib/evm.ex @@ -41,10 +41,9 @@ defmodule Evm do @type t :: %Evm.Task{} - @spec store(Evm.Task.t()) :: CMerkleTree.t() + @spec store(Evm.Task.t()) :: list() def store(%Task{chain_state: state} = task) do - State.ensure_account(state, address(task)) - |> Account.tree() + State.storage_to_list(state, address(task)) end def code(%Task{code: code}), do: code @@ -498,16 +497,10 @@ defmodule Evm do end defp cache_account(state, port, address) do - tree = - State.ensure_account(state, address) - |> Chain.Account.tree() - - # IO.puts("value size: #{CMerkleTree.size(tree)}") - cache = - if CMerkleTree.size(tree) < 100 do + if State.storage_size(state, address) < 100 do values = - CMerkleTree.to_list(tree) + State.storage_to_list(state, address) |> Enum.map(fn {k, v} -> [k, v] end) [ @@ -562,14 +555,12 @@ defmodule Evm do ) do count = Diode.evm_storage_read_ahead() |> max(0) |> min(254) |> Kernel.+(1) - tree = - state(evm) - |> State.ensure_account(addr) - |> Chain.Account.tree() - entries = - CMerkleTree.get_range_raw(tree, key, count) - |> Enum.map(fn {k, v} -> [k, v] end) + State.storage_get_range(state(evm), addr, key, count) + |> Enum.map(fn + {k, nil} -> [k, <<0::unsigned-size(256)>>] + {k, v} -> [k, v] + end) true = Port.command(evm.port, [<> | entries]) {:cont, evm} @@ -754,13 +745,7 @@ defmodule Evm do defp process_updates(rest, state) do updates = parse_map(rest, %{}) - - Enum.reduce(updates, state, fn {addr, kvs}, state -> - acc = State.ensure_account(state, addr) - root = CMerkleTree.insert_items(Account.tree(acc), Map.to_list(kvs)) - acc = Chain.Account.put_tree(acc, root) - State.set_account(state, addr, acc) - end) + State.storage_put_map(state, updates) end defp parse_map( diff --git a/lib/hash.ex b/lib/hash.ex index 7818a5e..2481bb0 100644 --- a/lib/hash.ex +++ b/lib/hash.ex @@ -7,18 +7,21 @@ defmodule Hash do :binary.decode_unsigned(hash) end + def to_bytes32(nil), do: <<0::unsigned-size(256)>> + def to_bytes32(hash = <<_::256>>) do hash end - def to_bytes32(hash = <<_::160>>) do - <<0::96, hash::binary-size(20)>> - end - def to_bytes32(hash) when is_integer(hash) do <> end + def to_bytes32(bin) when is_binary(bin) and byte_size(bin) < 32 do + missing = (32 - byte_size(bin)) * 8 + <<0::unsigned-size(missing), bin::binary>> + end + def printable(nil) do "nil" end diff --git a/lib/network/edge_v2.ex b/lib/network/edge_v2.ex index 8492474..a953eae 100644 --- a/lib/network/edge_v2.ex +++ b/lib/network/edge_v2.ex @@ -61,11 +61,11 @@ defmodule Network.EdgeV2 do ["getstateroots", index] -> BlockProcess.with_block(to_num(index), fn block -> - Chain.Block.state_tree(block) - |> CMerkleTree.root_hashes() + Chain.Block.state_root_hashes(block) end) |> response() + # state account inclusion proof uses map-owned state_trie via Block.account_proof/2 ["getaccountroot", index, id] -> BlockProcess.with_block(to_num(index), fn block -> Chain.Block.state(block) @@ -74,9 +74,9 @@ defmodule Network.EdgeV2 do nil -> error("account does not exist") - %Chain.Account{} -> - proof = Chain.Block.state_tree(block) |> CMerkleTree.get_proofs(id) - root = CMerkleTree.root_hash(Chain.Block.account_tree(block, id)) + account = %Chain.Account{} -> + proof = Chain.Block.account_proof(block, id) + root = Chain.Account.root_hash(account) response(root, proof) end end) @@ -92,15 +92,13 @@ defmodule Network.EdgeV2 do error("account does not exist") account = %Chain.Account{} -> - proof = - Chain.Block.state_tree(block) - |> CMerkleTree.get_proofs(id) + proof = Chain.Block.account_proof(block, id) response( %{ nonce: account.nonce, balance: account.balance, - storage_root: CMerkleTree.root_hash(Chain.Block.account_tree(block, id)), + storage_root: Chain.Account.root_hash(account), code: Chain.Account.codehash(account) }, proof @@ -109,28 +107,34 @@ defmodule Network.EdgeV2 do end) ["getaccountroots", index, id] -> - BlockProcess.with_account_tree(to_num(index), id, fn - nil -> error("account does not exist") - tree -> response(CMerkleTree.root_hashes(tree)) + BlockProcess.with_block(to_num(index), fn block -> + case Chain.Block.state(block) |> Chain.State.account(id) do + nil -> error("account does not exist") + %Chain.Account{} -> response(Chain.Block.account_storage_root_hashes(block, id)) + end end) ["getaccountvalue", index, id, key] -> - BlockProcess.with_account_tree(to_num(index), id, fn - nil -> error("account does not exist") - tree -> response(CMerkleTree.get_proofs(tree, key)) + BlockProcess.with_block(to_num(index), fn block -> + case Chain.Block.state(block) |> Chain.State.account(id) do + nil -> error("account does not exist") + %Chain.Account{} -> response(Chain.Block.account_storage_get_proofs(block, id, key)) + end end) ["getaccountvalues", index, id | keys] -> - BlockProcess.with_account_tree(to_num(index), id, fn - nil -> - error("account does not exist") - - tree -> - response( - Enum.map(keys, fn key -> - CMerkleTree.get_proofs(tree, key) - end) - ) + BlockProcess.with_block(to_num(index), fn block -> + case Chain.Block.state(block) |> Chain.State.account(id) do + nil -> + error("account does not exist") + + %Chain.Account{} -> + response( + Enum.map(keys, fn key -> + Chain.Block.account_storage_get_proofs(block, id, key) + end) + ) + end end) ["sendtransaction", tx] -> @@ -139,6 +143,7 @@ defmodule Network.EdgeV2 do err = Chain.with_peak(fn peak -> + # Peak is State.lock'd in cache; clone/1 forks a writable candidate. state = Chain.Block.state(peak) |> Chain.State.clone() case Chain.Transaction.apply(tx, peak, state) do diff --git a/lib/network/rpc.ex b/lib/network/rpc.ex index 4e73a8f..c543960 100644 --- a/lib/network/rpc.ex +++ b/lib/network/rpc.ex @@ -272,15 +272,20 @@ defmodule Network.Rpc do "eth_getStorageAt" -> [address, location, ref] = params - get_account([address, ref]) - |> Chain.Account.storage_value(Base16.decode_int(location)) - |> result() + with_block(ref, fn block -> + Chain.Block.state(block) + |> Chain.State.storage_value(Base16.decode(address), Base16.decode_int(location)) + |> result() + end) "eth_getStorage" -> - get_account(params) - |> Chain.Account.tree() - |> CMerkleTree.to_list() - |> result() + [address, ref] = params + + with_block(ref, fn block -> + Chain.Block.state(block) + |> Chain.State.storage_to_list(Base16.decode(address)) + |> result() + end) "eth_estimateGas" -> # TODO real estimate @@ -856,6 +861,7 @@ defmodule Network.Rpc do end defp apply_transaction(tx, block) do + # Peak/cached blocks are State.lock'd; use clone/1 for a writable fork. state = Block.state(block) |> Chain.State.clone() case Chain.Transaction.apply(tx, block, state) do diff --git a/lib/shell.ex b/lib/shell.ex index 7170905..a2d34bf 100644 --- a/lib/shell.ex +++ b/lib/shell.ex @@ -43,6 +43,7 @@ defmodule Shell do def call_tx(tx, blockRef) do Stats.tc(:call_tx, fn -> Network.Rpc.with_block(blockRef, fn block -> + # Cached blocks are State.lock'd; clone/1 is required for a writable fork. state = Chain.Block.state(block) |> Chain.State.clone() Stats.tc(:apply, fn -> @@ -119,8 +120,7 @@ defmodule Shell do def get_slot(address, slot) do Chain.with_peak_state(fn state -> - Chain.State.ensure_account(state, address) - |> Chain.Account.storage_value(slot) + Chain.State.storage_value(state, Chain.State.normalize_address(address), slot) end) end diff --git a/scripts/cmerkle_bench.exs b/scripts/cmerkle_bench.exs deleted file mode 100644 index 1076a17..0000000 --- a/scripts/cmerkle_bench.exs +++ /dev/null @@ -1,76 +0,0 @@ -# size = 10_000 -# ref = Base16.decode("0x96c2b5d03aa8e52230e74d9a08359c38c7608e5d9aba18773f3c90baf3806ccb") - -Application.ensure_all_started(:weak_ref) - -size = 1000 -ref = Base16.decode("0xe3545ad7961427b2a49d0b0a7b4a61e040acaefc432b69bcc1066b7c8c460a6b") -# ref = Base16.decode("0x3b99d56aa16278d50e85a8ea0939e62180c4f0305b773775f1844c3303a41897") 100_000 - -test_data = - Enum.map(1..size, fn idx -> - hash = Diode.hash("!#{idx}!") - {hash, hash} - end) - -none = spawn(fn -> - EtsLru.new(:cmerkle, 5) - Process.sleep(:infinity) -end) - - -for i <- 1..1000000 do - time_ms = :timer.tc(fn -> - tree_size = 1000 - tree_size_1 = tree_size + 1 - for _ <- 1..tree_size do - a = CMerkleTree.hash("b1000000!") - item = {a, a} - - WeakRef.new(none) - - tree = CMerkleTree.insert_items(CMerkleTree.new(), test_data) |> CMerkleTree.lock() - tree2 = CMerkleTree.clone(tree) - |> CMerkleTree.lock() - |> CMerkleTree.clone() - |> CMerkleTree.lock() - |> CMerkleTree.clone() - |> CMerkleTree.lock() - |> CMerkleTree.clone() - |> CMerkleTree.lock() - |> CMerkleTree.clone() - |> CMerkleTree.lock() - |> CMerkleTree.clone() - |> CMerkleTree.lock() - |> CMerkleTree.clone() - |> CMerkleTree.insert_items([item]) - - _proof = tree2 |> CMerkleTree.get_proofs(a) - _proof2 = tree |> CMerkleTree.get_proofs(a) - ^tree_size = CMerkleTree.size(tree) - ^tree_size = CMerkleTree.to_list(tree) |> length() - ^tree_size_1 = CMerkleTree.size(tree2) - ^tree_size_1 = CMerkleTree.to_list(tree2) |> length() - 92 = CMerkleTree.bucket_count(tree) - 92 = CMerkleTree.bucket_count(tree2) - - %{^a => {nil, b}} = CMerkleTree.difference(tree, tree2) - CMerkleTree.from_map(%{a => b}) - - nil = CMerkleTree.get(tree, a) - ^a = CMerkleTree.get(tree2, a) - - - if CMerkleTree.root_hash(tree) != ref do - raise("Error #{Base16.encode(CMerkleTree.root_hash(tree))} != #{Base16.encode(ref)}") - end - - EtsLru.put(:cmerkle, CMerkleTree.root_hash(tree), tree) - end - end) - |> elem(0) - |> div(1000) - - IO.puts("Run #{i} #{time_ms}ms") - :erlang.garbage_collect() -end diff --git a/scripts/cmerkle_bench2.exs b/scripts/cmerkle_bench2.exs deleted file mode 100644 index 2189326..0000000 --- a/scripts/cmerkle_bench2.exs +++ /dev/null @@ -1,42 +0,0 @@ -# size = 10_000 -# ref = Base16.decode("0x96c2b5d03aa8e52230e74d9a08359c38c7608e5d9aba18773f3c90baf3806ccb") - -Application.ensure_all_started(:weak_ref) - -size = 1000 -ref = Base16.decode("0xe3545ad7961427b2a49d0b0a7b4a61e040acaefc432b69bcc1066b7c8c460a6b") -# ref = Base16.decode("0x3b99d56aa16278d50e85a8ea0939e62180c4f0305b773775f1844c3303a41897") 100_000 - -for i <- 1..1000000 do - time_ms = :timer.tc(fn -> - tree_size = 1000 - for _ <- 1..tree_size do - test_data = - Enum.map(1..size, fn idx -> - hash = Diode.hash("!#{idx}!") - {hash, hash} - end) - - tree = CMerkleTree.new() - for {key, value} <- test_data do - CMerkleTree.insert_item(tree, {key, value}) - end - for {key, value} <- test_data do - ^value = CMerkleTree.get(tree, key) - CMerkleTree.get_proofs(tree, key) != nil - end - ^tree_size = length(CMerkleTree.to_list(tree)) - CMerkleTree.lock(tree) - - if CMerkleTree.root_hash(tree) != ref do - raise("Error #{Base16.encode(CMerkleTree.root_hash(tree))} != #{Base16.encode(ref)}") - end - - end - end) - |> elem(0) - |> div(1000) - - IO.puts("Run #{i} #{time_ms}ms") - :erlang.garbage_collect() -end diff --git a/scripts/cmerkle_bench3.exs b/scripts/cmerkle_bench3.exs deleted file mode 100644 index 87ca581..0000000 --- a/scripts/cmerkle_bench3.exs +++ /dev/null @@ -1,28 +0,0 @@ -Application.ensure_all_started(:mutable_map) - - -# uncompact = fn %Chain.State{accounts: old_accounts}-> -# Enum.reduce(old_accounts, MutableMap.new(), fn {id, acc}, accounts -> -# MutableMap.put(accounts, id, Chain.Account.uncompact(acc)) -# end) -# end - - -EtsLru.new(:leak_state, 15) -state = File.read!("leak_state.bin") - -for i <- 1..100 do - one = EtsLru.fetch(:leak_state, i, fn -> - ret = :erlang.binary_to_term(state) - |> Chain.State.uncompact() - |> Chain.State.lock() - # |> IO.inspect() - - :erlang.garbage_collect() - :erlang.garbage_collect(Process.whereis(MutableMap.Beacon)) - - ret - end) - - IO.puts("#{i}: #{map_size(one)}") -end diff --git a/scripts/cmerkle_deadlock_watchdog.exs b/scripts/cmerkle_deadlock_watchdog.exs index d0f0315..f1587c6 100644 --- a/scripts/cmerkle_deadlock_watchdog.exs +++ b/scripts/cmerkle_deadlock_watchdog.exs @@ -1,4 +1,4 @@ -# CMerkleTree NIF — deadlock / hang watchdog for fuzz and stress harnesses. +# CAccountMap NIF — deadlock / hang watchdog for fuzz and stress harnesses. # # Wraps a child command, monitors stdout/stderr for progress markers, and exits # with code 124 if the child stops making progress or exceeds wall-clock budget. @@ -40,7 +40,7 @@ defmodule CMerkleDeadlockWatchdog do poll_ms = Keyword.get(opts, :poll_interval, 5) * 1_000 IO.puts(:stderr, """ - === CMerkleTree deadlock watchdog === + === CAccountMap deadlock watchdog === command=#{inspect(cmd)} progress_timeout=#{div(progress_timeout_ms, 1000)}s wall_timeout=#{format_wall(wall_timeout_ms)} poll=#{div(poll_ms, 1000)}s """) diff --git a/scripts/cmerkle_fuzz.exs b/scripts/cmerkle_fuzz.exs index 484b9cc..e0f722d 100644 --- a/scripts/cmerkle_fuzz.exs +++ b/scripts/cmerkle_fuzz.exs @@ -1,4 +1,4 @@ -# CMerkleTree NIF stress / fuzz harness. +# CAccountMap NIF stress / fuzz harness (map + storage list APIs only). # Run from repo root (use --no-start to avoid booting the full Diode app): # mix run --no-start scripts/cmerkle_fuzz.exs -- --iterations 10000 --seed 42 # Environment (optional): MERKLE_FUZZ_ITERATIONS, MERKLE_FUZZ_SEED, MERKLE_FUZZ_MAX_KEYS @@ -6,7 +6,7 @@ # Options: # --iterations N number of fuzz rounds (default: 0 = run until SIGINT) # --seed N RNG seed for reproducibility (default: random) -# --max-keys N cap keys per tree per scenario (default: 900) +# --max-keys N cap accounts / slots per scenario (default: 900) # # Each successful round prints: FUZZ_OK # On Elixir exception, prints stack trace then exits non-zero. @@ -66,7 +66,7 @@ defmodule CMerkleFuzz do :rand.seed(:exsss, {seed, seed, seed}) IO.puts(:stderr, """ - === CMerkleTree fuzz === + === CAccountMap fuzz === #{runtime_banner()} seed=#{seed} iterations=#{iterations |> format_iters()} max_keys=#{max_keys} """) @@ -114,39 +114,30 @@ defmodule CMerkleFuzz do end defp run_round(round, ctx) do - scenario = ctx[:scenario] || :rand.uniform(30) + scenario = ctx[:scenario] || :rand.uniform(21) case scenario do - 1 -> s_string_batch_insert_diff(round, ctx) - 2 -> s_u256_sequential_diff(round, ctx) - 3 -> s_clone_parallel_extensions_diff(round, ctx) - 4 -> s_clone_overwrite_diff(round, ctx) - 5 -> s_triple_fork_diff(round, ctx) - 6 -> s_delete_and_reinsert(round, ctx) - 7 -> s_proofs_and_roots(round, ctx) - 8 -> s_lock_clone_insert(round, ctx) - 9 -> s_lock_and_difference(round, ctx) - 10 -> s_account_map_uncompact(round, ctx) - 11 -> s_account_map_clone_mutate(round, ctx) - 12 -> s_uncompact_and_storage_diff(round, ctx) - 13 -> s_account_get_materialize_diff(round, ctx) - 14 -> s_gc_during_lock_diff(round, ctx) - 15 -> s_import_map_lock_diff(round, ctx) - 16 -> s_state_lock_clone_mutate(round, ctx) - 17 -> s_account_map_lock_bulk(round, ctx) - 18 -> s_account_map_lock_concurrent(round, ctx) - 19 -> s_account_map_list_diff_large_compact(round, ctx) - 20 -> s_list_diff_vs_to_list(round, ctx) - 21 -> s_list_diff_vs_account_map_lock(round, ctx) - 22 -> s_list_diff_vs_clone_put(round, ctx) - 23 -> s_list_diff_vs_storage_difference(round, ctx) - 24 -> s_dual_map_list_diff_order(round, ctx) - 25 -> s_list_diff_compact_vs_live(round, ctx) - 26 -> s_list_diff_vs_uncompact(round, ctx) - 27 -> s_list_diff_vs_state_lock(round, ctx) - 28 -> s_list_diff_vs_account_map_get(round, ctx) - 29 -> s_list_diff_gc_pressure(round, ctx) - 30 -> s_prepare_state_composite(round, ctx) + 1 -> s_account_map_uncompact(round, ctx) + 2 -> s_account_map_clone_mutate(round, ctx) + 3 -> s_uncompact_and_map_diff(round, ctx) + 4 -> s_account_get_materialize_diff(round, ctx) + 5 -> s_gc_during_map_lock_diff(round, ctx) + 6 -> s_state_lock_clone_mutate(round, ctx) + 7 -> s_account_map_lock_bulk(round, ctx) + 8 -> s_account_map_lock_concurrent(round, ctx) + 9 -> s_account_map_list_diff_large_compact(round, ctx) + 10 -> s_list_diff_vs_to_list(round, ctx) + 11 -> s_list_diff_vs_account_map_lock(round, ctx) + 12 -> s_list_diff_vs_clone_put(round, ctx) + 13 -> s_list_diff_vs_storage_apis(round, ctx) + 14 -> s_dual_map_list_diff_order(round, ctx) + 15 -> s_list_diff_compact_vs_live(round, ctx) + 16 -> s_list_diff_vs_uncompact(round, ctx) + 17 -> s_list_diff_vs_state_lock(round, ctx) + 18 -> s_list_diff_vs_account_map_get(round, ctx) + 19 -> s_list_diff_gc_pressure(round, ctx) + 20 -> s_prepare_state_composite(round, ctx) + 21 -> s_clone_equivalence(round, ctx) other -> raise("unknown fuzz scenario #{inspect(other)}") end @@ -161,279 +152,71 @@ defmodule CMerkleFuzz do IO.puts("FUZZ_OK #{round} #{scenario}") end - defp s_string_batch_insert_diff(_round, %{max_keys: mk}) do - n = :rand.uniform(div(mk, 2)) + 40 - data = Enum.map(1..n, fn i -> {String.pad_leading("#{i}", 32), CMerkleTree.hash("f#{i}")} end) - {a, rest} = Enum.split(data, div(n * 3, 5)) - {b, c} = Enum.split(rest, div(length(rest), 2) |> max(1)) - - base = CMerkleTree.new() |> CMerkleTree.insert_items(a) - - t2 = - base |> CMerkleTree.clone() |> CMerkleTree.insert_items(b ++ c) - - t_alt = - base - |> CMerkleTree.clone() - |> CMerkleTree.insert_items(b) - |> CMerkleTree.insert_items(Enum.take(c, min(length(c), 50))) - - _ = CMerkleTree.difference(t2, t_alt) - _ = CMerkleTree.root_hash(t2) - _ = CMerkleTree.root_hash(t_alt) - end - - defp s_u256_sequential_diff(_round, %{max_keys: mk}) do - hi = :rand.uniform(min(mk, 520)) + 80 - - slots = - Enum.map(0..hi, fn i -> - {<>, <>} - end) - - base = CMerkleTree.new() |> CMerkleTree.insert_items(slots) - - left = - base - |> CMerkleTree.clone() - |> CMerkleTree.insert_items( - Enum.map((hi + 1)..(hi + 40), fn i -> - {<>, <>} - end) - ) - - right = - base - |> CMerkleTree.clone() - |> CMerkleTree.insert_items( - Enum.map((hi + 41)..(hi + 95), fn i -> - {<>, <>} - end) - ) - - _ = CMerkleTree.difference(left, right) - _ = CMerkleTree.root_hash(left) - _ = CMerkleTree.root_hash(right) - end - - defp s_clone_parallel_extensions_diff(_round, %{max_keys: mk}) do - hi = :rand.uniform(min(mk, 400)) + 50 - - base = - CMerkleTree.new() - |> CMerkleTree.insert_items( - Enum.map(0..hi, fn i -> {<>, <>} end) - ) - - ext1 = (hi + 1)..(hi + 30) - ext2 = (hi + 31)..(hi + 70) - - left = - base - |> CMerkleTree.clone() - |> CMerkleTree.insert_items( - Enum.map(ext1, fn i -> {<>, <>} end) - ) - - right = - base - |> CMerkleTree.clone() - |> CMerkleTree.insert_items( - Enum.map(ext2, fn i -> {<>, <>} end) - ) - - _ = CMerkleTree.difference(left, right) - end - - defp s_clone_overwrite_diff(_round, %{max_keys: mk}) do - hi = :rand.uniform(min(mk, 350)) + 80 - - base = - CMerkleTree.new() - |> CMerkleTree.insert_items( - Enum.map(0..hi, fn i -> {<>, <>} end) - ) - - left = - base - |> CMerkleTree.clone() - |> CMerkleTree.insert_items( - Enum.map((hi + 1)..(hi + 35), fn i -> - {<>, <>} - end) - ) - - lo = div(hi, 4) - mid = lo + :rand.uniform(max(div(hi - lo, 2), 5)) - - right = - base - |> CMerkleTree.clone() - |> then(fn t -> - Enum.reduce(lo..mid, t, fn i, acc -> - CMerkleTree.insert(acc, <>, <>) - end) - end) - - _ = CMerkleTree.difference(left, right) - end - - defp s_triple_fork_diff(_round, %{max_keys: mk}) do - n = :rand.uniform(min(mk, 280)) + 60 - - base = - CMerkleTree.new() - |> CMerkleTree.insert_items( - Enum.map(1..n, fn i -> {String.pad_leading("#{i}", 32), CMerkleTree.hash("b#{i}")} end) - ) - - a = - base - |> CMerkleTree.clone() - |> CMerkleTree.insert(String.pad_leading("x", 32), CMerkleTree.hash("x")) - - b = - base - |> CMerkleTree.clone() - |> CMerkleTree.insert(String.pad_leading("y", 32), CMerkleTree.hash("y")) - - c = - base - |> CMerkleTree.clone() - |> CMerkleTree.insert(String.pad_leading("z", 32), CMerkleTree.hash("z")) - - _ = CMerkleTree.difference(a, b) - _ = CMerkleTree.difference(b, c) - _ = CMerkleTree.difference(a, c) - end - - defp s_delete_and_reinsert(_round, %{max_keys: mk}) do - n = :rand.uniform(min(mk, 200)) + 30 - keys = Enum.map(1..n, fn i -> String.pad_leading("#{i}", 32) end) - - t = - Enum.reduce(keys, CMerkleTree.new(), fn k, acc -> - CMerkleTree.insert(acc, k, CMerkleTree.hash(k)) - end) - - to_del = Enum.take_random(keys, div(length(keys), 3) |> max(3)) - - t = - Enum.reduce(to_del, t, fn k, acc -> - CMerkleTree.delete(acc, k) - end) - - t = - Enum.reduce(to_del, t, fn k, acc -> - CMerkleTree.insert(acc, k, CMerkleTree.hash("re#{k}")) - end) - - _ = CMerkleTree.root_hash(t) - _ = CMerkleTree.size(t) - end - - defp s_proofs_and_roots(_round, %{max_keys: mk}) do - hi = :rand.uniform(min(180, mk)) + 20 - - t = - CMerkleTree.new() - |> CMerkleTree.insert_items( - Enum.map(0..hi, fn i -> {<>, <>} end) - ) - - _ = CMerkleTree.root_hash(t) - _ = CMerkleTree.root_hashes(t) - k = <<:rand.uniform(hi)::unsigned-size(256)>> - _ = CMerkleTree.get_proofs(t, k) - end - - # --- S8–S18: lock, account map, GC, bulk account_map_lock (see c_src/LOCK_ORDER.md) --- - defp addr(i), do: <> defp slot(i), do: <> - defp build_compact_accounts(n) do - for i <- 1..n, into: %{} do - tree = - CMerkleTree.insert(CMerkleTree.new(), slot(i), <>) - - acc = %Account{nonce: i, balance: i * 1_000, storage_root: tree, code: <>} - {addr(i), Account.compact(acc)} - end + defp storage_list(i, mult \\ 3) do + [{slot(i), <>}] end - defp s_lock_clone_insert(_round, %{max_keys: mk}) do - n = :rand.uniform(min(mk, 120)) + 20 - - base = - CMerkleTree.new() - |> CMerkleTree.insert_items( - Enum.map(1..n, fn i -> {String.pad_leading("lk#{i}", 32), CMerkleTree.hash("lk#{i}")} end) - ) - - locked = - if :rand.uniform(2) == 1 do - base - |> CMerkleTree.clone() - |> CMerkleTree.insert(String.pad_leading("pre_lock", 32), CMerkleTree.hash("pre")) - |> CMerkleTree.lock() - else - base - |> CMerkleTree.clone() - |> CMerkleTree.lock() - end - - _ = CMerkleTree.root_hash(locked) + defp build_compact_accounts(n) do + Enum.reduce(1..n, State.new(), fn i, st -> + acc = %Account{ + nonce: i, + balance: i * 1_000, + storage_root: storage_list(i), + code: <>, + map_backed: false + } + + State.set_account(st, addr(i), acc) + end) + |> State.compact() + |> Map.fetch!(:accounts) end - defp s_lock_and_difference(_round, %{max_keys: mk}) do - n = :rand.uniform(min(mk, 100)) + 30 - - shared = - CMerkleTree.new() - |> CMerkleTree.insert_items( - Enum.map(1..n, fn i -> {String.pad_leading("sh#{i}", 32), CMerkleTree.hash("sh#{i}")} end) - ) - - other = - CMerkleTree.new() - |> CMerkleTree.insert_items( - Enum.map(1..n, fn i -> {String.pad_leading("ot#{i}", 32), CMerkleTree.hash("ot#{i}")} end) - ) - - _ = - shared - |> CMerkleTree.clone() - |> CMerkleTree.lock() + defp build_account_map(n) do + Enum.reduce(1..n, CAccountMap.new(), fn i, acc -> + CAccountMap.put(acc, addr(i), i, i * 1_000, storage_list(i), <>) + end) + end - _ = CMerkleTree.difference(shared, other) - _ = CMerkleTree.difference(other, shared) + defp build_live_state(n) do + Enum.reduce(1..n, State.new(), fn i, st -> + State.set_account(st, addr(i), %{ + Account.new(nonce: i) + | storage_root: [{slot(i), <>}], + map_backed: false, + root_hash: nil + }) + end) + |> State.normalize() end defp s_account_map_uncompact(_round, _ctx) do n = :rand.uniform(180) + 20 compact = build_compact_accounts(n) - {accounts, _store, _hash} = CAccountMap.uncompact_state(compact) + {accounts, _hash} = CAccountMap.uncompact_state(compact) if CAccountMap.size(accounts) != n, do: raise("uncompact size mismatch") end defp s_account_map_clone_mutate(_round, _ctx) do n = :rand.uniform(80) + 10 compact = build_compact_accounts(n) - {accounts, _store, _hash} = CAccountMap.uncompact_state(compact) + {accounts, _hash} = CAccountMap.uncompact_state(compact) fork = accounts |> CAccountMap.clone() - |> CAccountMap.put(addr(1), 99, 99_000, CMerkleTree.new(), <<99>>) + |> CAccountMap.put(addr(1), 99, 99_000, [], <<99>>) _ = CAccountMap.to_list(accounts) if CAccountMap.size(fork) != n, do: raise("fork size mismatch") end - defp s_uncompact_and_storage_diff(_round, _ctx) do + defp s_uncompact_and_map_diff(_round, _ctx) do n = :rand.uniform(60) + 20 compact = build_compact_accounts(n) @@ -443,19 +226,15 @@ defmodule CMerkleFuzz do 1..4 |> Task.async_stream( fn i -> - a = - CMerkleTree.insert_items(CMerkleTree.new(), [ - {slot(i), <>}, - {slot(i + 1000), <>} - ]) + a = build_account_map(10 + i) + b = CAccountMap.clone(a) b = - CMerkleTree.insert_items(CMerkleTree.new(), [ - {slot(i), <>}, - {slot(i + 2000), <>} - ]) + CAccountMap.storage_put_map(b, %{ + addr(1) => %{slot(i + 1000) => <>} + }) - CMerkleTree.difference(a, b) + CAccountMap.difference_full(a, b) end, max_concurrency: 4, timeout: 60_000, @@ -463,7 +242,7 @@ defmodule CMerkleFuzz do ) |> Enum.to_list() - {accounts, _store, _hash} = Task.await(parent, 120_000) + {accounts, _hash} = Task.await(parent, 120_000) if length(diffs) != 4, do: raise("diff task count mismatch") if CAccountMap.size(accounts) != n, do: raise("uncompact size mismatch") end @@ -471,42 +250,28 @@ defmodule CMerkleFuzz do defp s_account_get_materialize_diff(_round, _ctx) do n = :rand.uniform(40) + 5 compact = build_compact_accounts(n) - {accounts, _store, _hash} = CAccountMap.uncompact_state(compact) - - {_, _, storage, _} = CAccountMap.get(accounts, addr(1)) + {accounts, _hash} = CAccountMap.uncompact_state(compact) - alt = - CMerkleTree.insert(CMerkleTree.clone(storage), slot(9999), <<9999::unsigned-size(256)>>) + {_n, _b, root, _c} = CAccountMap.get(accounts, addr(1)) + if not is_binary(root) or byte_size(root) != 32, do: raise("expected 32-byte root hash") - _ = CMerkleTree.difference(storage, alt) + _ = CAccountMap.storage_get(accounts, addr(1), slot(1)) + _ = CAccountMap.storage_root_hash(accounts, addr(1)) + _ = CAccountMap.storage_to_list(accounts, addr(1)) _ = CAccountMap.get(accounts, addr(2)) end - defp s_gc_during_lock_diff(_round, %{max_keys: mk}) do - n = :rand.uniform(min(mk, 80)) + 20 - - shared = - CMerkleTree.new() - |> CMerkleTree.insert_items( - Enum.map(1..n, fn i -> {String.pad_leading("gc#{i}", 32), CMerkleTree.hash("gc#{i}")} end) - ) - - other = - CMerkleTree.new() - |> CMerkleTree.insert_items( - Enum.map(1..n, fn i -> {String.pad_leading("go#{i}", 32), CMerkleTree.hash("go#{i}")} end) - ) + defp s_gc_during_map_lock_diff(_round, _ctx) do + map = build_account_map(:rand.uniform(60) + 20) + fork = CAccountMap.clone(map) 1..6 |> Task.async_stream( fn w -> if rem(w, 2) == 0 do - _ = - shared - |> CMerkleTree.clone() - |> CMerkleTree.lock() + _ = CAccountMap.lock(map) else - _ = CMerkleTree.difference(shared, other) + _ = CAccountMap.difference_full(map, fork) end :ok @@ -517,39 +282,10 @@ defmodule CMerkleFuzz do ) |> Stream.run() - short = - Enum.map(1..12, fn i -> - {String.pad_leading("tmp#{i}", 32), CMerkleTree.hash("tmp#{i}")} - end) - - _ = CMerkleTree.new() |> CMerkleTree.insert_items(short) + _ = CAccountMap.new() |> CAccountMap.lock() :erlang.garbage_collect() end - defp s_import_map_lock_diff(_round, %{max_keys: mk}) do - n = :rand.uniform(min(mk, 150)) + 30 - - items = - for i <- 1..n, into: %{} do - {String.pad_leading("im#{i}", 32), CMerkleTree.hash("im#{i}")} - end - - tree = CMerkleTree.new() |> CMerkleTree.import_map(items) - - sibling = - tree - |> CMerkleTree.clone() - |> CMerkleTree.insert(String.pad_leading("extra", 32), CMerkleTree.hash("extra")) - - _ = - tree - |> CMerkleTree.clone() - |> CMerkleTree.lock() - - _ = CMerkleTree.difference(tree, sibling) - _ = CMerkleTree.root_hash(tree) - end - defp s_state_lock_clone_mutate(_round, _ctx) do n = :rand.uniform(60) + 10 compact = build_compact_accounts(n) @@ -559,9 +295,12 @@ defmodule CMerkleFuzz do |> then(fn accounts -> %State{accounts: accounts} end) |> State.uncompact() - if :rand.uniform(2) == 1 do - parent = State.normalize(parent) - end + parent = + if :rand.uniform(2) == 1 do + State.normalize(parent) + else + parent + end _ = State.hash(parent) Chain.State.lock(parent) @@ -573,12 +312,9 @@ defmodule CMerkleFuzz do Enum.reduce(1..writes, fork, fn j, state -> id = addr(rem(j, n) + 1) - acc = - state - |> State.account(id) - |> Account.storage_set_value(slot(j + 50_000), <>) - - State.set_account(state, id, acc) + State.storage_put_map(state, %{ + id => %{slot(j + 50_000) => <>} + }) end) fork_hash = State.hash(fork) @@ -587,7 +323,7 @@ defmodule CMerkleFuzz do for j <- 1..writes do id = addr(rem(j, n) + 1) - if Account.storage_value(State.account(parent, id), slot(j + 50_000)) != + if State.storage_value(parent, id, slot(j + 50_000)) != <<0::unsigned-size(256)>> do raise("parent mutated after lock->clone fork write") end @@ -601,26 +337,15 @@ defmodule CMerkleFuzz do map = Enum.reduce(1..n, CAccountMap.new(), fn i, acc -> g = div(i - 1, group) - - storage = - CMerkleTree.insert_items(CMerkleTree.new(), [ - {slot(g + 1), <>} - ]) - - CAccountMap.put(acc, addr(i), i, i * 1_000, storage, <>) + CAccountMap.put(acc, addr(i), i, i * 1_000, storage_list(g + 1, 1), <>) end) - store = - CMerkleTree.insert_items(CMerkleTree.new(), [ - {slot(88_888), <<88_888::unsigned-size(256)>>} - ]) - - _ = CAccountMap.lock(map, store) + _ = CAccountMap.lock(map) fork = map |> CAccountMap.clone() - |> CAccountMap.put(addr(1), 77, 77_000, CMerkleTree.new(), <<77>>) + |> CAccountMap.put(addr(1), 77, 77_000, [], <<77>>) if CAccountMap.size(fork) != n, do: raise("fork size mismatch") @@ -630,27 +355,18 @@ defmodule CMerkleFuzz do defp s_account_map_lock_concurrent(_round, _ctx) do n = :rand.uniform(60) + 20 - - map = - Enum.reduce(1..n, CAccountMap.new(), fn i, acc -> - storage = - CMerkleTree.insert(CMerkleTree.new(), slot(i), <>) - - CAccountMap.put(acc, addr(i), i, i * 1_000, storage, <>) - end) - - {_, _, storage_a, _} = CAccountMap.get(map, addr(1)) - {_, _, storage_b, _} = CAccountMap.get(map, addr(min(n, 2))) - + map = build_account_map(n) workers = 6 1..workers |> Task.async_stream( fn w -> if rem(w, 2) == 0 do - _ = CAccountMap.lock(map, nil) + _ = CAccountMap.lock(map) else - _ = CMerkleTree.difference(storage_a, storage_b) + _ = CAccountMap.storage_root_hash(map, addr(1)) + _ = CAccountMap.storage_root_hash(map, addr(min(n, 2))) + _ = CAccountMap.storage_to_list(map, addr(1)) end :ok @@ -662,44 +378,40 @@ defmodule CMerkleFuzz do |> Stream.run() end - # --- S19–S30: native account_map list_difference (see c_src/LOCK_ORDER.md D-C7, D-D7) --- - defp s_account_map_list_diff_large_compact(_round, _ctx) do n = :rand.uniform(200) + 100 compact = build_compact_accounts(n) - {base, _store, _hash} = CAccountMap.uncompact_state(compact) + {base, _hash} = CAccountMap.uncompact_state(compact) fork = CAccountMap.clone(base) |> then(fn map -> i = :rand.uniform(n) - {nonce, balance, storage, code} = CAccountMap.get(map, addr(i)) - - storage = - CMerkleTree.insert( - CMerkleTree.clone(storage), - slot(i + 400_000), - <> - ) - CAccountMap.put(map, addr(i), nonce + 1, balance, storage, code) + map + |> CAccountMap.storage_put_map(%{ + addr(i) => %{slot(i + 400_000) => <>} + }) + |> then(fn m -> + {nonce, balance, _root, code} = CAccountMap.get(m, addr(i)) + CAccountMap.put_meta(m, addr(i), nonce + 1, balance, code) + end) end) - _ = CAccountMap.list_difference(base, fork) + _ = CAccountMap.difference_full(base, fork) end defp s_list_diff_vs_to_list(_round, _ctx) do n = :rand.uniform(80) + 20 map = build_account_map(n) fork = CAccountMap.clone(map) - workers = 6 1..workers |> Task.async_stream( fn w -> if rem(w, 2) == 0 do - _ = CAccountMap.list_difference(map, fork) + _ = CAccountMap.difference_full(map, fork) else _ = CAccountMap.to_list(map) end @@ -716,16 +428,15 @@ defmodule CMerkleFuzz do defp s_list_diff_vs_account_map_lock(_round, _ctx) do map = build_account_map(:rand.uniform(60) + 20) fork = CAccountMap.clone(map) - workers = 6 1..workers |> Task.async_stream( fn w -> if rem(w, 2) == 0 do - _ = CAccountMap.list_difference(map, fork) + _ = CAccountMap.difference_full(map, fork) else - _ = CAccountMap.lock(map, nil) + _ = CAccountMap.lock(map) end :ok @@ -739,7 +450,6 @@ defmodule CMerkleFuzz do defp s_list_diff_vs_clone_put(_round, _ctx) do map = build_account_map(:rand.uniform(50) + 15) - workers = 6 1..workers @@ -748,10 +458,10 @@ defmodule CMerkleFuzz do fork = CAccountMap.clone(map) if rem(w, 2) == 0 do - _ = CAccountMap.list_difference(map, fork) + _ = CAccountMap.difference_full(map, fork) else i = rem(w, 10) + 1 - storage = CMerkleTree.insert(CMerkleTree.new(), slot(w + i), <>) + storage = [{slot(w + i), <>}] _ = CAccountMap.put(fork, addr(i), w, w * 100, storage, <>) end @@ -764,20 +474,19 @@ defmodule CMerkleFuzz do |> Stream.run() end - defp s_list_diff_vs_storage_difference(_round, _ctx) do + defp s_list_diff_vs_storage_apis(_round, _ctx) do map = build_account_map(:rand.uniform(40) + 10) - {_, _, sa, _} = CAccountMap.get(map, addr(1)) - {_, _, sb, _} = CAccountMap.get(map, addr(2)) - workers = 6 1..workers |> Task.async_stream( fn w -> if rem(w, 2) == 0 do - _ = CAccountMap.list_difference(map, CAccountMap.clone(map)) + _ = CAccountMap.difference_full(map, CAccountMap.clone(map)) else - _ = CMerkleTree.difference(sa, sb) + _ = CAccountMap.storage_root_hash(map, addr(1)) + _ = CAccountMap.storage_root_hash(map, addr(2)) + _ = CAccountMap.storage_to_list(map, addr(1)) end :ok @@ -793,12 +502,16 @@ defmodule CMerkleFuzz do a = build_account_map(:rand.uniform(50) + 10) b = CAccountMap.clone(a) i = :rand.uniform(10) + 1 - {nonce, balance, storage, code} = CAccountMap.get(b, addr(i)) - storage = - CMerkleTree.insert(CMerkleTree.clone(storage), slot(88_888), <<88_888::unsigned-size(256)>>) - - b = CAccountMap.put(b, addr(i), nonce + 1, balance, storage, code) + b = + b + |> CAccountMap.storage_put_map(%{ + addr(i) => %{slot(88_888) => <<88_888::unsigned-size(256)>>} + }) + |> then(fn m -> + {nonce, balance, _root, code} = CAccountMap.get(m, addr(i)) + CAccountMap.put_meta(m, addr(i), nonce + 1, balance, code) + end) workers = 4 @@ -806,9 +519,9 @@ defmodule CMerkleFuzz do |> Task.async_stream( fn w -> if rem(w, 2) == 0 do - _ = CAccountMap.list_difference(a, b) + _ = CAccountMap.difference_full(a, b) else - _ = CAccountMap.list_difference(b, a) + _ = CAccountMap.difference_full(b, a) end :ok @@ -829,31 +542,25 @@ defmodule CMerkleFuzz do fork = live |> State.clone() - |> then(fn st -> - id = addr(rem(:rand.uniform(n), n) + 1) - acc = State.account(st, id) - - tree = - Account.tree(acc) - |> CMerkleTree.insert(slot(55_555), <<55_555::unsigned-size(256)>>) - - State.set_account(st, id, Account.put_tree(acc, tree)) - end) + |> State.storage_put_map(%{ + addr(rem(:rand.uniform(n), n) + 1) => %{ + slot(55_555) => <<55_555::unsigned-size(256)>> + } + }) - _ = CAccountMap.list_difference(compact_nif, fork.accounts) + _ = CAccountMap.difference_full(compact_nif, fork.accounts) end defp s_list_diff_vs_uncompact(_round, _ctx) do compact = build_compact_accounts(:rand.uniform(80) + 20) - workers = 4 1..workers |> Task.async_stream( fn w -> if rem(w, 2) == 0 do - {accounts, _store, _hash} = CAccountMap.uncompact_state(compact) - _ = CAccountMap.list_difference(accounts, CAccountMap.clone(accounts)) + {accounts, _hash} = CAccountMap.uncompact_state(compact) + _ = CAccountMap.difference_full(accounts, CAccountMap.clone(accounts)) else _ = CAccountMap.uncompact_state(compact) end @@ -870,7 +577,6 @@ defmodule CMerkleFuzz do defp s_list_diff_vs_state_lock(_round, _ctx) do st = build_live_state(:rand.uniform(60) + 15) fork = State.clone(st) - workers = 6 1..workers @@ -893,14 +599,13 @@ defmodule CMerkleFuzz do defp s_list_diff_vs_account_map_get(_round, _ctx) do map = build_account_map(:rand.uniform(40) + 10) - workers = 6 1..workers |> Task.async_stream( fn w -> if rem(w, 2) == 0 do - _ = CAccountMap.list_difference(map, CAccountMap.clone(map)) + _ = CAccountMap.difference_full(map, CAccountMap.clone(map)) else _ = CAccountMap.get(map, addr(rem(w, 10) + 1)) end @@ -919,8 +624,8 @@ defmodule CMerkleFuzz do fork = CAccountMap.clone(map) for _ <- 1..8 do - _ = CAccountMap.list_difference(map, fork) - short = CMerkleTree.new() |> CMerkleTree.clone() |> CMerkleTree.lock() + _ = CAccountMap.difference_full(map, fork) + short = CAccountMap.new() |> CAccountMap.lock() _ = short :erlang.garbage_collect() end @@ -935,13 +640,10 @@ defmodule CMerkleFuzz do |> State.clone() |> then(fn st -> i = :rand.uniform(n) - acc = State.account(st, addr(i)) - tree = - Account.tree(acc) - |> CMerkleTree.insert(slot(i + 200_000), <>) - - State.set_account(st, addr(i), Account.put_tree(acc, tree)) + State.storage_put_map(st, %{ + addr(i) => %{slot(i + 200_000) => <>} + }) end) workers = 8 @@ -953,7 +655,7 @@ defmodule CMerkleFuzz do 0 -> _ = State.difference(prev, block) 1 -> _ = State.lock(State.clone(block)) 2 -> _ = CAccountMap.to_list(block.accounts) - _ -> _ = CAccountMap.list_difference(prev.accounts, block.accounts) + _ -> _ = CAccountMap.difference_full(prev.accounts, block.accounts) end :ok @@ -965,19 +667,28 @@ defmodule CMerkleFuzz do |> Stream.run() end - defp build_account_map(n) do - Enum.reduce(1..n, CAccountMap.new(), fn i, acc -> - storage = CMerkleTree.insert(CMerkleTree.new(), slot(i), <>) - CAccountMap.put(acc, addr(i), i, i * 1_000, storage, <>) - end) - end + defp s_clone_equivalence(_round, _ctx) do + n = :rand.uniform(40) + 10 + map = build_account_map(n) + state = %Chain.State{accounts: map} + id = addr(:rand.uniform(n)) - defp build_live_state(n) do - Enum.reduce(1..n, State.new(), fn i, st -> - storage = CMerkleTree.insert(CMerkleTree.new(), slot(i), <>) - State.set_account(st, addr(i), Account.put_tree(Account.new(nonce: i), storage)) - end) - |> State.normalize() + mutate = fn st -> + Chain.State.storage_put_map(st, %{ + id => %{slot(888_888) => <<99::unsigned-size(256)>>} + }) + end + + fork_a = state |> Chain.State.clone() |> mutate.() + fork_b = state |> Chain.State.clone() |> mutate.() + + if Chain.State.hash(fork_a) != Chain.State.hash(fork_b) do + raise "clone fork hash mismatch" + end + + if CAccountMap.storage_get(map, id, slot(888_888)) != nil do + raise "clone corrupted parent storage" + end end defp read_proc_rss_kb do diff --git a/scripts/cmerkle_fuzz.sh b/scripts/cmerkle_fuzz.sh index 2e7cf08..14b4adc 100755 --- a/scripts/cmerkle_fuzz.sh +++ b/scripts/cmerkle_fuzz.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Long-running CMerkleTree NIF fuzzer with crash logging. +# Long-running CAccountMap NIF fuzzer with crash logging. # Usage (from repository root): # ./scripts/cmerkle_fuzz.sh # MERKLE_FUZZ_ITERATIONS=5000 MERKLE_FUZZ_SEED=42 ./scripts/cmerkle_fuzz.sh @@ -31,7 +31,7 @@ fi write_header() { { - echo "========== CMerkleTree fuzz crash log ==========" + echo "========== CAccountMap fuzz crash log ==========" echo "started_utc=$(date -u +%Y-%m-%dT%H:%M:%SZ)" echo "host=$(hostname 2>/dev/null || echo unknown)" echo "pwd=$(pwd)" diff --git a/scripts/cmerkle_heap_assumptions.exs b/scripts/cmerkle_heap_assumptions.exs deleted file mode 100644 index 9635a57..0000000 --- a/scripts/cmerkle_heap_assumptions.exs +++ /dev/null @@ -1,333 +0,0 @@ -# CMerkleTree NIF — targeted stress for heap / invalid-free hypotheses (live crash triage). -# -# Hypothesis map (see c_src/preallocator.hpp, merkletree.cpp, nif.cpp): -# A — PreAllocator destroy_item/backbuffer vs ~PreAllocator slot count (pair_t ~ on valid storage) -# B — GlobalStripePool: stripe malloc reused across Tree lifetimes; destructor + pool put -# C — Second stripe: only first slot used after full first stripe (partial stripe destructor) -# D — ItemPool COW: clone + write → fork_for_write / clone_for_fork + pair_list clone -# E — split_node / large trie (leaf_bucket churn, push_back_no_delete paths) -# F — NIF locked_states enter_lock/leave_lock + root_hash dedup (live server pattern) -# G — difference/into tree — insert_item on forked structures -# H — get_proofs + root_hashes — read paths after dirty/update_merkle_hash_count -# -# Run from repo root: -# mix run --no-start scripts/cmerkle_heap_assumptions.exs -- --rounds 200 -# -# Options: -# --rounds N repetitions per scenario (default: 150) -# --suite-pass N run the full scenario list this many times (default: 1) -# --seed N RNG seed (default: env MERKLE_HEAP_SEED or fixed 20260415) -# -# Exit 0 only if every scenario completes without raising. Native crashes (abort/segfault) -# are not catchable in Elixir; use ASan build or MERKLE_FUZZ_ASAN with cmerkle_fuzz.sh pattern. - -defmodule CMerkleHeapAssumptions do - @moduledoc false - - def normalize_argv(argv) do - case argv do - ["--" | rest] -> rest - other -> other - end - end - - def main(argv) do - argv = normalize_argv(argv) - - {opts, _} = - OptionParser.parse!(argv, - strict: [ - rounds: :integer, - suite_pass: :integer, - seed: :integer - ] - ) - - rounds = Keyword.get(opts, :rounds, env_int("MERKLE_HEAP_ROUNDS") || 150) - suite_pass = Keyword.get(opts, :suite_pass, env_int("MERKLE_HEAP_SUITE_PASS") || 1) - - seed = - Keyword.get(opts, :seed) || - env_int("MERKLE_HEAP_SEED") || - 20_260_415 - - :rand.seed(:exsss, {seed, seed, seed}) - - {_ib, _pb, _plb, _tb, stripe} = CMerkleTree.struct_sizes() - - IO.puts(:stderr, """ - === CMerkleTree heap assumption stress === - otp=#{System.otp_release()} elixir=#{System.version()} pid=#{:os.getpid()} - seed=#{seed} rounds_per_scenario=#{rounds} suite_passes=#{suite_pass} stripe_size=#{stripe} - """) - - ctx = %{rounds: rounds, stripe: stripe, seed: seed} - - Enum.each(1..suite_pass, fn pass -> - IO.puts(:stderr, "--- suite pass #{pass}/#{suite_pass} ---") - - for {id, title, fun} <- scenarios() do - t0 = System.monotonic_time(:millisecond) - - try do - fun.(ctx) - dt = System.monotonic_time(:millisecond) - t0 - IO.puts(:stderr, "ASSUMPTION_OK #{id} #{inspect(title)} #{dt}ms") - catch - kind, reason -> - IO.puts(:stderr, "ASSUMPTION_FAIL #{id} #{inspect(title)} kind=#{inspect(kind)} reason=#{inspect(reason)}") - :erlang.raise(kind, reason, __STACKTRACE__) - end - end - end) - - IO.puts(:stderr, "=== all scenarios finished (Elixir layer); exit 0 ===") - end - - defp env_int(name) do - case System.get_env(name) do - nil -> nil - "" -> nil - s -> String.to_integer(String.trim(s)) - end - rescue - ArgumentError -> nil - end - - defp scenarios do - [ - {:A, "destroy_item/backbuffer: heavy delete+reinsert on one tree", &a_delete_reinsert_churn/1}, - {:B, "GlobalStripePool: many short-lived trees (alloc+drop)", &b_short_lived_trees/1}, - {:C, "stripe boundary: exactly stripe+1 keys then shrink", &c_second_stripe_partial/1}, - {:D, "COW fork: clone then write (make_writeable path)", &d_clone_write_fork/1}, - {:E, "large trie: many inserts (split_node / internal nodes)", &e_large_many_keys/1}, - {:F, "lock + clone chain (NIF locked_states / root_hash dedup)", &f_lock_clone_chain/1}, - {:G, "difference on large divergent trees", &g_difference_heavy/1}, - {:H, "proofs + root_hashes after deep updates", &h_proofs_and_hashes/1}, - {:I, "account_map_lock bulk identical storage roots", &i_identical_root_lock_bulk/1}, - {:J, "account_map list_difference bounded shared_states growth", &j_list_difference_heap/1} - ] - end - - # A: pair_t destroy_item -> backbuffer; PreAllocator ~ must match constructed slots. - defp a_delete_reinsert_churn(%{rounds: r}) do - n = max(120, min(r, 400)) - keys = Enum.map(1..n, fn i -> String.pad_leading("#{i}", 32) end) - - t = - Enum.reduce(keys, CMerkleTree.new(), fn k, acc -> - CMerkleTree.insert(acc, k, CMerkleTree.hash("a#{k}")) - end) - - Enum.each(1..r, fn round -> - to_del = Enum.take_random(keys, min(40 + rem(round, 30), div(n, 2))) - - t = - Enum.reduce(to_del, t, fn k, acc -> - CMerkleTree.delete(acc, k) - end) - - t = - Enum.reduce(to_del, t, fn k, acc -> - CMerkleTree.insert(acc, k, CMerkleTree.hash("b#{k}#{round}")) - end) - - _ = CMerkleTree.root_hash(t) - _ = CMerkleTree.size(t) - :ok - end) - end - - # B: tree destroyed -> SharedState ~Tree -> PreAllocator ~ ; stripes may return to global pool. - defp b_short_lived_trees(%{rounds: r}) do - Enum.each(1..max(r, 200), fn i -> - k = rem(i, 50) + 5 - - data = - Enum.map(1..k, fn j -> - {String.pad_leading("#{i}_#{j}", 32), CMerkleTree.hash("s#{i}_#{j}")} - end) - - t = CMerkleTree.new() |> CMerkleTree.insert_items(data) - _ = CMerkleTree.root_hash(t) - _ = CMerkleTree.bucket_count(t) - :ok - end) - end - - # C: first stripe full (8) then minimal use of second stripe — destructor len on stripe 2. - defp c_second_stripe_partial(%{rounds: r, stripe: s}) do - Enum.each(1..max(div(r, 2), 40), fn pass -> - # Fill exactly s keys (one full stripe of pair allocations), then add one on next stripe. - base = Enum.map(1..s, fn i -> {String.pad_leading("c#{pass}_#{i}", 32), CMerkleTree.hash("c#{i}")} end) - extra = {String.pad_leading("c#{pass}_extra", 32), CMerkleTree.hash("extra")} - - t = CMerkleTree.new() |> CMerkleTree.insert_items(base) - t = CMerkleTree.insert(t, elem(extra, 0), elem(extra, 1)) - _ = CMerkleTree.root_hash(t) - - # Shrink back toward boundary (deletes use insert null — churns PreAllocator). - t = - Enum.reduce(1..div(s, 2), t, fn i, acc -> - CMerkleTree.delete(acc, String.pad_leading("c#{pass}_#{i}", 32)) - end) - - _ = CMerkleTree.root_hash(t) - _ = CMerkleTree.size(t) - :ok - end) - end - - # D: shared Tree copy + pool; write triggers clone_for_write / fork. - defp d_clone_write_fork(%{rounds: r}) do - n = max(80, min(r, 350)) - - base = - CMerkleTree.new() - |> CMerkleTree.insert_items( - Enum.map(1..n, fn i -> {String.pad_leading("d#{i}", 32), CMerkleTree.hash("d#{i}")} end) - ) - - Enum.each(1..max(div(r, 3), 30), fn i -> - a = CMerkleTree.clone(base) |> CMerkleTree.insert(String.pad_leading("dx#{i}", 32), CMerkleTree.hash("dx#{i}")) - b = CMerkleTree.clone(base) |> CMerkleTree.insert(String.pad_leading("dy#{i}", 32), CMerkleTree.hash("dy#{i}")) - _ = CMerkleTree.root_hash(a) - _ = CMerkleTree.root_hash(b) - _ = CMerkleTree.difference(a, b) - :ok - end) - end - - # E: many keys — stress split_node, internal merge paths in update_merkle_hash_count. - defp e_large_many_keys(%{rounds: r}) do - n = max(800, min(r * 8, 6000)) - - data = - Enum.map(1..n, fn i -> - {String.pad_leading("e#{i}", 32), CMerkleTree.hash("e#{i}")} - end) - - t = CMerkleTree.new() |> CMerkleTree.insert_items(data) - _ = CMerkleTree.root_hash(t) - _ = CMerkleTree.root_hashes(t) - _ = CMerkleTree.bucket_count(t) - :ok - end - - # F: lock registers root in NIF map; clone hits enter_lock path — live server pattern. - defp f_lock_clone_chain(%{rounds: r}) do - data = Enum.map(1..120, fn i -> {String.pad_leading("f#{i}", 32), CMerkleTree.hash("f#{i}")} end) - base = CMerkleTree.new() |> CMerkleTree.insert_items(data) |> CMerkleTree.lock() - - Enum.each(1..max(div(r, 5), 25), fn i -> - t = - CMerkleTree.clone(base) - |> CMerkleTree.insert(String.pad_leading("fz#{i}", 32), CMerkleTree.hash("fz#{i}")) - |> CMerkleTree.lock() - - _ = CMerkleTree.root_hash(t) - :ok - end) - end - - # G: difference walks both trees — clone_for_fork on into.insert_item. - defp g_difference_heavy(%{rounds: r}) do - m = max(200, min(r * 3, 900)) - - a = - Enum.map(1..m, fn i -> {String.pad_leading("g#{i}", 32), CMerkleTree.hash("ga#{i}")} end) - - b = - Enum.map(1..m, fn i -> {String.pad_leading("g#{i}", 32), CMerkleTree.hash("gb#{i}")} end) - - ta = CMerkleTree.new() |> CMerkleTree.insert_items(a) - tb = CMerkleTree.new() |> CMerkleTree.insert_items(b) - diff = CMerkleTree.difference(ta, tb) - _ = map_size(diff) - _ = CMerkleTree.root_hash(ta) - _ = CMerkleTree.root_hash(tb) - :ok - end - - # H: get_proofs + root_hashes after updates — read paths over possibly merged buckets. - defp h_proofs_and_hashes(%{rounds: r}) do - n = max(100, min(r * 2, 600)) - keys = Enum.map(1..n, fn i -> String.pad_leading("h#{i}", 32) end) - - t = - Enum.reduce(keys, CMerkleTree.new(), fn k, acc -> - CMerkleTree.insert(acc, k, CMerkleTree.hash(k)) - end) - - Enum.each(1..min(80, max(div(r, 2), 20)), fn _ -> - k = Enum.random(keys) - _ = CMerkleTree.get_proofs(t, k) - _ = CMerkleTree.root_hashes(t) - :ok - end) - end - - defp i_identical_root_lock_bulk(%{rounds: r}) do - n = max(40, min(r, 120)) - - Enum.each(1..max(div(r, 2), 20), fn _pass -> - map = - Enum.reduce(1..n, CAccountMap.new(), fn i, acc -> - storage = - CMerkleTree.insert_items(CMerkleTree.new(), [ - {String.pad_leading("i1", 32), CMerkleTree.hash("i1")}, - {String.pad_leading("i2", 32), CMerkleTree.hash("i2")} - ]) - - CAccountMap.put(acc, <>, i, i * 1_000, storage, <>) - end) - - _ = CAccountMap.lock(map) - {_locked, orphans, _shared, _res} = CMerkleTree.nif_stats() - if orphans > 0, do: raise("scenario I orphans=#{orphans}") - :ok - end) - end - - defp j_list_difference_heap(%{rounds: r}) do - n = max(40, min(r, 150)) - - base = - Enum.reduce(1..n, CAccountMap.new(), fn i, acc -> - storage = CMerkleTree.insert(CMerkleTree.new(), slot(i), <>) - CAccountMap.put(acc, <>, i, i * 1_000, storage, <>) - end) - - fork = - CAccountMap.clone(base) - |> then(fn map -> - {nonce, balance, storage, code} = CAccountMap.get(map, <<1::unsigned-size(160)>>) - - storage = - CMerkleTree.insert( - CMerkleTree.clone(storage), - slot(99_999), - <<99_999::unsigned-size(256)>> - ) - - CAccountMap.put(map, <<1::unsigned-size(160)>>, nonce + 1, balance, storage, code) - end) - - {_l0, orphans0, shared0, _r0} = CMerkleTree.nif_stats() - if orphans0 > 0, do: raise("scenario J initial orphans=#{orphans0}") - - Enum.each(1..max(div(r, 2), 30), fn _ -> - _ = CAccountMap.list_difference(base, fork) - :erlang.garbage_collect() - end) - - {_l1, orphans1, shared1, _r1} = CMerkleTree.nif_stats() - if orphans1 > 0, do: raise("scenario J orphans=#{orphans1}") - if shared1 - shared0 > 800, do: raise("scenario J shared_states growth #{shared1 - shared0}") - :ok - end - - defp slot(i), do: <> -end - -CMerkleHeapAssumptions.main(System.argv()) diff --git a/scripts/cmerkle_leak_test.exs b/scripts/cmerkle_leak_test.exs index 37955cc..d4fb524 100644 --- a/scripts/cmerkle_leak_test.exs +++ b/scripts/cmerkle_leak_test.exs @@ -1,4 +1,4 @@ -# CMerkleTree NIF memory leak regression harness. +# CAccountMap NIF memory leak regression harness. # # Run from repo root: # mix run --no-start scripts/cmerkle_leak_test.exs -- --rounds 50 --max-delta-kb 81920 @@ -12,6 +12,7 @@ # --seed N RNG seed (default: 20260709) # # Emits LEAK_OK on success. Exit non-zero on RSS regression. +# Uses CMerkleTree.nif_stats/0 for orphan SharedState counts. defmodule CMerkleLeakTest do @moduledoc false @@ -48,7 +49,7 @@ defmodule CMerkleLeakTest do :rand.seed(:exsss, {seed, seed, seed}) IO.puts(:stderr, """ - === CMerkleTree leak test === + === CAccountMap leak test === otp=#{System.otp_release()} pid=#{:os.getpid()} rounds=#{rounds} accounts=#{accounts} max_delta_kb=#{max_delta_kb} plateau=#{plateau} scenarios=#{inspect(scenarios)} @@ -103,22 +104,19 @@ defmodule CMerkleLeakTest do end) end + # A: empty / tiny map lock storm (frozen-only; no bare-tree lock) defp run_workload("A", rounds, _accounts) do Enum.each(1..rounds, fn _ -> - CMerkleTree.new() |> CMerkleTree.lock() + CAccountMap.new() |> CAccountMap.lock() end) end + # B: account_map_lock with identical list-storage roots defp run_workload("B", rounds, accounts) do Enum.each(1..rounds, fn _ -> map = Enum.reduce(1..accounts, CAccountMap.new(), fn i, acc -> - storage = - CMerkleTree.insert_items(CMerkleTree.new(), [ - {slot(1), val(1)}, - {slot(2), val(2)} - ]) - + storage = [{slot(1), val(1)}, {slot(2), val(2)}] CAccountMap.put(acc, addr(i), i, i * 1_000, storage, <>) end) @@ -126,6 +124,7 @@ defmodule CMerkleLeakTest do end) end + # C: compact → uncompact → normalize → lock (block-sync shaped) defp run_workload("C", rounds, accounts) do Enum.each(1..rounds, fn _ -> state = @@ -133,11 +132,9 @@ defmodule CMerkleLeakTest do acc = %Account{ nonce: i, balance: i * 1_000, - storage_root: - CMerkleTree.insert_items(CMerkleTree.new(), [ - {slot(i), val(i)} - ]), - code: <> + storage_root: [{slot(i), val(i)}], + code: <>, + map_backed: false } State.set_account(st, addr(i), acc) @@ -161,6 +158,7 @@ defmodule CMerkleLeakTest do defp warmup_gc do force_gc() + _ = CAccountMap.new() _ = CMerkleTree.nif_stats() end diff --git a/scripts/cmerkle_leak_watchdog.exs b/scripts/cmerkle_leak_watchdog.exs index 72df6a9..50a6f84 100644 --- a/scripts/cmerkle_leak_watchdog.exs +++ b/scripts/cmerkle_leak_watchdog.exs @@ -1,10 +1,10 @@ -# CMerkleTree NIF — memory leak watchdog for cmerkle_leak_test.exs. +# CAccountMap NIF — memory leak watchdog for cmerkle_leak_test.exs. # # Run from repo root: # mix run --no-start scripts/cmerkle_leak_watchdog.exs -- \ # mix run --no-start scripts/cmerkle_leak_test.exs -- --rounds 50 # -# Options (before `--`): +# Options (before the child command; stop at first non-option like deadlock watchdog): # --progress-timeout SEC no LEAK_OK for this long (default: 120) # --wall-timeout SEC max total runtime (default: 0 = unlimited) # --poll-interval SEC poll interval (default: 5) @@ -32,7 +32,7 @@ defmodule CMerkleLeakWatchdog do poll_ms = Keyword.get(opts, :poll_interval, 5) * 1_000 IO.puts(:stderr, """ - === CMerkleTree leak watchdog === + === CAccountMap leak watchdog === command=#{inspect(cmd)} progress_timeout=#{div(progress_timeout_ms, 1000)}s wall_timeout=#{format_wall(wall_timeout_ms)} """) @@ -76,6 +76,12 @@ defmodule CMerkleLeakWatchdog do check_timeouts(port, ctx) {^port, {:exit_status, status}} -> + if status == 0 do + IO.puts(:stderr, "LEAK_WATCHDOG_OK exit=#{status}") + else + IO.puts(:stderr, "LEAK_WATCHDOG_FAIL exit=#{status}") + end + System.halt(status) after ctx.poll_ms -> @@ -104,35 +110,33 @@ defmodule CMerkleLeakWatchdog do defp format_wall(0), do: "unlimited" defp format_wall(ms), do: "#{div(ms, 1000)}s" - defp normalize_argv(argv) do - case argv do - ["--" | rest] -> rest - other -> other - end + defp normalize_argv(["--" | rest]), do: rest + defp normalize_argv(other), do: other + + # Manual parse (not OptionParser): child commands may contain --no-start etc. + defp parse_watchdog_argv(argv), do: parse_watchdog_argv(argv, []) + + defp parse_watchdog_argv([], opts), do: {Enum.reverse(opts), []} + + defp parse_watchdog_argv(["--progress-timeout", val | rest], opts) do + parse_watchdog_argv(rest, [{:progress_timeout, String.to_integer(val)} | opts]) end - defp parse_watchdog_argv(argv) do - {opts, rest} = - OptionParser.parse!(argv, - strict: [ - progress_timeout: :integer, - wall_timeout: :integer, - poll_interval: :integer - ] - ) + defp parse_watchdog_argv(["--wall-timeout", val | rest], opts) do + parse_watchdog_argv(rest, [{:wall_timeout, String.to_integer(val)} | opts]) + end - case Enum.split_while(rest, &(&1 != "--")) do - {pre, ["--" | cmd]} -> {opts, cmd} - {pre, []} -> {opts, pre} - {pre, cmd} -> {opts, pre ++ cmd} - end + defp parse_watchdog_argv(["--poll-interval", val | rest], opts) do + parse_watchdog_argv(rest, [{:poll_interval, String.to_integer(val)} | opts]) end - defp resolve_command([cmd | args]) do - case :os.type() do - {:unix, :darwin} -> {String.to_charlist(cmd), Enum.map(args, &String.to_charlist/1)} - _ -> {cmd, args} - end + defp parse_watchdog_argv(["--" | rest], opts), do: {Enum.reverse(opts), rest} + + defp parse_watchdog_argv(rest, opts), do: {Enum.reverse(opts), rest} + + defp resolve_command([bin | args]) do + exe = System.find_executable(bin) || bin + {exe, args} end end diff --git a/scripts/cmerkle_memory_bench.exs b/scripts/cmerkle_memory_bench.exs deleted file mode 100644 index fc21602..0000000 --- a/scripts/cmerkle_memory_bench.exs +++ /dev/null @@ -1,192 +0,0 @@ -# CMerkleTree RSS workloads (plan: workloads A–D). -# -# Usage (from repo root, after `mix compile`): -# mix run scripts/cmerkle_memory_bench.exs -# mix run scripts/cmerkle_memory_bench.exs -- --workload all --trees 25 --pairs 800 --samples 3 -# -# Workloads A/B/C retain `trees` handles (B/C: one near-duplicate tree per handle). Lower `--trees` -# if the VM RSS is too high. See scripts/cmerkle_memory_evaluation_report.md for results. -# -# CSV columns: workload,iteration,trees,pairs,vm_rss_kb,vm_hwm_kb,node_count,pair_count,approx_bytes,wall_ms - -defmodule CMerkleMemoryBench do - @moduledoc false - - def main(argv \\ []) do - argv = normalize_argv(argv) - - {opts, _, _} = - OptionParser.parse(argv, - strict: [ - workload: :string, - trees: :integer, - pairs: :integer, - samples: :integer, - csv: :boolean - ], - aliases: [w: :workload] - ) - - workload = Keyword.get(opts, :workload, "all") - # Retained trees (B/C hold one fork per k); lower this if RSS is too high. - trees_k = Keyword.get(opts, :trees, 25) - pairs_n = Keyword.get(opts, :pairs, 800) - samples = Keyword.get(opts, :samples, 2) - csv = Keyword.get(opts, :csv, true) - - if not csv do - IO.puts("# struct_sizes: #{inspect(CMerkleTree.struct_sizes())}") - dbg_malloc() - end - - workloads = - case workload do - "all" -> ~w(a b c d) - w -> String.split(w, ",", trim: true) - end - - if csv do - IO.puts("workload,iteration,trees,pairs,vm_rss_kb,vm_hwm_kb,node_count,pair_count,approx_bytes,wall_ms") - end - - for w <- workloads, i <- 1..samples do - run = fn -> run_workload(w, trees_k, pairs_n) end - {t0, _} = :timer.tc(run) - {rss, hwm} = read_proc_rss_hwm() - {nodes, pairs_c, approx} = sample_stats() - - if csv do - IO.puts( - "#{w},#{i},#{trees_k},#{pairs_n},#{rss},#{hwm},#{nodes},#{pairs_c},#{approx},#{div(t0, 1000)}" - ) - else - IO.inspect({w, i, rss, hwm, nodes, pairs_c, approx, div(t0, 1000)}, label: :sample) - end - end - - :ok - end - - defp dbg_malloc do - case CMerkleTree.malloc_info() do - :unsupported -> IO.puts("# malloc_info: unsupported") - bin when is_binary(bin) -> IO.puts(String.slice(bin, 0, 400)) - end - end - - defp read_proc_rss_hwm do - path = "/proc/#{System.pid()}/status" - - case File.read(path) do - {:ok, body} -> - rss = parse_kv(body, "VmRSS:") - hwm = parse_kv(body, "VmHWM:") - {rss, hwm} - - _ -> - {0, 0} - end - end - - defp parse_kv(body, key) do - case Regex.run(~r/#{Regex.escape(key)}\s+(\d+)\s+kB/i, body) do - [_, n] -> String.to_integer(n) - _ -> 0 - end - end - - defp sample_stats do - case Process.get(:cmerkle_mem_bench_tree) do - nil -> {0, 0, 0} - t -> CMerkleTree.memory_stats(t) - end - end - - defp test_pairs(n) do - Enum.map(1..n, fn idx -> - {String.pad_leading("#{idx}", 32), CMerkleTree.hash("#{idx}")} - end) - end - - # A: many clones share one SharedState — minimal duplication. - defp run_workload("a", trees_k, pairs_n) do - data = test_pairs(pairs_n) - base = CMerkleTree.from_list(data) |> CMerkleTree.lock() - - refs = - for _ <- 1..trees_k do - CMerkleTree.clone(base) - end - - Process.put(:cmerkle_mem_bench_tree, hd(refs)) - _ = refs - :erlang.garbage_collect() - end - - # B: one locked base, then `trees_k` times clone → insert one leaf (COW full copy per fork). All retained. - # The base handle is scoped so the original SharedState can be collected once forks have diverged. - defp run_workload("b", trees_k, pairs_n) do - data = test_pairs(pairs_n) - extra = CMerkleTree.hash("extra-leaf") - - forks = - (fn -> - base = CMerkleTree.from_list(data) |> CMerkleTree.lock() - - Enum.map(1..trees_k, fn _ -> - CMerkleTree.clone(base) |> CMerkleTree.insert(extra, extra) - end) - end).() - - Process.put(:cmerkle_mem_bench_tree, hd(forks)) - _ = forks - :erlang.garbage_collect() - end - - # C: `trees_k` independent trees (same keys, last value varies). All retained — no COW sharing. - defp run_workload("c", trees_k, pairs_n) do - data = test_pairs(pairs_n) - {k, _v} = List.last(data) - - trees = - Enum.map(1..trees_k, fn i -> - alt = CMerkleTree.hash("alt#{i}") - CMerkleTree.from_list(Enum.drop(data, -1) ++ [{k, alt}]) - end) - - Process.put(:cmerkle_mem_bench_tree, hd(trees)) - _ = trees - :erlang.garbage_collect() - end - - # D: churn — allocate and drop trees to stress PreAllocator malloc/free stripes. - defp run_workload("d", trees_k, pairs_n) do - data = test_pairs(pairs_n) - - last = - Enum.reduce(1..trees_k, nil, fn _, _acc -> - t = CMerkleTree.from_list(data) - _ = CMerkleTree.root_hash(t) - t - end) - - Process.put(:cmerkle_mem_bench_tree, last) - :erlang.garbage_collect() - end - - defp run_workload(other, _, _) do - raise ArgumentError, "unknown workload #{inspect(other)}, expected a|b|c|d|all" - end - - # `mix run scripts/foo.exs -- --trees 5` may pass ["scripts/foo.exs", "--", "--trees", "5"]. - defp normalize_argv(argv) do - argv - |> Enum.flat_map(fn - "--" -> [] - x -> [x] - end) - |> Enum.reject(&String.ends_with?(&1, ".exs")) - end -end - -CMerkleMemoryBench.main(System.argv()) diff --git a/scripts/cmerkle_memory_evaluation_report.md b/scripts/cmerkle_memory_evaluation_report.md deleted file mode 100644 index e317dcc..0000000 --- a/scripts/cmerkle_memory_evaluation_report.md +++ /dev/null @@ -1,132 +0,0 @@ -# CMerkleTree memory evaluation report - -This report summarizes runs of the tooling added for the memory-efficiency plan: Elixir RSS workloads (`scripts/cmerkle_memory_bench.exs`), native copy harness (`c_src/mem_harness.cpp`), stripe-size comparison (`scripts/compare_merkle_stripe_size.sh`), Valgrind Massif (optional), and NIF introspection (`CMerkleTree.struct_sizes/0`, `memory_stats/1`, `malloc_info/0`). - -**Environment:** Linux, local dev run (single machine, not isolated from other OS noise). VmRSS is process-wide (BEAM + NIF + libc); incremental deltas between workloads isolate **relative** C++ pressure but absolute numbers include a large Erlang baseline. - -**Fixes applied during evaluation:** `cmerkle_memory_bench.exs` now strips `mix run` argv noise (`*.exs`, `--`) so `--trees` / `--pairs` work; workloads **B** and **C** retain **all** forked/independent trees (not only the last survivor); workload **B** uses **one** locked base and repeated `clone → insert` inside a scoped function so the base handle is not pinned after forks diverge. Default `--trees` is **25** to keep RSS manageable when retaining full COW copies. - ---- - -## 1. Static layout (NIF `struct_sizes/0`) - -Measured C++ sizes (bytes): - -| Field / object | Size | -|------------------|-----:| -| `Item` | 728 | -| `pair_t` | 88 | -| `pair_list_t` | 152 | -| `Tree` (shell) | 136 | -| `MERKLE_STRIPE_SIZE` | 8 (default) | - -`memory_stats/1` approximates `nodes × sizeof(Item) + pairs × sizeof(pair_t)` (does **not** include `std::vector` key storage in `pair_t`). - -For a sample with **800 pairs** and **139** trie nodes: **≈171,592** bytes from that formula alone; keys add further heap not in this line. - ---- - -## 2. Workload RSS (Elixir, `pairs=800`, `trees=25`, 2 iterations) - -| Workload | Role | VmRSS (kb) typical | Notes | -|----------|------|-------------------:|-------| -| **A** | 25 `clone`s of one locked base (shared `SharedState`) | ~114,100–114,300 | Minimal duplication of C++ tree | -| **B** | 25 forks from one base: `clone` → insert one extra leaf (full COW copy each), all retained | ~119,400–119,700 | Near-duplicate trees (801 pairs each) | -| **C** | 25 independent trees (same keys, last value differs) | ~119,600–119,700 | Same order of retained data as B | -| **D** | Churn: build/`root_hash` 25×, keep last tree only | ~119,300–119,500 | Single tree retained; RSS still reflects allocator/BEAM high watermark | - -**Delta A → B (retained forks):** about **+5,350 kb VmRSS** for **25** forks → **~214 kb per fork** at the process level (includes libc/allocator; aligns in order-of-magnitude with the **~171 kb** structural `memory_stats` increment for one extra duplicated tree of this shape). - -**Interpretation:** Sharing handles (A) is far cheaper than retaining many COW-copied trees (B/C). Any optimization that avoids **full** `Tree` duplication on single-leaf updates (structural COW, persistent trie, or subtree interning) targets the gap between **A** and **B**. - ---- - -## 3. Native harness (`mem_harness`: full `Tree` copy after building N pairs) - -Runs **without** the BEAM; VmRSS read from `/proc/self/status` (small process). - -| N (pairs) | Nodes (base=copy) | VmRSS delta (copy) kb | -|----------:|------------------:|----------------------:| -| 500 | 91 | 268 | -| 1,500 | 267 | 532 | -| 2,000 | 349 | 656 | -| 3,000 | 537 | 932 | - -Copy cost scales roughly linearly with trie size (full duplicate of nodes + pairs). - ---- - -## 4. Stripe size (`MERKLE_STRIPE_SIZE` 8 vs 32 vs 64, `N=2000`) - -Native harness, same code path: - -| Stripe | VmRSS delta (kb) after copy | -|-------:|----------------------------:| -| 8 | 656 | -| 32 | 648 | -| 64 | 652 | - -**Finding:** **Neutral (N)** — changing stripe size did not materially change measured RSS in this scenario (<2% swing). Further tuning might help fragmentation under different churn, not shown here. - ---- - -## 5. Valgrind Massif (optional, `mem_harness` N=1500) - -Under Massif, peak **heap** for the small harness stayed on the order of **~1 MB** (chart peak ~910 KB; snapshot details mix libstdc++/program allocations). Use this for **malloc attribution** of the native binary, not for BEAM RSS. - ---- - -## 6. Candidate impact matrix (plan backlog) - -Ratings follow the plan: **S** strong (>10% on target workload where applicable), **M** moderate, **L** low, **N** neutral / not evidenced. - -| # | Candidate | Rating | Evidence from this evaluation | -|---|-----------|--------|------------------------------| -| 1 | Structural COW / path copying | **S** (potential) | A vs B: **~5.35 MB** extra for **25** retained forks; native copy delta grows with **N** (§3). | -| 2 | Intern / content-addressed subtrees | **M** (potential) | Not implemented; would attack duplicate subtrees across **different** trees — high value if large shared prefixes. | -| 3 | Split `Item` leaf vs internal | **M** | `Item` = **728 B** × **node_count** dominates `memory_stats` model; internal nodes still carry leaf-shaped fields today. | -| 4 | Lazy / heap `hash_values[16]` | **M** | **512 B** of **728 B** per `Item` is hashes; savings scale with **node_count** on cold paths. | -| 5 | Smaller / variable `pair_list_t` | **L** | Fixed **152 B** per node + pointers; helps most when many sparse buckets. | -| 6 | Larger stripes / mmap arenas | **N** | §4: **8/32/64** stripes ~same RSS on harness. | -| 7 | Cross-tree allocator pools | **M** (churn) | Not isolated; D suggests allocator/BEAM retention can linger; worth profiling under destroy-heavy workloads. | -| 8 | Key interning (`bin_t`) | **M**–**L** | Not measured; **M** if keys repeat across trees, **L** if mostly unique 32-byte keys. | -| 9 | Prefix compression (`bits_t`) | **M** | Depth-dependent; not separately measured. | -| 10 | Drop redundant `key_hash` | **L** | **32 B × pair_count**; trades CPU vs RAM. | -| 11 | Struct packing / field order | **L** | Minor vs **728 B** `Item`. | -| 12 | Proof object pooling | **L** | Transient `unique_ptr` proof trees; affects peak under proof-heavy load, not steady RSS here. | -| 13 | Merge/collapse (`destroy_item` on shrink) | **L**–**N** | Code path already calls `destroy_item` when collapsing; revisit if profiling shows stranded nodes. | -| 14 | mmap bulk import arena | **M** (bulk) | Relevant for `import_map`-style loads; not covered by these micro-benches. | -| 15 | Tune `LEAF_SIZE` (arity) | **M** (workload) | Trade depth vs per-node width; needs separate sweep. | -| 16 | Erlang: avoid accidental unsharing | **S** (usage) | **A**: **25** clones of one locked tree share one `SharedState`; **B**: **25** retained forks each hold a full COW copy (~**+5.35 MB** VmRSS vs **A** in §2). | - ---- - -## 7. Recommendations (priority) - -1. **Product/API:** Prefer **shared** `SharedState` (clones, batched writes) until a mutation is required — largest win without C++ changes (aligns with **A vs B**). -2. **C++:** Prioritize **structural sharing** on `make_writeable` (path copy or interned DAG) for workloads like **B**. -3. **Quick wins:** **Lazy `hash_values`** and **leaf/internal `Item` split** — justified by §1 size breakdown; validate with `memory_stats` + RSS after each change. -4. **Deprioritize for RSS:** Stripe-only tuning (**§4**), unless Massif shows allocator hotspots in long-running churn tests. - ---- - -## 8. Commands reference - -```bash -# Elixir RSS CSV (default trees=25) -MIX_ENV=test mix run --no-start scripts/cmerkle_memory_bench.exs -- --workload all --samples 3 - -# Native copy cost -make -C c_src mem_harness -./c_src/mem_harness.bin 2000 - -# Stripe comparison -./scripts/compare_merkle_stripe_size.sh 2000 - -# Massif (optional) -./scripts/profile_cmerkle_massif.sh 1500 -``` - ---- - -*Generated from automated runs on the evaluation host; re-run before release to capture your environment.* diff --git a/scripts/cmerkle_parallel_stress.exs b/scripts/cmerkle_parallel_stress.exs index e7761b8..eb0095f 100644 --- a/scripts/cmerkle_parallel_stress.exs +++ b/scripts/cmerkle_parallel_stress.exs @@ -1,23 +1,10 @@ -# CMerkleTree — parallel / concurrency stress (targets live-only failures). +# CAccountMap / Chain.State — parallel concurrency stress. # -# Erlang schedules NIF calls on many schedulers; the NIF uses: -# - One ErlNifMutex per SharedState (every insert/get/root_hash/difference arm takes Lock(mt)) -# - std::mutex on GlobalStripePool (preallocator.hpp) -# - A global mutex on LockedStates (enter_lock / leave_lock / resource destructor) +# Hammers account-map NIFs from many Tasks: map mutexes, storage COW, +# difference_full dual-lock order, compact/uncompact, and State.lock/clone. # -# These workloads intentionally hammer the same resource from many Tasks to flush: -# mutex omissions, refcount/has_clone races, deadlock in difference lock ordering, -# and pool stripe contention. -# -# Confirmed bug class (parallel clone + insert): COW splits a SharedState so two Trees -# share one ItemPool and one PreAllocator while each Erlang resource still uses its own -# ErlNifMutex — concurrent refcnt / pair slab mutation raced. Fixed in C++ with a -# std::recursive_mutex on ItemPool (see item_pool.cpp) and on PreAllocator -# (preallocator.hpp). Run this script after changes to native trie code. -# -# Thread Sanitizer (not ASan): to hunt data races in C++, rebuild the NIF with -# -fsanitize=thread -# and run this script the same way as ASan (swap priv/merkletree_nif.so). TSan + BEAM can be noisy. +# Thread Sanitizer (not ASan): rebuild the NIF with -fsanitize=thread and run +# this script the same way as ASan (swap priv/merkletree_nif.so). TSan + BEAM can be noisy. # # Run: # mix run --no-start scripts/cmerkle_parallel_stress.exs -- --waves 3 --tasks 48 @@ -27,8 +14,8 @@ # --waves N repeat full suite this many times (default: 2) # --ops N operations per worker in same-tree scenarios (default: 40) # --seed N RNG seed -# --accounts N account count for P12/P14 (default: 200) -# --scenario PX run only scenario PX (e.g. P10); repeat for multiple +# --accounts N account count for map scenarios (default: 200) +# --scenario PX run only scenario PX (e.g. P12); repeat for multiple # # Exit 0 if all waves complete. Native crashes are not caught in Elixir. @@ -39,39 +26,18 @@ defmodule CMerkleParallelStress do # --- Theory map (for triage notes) --- # - # P1 Same CMerkleTree ref, concurrent insert/get/root_hash/root_hashes/size — - # relies on per-SharedState mutex; would expose missing Lock() on any NIF path. - # - # P2 Concurrent difference(ta, tb) identical pair — lock order is by SharedState - # address (nif.cpp); should not deadlock; stresses dual-lock hold time. - # - # P3 Concurrent difference(ta, tb) vs difference(tb, ta) argument order — - # same ordering rule; overlapping waves stress scheduler interleaving. - # - # P4 Clone storm: many workers clone same base then mutate (has_clone / make_writeable). - # - # P5 Interleaved: half workers mutate T, half run difference(T, U) — contention on first tree. - # - # P6 locked_states: concurrent lock/1 on same locked root_hash dedup path (enter_lock). - # - # P7 Many disposable trees in parallel — GlobalStripePool take/put under contention. - # - # P8 get_proofs + to_list concurrently on same tree — read-heavy + each() traversal. - # - # P9 Concurrent lock/1 (enter_lock dedup) + difference/2 on overlapping trees — - # reproduces production deadlock: locked_states_mutex held while waiting on a - # second tree mutex vs difference_raw dual-lock ordering. - # - # P10 leave_lock storm + lock + difference + three-tree overlap (D-A4, D-B2, D-B5) - # P11 mass disposable trees + GC while difference/lock (D-A2, D-A5) - # P12 account_map uncompact_state + storage difference workers (D-C2, D-D3) - # P13 account_map clone/put/delete + storage difference (D-C1, D-C4) + # P12 account_map uncompact_state + difference_full workers (D-C2, D-D3) + # P13 account_map clone/put/delete + storage APIs (D-C1, D-C4) # P14 Chain.State difference + lock + uncompact + lock→clone→write composite (D-D1–D-D4) - # P15 dirty-scheduler saturation: lock, difference, uncompact, clone, get_proofs + # P15 dirty-scheduler saturation: lock, difference_full, uncompact, storage # P16 concurrent State.lock on compact states (block-sync memory path) + # P17 prepare_state pipeline (native diff + lock + to_list) + # P18 writer sim / P18L clone eth_call + # P19 large map small delta + # P20 dirty saturation + difference_full # - @all_scenarios ~w(P1 P2 P3 P4 P5 P6 P7 P8 P9 P10 P11 P12 P13 P14 P15 P16 P17 P18 P19 P20) + @all_scenarios ~w(P12 P13 P14 P15 P16 P17 P18 P18L P19 P20) def normalize_argv(argv) do case argv do @@ -117,7 +83,7 @@ defmodule CMerkleParallelStress do :rand.seed(:exsss, {seed, seed, seed}) IO.puts(:stderr, """ - === CMerkleTree parallel stress === + === CAccountMap parallel stress === otp=#{System.otp_release()} elixir=#{System.version()} schedulers=#{System.schedulers_online()} seed=#{seed} tasks=#{tasks} waves=#{waves} ops_per_task=#{ops} accounts=#{accounts} scenarios=#{Enum.join(scenarios, ",")} @@ -143,39 +109,6 @@ defmodule CMerkleParallelStress do defp run_scenario(name, ctx) do case name do - "P1" -> - run_named("P1_same_tree_rw", fn -> p1_same_tree_rw(ctx) end) - - "P2" -> - run_named("P2_concurrent_difference_ab", fn -> p2_concurrent_difference(ctx, :ab) end) - - "P3" -> - run_named("P3_concurrent_difference_ba", fn -> p2_concurrent_difference(ctx, :ba) end) - - "P4" -> - run_named("P4_clone_write_storm", fn -> p4_clone_storm(ctx) end) - - "P5" -> - run_named("P5_interleave_mutate_and_diff", fn -> p5_interleave(ctx) end) - - "P6" -> - run_named("P6_concurrent_lock", fn -> p6_concurrent_lock(ctx) end) - - "P7" -> - run_named("P7_many_shortlived_trees", fn -> p7_shortlived_parallel(ctx) end) - - "P8" -> - run_named("P8_proofs_and_to_list", fn -> p8_proofs_to_list(ctx) end) - - "P9" -> - run_named("P9_lock_and_difference", fn -> p9_lock_and_difference(ctx) end) - - "P10" -> - run_named("P10_leave_lock_storm", fn -> p10_leave_lock_storm(ctx) end) - - "P11" -> - run_named("P11_gc_disposable_trees", fn -> p11_gc_disposable_trees(ctx) end) - "P12" -> run_named("P12_uncompact_and_diff", fn -> p12_uncompact_and_diff(ctx) end) @@ -197,6 +130,9 @@ defmodule CMerkleParallelStress do "P18" -> run_named("P18_writer_sim", fn -> p18_writer_sim(ctx) end) + "P18L" -> + run_named("P18_clone_eth_call", fn -> p18_clone_eth_call(ctx) end) + "P19" -> run_named("P19_large_map_small_delta", fn -> p19_large_map_small_delta(ctx) end) @@ -222,425 +158,47 @@ defmodule CMerkleParallelStress do end end - defp p1_same_tree_rw(%{tasks: tasks, ops: ops}) do - seed_items = - Enum.map(1..50, fn i -> - {String.pad_leading("p1#{i}", 32), CMerkleTree.hash("seed#{i}")} - end) - - tree = CMerkleTree.new() |> CMerkleTree.insert_items(seed_items) - - 1..tasks - |> Task.async_stream( - fn w -> - Enum.each(1..ops, fn j -> - k = rem(w * 997 + j * 31, 400) - key = String.pad_leading("pk#{k}", 32) - tree = CMerkleTree.insert(tree, key, CMerkleTree.hash("v#{w}#{j}")) - _ = CMerkleTree.get(tree, key) - _ = CMerkleTree.root_hash(tree) - _ = CMerkleTree.size(tree) - _ = CMerkleTree.root_hashes(tree) - end) - - :ok - end, - max_concurrency: tasks, - timeout: :infinity, - ordered: false - ) - |> Stream.run() - end - - defp p2_concurrent_difference(%{tasks: tasks}, order) do - a = - Enum.map(1..120, fn i -> - {String.pad_leading("da#{i}", 32), CMerkleTree.hash("da#{i}")} - end) - - b = - Enum.map(1..120, fn i -> - {String.pad_leading("db#{i}", 32), CMerkleTree.hash("db#{i}")} - end) - - ta = CMerkleTree.new() |> CMerkleTree.insert_items(a) - tb = CMerkleTree.new() |> CMerkleTree.insert_items(b) - - 1..tasks - |> Task.async_stream( - fn _ -> - _ = - case order do - :ab -> CMerkleTree.difference(ta, tb) - :ba -> CMerkleTree.difference(tb, ta) - end - - :ok - end, - max_concurrency: tasks, - timeout: :infinity, - ordered: false - ) - |> Stream.run() - end - - defp p4_clone_storm(%{tasks: tasks, ops: ops}) do - base = - CMerkleTree.new() - |> CMerkleTree.insert_items( - Enum.map(1..80, fn i -> {String.pad_leading("b#{i}", 32), CMerkleTree.hash("b#{i}")} end) - ) - - 1..tasks - |> Task.async_stream( - fn w -> - Enum.reduce(1..ops, CMerkleTree.clone(base), fn j, acc -> - CMerkleTree.insert( - acc, - String.pad_leading("c#{w}_#{j}", 32), - CMerkleTree.hash("x#{w}#{j}") - ) - end) - - :ok - end, - max_concurrency: tasks, - timeout: :infinity, - ordered: false - ) - |> Stream.run() - end - - defp p5_interleave(%{tasks: tasks, ops: ops}) do - ta = - CMerkleTree.new() - |> CMerkleTree.insert_items( - Enum.map(1..100, fn i -> - {String.pad_leading("ia#{i}", 32), CMerkleTree.hash("ia#{i}")} - end) - ) - - tb = - CMerkleTree.new() - |> CMerkleTree.insert_items( - Enum.map(1..100, fn i -> - {String.pad_leading("ib#{i}", 32), CMerkleTree.hash("ib#{i}")} - end) - ) - - mutators = div(tasks, 2) |> max(1) - differ = tasks - mutators - - step = fn tag -> - Task.async_stream( - 1..mutators, - fn w -> - Enum.each(1..ops, fn j -> - k = String.pad_leading("m#{tag}#{w}_#{j}", 32) - _ = CMerkleTree.insert(ta, k, CMerkleTree.hash("mut#{w}#{j}")) - end) - - :ok - end, - max_concurrency: mutators, - timeout: :infinity, - ordered: false - ) - |> Stream.run() - end - - diff_f = fn -> - Task.async_stream( - 1..differ, - fn _ -> - _ = CMerkleTree.difference(ta, tb) - :ok - end, - max_concurrency: differ, - timeout: :infinity, - ordered: false - ) - |> Stream.run() - end - - # Interleave: waves of concurrent diff with concurrent mutators - step.(:a) - diff_f.() - step.(:b) - diff_f.() - end - - defp p6_concurrent_lock(%{tasks: tasks}) do - data = - Enum.map(1..60, fn i -> {String.pad_leading("L#{i}", 32), CMerkleTree.hash("L#{i}")} end) - - base = CMerkleTree.new() |> CMerkleTree.insert_items(data) - - 1..tasks - |> Task.async_stream( - fn _ -> - _ = - base - |> CMerkleTree.clone() - |> CMerkleTree.lock() - - :ok - end, - max_concurrency: tasks, - timeout: :infinity, - ordered: false - ) - |> Stream.run() - end - - defp p9_lock_and_difference(%{tasks: tasks, ops: ops}) do - shared = - CMerkleTree.new() - |> CMerkleTree.insert_items( - Enum.map(1..100, fn i -> {String.pad_leading("s#{i}", 32), CMerkleTree.hash("s#{i}")} end) - ) - - other = - CMerkleTree.new() - |> CMerkleTree.insert_items( - Enum.map(1..100, fn i -> {String.pad_leading("o#{i}", 32), CMerkleTree.hash("o#{i}")} end) - ) - - lockers = div(tasks, 3) |> max(1) - differ = tasks - lockers - - Task.async_stream( - 1..lockers, - fn _ -> - _ = - shared - |> CMerkleTree.clone() - |> CMerkleTree.lock() - - :ok - end, - max_concurrency: lockers, - timeout: :infinity, - ordered: false - ) - |> Stream.run() - - Task.async_stream( - 1..differ, - fn w -> - Enum.each(1..ops, fn j -> - k = String.pad_leading("p9#{w}_#{j}", 32) - _ = CMerkleTree.insert(shared, k, CMerkleTree.hash("p9#{w}#{j}")) - _ = CMerkleTree.difference(shared, other) - _ = CMerkleTree.difference(other, shared) - end) - - :ok - end, - max_concurrency: differ, - timeout: :infinity, - ordered: false - ) - |> Stream.run() - end - - defp p7_shortlived_parallel(%{tasks: tasks}) do - 1..tasks - |> Task.async_stream( - fn w -> - items = - Enum.map(1..25, fn j -> - {String.pad_leading("s#{w}_#{j}", 32), CMerkleTree.hash("s#{w}_#{j}")} - end) - - t = CMerkleTree.new() |> CMerkleTree.insert_items(items) - _ = CMerkleTree.root_hash(t) - _ = CMerkleTree.bucket_count(t) - :ok - end, - max_concurrency: tasks, - timeout: :infinity, - ordered: false - ) - |> Stream.run() - end - - defp p8_proofs_to_list(%{tasks: tasks, ops: ops}) do - keys = Enum.map(1..80, fn i -> String.pad_leading("h#{i}", 32) end) - - tree = - Enum.reduce(keys, CMerkleTree.new(), fn k, acc -> - CMerkleTree.insert(acc, k, CMerkleTree.hash(k)) - end) - - 1..tasks - |> Task.async_stream( - fn w -> - Enum.each(1..ops, fn j -> - k = Enum.at(keys, rem(w + j, length(keys))) - _ = CMerkleTree.get_proofs(tree, k) - _ = CMerkleTree.to_list(tree) - _ = CMerkleTree.root_hash(tree) - end) - - :ok - end, - max_concurrency: tasks, - timeout: :infinity, - ordered: false - ) - |> Stream.run() - end - defp addr(i), do: <> defp slot(i), do: <> + defp storage_list(i, mult \\ 5) do + [{slot(i), <>}] + end + defp build_compact_accounts(n) do - for i <- 1..n, into: %{} do - tree = - CMerkleTree.insert(CMerkleTree.new(), slot(i), <>) + Enum.reduce(1..n, State.new(), fn i, st -> + acc = %Account{ + nonce: i, + balance: i * 1_000, + storage_root: storage_list(i), + code: <>, + map_backed: false + } - acc = %Account{nonce: i, balance: i * 1_000, storage_root: tree, code: <>} - {addr(i), Account.compact(acc)} - end + State.set_account(st, addr(i), acc) + end) + |> State.compact() + |> Map.fetch!(:accounts) end defp build_live_state(n) do Enum.reduce(1..n, State.new(), fn i, st -> - tree = - CMerkleTree.insert_items(CMerkleTree.new(), [ + acc = %Account{ + nonce: i, + balance: i * 1_000, + storage_root: [ {slot(i), <>}, {slot(i + 10_000), <>} - ]) + ], + code: <>, + map_backed: false + } - acc = %Account{nonce: i, balance: i * 1_000, storage_root: tree, code: <>} State.set_account(st, addr(i), acc) end) end - defp p10_leave_lock_storm(%{tasks: tasks, ops: ops}) do - shared = - CMerkleTree.new() - |> CMerkleTree.insert_items( - Enum.map(1..100, fn i -> - {String.pad_leading("p10s#{i}", 32), CMerkleTree.hash("p10s#{i}")} - end) - ) - - tb = - CMerkleTree.new() - |> CMerkleTree.insert_items( - Enum.map(1..100, fn i -> - {String.pad_leading("p10b#{i}", 32), CMerkleTree.hash("p10b#{i}")} - end) - ) - - tc = - CMerkleTree.new() - |> CMerkleTree.insert_items( - Enum.map(1..80, fn i -> - {String.pad_leading("p10c#{i}", 32), CMerkleTree.hash("p10c#{i}")} - end) - ) - - third = div(tasks, 4) |> max(1) - rest = tasks - third - - 1..third - |> Task.async_stream( - fn w -> - items = - Enum.map(1..20, fn j -> - {String.pad_leading("d#{w}_#{j}", 32), CMerkleTree.hash("d#{w}_#{j}")} - end) - - _ = CMerkleTree.new() |> CMerkleTree.insert_items(items) - :ok - end, - max_concurrency: third, - timeout: :infinity, - ordered: false - ) - |> Stream.run() - - 1..rest - |> Task.async_stream( - fn w -> - Enum.each(1..ops, fn j -> - if rem(w + j, 4) == 0 do - _ = - shared - |> CMerkleTree.clone() - |> CMerkleTree.lock() - else - _ = CMerkleTree.difference(shared, tb) - _ = CMerkleTree.difference(shared, tc) - _ = CMerkleTree.difference(tb, tc) - end - end) - - :ok - end, - max_concurrency: rest, - timeout: :infinity, - ordered: false - ) - |> Stream.run() - - :erlang.garbage_collect() - end - - defp p11_gc_disposable_trees(%{tasks: tasks, ops: ops}) do - anchor = - CMerkleTree.new() - |> CMerkleTree.insert_items( - Enum.map(1..60, fn i -> - {String.pad_leading("gca#{i}", 32), CMerkleTree.hash("gca#{i}")} - end) - ) - - other = - CMerkleTree.new() - |> CMerkleTree.insert_items( - Enum.map(1..60, fn i -> - {String.pad_leading("gco#{i}", 32), CMerkleTree.hash("gco#{i}")} - end) - ) - - 1..tasks - |> Task.async_stream( - fn w -> - Enum.each(1..ops, fn j -> - disposable = - CMerkleTree.new() - |> CMerkleTree.insert_items( - Enum.map(1..15, fn k -> - {String.pad_leading("t#{w}_#{j}_#{k}", 32), CMerkleTree.hash("t#{w}#{j}#{k}")} - end) - ) - - _ = CMerkleTree.root_hash(disposable) - _ = CMerkleTree.difference(anchor, other) - - if rem(j, 5) == 0 do - _ = - anchor - |> CMerkleTree.clone() - |> CMerkleTree.lock() - end - end) - - if rem(w, 4) == 0, do: :erlang.garbage_collect() - :ok - end, - max_concurrency: tasks, - timeout: :infinity, - ordered: false - ) - |> Stream.run() - end - defp p12_uncompact_and_diff(%{tasks: tasks, accounts: n}) do compact = build_compact_accounts(n) workers = div(tasks, 2) |> max(1) @@ -650,7 +208,7 @@ defmodule CMerkleParallelStress do Task.async_stream( 1..uncompact_workers, fn _ -> - {accounts, _store, _hash} = CAccountMap.uncompact_state(compact) + {accounts, _hash} = CAccountMap.uncompact_state(compact) CAccountMap.size(accounts) end, max_concurrency: uncompact_workers, @@ -665,18 +223,25 @@ defmodule CMerkleParallelStress do 1..workers, fn i -> a = - CMerkleTree.insert_items(CMerkleTree.new(), [ - {slot(i), <>}, - {slot(i + 5000), <>} - ]) + Enum.reduce(1..(8 + rem(i, 5)), CAccountMap.new(), fn j, acc -> + CAccountMap.put( + acc, + addr(j), + j, + j * 100, + [{slot(j), <>}], + <> + ) + end) b = - CMerkleTree.insert_items(CMerkleTree.new(), [ - {slot(i), <>}, - {slot(i + 9000), <>} - ]) + a + |> CAccountMap.clone() + |> CAccountMap.storage_put_map(%{ + addr(1) => %{slot(i + 9000) => <>} + }) - CMerkleTree.difference(a, b) + CAccountMap.difference_full(a, b) end, max_concurrency: workers, timeout: :infinity, @@ -693,7 +258,7 @@ defmodule CMerkleParallelStress do defp p13_account_map_contention(%{tasks: tasks, ops: ops}) do n = min(tasks, 80) compact = build_compact_accounts(n) - {base, _store, _hash} = CAccountMap.uncompact_state(compact) + {base, _hash} = CAccountMap.uncompact_state(compact) 1..tasks |> Task.async_stream( @@ -702,24 +267,14 @@ defmodule CMerkleParallelStress do case rem(w + j, 5) do 0 -> fork = CAccountMap.clone(base) - - new_storage = - CMerkleTree.insert(CMerkleTree.new(), slot(w + j), <>) - + new_storage = [{slot(w + j), <>}] _ = CAccountMap.put(fork, addr(rem(w, n) + 1), j, j * 100, new_storage, <>) 1 -> id = addr(rem(w, n) + 1) - {_, _, storage, _} = CAccountMap.get(base, id) - - alt = - CMerkleTree.insert( - CMerkleTree.clone(storage), - slot(w + j + 50_000), - <> - ) - - _ = CMerkleTree.difference(storage, alt) + _ = CAccountMap.storage_root_hash(base, id) + _ = CAccountMap.storage_to_list(base, id) + _ = CAccountMap.storage_get(base, id, slot(w + j + 50_000)) 2 -> fork = CAccountMap.clone(base) @@ -748,13 +303,10 @@ defmodule CMerkleParallelStress do |> then(fn st -> Enum.reduce(1..min(ops, n), st, fn i, acc -> id = addr(rem(i, n) + 1) - acc0 = State.account(acc, id) - tree = Account.tree(acc0) - tree = - CMerkleTree.insert(tree, slot(i + 100_000), <>) - - State.set_account(acc, id, Account.put_tree(acc0, tree)) + State.storage_put_map(acc, %{ + id => %{slot(i + 100_000) => <>} + }) end) end) @@ -792,13 +344,12 @@ defmodule CMerkleParallelStress do locked = State.lock(prev) fork = State.clone(locked) id = addr(rem(w, n) + 1) - acc0 = State.account(fork, id) - tree = Account.tree(acc0) - tree = - CMerkleTree.insert(tree, slot(w + 300_000), <>) + fork = + State.storage_put_map(fork, %{ + id => %{slot(w + 300_000) => <>} + }) - _ = State.set_account(fork, id, Account.put_tree(acc0, tree)) _ = State.hash(fork) :ok end) @@ -820,52 +371,32 @@ defmodule CMerkleParallelStress do State.set_account(st, addr(1), %Account{ nonce: 999, balance: 999, - storage_root: CMerkleTree.new(), - code: <<>> + storage_root: [], + code: <<>>, + map_backed: false }) end) - keys = - Enum.map(1..60, fn i -> String.pad_leading("ds#{i}", 32) end) - - proof_tree = - Enum.reduce(keys, CMerkleTree.new(), fn k, acc -> - CMerkleTree.insert(acc, k, CMerkleTree.hash(k)) - end) - 1..tasks |> Task.async_stream( fn w -> - case rem(w, 6) do + case rem(w, 5) do 0 -> - _ = - proof_tree - |> CMerkleTree.clone() - |> CMerkleTree.lock() + _ = CAccountMap.lock(live.accounts) 1 -> - _ = - CMerkleTree.difference( - Account.tree(State.account(live, addr(1))), - Account.tree(State.account(other, addr(1))) - ) + _ = CAccountMap.storage_root_hash(live.accounts, addr(1)) + _ = CAccountMap.storage_root_hash(other.accounts, addr(1)) + _ = CAccountMap.storage_to_list(live.accounts, addr(1)) 2 -> _ = CAccountMap.uncompact_state(compact) 3 -> - k = Enum.at(keys, rem(w, length(keys))) - _ = CMerkleTree.get_proofs(proof_tree, k) - _ = CMerkleTree.to_list(proof_tree) - - 4 -> - _ = - proof_tree - |> CMerkleTree.clone() - |> CMerkleTree.insert(String.pad_leading("x#{w}", 32), CMerkleTree.hash("x#{w}")) + _ = CAccountMap.difference_full(live.accounts, other.accounts) _ -> - _ = CAccountMap.lock(live.accounts, Map.get(live, :store)) + _ = CAccountMap.to_list(live.accounts) end :ok @@ -886,13 +417,10 @@ defmodule CMerkleParallelStress do |> then(fn st -> Enum.reduce(1..min(ops, n), st, fn i, acc -> id = addr(rem(i, n) + 1) - acc0 = State.account(acc, id) - tree = Account.tree(acc0) - - tree = - CMerkleTree.insert(tree, slot(i + 300_000), <>) - State.set_account(acc, id, Account.put_tree(acc0, tree)) + State.storage_put_map(acc, %{ + id => %{slot(i + 300_000) => <>} + }) end) end) @@ -932,13 +460,10 @@ defmodule CMerkleParallelStress do |> then(fn st -> Enum.reduce(1..min(ops, n), st, fn i, acc -> id = addr(rem(i, n) + 1) - acc0 = State.account(acc, id) - tree = Account.tree(acc0) - tree = - CMerkleTree.insert(tree, slot(i + 100_000), <>) - - State.set_account(acc, id, Account.put_tree(acc0, tree)) + State.storage_put_map(acc, %{ + id => %{slot(i + 100_000) => <>} + }) end) end) @@ -958,7 +483,7 @@ defmodule CMerkleParallelStress do run.(:diff, differ, fn _ -> State.difference(prev, block) end) run.(:lock, lockers, fn _ -> State.lock(State.clone(block)) end) run.(:legacy, legacy, fn _ -> CAccountMap.to_list(block.accounts) end) - run.(:native, native, fn _ -> CAccountMap.list_difference(prev.accounts, block.accounts) end) + run.(:native, native, fn _ -> CAccountMap.difference_full(prev.accounts, block.accounts) end) end defp p18_writer_sim(%{tasks: tasks, accounts: n}) do @@ -967,15 +492,9 @@ defmodule CMerkleParallelStress do block = prev |> State.clone() - |> then(fn st -> - acc = State.account(st, addr(1)) - - tree = - Account.tree(acc) - |> CMerkleTree.insert(slot(400_000), <<400_000::unsigned-size(256)>>) - - State.set_account(st, addr(1), Account.put_tree(acc, tree)) - end) + |> State.storage_put_map(%{ + addr(1) => %{slot(400_000) => <<400_000::unsigned-size(256)>>} + }) writer = Task.async(fn -> @@ -990,12 +509,19 @@ defmodule CMerkleParallelStress do map = block.accounts case rem(w, 4) do - 0 -> _ = CAccountMap.lock(map, nil) - 1 -> _ = CAccountMap.to_list(map) - 2 -> {_, _, sa, _} = CAccountMap.get(map, addr(1)) - {_, _, sb, _} = CAccountMap.get(map, addr(rem(w, n) + 1)) - _ = CMerkleTree.difference(sa, sb) - _ -> _ = CAccountMap.delete(CAccountMap.clone(map), addr(rem(w, n) + 1)) + 0 -> + _ = CAccountMap.lock(map) + + 1 -> + _ = CAccountMap.to_list(map) + + 2 -> + _ = CAccountMap.storage_root_hash(map, addr(1)) + _ = CAccountMap.storage_root_hash(map, addr(rem(w, n) + 1)) + _ = CAccountMap.storage_to_list(map, addr(1)) + + _ -> + _ = CAccountMap.delete(CAccountMap.clone(map), addr(rem(w, n) + 1)) end :ok @@ -1009,6 +535,29 @@ defmodule CMerkleParallelStress do Task.await(writer, :infinity) end + defp p18_clone_eth_call(%{tasks: tasks, accounts: n}) do + base = build_live_state(max(n, 50)) + + 1..tasks + |> Task.async_stream( + fn w -> + id = addr(rem(w, max(n, 50)) + 1) + + fork = + base + |> State.clone() + |> State.storage_put_map([{id, [{slot(900_000 + w), <>}]}]) + + _ = State.hash(fork) + :ok + end, + max_concurrency: tasks, + timeout: :infinity, + ordered: false + ) + |> Stream.run() + end + defp p19_large_map_small_delta(%{tasks: tasks}) do n = 400 base = build_live_state(n) @@ -1019,9 +568,10 @@ defmodule CMerkleParallelStress do |> then(fn st -> Enum.reduce(1..5, st, fn i, acc -> id = addr(i) - acc0 = State.account(acc, id) - tree = Account.tree(acc0) |> CMerkleTree.insert(slot(i + 500_000), <>) - State.set_account(acc, id, Account.put_tree(acc0, tree)) + + State.storage_put_map(acc, %{ + id => %{slot(i + 500_000) => <>} + }) end) end) @@ -1047,8 +597,9 @@ defmodule CMerkleParallelStress do State.set_account(st, addr(1), %Account{ nonce: 999, balance: 999, - storage_root: CMerkleTree.new(), - code: <<>> + storage_root: [], + code: <<>>, + map_backed: false }) end) @@ -1058,11 +609,22 @@ defmodule CMerkleParallelStress do |> Task.async_stream( fn w -> case rem(w, 5) do - 0 -> _ = CAccountMap.list_difference(map, other.accounts) - 1 -> _ = CAccountMap.lock(map, nil) - 2 -> _ = State.difference(live, other) - 3 -> _ = CMerkleTree.difference(Account.tree(State.account(live, addr(1))), Account.tree(State.account(other, addr(1)))) - _ -> _ = CAccountMap.to_list(map) + 0 -> + _ = CAccountMap.difference_full(map, other.accounts) + + 1 -> + _ = CAccountMap.lock(map) + + 2 -> + _ = State.difference(live, other) + + 3 -> + _ = CAccountMap.storage_root_hash(live.accounts, addr(1)) + _ = CAccountMap.storage_root_hash(other.accounts, addr(1)) + _ = CAccountMap.storage_to_list(live.accounts, addr(1)) + + _ -> + _ = CAccountMap.to_list(map) end :ok diff --git a/scripts/merkle_asan_and_recovery.md b/scripts/merkle_asan_and_recovery.md index c5234ed..14394cc 100644 --- a/scripts/merkle_asan_and_recovery.md +++ b/scripts/merkle_asan_and_recovery.md @@ -36,7 +36,7 @@ export LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libasan.so.8 ## Recovering a node after bad deltas / malloc corruption -Symptoms: `apply_difference` raises on `^a = CMerkleTree.get(tree, key)` (delta old value does not match parent trie), or native heap errors in the NIF. +Symptoms: `apply_difference` raises when a delta old value does not match the parent storage trie, or native heap errors in the NIF. 1. **Stop** the node. 2. **Find the last good block** (highest block number that still loads `Model.ChainSql.state/1` successfully, or the parent hash of the first failing block). Use logs, a backup, or binary search over block numbers. diff --git a/scripts/merkle_bench.exs b/scripts/merkle_bench.exs deleted file mode 100644 index e7195af..0000000 --- a/scripts/merkle_bench.exs +++ /dev/null @@ -1,27 +0,0 @@ -# size = 10_000 -# ref = Base16.decode("0x96c2b5d03aa8e52230e74d9a08359c38c7608e5d9aba18773f3c90baf3806ccb") - -size = 100_000 -ref = Base16.decode("0x3b99d56aa16278d50e85a8ea0939e62180c4f0305b773775f1844c3303a41897") - -test_data = - Enum.map(1..size, fn idx -> - hash = Diode.hash("!#{idx}!") - {hash, hash} - end) - -for i <- 1..3 do - time_ms = :timer.tc(fn -> - for _ <- 1..100 do - tree = HeapMerkleTree.insert_items(HeapMerkleTree.new(), test_data) - - if HeapMerkleTree.root_hash(tree) != ref do - raise("Error #{Base16.encode(HeapMerkleTree.root_hash(tree))} != #{Base16.encode(ref)}") - end - end - end) - |> elem(0) - |> div(1000) - - IO.puts("Run #{i} #{time_ms}ms") -end diff --git a/scripts/profile_uncompact_nif.exs b/scripts/profile_uncompact_nif.exs index 45cac41..254dd09 100644 --- a/scripts/profile_uncompact_nif.exs +++ b/scripts/profile_uncompact_nif.exs @@ -1,8 +1,8 @@ # NIF-only uncompact for Valgrind profiling (same 14k fixture as chain_state_uncompact_test). -# No full app.start — only loads the NIF via CMerkleTree.new/0. +# No full app.start — only loads the NIF via CAccountMap.new/0. alias Chain.State -_ = CMerkleTree.new() +_ = CAccountMap.new() account_count = String.to_integer(System.get_env("UNCOMPACT_PROFILE_COUNT", "14609")) fixture = Path.join(System.tmp_dir!(), "diode_uncompact_perf_14609.bin") diff --git a/scripts/state_delta_apply_bench.exs b/scripts/state_delta_apply_bench.exs new file mode 100644 index 0000000..684d208 --- /dev/null +++ b/scripts/state_delta_apply_bench.exs @@ -0,0 +1,340 @@ +# Benchmark ChainSql state(delta:…) — the path behind prod +# "state(delta:0x...) took Nms" +# in Model.ChainSql.state/1 for non-jump blocks. +# +# Non-jump blocks store {jump_hash, difference(jump_state, block_state)}. do_state: +# jump = EtsLru.fetch(JumpState, …) # often cached +# jump |> State.clone() |> State.apply_difference(delta) |> State.normalize() +# Warns when wall time > 2s. +# +# This bench times the clone + apply_difference + normalize segment (cached jump). +# +# Run from repo root (no full app start): +# mix run --no-start scripts/state_delta_apply_bench.exs +# mix run --no-start scripts/state_delta_apply_bench.exs -- --accounts 14000 --changed 20 +# mix run --no-start scripts/state_delta_apply_bench.exs -- --scenario all --warmup 1 --iters 3 +# mix run --no-start scripts/state_delta_apply_bench.exs -- --eprof +# +# Scenarios: +# small_delta — jump peak + few changed accounts (common block) +# medium_delta — hundreds of changed accounts +# large_delta — thousands of changed accounts (near ChainSql >10k bug threshold) +# storage_heavy — few accounts, fat storage key deltas +# cold_jump_uncompact — uncompact jump then apply (JumpState cache miss proxy) +# all — run every scenario + +defmodule StateDeltaApplyBench do + @moduledoc false + + alias Chain.State + + def main(argv \\ System.argv()) do + argv = normalize_argv(argv) + + {opts, _, _} = + OptionParser.parse(argv, + strict: [ + accounts: :integer, + changed: :integer, + slots: :integer, + fat_slots: :integer, + warmup: :integer, + iters: :integer, + scenario: :string, + eprof: :boolean, + csv: :boolean + ] + ) + + accounts = Keyword.get(opts, :accounts, 14_000) + changed = Keyword.get(opts, :changed, 20) + slots = Keyword.get(opts, :slots, 8) + fat_slots = Keyword.get(opts, :fat_slots, 2_000) + warmup = Keyword.get(opts, :warmup, 1) + iters = Keyword.get(opts, :iters, 3) + scenario = Keyword.get(opts, :scenario, "all") + eprof? = Keyword.get(opts, :eprof, false) + csv? = Keyword.get(opts, :csv, false) + + _ = CAccountMap.new() + + IO.puts(:stderr, """ + === State delta-apply bench (ChainSql state(delta:…)) === + accounts=#{accounts} changed=#{changed} slots/account=#{slots} fat_slots=#{fat_slots} + warmup=#{warmup} iters=#{iters} scenario=#{scenario} eprof=#{eprof?} + """) + + scenarios = + case scenario do + "all" -> + [ + {:small_delta, fn -> build_delta_case(accounts, changed, slots, :live) end}, + {:medium_delta, + fn -> build_delta_case(accounts, min(changed * 10, accounts), slots, :live) end}, + {:large_delta, + fn -> build_delta_case(accounts, min(2_000, accounts), slots, :live) end}, + {:storage_heavy, + fn -> build_delta_case(min(accounts, 40), min(changed, 20), fat_slots, :live) end}, + {:cold_jump_uncompact, + fn -> build_delta_case(accounts, changed, slots, :from_compact) end} + ] + + name -> + builder = + case name do + "small_delta" -> + fn -> build_delta_case(accounts, changed, slots, :live) end + + "medium_delta" -> + fn -> build_delta_case(accounts, min(changed * 10, accounts), slots, :live) end + + "large_delta" -> + fn -> build_delta_case(accounts, min(2_000, accounts), slots, :live) end + + "storage_heavy" -> + fn -> build_delta_case(min(accounts, 40), min(changed, 20), fat_slots, :live) end + + "cold_jump_uncompact" -> + fn -> build_delta_case(accounts, changed, slots, :from_compact) end + + other -> + raise "unknown --scenario #{inspect(other)}" + end + + [{String.to_atom(name), builder}] + end + + if csv? do + IO.puts( + "scenario,iter,map_size,delta_accounts,storage_keys,clone_ms,apply_ms,normalize_ms,uncompact_ms,total_ms,rss_kb" + ) + end + + Enum.each(scenarios, fn {name, builder} -> + IO.puts(:stderr, "--- building #{name} ---") + {build_us, fixture} = :timer.tc(builder) + map_size = CAccountMap.size(fixture.base.accounts) + + IO.puts( + :stderr, + "built #{name} in #{div(build_us, 1000)}ms map_size=#{map_size} " <> + "delta_accounts=#{length(fixture.delta)} rss=#{div(read_rss_kb(), 1024)}MB" + ) + + for _ <- 1..warmup do + _ = timed_delta_apply(fixture) + end + + if eprof? do + profile_eprof(name, fixture) + end + + samples = + for i <- 1..iters do + sample = timed_delta_apply(fixture) + print_sample(name, i, map_size, sample, csv?) + sample + end + + summarize(name, samples) + end) + + IO.puts(:stderr, """ + + Interpretation hints: + - Matches Model.ChainSql.do_state for {prev_hash, delta} rows (JumpState hit). + - Prod warning fires at >2000ms total for clone+apply+normalize. + - cold_jump_uncompact adds uncompact_ms (JumpState miss proxy). + - apply_ms dominates when delta_accounts or storage_keys is large. + - large_delta approaches the ChainSql length(delta)>10_000 recompress trigger. + """) + end + + defp timed_delta_apply(%{base: base, delta: delta, compact_jump: nil}) do + do_timed_apply(base, delta, 0) + end + + defp timed_delta_apply(%{base: _base, delta: delta, compact_jump: compact}) do + {uncompact_us, jump} = + :timer.tc(fn -> + State.uncompact(compact) |> State.lock() + end) + + sample = do_timed_apply(jump, delta, div(uncompact_us, 1000)) + %{sample | total_ms: sample.total_ms + sample.uncompact_ms} + end + + defp do_timed_apply(base, delta, uncompact_ms) do + {total_us, {clone_us, apply_us, normalize_us, result}} = + :timer.tc(fn -> + {clone_us, cloned} = :timer.tc(fn -> State.clone(base) end) + + {apply_us, applied} = + :timer.tc(fn -> + State.apply_difference(cloned, delta) + end) + + {normalize_us, normalized} = + :timer.tc(fn -> + State.normalize(applied) + end) + + {clone_us, apply_us, normalize_us, normalized} + end) + + storage_keys = + Enum.reduce(delta, 0, fn {_id, report}, acc -> + acc + map_size(Map.get(report, :state, %{})) + end) + + %{ + delta_accounts: length(delta), + storage_keys: storage_keys, + clone_ms: div(clone_us, 1000), + apply_ms: div(apply_us, 1000), + normalize_ms: div(normalize_us, 1000), + uncompact_ms: uncompact_ms, + total_ms: div(total_us, 1000) + uncompact_ms, + hash: result.hash, + rss_kb: read_rss_kb() + } + end + + defp print_sample(name, i, map_size, sample, true) do + IO.puts( + "#{name},#{i},#{map_size},#{sample.delta_accounts},#{sample.storage_keys}," <> + "#{sample.clone_ms},#{sample.apply_ms},#{sample.normalize_ms},#{sample.uncompact_ms}," <> + "#{sample.total_ms},#{sample.rss_kb}" + ) + end + + defp print_sample(name, i, map_size, sample, false) do + IO.puts(:stderr, """ + #{name} iter=#{i} map_size=#{map_size} delta_accounts=#{sample.delta_accounts} storage_keys=#{sample.storage_keys} + clone_ms=#{sample.clone_ms} apply_ms=#{sample.apply_ms} normalize_ms=#{sample.normalize_ms} uncompact_ms=#{sample.uncompact_ms} + total_ms=#{sample.total_ms} rss_mb=#{div(sample.rss_kb, 1024)} + """) + end + + defp summarize(name, samples) do + n = length(samples) + avg = fn key -> div(Enum.sum(Enum.map(samples, &Map.fetch!(&1, key))), n) end + + IO.puts( + :stderr, + "SUMMARY #{name} avg_total_ms=#{avg.(:total_ms)} avg_apply_ms=#{avg.(:apply_ms)} " <> + "avg_clone_ms=#{avg.(:clone_ms)} avg_normalize_ms=#{avg.(:normalize_ms)} " <> + "avg_uncompact_ms=#{avg.(:uncompact_ms)} delta_accounts=#{hd(samples).delta_accounts}" + ) + end + + defp profile_eprof(name, %{base: base, delta: delta, compact_jump: nil}) do + IO.puts(:stderr, "eprof #{name} (clone+apply+normalize)...") + :eprof.start() + + :eprof.profile(fn -> + base |> State.clone() |> State.apply_difference(delta) |> State.normalize() + end) + + :eprof.analyze(:total) + :eprof.stop() + end + + defp profile_eprof(name, fixture) do + profile_eprof(name, %{ + fixture + | compact_jump: nil, + base: State.uncompact(fixture.compact_jump) + }) + end + + # mode :live — base is already an uncompacted/locked peak (JumpState hit) + # mode :from_compact — keep compact jump aside; each sample uncompacts first + defp build_delta_case(n, changed, slots, mode) do + live = build_live_state(n, slots) |> State.normalize() |> State.lock() + compact = State.compact(live) + + base = + case mode do + :live -> live |> State.compact() |> State.uncompact() |> State.lock() + :from_compact -> nil + end + + working = + case mode do + :live -> base + :from_compact -> State.uncompact(compact) |> State.lock() + end + + next = mutate(working, changed, slots) + delta = State.difference(working, next) + + %{ + base: working, + delta: delta, + compact_jump: if(mode == :from_compact, do: compact, else: nil) + } + end + + defp build_live_state(n, slots_per) do + Enum.reduce(1..n, State.new(), fn i, st -> + updates = + for s <- 1..slots_per, into: %{} do + {slot(i * 10_000 + s), <>} + end + + State.set_account(st, addr(i), %Chain.Account{ + nonce: i, + balance: i * 100, + storage_root: Map.to_list(updates), + code: <>, + map_backed: false + }) + end) + end + + defp mutate(state, changed, slots_per) do + state + |> State.clone() + |> then(fn st -> + Enum.reduce(1..changed, st, fn i, acc -> + id = addr(i) + + updates = + for s <- 1..max(div(slots_per, 4), 1), into: %{} do + {slot(i * 10_000 + s), <>} + end + + acc = State.storage_put_map(acc, %{id => updates}) + meta = State.ensure_account(acc, id) + State.set_account(acc, id, %{meta | nonce: meta.nonce + 1}) + end) + end) + |> State.normalize() + end + + defp addr(i), do: <> + defp slot(i), do: <> + + defp read_rss_kb do + case File.read("/proc/self/status") do + {:ok, status} -> + case Regex.run(~r/VmRSS:\s+(\d+)/, status) do + [_, kb] -> String.to_integer(kb) + _ -> 0 + end + + _ -> + 0 + end + end + + defp normalize_argv(argv) do + case Enum.split_while(argv, &(&1 != "--")) do + {_, ["--" | rest]} -> rest + {all, _} -> all + end + end +end + +StateDeltaApplyBench.main() diff --git a/scripts/state_diff_bench.exs b/scripts/state_diff_bench.exs new file mode 100644 index 0000000..4db133b --- /dev/null +++ b/scripts/state_diff_bench.exs @@ -0,0 +1,378 @@ +# Benchmark / profile Chain.State.difference (the path behind +# "State diff took longer than 1s ... accounts=N" — historical prod warning). +# +# prepare_state on non-jump blocks does: +# State.difference(prev_state, block_state) +# which is mostly CAccountMap.difference_full/2 plus light Elixir wrapping +# (decode_storage_diff; roots come from the NIF 6-tuple). +# +# After Phases A–E (docs/specs/change-state-diff-perf.md): difference_full is +# state_trie-driven (O(changed)), compact roots are cached, and elixir_ms ≈ 0. +# Use compact_small_delta / live_small_delta at 14k/20/32 as the acceptance gate +# (avg nif_ms < 20). +# +# Run from repo root (no full app start): +# mix run --no-start scripts/state_diff_bench.exs +# mix run --no-start scripts/state_diff_bench.exs -- --accounts 5000 --changed 20 --slots 8 +# mix run --no-start scripts/state_diff_bench.exs -- --scenario all --warmup 1 --iters 3 +# mix run --no-start scripts/state_diff_bench.exs -- --eprof +# +# Scenarios: +# live_small_delta — large live map, mutate K accounts +# locked_peak_delta — lock peak then clone+mutate (prepare_state / writer pattern) +# compact_small_delta — jump-shaped compact→uncompact peak + small delta +# storage_only_delta — storage writes only (no nonce bump) +# fat_storage — few accounts, huge storage trees (storage-diff dominated) +# all — run every scenario + +defmodule StateDiffBench do + @moduledoc false + + alias Chain.State + + def main(argv \\ System.argv()) do + argv = normalize_argv(argv) + + {opts, _, _} = + OptionParser.parse(argv, + strict: [ + accounts: :integer, + changed: :integer, + slots: :integer, + fat_slots: :integer, + warmup: :integer, + iters: :integer, + scenario: :string, + eprof: :boolean, + csv: :boolean + ] + ) + + accounts = Keyword.get(opts, :accounts, 5_000) + changed = Keyword.get(opts, :changed, 20) + slots = Keyword.get(opts, :slots, 8) + fat_slots = Keyword.get(opts, :fat_slots, 8_000) + warmup = Keyword.get(opts, :warmup, 1) + iters = Keyword.get(opts, :iters, 3) + scenario = Keyword.get(opts, :scenario, "all") + eprof? = Keyword.get(opts, :eprof, false) + csv? = Keyword.get(opts, :csv, false) + + _ = CAccountMap.new() + + IO.puts(:stderr, """ + === State.difference bench === + accounts=#{accounts} changed=#{changed} slots/account=#{slots} fat_slots=#{fat_slots} + warmup=#{warmup} iters=#{iters} scenario=#{scenario} eprof=#{eprof?} + """) + + scenarios = + case scenario do + "all" -> + [ + {:live_small_delta, fn -> build_live_small_delta(accounts, changed, slots) end}, + {:locked_peak_delta, fn -> build_locked_peak_delta(accounts, changed, slots) end}, + {:compact_small_delta, fn -> build_compact_small_delta(accounts, changed, slots) end}, + {:storage_only_delta, fn -> build_storage_only_delta(accounts, changed, slots) end}, + {:fat_storage, fn -> build_fat_storage(min(accounts, 40), changed, fat_slots) end} + ] + + name -> + builder = + case name do + "live_small_delta" -> fn -> build_live_small_delta(accounts, changed, slots) end + "locked_peak_delta" -> fn -> build_locked_peak_delta(accounts, changed, slots) end + "compact_small_delta" -> fn -> build_compact_small_delta(accounts, changed, slots) end + "compact_vs_live" -> fn -> build_compact_small_delta(accounts, changed, slots) end + "storage_only_delta" -> fn -> build_storage_only_delta(accounts, changed, slots) end + "fat_storage" -> fn -> build_fat_storage(min(accounts, 40), changed, fat_slots) end + other -> raise "unknown --scenario #{inspect(other)}" + end + + [{String.to_atom(name), builder}] + end + + if csv? do + IO.puts( + "scenario,iter,map_size,changed,nif_ms,elixir_ms,total_ms,delta_accounts,storage_keys,rss_kb" + ) + end + + Enum.each(scenarios, fn {name, builder} -> + IO.puts(:stderr, "--- building #{name} ---") + {build_us, {prev, next}} = :timer.tc(builder) + map_size = CAccountMap.size(prev.accounts) + + IO.puts( + :stderr, + "built #{name} in #{div(build_us, 1000)}ms map_size=#{map_size} rss=#{div(read_rss_kb(), 1024)}MB" + ) + + for _ <- 1..warmup do + _ = timed_difference(prev, next) + end + + if eprof? do + profile_eprof(name, prev, next) + end + + samples = + for i <- 1..iters do + sample = timed_difference(prev, next) + print_sample(name, i, map_size, sample, csv?) + sample + end + + summarize(name, samples) + end) + + IO.puts(:stderr, """ + + Interpretation hints: + - compact_small_delta / live_small_delta with small changed count: Phase E trie-driven + candidate set + Phase A cached compact roots; expect nif_ms well under 20. + - elixir_ms ≈ 0: Phase C roots come from the NIF 6-tuple (no storage_root_hash refetch). + - fat_storage nif-heavy with small map_size: build_storage_diff_list / Tree::difference. + """) + end + + defp timed_difference(prev, next) do + # Single wall-time sample with nested phase timers (avoids nif_ms > total_ms). + {total_us, {nif_us, elixir_us, result}} = + :timer.tc(fn -> + {nif_us, full} = + :timer.tc(fn -> + CAccountMap.difference_full(prev.accounts, next.accounts) + end) + + {elixir_us, result} = + :timer.tc(fn -> + Enum.map(full, fn {id, side_a, side_b, state_diff, root_a, root_b} -> + report = + %{} + |> put_field(:nonce, side_a, side_b) + |> put_field(:balance, side_a, side_b) + |> put_field(:code, side_a, side_b) + + storage_map = CAccountMap.decode_storage_diff(state_diff) + + report = + if map_size(storage_map) > 0 do + Map.merge(report, %{ + state: storage_map, + root_hash: { + decode_root(root_a), + decode_root(root_b) + } + }) + else + report + end + + {id, report} + end) + end) + + {nif_us, elixir_us, result} + end) + + storage_keys = + Enum.reduce(result, 0, fn {_id, report}, acc -> + acc + map_size(Map.get(report, :state, %{})) + end) + + %{ + nif_ms: div(nif_us, 1000), + elixir_ms: div(elixir_us, 1000), + total_ms: div(total_us, 1000), + delta_accounts: length(result), + storage_keys: storage_keys, + rss_kb: read_rss_kb() + } + end + + defp put_field(report, field, side_a, side_b) do + a = side_field(side_a, field) + b = side_field(side_b, field) + + if a == b do + report + else + Map.put(report, field, {a, b}) + end + end + + defp decode_root(nil), + do: CAccountMap.storage_root_hash(CAccountMap.new(), <<0::unsigned-size(160)>>) + + defp decode_root(<<_::binary-size(32)>> = root), do: root + + defp side_field(nil, :nonce), do: 0 + defp side_field(nil, :balance), do: 0 + defp side_field(nil, :code), do: "" + defp side_field({nonce, _, _}, :nonce), do: nonce + defp side_field({_, balance, _}, :balance) when is_integer(balance), do: balance + + defp side_field({_, balance, _}, :balance) when is_binary(balance), + do: :binary.decode_unsigned(balance) + + defp side_field({_, _, code}, :code), do: code + + defp print_sample(name, i, map_size, sample, true) do + IO.puts( + "#{name},#{i},#{map_size},#{sample.delta_accounts},#{sample.nif_ms},#{sample.elixir_ms},#{sample.total_ms},#{sample.delta_accounts},#{sample.storage_keys},#{sample.rss_kb}" + ) + end + + defp print_sample(name, i, map_size, sample, false) do + nif_pct = + if sample.total_ms > 0 do + Float.round(100.0 * sample.nif_ms / sample.total_ms, 1) + else + 0.0 + end + + IO.puts(:stderr, """ + #{name} iter=#{i} map_size=#{map_size} changed=#{sample.delta_accounts} storage_keys=#{sample.storage_keys} + nif_ms=#{sample.nif_ms} elixir_ms=#{sample.elixir_ms} total_ms=#{sample.total_ms} (nif=#{nif_pct}%) + rss_mb=#{div(sample.rss_kb, 1024)} + """) + end + + defp summarize(name, samples) do + n = length(samples) + avg = fn key -> div(Enum.sum(Enum.map(samples, &Map.fetch!(&1, key))), n) end + + IO.puts( + :stderr, + "SUMMARY #{name} avg_nif_ms=#{avg.(:nif_ms)} avg_elixir_ms=#{avg.(:elixir_ms)} " <> + "avg_total_ms=#{avg.(:total_ms)} changed=#{hd(samples).delta_accounts}" + ) + end + + defp profile_eprof(name, prev, next) do + IO.puts(:stderr, "eprof #{name} (State.difference)...") + :eprof.start() + :eprof.profile(fn -> State.difference(prev, next) end) + :eprof.analyze(:total) + :eprof.stop() + end + + # --- builders (prepare_state-shaped) --- + + defp build_live_small_delta(n, changed, slots) do + prev = build_live_state(n, slots) + next = mutate(prev, changed, slots) + {prev, next} + end + + defp build_locked_peak_delta(n, changed, slots) do + peak = build_live_state(n, slots) |> State.normalize() |> State.lock() + next = mutate(peak, changed, slots) + {peak, next} + end + + # Jump-block shaped: uncompact leaves compact_storage with cached roots; small + # deltas should stay O(changed) via state_trie-driven difference_full. + defp build_compact_small_delta(n, changed, slots) do + peak = build_live_state(n, slots) |> State.normalize() |> State.lock() + prev = peak |> State.compact() |> State.uncompact() |> State.lock() + next = mutate(prev, changed, slots) + {prev, next} + end + + # Storage-only mutations (no nonce bump): storage root change still updates + # state_trie so Phase E finds the candidates. + defp build_storage_only_delta(n, changed, slots) do + prev = build_live_state(n, slots) |> State.normalize() |> State.lock() + + next = + prev + |> State.clone() + |> then(fn st -> + Enum.reduce(1..changed, st, fn i, acc -> + id = addr(i) + + updates = + for s <- 1..max(div(slots, 4), 1), into: %{} do + {slot(i * 10_000 + s), <>} + end + + State.storage_put_map(acc, %{id => updates}) + end) + end) + |> State.normalize() + + {prev, next} + end + + defp build_fat_storage(n, changed, fat_slots) do + prev = build_live_state(n, fat_slots) + next = mutate(prev, min(changed, n), fat_slots) + {prev, next} + end + + defp build_live_state(n, slots_per) do + Enum.reduce(1..n, State.new(), fn i, st -> + updates = + for s <- 1..slots_per, into: %{} do + {slot(i * 10_000 + s), <>} + end + + State.set_account(st, addr(i), %Chain.Account{ + nonce: i, + balance: i * 100, + storage_root: Map.to_list(updates), + code: <>, + map_backed: false + }) + end) + |> State.normalize() + end + + defp mutate(state, changed, slots_per) do + state + |> State.clone() + |> then(fn st -> + Enum.reduce(1..changed, st, fn i, acc -> + id = addr(i) + + updates = + for s <- 1..max(div(slots_per, 4), 1), into: %{} do + {slot(i * 10_000 + s), <>} + end + + # Bump nonce so the account RLP / state_trie leaf changes (Phase E candidate). + acc = State.storage_put_map(acc, %{id => updates}) + meta = State.ensure_account(acc, id) + State.set_account(acc, id, %{meta | nonce: meta.nonce + 1}) + end) + end) + |> State.normalize() + end + + defp addr(i), do: <> + defp slot(i), do: <> + + defp normalize_argv(argv) do + case argv do + ["--" | rest] -> rest + other -> other + end + end + + defp read_rss_kb do + case File.read("/proc/self/status") do + {:ok, body} -> + case Regex.run(~r/^VmRSS:\s+(\d+)\s+kB/m, body) do + [_, n] -> String.to_integer(n) + _ -> 0 + end + + _ -> + 0 + end + end +end + +StateDiffBench.main(System.argv()) diff --git a/scripts/state_uncompact_bench.exs b/scripts/state_uncompact_bench.exs new file mode 100644 index 0000000..45122b3 --- /dev/null +++ b/scripts/state_uncompact_bench.exs @@ -0,0 +1,223 @@ +# Benchmark Chain.State.uncompact — the path behind prod +# "state(uncompact:0x...) took Nms" +# in Model.ChainSql.state/1 when a jump block's compact state is loaded from DB. +# +# Jump blocks store a compact %Chain.State{}; do_state then runs State.uncompact/1 +# (CAccountMap.uncompact_state/1). Warns when wall time > 2s. +# +# Run from repo root (no full app start): +# mix run --no-start scripts/state_uncompact_bench.exs +# mix run --no-start scripts/state_uncompact_bench.exs -- --accounts 14000 --slots 32 +# mix run --no-start scripts/state_uncompact_bench.exs -- --scenario all --warmup 1 --iters 3 +# mix run --no-start scripts/state_uncompact_bench.exs -- --eprof +# +# Scenarios: +# sparse_jump — many accounts, 1 slot each (typical DB jump-block shape) +# fat_slots — many accounts × slots/account (heavier compact payload) +# nif_only — CAccountMap.uncompact_state only (no %State{} wrap) +# all — run every scenario + +defmodule StateUncompactBench do + @moduledoc false + + alias Chain.State + + def main(argv \\ System.argv()) do + argv = normalize_argv(argv) + + {opts, _, _} = + OptionParser.parse(argv, + strict: [ + accounts: :integer, + slots: :integer, + warmup: :integer, + iters: :integer, + scenario: :string, + eprof: :boolean, + csv: :boolean + ] + ) + + accounts = Keyword.get(opts, :accounts, 14_000) + slots = Keyword.get(opts, :slots, 8) + warmup = Keyword.get(opts, :warmup, 1) + iters = Keyword.get(opts, :iters, 3) + scenario = Keyword.get(opts, :scenario, "all") + eprof? = Keyword.get(opts, :eprof, false) + csv? = Keyword.get(opts, :csv, false) + + _ = CAccountMap.new() + + IO.puts(:stderr, """ + === State.uncompact bench (ChainSql state(uncompact:…)) === + accounts=#{accounts} slots/account=#{slots} + warmup=#{warmup} iters=#{iters} scenario=#{scenario} eprof=#{eprof?} + """) + + scenarios = + case scenario do + "all" -> + [ + {:sparse_jump, fn -> build_compact_state(accounts, 1) end}, + {:fat_slots, fn -> build_compact_state(accounts, slots) end}, + {:nif_only, fn -> build_compact_state(accounts, slots) end} + ] + + name -> + builder = + case name do + "sparse_jump" -> fn -> build_compact_state(accounts, 1) end + "fat_slots" -> fn -> build_compact_state(accounts, slots) end + "nif_only" -> fn -> build_compact_state(accounts, slots) end + other -> raise "unknown --scenario #{inspect(other)}" + end + + [{String.to_atom(name), builder}] + end + + if csv? do + IO.puts("scenario,iter,map_size,uncompact_ms,nif_ms,rss_kb") + end + + Enum.each(scenarios, fn {name, builder} -> + IO.puts(:stderr, "--- building #{name} ---") + {build_us, compact} = :timer.tc(builder) + map_size = compact_account_count(compact) + + IO.puts( + :stderr, + "built #{name} in #{div(build_us, 1000)}ms map_size=#{map_size} rss=#{div(read_rss_kb(), 1024)}MB" + ) + + for _ <- 1..warmup do + _ = timed_uncompact(name, compact) + end + + if eprof? do + profile_eprof(name, compact) + end + + samples = + for i <- 1..iters do + sample = timed_uncompact(name, compact) + print_sample(name, i, map_size, sample, csv?) + sample + end + + summarize(name, samples) + end) + + IO.puts(:stderr, """ + + Interpretation hints: + - Matches Model.ChainSql.do_state when DB row is %Chain.State{} (jump block). + - Prod warning fires at >2000ms; sparse_jump ~14k accounts is the usual shape. + - nif_ms ≈ uncompact_ms: cost is inside account_map_uncompact_state. + """) + end + + # What ChainSql times: State.uncompact/1 on a compact Elixir account map. + defp timed_uncompact(:nif_only, %State{accounts: accounts}) when is_map(accounts) do + {us, _} = :timer.tc(fn -> CAccountMap.uncompact_state(accounts) end) + + %{ + uncompact_ms: div(us, 1000), + nif_ms: div(us, 1000), + rss_kb: read_rss_kb() + } + end + + defp timed_uncompact(_name, %State{} = compact) do + {api_us, _} = :timer.tc(fn -> State.uncompact(compact) end) + + %{ + uncompact_ms: div(api_us, 1000), + nif_ms: div(api_us, 1000), + rss_kb: read_rss_kb() + } + end + + defp print_sample(name, i, map_size, sample, true) do + IO.puts("#{name},#{i},#{map_size},#{sample.uncompact_ms},#{sample.nif_ms},#{sample.rss_kb}") + end + + defp print_sample(name, i, map_size, sample, false) do + IO.puts(:stderr, """ + #{name} iter=#{i} map_size=#{map_size} + uncompact_ms=#{sample.uncompact_ms} nif_ms=#{sample.nif_ms} + rss_mb=#{div(sample.rss_kb, 1024)} + """) + end + + defp summarize(name, samples) do + n = length(samples) + avg = fn key -> div(Enum.sum(Enum.map(samples, &Map.fetch!(&1, key))), n) end + + IO.puts( + :stderr, + "SUMMARY #{name} avg_uncompact_ms=#{avg.(:uncompact_ms)} avg_nif_ms=#{avg.(:nif_ms)}" + ) + end + + defp profile_eprof(name, %State{} = compact) do + IO.puts(:stderr, "eprof #{name} (State.uncompact)...") + :eprof.start() + :eprof.profile(fn -> State.uncompact(compact) end) + :eprof.analyze(:total) + :eprof.stop() + end + + defp build_compact_state(n, slots_per) do + build_live_state(n, slots_per) + |> State.normalize() + |> State.lock() + |> State.compact() + end + + defp compact_account_count(%State{accounts: accounts}) when is_map(accounts), + do: map_size(accounts) + + defp compact_account_count(%State{accounts: accounts}), do: CAccountMap.size(accounts) + + defp build_live_state(n, slots_per) do + Enum.reduce(1..n, State.new(), fn i, st -> + updates = + for s <- 1..slots_per, into: %{} do + {slot(i * 10_000 + s), <>} + end + + State.set_account(st, addr(i), %Chain.Account{ + nonce: i, + balance: i * 100, + storage_root: Map.to_list(updates), + code: <>, + map_backed: false + }) + end) + end + + defp addr(i), do: <> + defp slot(i), do: <> + + defp read_rss_kb do + case File.read("/proc/self/status") do + {:ok, status} -> + case Regex.run(~r/VmRSS:\s+(\d+)/, status) do + [_, kb] -> String.to_integer(kb) + _ -> 0 + end + + _ -> + 0 + end + end + + defp normalize_argv(argv) do + case Enum.split_while(argv, &(&1 != "--")) do + {_, ["--" | rest]} -> rest + {all, _} -> all + end + end +end + +StateUncompactBench.main() diff --git a/test/caccount_map_lifetime_test.exs b/test/caccount_map_lifetime_test.exs index a52c2ef..217a94c 100644 --- a/test/caccount_map_lifetime_test.exs +++ b/test/caccount_map_lifetime_test.exs @@ -5,6 +5,9 @@ # Lifetime / refcount regression tests for CAccountMap NIF resources. # Targets bugs like premature SharedState deletion while merkletree resources # are still alive (ethr_mutex_lock EINVAL on destroyed mutex). +# +# CAccountMap.get/2 returns a 32-byte storage root hash (never a live trie). +# Storage usability is checked via storage_get / storage_root_hash / storage_put_map. defmodule CAccountMapLifetimeTest do use ExUnit.Case, async: false @@ -30,12 +33,12 @@ defmodule CAccountMapLifetimeTest do end defp sample_account(n) do - tree = - CMerkleTree.insert_items(CMerkleTree.new(), [ - {slot(n), val(n)} - ]) - - %Account{nonce: n, balance: n * 1_000, storage_root: tree, code: <>} + %Account{ + nonce: n, + balance: n * 1_000, + storage_root: [{slot(n), val(n)}], + code: <> + } end defp put_sample(map, i) do @@ -46,25 +49,33 @@ defmodule CAccountMapLifetimeTest do Enum.reduce(1..n_accounts, CAccountMap.new(), &put_sample(&2, &1)) end - defp assert_storage_usable(storage) do - hash = CMerkleTree.root_hash(storage) - assert is_binary(hash) and byte_size(hash) > 0 + # Storage is no longer returned as a live resource from get/to_list. + # Verify the map can still read and that a clone remains writable. + defp assert_map_storage_usable(map, address) do + hash = CAccountMap.storage_root_hash(map, address) + assert is_binary(hash) and byte_size(hash) == 32 + + {_n, _b, root, _c} = CAccountMap.get(map, address) + assert root == hash + + fork = + map + |> CAccountMap.clone() + |> CAccountMap.storage_put_map(%{address => %{slot(9_999) => val(9_999)}}) - updated = - CMerkleTree.insert_items(storage, [ - {slot(9_999), val(9_999)} - ]) + assert CAccountMap.storage_get(fork, address, slot(9_999)) == val(9_999) + assert CAccountMap.storage_root_hash(map, address) == hash + end - assert is_binary(CMerkleTree.root_hash(updated)) + defp assert_state_storage_usable(state, address) do + assert_map_storage_usable(state.accounts, address) end - # CAccountMap.clone now produces writable (locked = false) storage tries that are - # distinct resources from the parent's, so account entries must be compared by - # value (storage root_hash) rather than by resource identity. + # Compare account entries by value (nonce/balance/root_hash/code). defp entry_value(:undefined), do: :undefined - defp entry_value({nonce, balance, storage, code}) do - {nonce, balance, CMerkleTree.root_hash(storage), code} + defp entry_value({nonce, balance, root_hash, code}) do + {nonce, balance, root_hash, code} end describe "clone GC must not corrupt parent storage" do @@ -78,8 +89,7 @@ defmodule CAccountMapLifetimeTest do entry_value(CAccountMap.get(base, addr(5))) end) - {_, _, storage, _} = CAccountMap.get(base, addr(5)) - assert_storage_usable(storage) + assert_map_storage_usable(base, addr(5)) end test "many ephemeral clones with reads between GC rounds" do @@ -103,8 +113,7 @@ defmodule CAccountMapLifetimeTest do end for i <- 1..8 do - {_, _, storage, _} = CAccountMap.get(base, addr(i)) - assert_storage_usable(storage) + assert_map_storage_usable(base, addr(i)) end end @@ -121,19 +130,14 @@ defmodule CAccountMapLifetimeTest do end) end - acc = State.account(state, addr(1)) - assert_storage_usable(Account.tree(acc)) + assert_state_storage_usable(state, addr(1)) assert State.hash(state) == State.hash(State.clone(state)) end end describe "shared storage pointers across accounts" do test "two accounts referencing the same storage trie survive clone GC" do - storage = - CMerkleTree.insert_items(CMerkleTree.new(), [ - {slot(1), val(1)}, - {slot(2), val(2)} - ]) + storage = [{slot(1), val(1)}, {slot(2), val(2)}] map = CAccountMap.new() @@ -149,14 +153,15 @@ defmodule CAccountMapLifetimeTest do end) end - {_, _, storage_a, _} = CAccountMap.get(map, addr(10)) - {_, _, storage_b, _} = CAccountMap.get(map, addr(11)) - assert storage_a == storage_b - assert_storage_usable(storage_a) + assert CAccountMap.storage_root_hash(map, addr(10)) == + CAccountMap.storage_root_hash(map, addr(11)) + + assert_map_storage_usable(map, addr(10)) + assert_map_storage_usable(map, addr(11)) end test "shared storage with fork mutation splits only the mutated branch" do - storage = CMerkleTree.insert_items(CMerkleTree.new(), [{slot(1), val(1)}]) + storage = [{slot(1), val(1)}] map = CAccountMap.new() @@ -167,21 +172,17 @@ defmodule CAccountMapLifetimeTest do fork = map |> CAccountMap.clone() - |> CAccountMap.put( - addr(20), - 9, - 900, - CMerkleTree.insert_items(storage, [{slot(99), val(99)}]), - <<99>> - ) + |> CAccountMap.storage_put_map(%{addr(20) => %{slot(99) => val(99)}}) + |> CAccountMap.put_meta(addr(20), 9, 900, <<99>>) assert {9, 900, _, <<99>>} = CAccountMap.get(fork, addr(20)) assert {1, 100, _, <<20>>} = CAccountMap.get(map, addr(20)) assert {2, 200, _, <<21>>} = CAccountMap.get(map, addr(21)) + assert CAccountMap.storage_get(fork, addr(20), slot(99)) == val(99) + assert CAccountMap.storage_get(map, addr(20), slot(99)) == nil end) - {_, _, parent_storage, _} = CAccountMap.get(map, addr(20)) - assert_storage_usable(parent_storage) + assert_map_storage_usable(map, addr(20)) end end @@ -203,15 +204,14 @@ defmodule CAccountMapLifetimeTest do assert CAccountMap.get(base, addr(2)) != :undefined assert CAccountMap.size(base) == 4 - {_, _, storage, _} = CAccountMap.get(base, addr(2)) - assert_storage_usable(storage) + assert_map_storage_usable(base, addr(2)) end test "put replaces storage root without leaving dangling tries" do base = put_sample(CAccountMap.new(), 3) - {_, _, old_storage, _} = CAccountMap.get(base, addr(3)) + {_, _, old_root, _} = CAccountMap.get(base, addr(3)) - new_storage = CMerkleTree.insert_items(CMerkleTree.new(), [{slot(7), val(7)}]) + new_storage = [{slot(7), val(7)}] base = CAccountMap.put( @@ -227,9 +227,11 @@ defmodule CAccountMapLifetimeTest do _fork = CAccountMap.clone(base) end) - assert {30, 30_000, storage, <<30>>} = CAccountMap.get(base, addr(3)) - assert storage != old_storage - assert_storage_usable(storage) + assert {30, 30_000, root, <<30>>} = CAccountMap.get(base, addr(3)) + assert is_binary(root) and byte_size(root) == 32 + assert root != old_root + assert CAccountMap.storage_get(base, addr(3), slot(7)) == val(7) + assert_map_storage_usable(base, addr(3)) end test "nested clone chain with middle resource dropped" do @@ -254,8 +256,7 @@ defmodule CAccountMapLifetimeTest do _ = c3 end) - {_, _, storage, _} = CAccountMap.get(base, addr(2)) - assert_storage_usable(storage) + assert_map_storage_usable(base, addr(2)) end end @@ -282,8 +283,7 @@ defmodule CAccountMapLifetimeTest do assert Enum.all?(Task.await_many(tasks, 60_000), &(&1 == :ok)) for i <- 1..6 do - {_, _, storage, _} = CAccountMap.get(base, addr(i)) - assert_storage_usable(storage) + assert_map_storage_usable(base, addr(i)) end end @@ -300,12 +300,11 @@ defmodule CAccountMapLifetimeTest do end base = - base - |> State.account(addr(1)) - |> Account.storage_set_value(slot(42), val(42)) - |> then(&State.set_account(base, addr(1), &1)) + State.storage_put_map(base, %{ + addr(1) => %{slot(42) => val(42)} + }) - assert Account.storage_value(State.account(base, addr(1)), slot(42)) == val(42) + assert State.storage_value(base, addr(1), slot(42)) == val(42) assert is_binary(State.hash(base)) end end @@ -326,9 +325,10 @@ defmodule CAccountMapLifetimeTest do refute Map.has_key?(listed, addr(99)) for i <- 1..5 do - {nonce, balance, storage, code} = Map.fetch!(listed, addr(i)) + {nonce, balance, root, code} = Map.fetch!(listed, addr(i)) assert {nonce, balance, code} == {i, i * 1_000, <>} - assert_storage_usable(storage) + assert is_binary(root) and byte_size(root) == 32 + assert_map_storage_usable(base, addr(i)) end end @@ -350,11 +350,9 @@ defmodule CAccountMapLifetimeTest do end describe "clone of a locked state stays writable (block sync path)" do - # Reproduces the startup block-sync hang: BlockProcess.cache_block/1 freezes the - # cached parent via Chain.State.lock/1 (mt->locked = true on every storage trie), - # and Block.create_empty/3 forks it via Chain.State.clone/1. The fork's storage - # tries must be writable (locked = false) or the EVM's storage writes return - # badarg and validation can never advance. + # BlockProcess.cache_block/1 freezes the cached parent via Chain.State.lock/1 + # (frozen account map). Block.create_empty/3 and RPC/EdgeV2/Shell fork via + # Chain.State.clone/1. The fork must accept storage_put_map writes. test "storage write in a fork of a State.lock'd state succeeds and isolates parent" do base = State.new() @@ -365,22 +363,42 @@ defmodule CAccountMapLifetimeTest do fork = State.clone(base) updated = - fork - |> State.account(addr(1)) - |> Account.storage_set_value(slot(42), val(42)) - |> then(&State.set_account(fork, addr(1), &1)) + State.storage_put_map(fork, %{ + addr(1) => %{slot(42) => val(42)} + }) - assert Account.storage_value(State.account(updated, addr(1)), slot(42)) == val(42) + assert State.storage_value(updated, addr(1), slot(42)) == val(42) assert is_binary(State.hash(updated)) # Parent stays frozen: the fork's COW write must not leak back. - assert Account.storage_value(State.account(base, addr(1)), slot(42)) == + assert State.storage_value(base, addr(1), slot(42)) == <<0::unsigned-size(256)>> - assert Account.storage_value(State.account(base, addr(1)), slot(1)) == val(1) + assert State.storage_value(base, addr(1), slot(1)) == val(1) assert is_binary(State.hash(base)) end + test "storage_put_map on locked parent raises and leaves parent unchanged" do + base = + State.new() + |> State.set_account(addr(1), sample_account(1)) + + before = State.storage_value(base, addr(1), slot(1)) + {_n, _b, root, _c} = CAccountMap.get(base.accounts, addr(1)) + assert byte_size(root) == 32 + + Chain.State.lock(base) + + assert_raise ArgumentError, fn -> + State.storage_put_map(base, %{addr(1) => %{slot(42) => val(42)}}) + end + + assert State.storage_value(base, addr(1), slot(1)) == before + + assert State.storage_value(base, addr(1), slot(42)) == + <<0::unsigned-size(256)>> + end + test "storage write in a fork of a locked state with many accounts" do base = Enum.reduce(1..6, State.new(), fn i, state -> @@ -393,48 +411,42 @@ defmodule CAccountMapLifetimeTest do fork = Enum.reduce(1..6, fork, fn i, state -> - acc = - state - |> State.account(addr(i)) - |> Account.storage_set_value(slot(100 + i), val(100 + i)) - - State.set_account(state, addr(i), acc) + State.storage_put_map(state, %{ + addr(i) => %{slot(100 + i) => val(100 + i)} + }) end) for i <- 1..6 do - assert Account.storage_value(State.account(fork, addr(i)), slot(100 + i)) == + assert State.storage_value(fork, addr(i), slot(100 + i)) == val(100 + i) end # Parent untouched. for i <- 1..6 do - assert Account.storage_value(State.account(base, addr(i)), slot(100 + i)) == + assert State.storage_value(base, addr(i), slot(100 + i)) == <<0::unsigned-size(256)>> end end end describe "map-account clone after lock (compact in-memory path)" do - test "fork of locked map-based state can write storage without mutating parent" do - accounts = %{ - addr(1) => sample_account(1), - addr(2) => sample_account(2) - } - - base = %State{State.new() | accounts: accounts} + test "fork of locked CAccountMap state can write storage without mutating parent" do + base = + State.new() + |> State.set_account(addr(1), sample_account(1)) + |> State.set_account(addr(2), sample_account(2)) Chain.State.lock(base) fork = State.clone(base) fork = - fork - |> State.account(addr(1)) - |> Account.storage_set_value(slot(77), val(77)) - |> then(&State.set_account(fork, addr(1), &1)) + State.storage_put_map(fork, %{ + addr(1) => %{slot(77) => val(77)} + }) - assert Account.storage_value(State.account(fork, addr(1)), slot(77)) == val(77) + assert State.storage_value(fork, addr(1), slot(77)) == val(77) - assert Account.storage_value(State.account(base, addr(1)), slot(77)) == + assert State.storage_value(base, addr(1), slot(77)) == <<0::unsigned-size(256)>> assert is_binary(State.hash(fork)) @@ -443,11 +455,7 @@ defmodule CAccountMapLifetimeTest do describe "shared storage trie dedup in account_map_clone" do test "lock then clone with multiple accounts sharing one storage trie stays writable" do - shared = - CMerkleTree.insert_items(CMerkleTree.new(), [ - {slot(1), val(1)}, - {slot(2), val(2)} - ]) + shared = [{slot(1), val(1)}, {slot(2), val(2)}] accounts = CAccountMap.new() @@ -461,19 +469,16 @@ defmodule CAccountMapLifetimeTest do fork = Enum.reduce(1..2, fork, fn i, state -> - acc = - state - |> State.account(addr(i)) - |> Account.storage_set_value(slot(100 + i), val(100 + i)) - - State.set_account(state, addr(i), acc) + State.storage_put_map(state, %{ + addr(i) => %{slot(100 + i) => val(100 + i)} + }) end) for i <- 1..2 do - assert Account.storage_value(State.account(fork, addr(i)), slot(100 + i)) == + assert State.storage_value(fork, addr(i), slot(100 + i)) == val(100 + i) - assert Account.storage_value(State.account(base, addr(i)), slot(100 + i)) == + assert State.storage_value(base, addr(i), slot(100 + i)) == <<0::unsigned-size(256)>> end end @@ -491,19 +496,16 @@ defmodule CAccountMapLifetimeTest do fork = Enum.reduce(1..10, fork, fn i, state -> - acc = - state - |> State.account(addr(i)) - |> Account.storage_set_value(slot(200 + i), val(200 + i)) - - State.set_account(state, addr(i), acc) + State.storage_put_map(state, %{ + addr(i) => %{slot(200 + i) => val(200 + i)} + }) end) for i <- 1..10 do - assert Account.storage_value(State.account(fork, addr(i)), slot(200 + i)) == + assert State.storage_value(fork, addr(i), slot(200 + i)) == val(200 + i) - assert Account.storage_value(State.account(base, addr(i)), slot(200 + i)) == + assert State.storage_value(base, addr(i), slot(200 + i)) == <<0::unsigned-size(256)>> end end @@ -520,24 +522,17 @@ defmodule CAccountMapLifetimeTest do |> State.uncompact() |> State.normalize() - assert restored.store != nil + assert is_binary(Chain.State.hash(restored)) Chain.State.lock(restored) fork = restored |> State.clone() - |> then(fn state -> - acc = - state - |> State.account(addr(1)) - |> Account.storage_set_value(slot(501), val(501)) - - State.set_account(state, addr(1), acc) - end) + |> State.storage_put_map(%{addr(1) => %{slot(501) => val(501)}}) - assert Account.storage_value(State.account(fork, addr(1)), slot(501)) == val(501) + assert State.storage_value(fork, addr(1), slot(501)) == val(501) - assert Account.storage_value(State.account(restored, addr(1)), slot(501)) == + assert State.storage_value(restored, addr(1), slot(501)) == <<0::unsigned-size(256)>> end end diff --git a/test/caccount_map_test.exs b/test/caccount_map_test.exs index 9e06a23..6730449 100644 --- a/test/caccount_map_test.exs +++ b/test/caccount_map_test.exs @@ -9,12 +9,12 @@ defmodule CAccountMapTest do defp addr(i), do: <> defp sample_account(n) do - tree = - CMerkleTree.insert_items(CMerkleTree.new(), [ - {<>, <>} - ]) - - %Account{nonce: n, balance: n * 1_000, storage_root: tree, code: <>} + %Account{ + nonce: n, + balance: n * 1_000, + storage_root: [{<>, <>}], + code: <> + } end defp put_sample(map, i) do @@ -25,8 +25,9 @@ defmodule CAccountMapTest do map = put_sample(CAccountMap.new(), 3) assert CAccountMap.size(map) == 1 - assert {3, 3000, storage, <<3>>} = CAccountMap.get(map, addr(3)) - assert CMerkleTree.root_hash(storage) == Account.root_hash(sample_account(3)) + assert {3, 3000, root, <<3>>} = CAccountMap.get(map, addr(3)) + assert is_binary(root) and byte_size(root) == 32 + assert root == CAccountMap.storage_root_hash(map, addr(3)) end test "delete removes account" do @@ -36,44 +37,47 @@ defmodule CAccountMapTest do assert CAccountMap.get(map, addr(2)) == :undefined end - test "lock via NIF dedupes shared storage and accepts optional store trie" do - shared = - CMerkleTree.insert_items(CMerkleTree.new(), [ - {<<1::unsigned-size(256)>>, <<2::unsigned-size(256)>>} - ]) + test "lock via NIF freezes map for fork" do + shared = [{<<1::unsigned-size(256)>>, <<2::unsigned-size(256)>>}] base = CAccountMap.new() |> CAccountMap.put(addr(1), 1, 1_000, shared, <<1>>) |> CAccountMap.put(addr(2), 2, 2_000, shared, <<2>>) - store = - CMerkleTree.insert_items(CMerkleTree.new(), [ - {<<9::unsigned-size(256)>>, <<9::unsigned-size(256)>>} - ]) - - assert ^base = CAccountMap.lock(base, store) + assert ^base = CAccountMap.lock(base) fork = CAccountMap.clone(base) fork = fork - |> CAccountMap.put(addr(1), 11, 11_000, CMerkleTree.new(), <<11>>) + |> CAccountMap.put(addr(1), 11, 11_000, [], <<11>>) assert {11, 11_000, _, <<11>>} = CAccountMap.get(fork, addr(1)) assert {1, 1_000, _, <<1>>} = CAccountMap.get(base, addr(1)) end - test "clone copies accounts with equal, writable storage" do + test "clone copies accounts with equal storage root hashes" do base = put_sample(CAccountMap.new(), 5) fork = CAccountMap.clone(base) - # The fork holds distinct (writable, locked = false) storage resources with the - # same content as the parent. - {5, 5000, base_storage, <<5>>} = CAccountMap.get(base, addr(5)) - {5, 5000, fork_storage, <<5>>} = CAccountMap.get(fork, addr(5)) - assert CMerkleTree.root_hash(base_storage) == CMerkleTree.root_hash(fork_storage) - refute base_storage == fork_storage + # get/2 returns 32-byte root hashes (not live resources); content must match. + {5, 5000, base_root, <<5>>} = CAccountMap.get(base, addr(5)) + {5, 5000, fork_root, <<5>>} = CAccountMap.get(fork, addr(5)) + assert byte_size(base_root) == 32 + assert base_root == fork_root + + assert CAccountMap.storage_root_hash(base, addr(5)) == + CAccountMap.storage_root_hash(fork, addr(5)) + + # Fork remains writable via storage_put_map; parent root stays unchanged. + fork = + CAccountMap.storage_put_map(fork, %{ + addr(5) => %{<<99::unsigned-size(256)>> => <<100::unsigned-size(256)>>} + }) + + assert CAccountMap.storage_root_hash(fork, addr(5)) != + CAccountMap.storage_root_hash(base, addr(5)) fork = put_sample(fork, 9) @@ -82,9 +86,9 @@ defmodule CAccountMapTest do test "large balance roundtrip via 256-bit encoding" do balance = Bitwise.bsl(1, 200) - storage = CMerkleTree.new() - map = CAccountMap.put(CAccountMap.new(), addr(2), 0, balance, storage, nil) - assert {0, ^balance, _, ""} = CAccountMap.get(map, addr(2)) + map = CAccountMap.put(CAccountMap.new(), addr(2), 0, balance, nil, nil) + assert {0, ^balance, root, ""} = CAccountMap.get(map, addr(2)) + assert is_binary(root) and byte_size(root) == 32 end test "State.clone uses native account map" do diff --git a/test/chain_account_hash_nif_test.exs b/test/chain_account_hash_nif_test.exs index 49bc6f3..d9d0f75 100644 --- a/test/chain_account_hash_nif_test.exs +++ b/test/chain_account_hash_nif_test.exs @@ -15,12 +15,41 @@ defmodule ChainAccountHashNifTest do defp val(i), do: <> defp sample_account(i) do - tree = - CMerkleTree.insert_items(CMerkleTree.new(), [ - {slot(i), val(i)} - ]) + %Account{ + nonce: i, + balance: i * 1_000, + storage_root: [{slot(i), val(i)}], + code: <>, + map_backed: false + } + end + + defp live_state(accounts) when is_list(accounts) do + Enum.reduce(accounts, State.new(), fn {id, acc}, st -> + State.set_account(st, id, acc) + end) + end + + defp compact_accounts(accounts) when is_list(accounts) do + live_state(accounts) |> State.compact() |> Map.fetch!(:accounts) + end + + defp hash_from_list_storage(%Account{storage_root: storage} = acc) when is_list(storage) do + root = + CAccountMap.new() + |> CAccountMap.put(addr(0), acc.nonce, acc.balance, storage, acc.code) + |> CAccountMap.storage_root_hash(addr(0)) + + Account.hash(%{ + acc + | root_hash: root, + storage_root: nil, + map_backed: true + }) + end - %Account{nonce: i, balance: i * 1_000, storage_root: tree, code: <>} + defp hash_from_compact(%Account{} = acc) do + Account.hash(acc) end test "uncompact_state account hashes match Account.hash/1 for multi-slot storage and large code" do @@ -28,26 +57,26 @@ defmodule ChainAccountHashNifTest do %Account{ nonce: 11, balance: 5_000, - storage_root: - CMerkleTree.insert_items(CMerkleTree.new(), [ - {slot(1), val(1)}, - {slot(2), val(2)}, - {slot(10), val(10)} - ]), - code: :binary.copy(<<0xCD>>, 1024) + storage_root: [ + {slot(1), val(1)}, + {slot(2), val(2)}, + {slot(10), val(10)} + ], + code: :binary.copy(<<0xCD>>, 1024), + map_backed: false } compact = - %{ - addr(1) => multi_slot |> Account.compact(), - addr(2) => sample_account(2) |> Account.compact() - } + compact_accounts([ + {addr(1), multi_slot}, + {addr(2), sample_account(2)} + ]) - {accounts, store, hash} = CAccountMap.uncompact_state(compact) + {accounts, hash} = CAccountMap.uncompact_state(compact) elixir_hashes = compact - |> Enum.map(fn {id, acc} -> {id, Account.hash(Account.uncompact(acc))} end) + |> Enum.map(fn {id, acc} -> {id, hash_from_compact(acc)} end) |> Map.new() nif_hashes = @@ -58,20 +87,18 @@ defmodule ChainAccountHashNifTest do |> Map.new() assert nif_hashes == elixir_hashes - assert hash == CMerkleTree.root_hash(store) + assert hash == CAccountMap.root_hash(accounts) end test "uncompact_state account hashes match Account.hash/1" do compact = - for i <- 1..8, into: %{} do - {addr(i), sample_account(i) |> Account.compact()} - end + compact_accounts(Enum.map(1..8, fn i -> {addr(i), sample_account(i)} end)) - {accounts, store, hash} = CAccountMap.uncompact_state(compact) + {accounts, hash} = CAccountMap.uncompact_state(compact) elixir_hashes = compact - |> Enum.map(fn {id, acc} -> {id, Account.hash(Account.uncompact(acc))} end) + |> Enum.map(fn {id, acc} -> {id, hash_from_compact(acc)} end) |> Map.new() nif_hashes = @@ -82,14 +109,7 @@ defmodule ChainAccountHashNifTest do |> Map.new() assert nif_hashes == elixir_hashes - - elixir_root = - elixir_hashes - |> CMerkleTree.from_map() - |> CMerkleTree.root_hash() - - assert hash == elixir_root - assert CMerkleTree.root_hash(store) == elixir_root + assert hash == CAccountMap.root_hash(accounts) end test "uncompact_state on CAccountMap resource matches Account.hash/1" do @@ -101,7 +121,7 @@ defmodule ChainAccountHashNifTest do end) end) - {accounts, store, hash} = CAccountMap.uncompact_state(original.accounts) + {accounts, hash} = CAccountMap.uncompact_state(original.accounts) elixir_hashes = original.accounts @@ -117,23 +137,21 @@ defmodule ChainAccountHashNifTest do |> Map.new() assert nif_hashes == elixir_hashes - assert hash == CMerkleTree.root_hash(store) + assert hash == CAccountMap.root_hash(accounts) end test "uncompact_state uses compact root_hash when present" do compact = - for i <- 1..4, into: %{} do - {addr(i), sample_account(i) |> Account.compact()} - end + compact_accounts(Enum.map(1..4, fn i -> {addr(i), sample_account(i)} end)) assert Enum.all?(compact, fn {_id, acc} -> Map.has_key?(acc, :root_hash) end) assert Enum.all?(compact, fn {_id, acc} -> Map.has_key?(acc, :code_hash) end) - {accounts, store, hash} = CAccountMap.uncompact_state(compact) + {accounts, hash} = CAccountMap.uncompact_state(compact) elixir_hashes = compact - |> Enum.map(fn {id, acc} -> {id, Account.hash(Account.uncompact(acc))} end) + |> Enum.map(fn {id, acc} -> {id, hash_from_compact(acc)} end) |> Map.new() nif_hashes = @@ -144,67 +162,61 @@ defmodule ChainAccountHashNifTest do |> Map.new() assert nif_hashes == elixir_hashes - assert hash == CMerkleTree.root_hash(store) + assert hash == CAccountMap.root_hash(accounts) end test "uncompact_state falls back without compact root_hash field" do acc = sample_account(1) - tree = Account.tree(acc) legacy_account = %Chain.Account{ nonce: acc.nonce, balance: acc.balance, - storage_root: {MapMerkleTree, [], Map.new(CMerkleTree.to_list(tree))}, - code: acc.code + storage_root: {MapMerkleTree, [], Map.new(acc.storage_root)}, + code: acc.code, + map_backed: false, + root_hash: nil } legacy_compact = %{addr(1) => legacy_account} - {accounts, store, hash} = CAccountMap.uncompact_state(legacy_compact) + {accounts, hash} = CAccountMap.uncompact_state(legacy_compact) - expected_hash = - legacy_account - |> Account.uncompact() - |> Account.hash() + {nonce, balance, storage, code} = CAccountMap.get(accounts, addr(1)) - expected_root = - %{addr(1) => expected_hash} - |> CMerkleTree.from_map() - |> CMerkleTree.root_hash() + assert Account.hash(Account.from_parts(nonce, balance, storage, code)) == + hash_from_list_storage(acc) - assert hash == expected_root - assert CMerkleTree.root_hash(store) == expected_root + assert hash == CAccountMap.root_hash(accounts) assert CAccountMap.size(accounts) == 1 end test "uncompact_state falls back without compact code_hash field" do acc = sample_account(1) - tree = Account.tree(acc) - legacy_account = - acc - |> Map.from_struct() - |> Map.put(:storage_root, {MapMerkleTree, [], Map.new(CMerkleTree.to_list(tree))}) - |> Map.put(:root_hash, Account.root_hash(acc)) - |> Map.delete(:code_hash) - |> then(&struct(Chain.Account, &1)) + root = + CAccountMap.new() + |> CAccountMap.put(addr(0), acc.nonce, acc.balance, acc.storage_root, acc.code) + |> CAccountMap.storage_root_hash(addr(0)) + + legacy_account = %Chain.Account{ + nonce: acc.nonce, + balance: acc.balance, + storage_root: {MapMerkleTree, [], Map.new(acc.storage_root)}, + code: acc.code, + map_backed: false, + root_hash: root + } legacy_compact = %{addr(1) => legacy_account} - {accounts, store, hash} = CAccountMap.uncompact_state(legacy_compact) + {accounts, hash} = CAccountMap.uncompact_state(legacy_compact) - expected_hash = - legacy_account - |> Account.uncompact() - |> Account.hash() + {nonce, balance, storage, code} = CAccountMap.get(accounts, addr(1)) - expected_root = - %{addr(1) => expected_hash} - |> CMerkleTree.from_map() - |> CMerkleTree.root_hash() + assert Account.hash(Account.from_parts(nonce, balance, storage, code)) == + hash_from_compact(legacy_account) - assert hash == expected_root - assert CMerkleTree.root_hash(store) == expected_root + assert hash == CAccountMap.root_hash(accounts) assert CAccountMap.size(accounts) == 1 end @@ -214,7 +226,7 @@ defmodule ChainAccountHashNifTest do end end - test "State.uncompact matches Elixir tree/1 hash" do + test "State.uncompact matches original state hash" do original = State.new() |> then(fn st -> diff --git a/test/chain_state_merkle_test.exs b/test/chain_state_merkle_test.exs index cd30c7d..4112f7c 100644 --- a/test/chain_state_merkle_test.exs +++ b/test/chain_state_merkle_test.exs @@ -5,8 +5,8 @@ # Regression tests: Chain.State.difference / apply_difference must round-trip # (matches persisted block delta replay in Model.ChainSql.do_state/1). # -# Heavy cases mirror production: Evm.process_updates uses 32-byte key/value maps and -# CMerkleTree.insert_items/2; State.clone/1 + Account.clone/1 share trie pools (COW); +# Heavy cases mirror production: Evm.process_updates uses 32-byte key/value maps; +# State.clone/1 shares trie pools (COW); # sequential <> slots maximize shared key prefixes (trie depth). defmodule ChainStateMerkleTest do # NIF uses process-global allocators / stripe pools; run sequentially to avoid cross-test races. @@ -45,8 +45,7 @@ defmodule ChainStateMerkleTest do end defp account_from_evm_map(kvs) when is_map(kvs) do - tree = CMerkleTree.insert_items(CMerkleTree.new(), Map.to_list(kvs)) - Account.put_tree(Account.new(), tree) + %{Account.new() | storage_root: Map.to_list(kvs), map_backed: false, root_hash: nil} end defp account_with_storage(pairs) do @@ -54,9 +53,7 @@ defmodule ChainStateMerkleTest do end defp account_with_storage(%Account{} = acc, pairs) do - Enum.reduce(pairs, acc, fn {k, v}, a -> - Account.storage_set_value(a, k, v) - end) + %{acc | storage_root: pairs, map_backed: false, root_hash: nil} end defp put_account(%State{} = st, i, acc), do: State.set_account(st, addr(i), acc) @@ -125,7 +122,7 @@ defmodule ChainStateMerkleTest do assert_roundtrip(prev, next) end - test "single account: delete storage slot (CMerkleTree.delete)" do + test "single account: delete storage slot (storage_put_map zero)" do prev = State.new() |> put_account( @@ -136,14 +133,10 @@ defmodule ChainStateMerkleTest do ]) ) - acc_prev = State.account(prev, addr(1)) - - tree2 = - acc_prev - |> Account.tree() - |> CMerkleTree.delete(word32(2)) - - next = State.set_account(prev, addr(1), Account.put_tree(acc_prev, tree2)) + next = + State.storage_put_map(prev, %{ + addr(1) => %{word32(2) => <<0::unsigned-size(256)>>} + }) assert_roundtrip(prev, next) end @@ -229,8 +222,8 @@ defmodule ChainStateMerkleTest do end end - describe "EVM-shaped payloads (32-byte key/value, insert_items)" do - test "batch insert_items like Evm.process_updates/2" do + describe "EVM-shaped payloads (32-byte key/value via storage_put_map)" do + test "batch storage updates like Evm.process_updates/2" do kvs = for i <- 0..399, into: %{} do {slot_u256(i), val_u256(Bitwise.bxor(i, 0xDEAD_BEEF))} @@ -307,16 +300,18 @@ defmodule ChainStateMerkleTest do assert_roundtrip(prev, next) end - test "root_hash on deep U256-slot tree (get_proofs shape is NIF-specific; root_hash stresses trie)" do - t = - CMerkleTree.insert_items( - CMerkleTree.new(), - Enum.map(0..220, fn i -> {slot_u256(i), val_u256(i)} end) + test "storage_root_hash and storage_get_proofs on deep U256-slot account" do + st = + State.new() + |> put_account( + 1, + account_with_storage(Enum.map(0..220, fn i -> {slot_u256(i), val_u256(i)} end)) ) - assert is_binary(CMerkleTree.root_hash(t)) - p = CMerkleTree.get_proofs(t, slot_u256(0)) - assert is_tuple(p) or is_map(p) + root = State.storage_root_hash(st, addr(1)) + assert is_binary(root) and byte_size(root) == 32 + p = State.storage_get_proofs(st, addr(1), slot_u256(0)) + assert is_tuple(p) or is_map(p) or is_list(p) end test "large mutation round-trip after independent clones (no lock)" do @@ -328,9 +323,11 @@ defmodule ChainStateMerkleTest do State.new() |> put_account( 1, - Enum.reduce(0..500, account_u256_slots(0..130), fn i, a -> - Account.storage_set_value(a, slot_u256(i), val_u256(i + 99)) - end) + account_with_storage( + Enum.map(0..500, fn i -> + {slot_u256(i), val_u256(i + 99)} + end) + ) ) assert_roundtrip(prev, next) @@ -338,14 +335,14 @@ defmodule ChainStateMerkleTest do end describe "lock → clone writable fork (block sync path)" do - test "normalized :store fork writable after parent lock" do + test "normalized state fork writable after parent lock" do base = State.new() |> put_account(1, account_with_storage([{word32(1), val32(1)}])) |> put_account(2, account_u256_slots(0..8)) |> State.normalize() - assert base.store != nil + assert is_binary(State.hash(base)) parent_hash = State.hash(base) Chain.State.lock(base) @@ -362,9 +359,9 @@ defmodule ChainStateMerkleTest do ) assert State.hash(fork) != parent_hash - assert Account.storage_value(State.account(fork, addr(1)), word32(99)) == val32(99) + assert State.storage_value(fork, addr(1), word32(99)) == val32(99) - assert Account.storage_value(State.account(base, addr(1)), word32(99)) == + assert State.storage_value(base, addr(1), word32(99)) == <<0::unsigned-size(256)>> assert is_binary(State.hash(base)) @@ -381,21 +378,18 @@ defmodule ChainStateMerkleTest do fork = Enum.reduce(1..2, fork, fn aid, st -> - acc = - st - |> State.account(addr(aid)) - |> Account.storage_set_value(slot_u256(500 + aid), val_u256(aid * 100)) - - put_account(st, aid, acc) + State.storage_put_map(st, %{ + addr(aid) => %{slot_u256(500 + aid) => val_u256(aid * 100)} + }) end) assert is_binary(State.hash(fork)) for aid <- 1..2 do - assert Account.storage_value(State.account(fork, addr(aid)), slot_u256(500 + aid)) == + assert State.storage_value(fork, addr(aid), slot_u256(500 + aid)) == val_u256(aid * 100) - assert Account.storage_value(State.account(parent, addr(aid)), slot_u256(500 + aid)) == + assert State.storage_value(parent, addr(aid), slot_u256(500 + aid)) == <<0::unsigned-size(256)>> end end diff --git a/test/chain_state_uncompact_test.exs b/test/chain_state_uncompact_test.exs index 3f3318c..b0d6940 100644 --- a/test/chain_state_uncompact_test.exs +++ b/test/chain_state_uncompact_test.exs @@ -18,22 +18,13 @@ defmodule ChainStateUncompactTest do defp val(i), do: <> defp sample_account(i) do - tree = - CMerkleTree.insert_items(CMerkleTree.new(), [ - {slot(i), val(i)} - ]) - - %Account{nonce: i, balance: i * 1_000, storage_root: tree, code: <>} - end - - defp compact_accounts_map(n_accounts) do - for i <- 1..n_accounts, into: %{} do - {addr(i), sample_account(i) |> Account.compact()} - end - end - - defp compact_state(n_accounts) do - %State{accounts: compact_accounts_map(n_accounts)} + %Account{ + nonce: i, + balance: i * 1_000, + storage_root: [{slot(i), val(i)}], + code: <>, + map_backed: false + } end defp live_state(n_accounts) do @@ -45,6 +36,18 @@ defmodule ChainStateUncompactTest do end) end + defp compact_state(n_accounts), do: State.compact(live_state(n_accounts)) + + defp compact_accounts_map(n_accounts), do: compact_state(n_accounts).accounts + + defp compact_via_state(%Account{} = acc, id \\ addr(1)) do + State.new() + |> State.set_account(id, acc) + |> State.compact() + |> Map.fetch!(:accounts) + |> Map.fetch!(id) + end + describe "uncompact correctness" do test "compact map round-trips through uncompact with stable state hash" do original = live_state(32) @@ -58,22 +61,22 @@ defmodule ChainStateUncompactTest do for i <- 1..32 do acc = State.account(restored, addr(i)) assert acc.nonce == i - assert Account.storage_value(acc, slot(i)) == val(i) + assert State.storage_value(restored, addr(i), slot(i)) == val(i) end end test "empty compact map" do restored = State.uncompact(%State{accounts: %{}}) assert CAccountMap.size(restored.accounts) == 0 - assert restored.store != nil - assert State.hash(restored) == CMerkleTree.root_hash(CMerkleTree.new()) + assert is_binary(Chain.State.hash(restored)) + assert State.hash(restored) == CAccountMap.root_hash(CAccountMap.new()) end test "empty CAccountMap resource" do restored = State.uncompact(%State{accounts: CAccountMap.new()}) assert CAccountMap.size(restored.accounts) == 0 - assert restored.store != nil - assert State.hash(restored) == CMerkleTree.root_hash(CMerkleTree.new()) + assert is_binary(Chain.State.hash(restored)) + assert State.hash(restored) == CAccountMap.root_hash(CAccountMap.new()) end test "live CAccountMap resource rebuilds state trie" do @@ -83,7 +86,7 @@ defmodule ChainStateUncompactTest do restored = State.uncompact(original) assert State.hash(restored) == hash assert CAccountMap.size(restored.accounts) == 8 - assert restored.store != nil + assert is_binary(Chain.State.hash(restored)) refute restored.accounts === original.accounts end @@ -101,12 +104,11 @@ defmodule ChainStateUncompactTest do %Account{ nonce: 3, balance: 99, - storage_root: - CMerkleTree.insert_items(CMerkleTree.new(), [ - {slot(1), val(1)}, - {slot(2), val(2)}, - {slot(3), val(3)} - ]), + storage_root: [ + {slot(1), val(1)}, + {slot(2), val(2)}, + {slot(3), val(3)} + ], code: :binary.copy(<<0xAB>>, 512) } @@ -129,13 +131,13 @@ defmodule ChainStateUncompactTest do assert acc1.nonce == 3 assert acc1.balance == 99 assert acc1.code == :binary.copy(<<0xAB>>, 512) - assert Account.storage_value(acc1, slot(1)) == val(1) - assert Account.storage_value(acc1, slot(2)) == val(2) - assert Account.storage_value(acc1, slot(3)) == val(3) + assert State.storage_value(restored, addr(1), slot(1)) == val(1) + assert State.storage_value(restored, addr(1), slot(2)) == val(2) + assert State.storage_value(restored, addr(1), slot(3)) == val(3) acc2 = State.account(restored, addr(2)) assert acc2.code == nil - assert CMerkleTree.size(Account.tree(acc2)) == 0 + assert State.storage_size(restored, addr(2)) == 0 end test "compact account edge cases: nil storage, nil code, large balance, list storage" do @@ -150,13 +152,7 @@ defmodule ChainStateUncompactTest do %{ addr(1) => list_storage_account, addr(2) => - %Account{ - nonce: 7, - balance: 42, - storage_root: nil, - code: nil - } - |> Account.compact() + compact_via_state(%Account{nonce: 7, balance: 42, storage_root: nil, code: nil}) } restored = State.uncompact(%State{accounts: compact}) @@ -164,13 +160,13 @@ defmodule ChainStateUncompactTest do acc1 = State.account(restored, addr(1)) assert acc1.balance == 100_000_000_000_000_000_000_000_000 - assert Account.storage_value(acc1, slot(1)) == val(1) + assert State.storage_value(restored, addr(1), slot(1)) == val(1) acc2 = State.account(restored, addr(2)) assert acc2.nonce == 7 assert acc2.balance == 42 assert acc2.code == nil - assert CMerkleTree.size(Account.tree(acc2)) == 0 + assert State.storage_size(restored, addr(2)) == 0 end test "lazy storage materializes on CAccountMap.get without State.account" do @@ -178,21 +174,28 @@ defmodule ChainStateUncompactTest do %Account{ nonce: 5, balance: 1_000, - storage_root: - CMerkleTree.insert_items(CMerkleTree.new(), [ - {slot(1), val(1)}, - {slot(2), val(2)} - ]), + storage_root: [ + {slot(1), val(1)}, + {slot(2), val(2)} + ], code: <<5>> } - compact = %{addr(1) => Account.compact(multi_slot)} - {accounts, _store, _hash} = CAccountMap.uncompact_state(compact) + compact = %{addr(1) => compact_via_state(multi_slot)} + {accounts, _hash} = CAccountMap.uncompact_state(compact) - {5, 1_000, storage, <<5>>} = CAccountMap.get(accounts, addr(1)) + {5, 1_000, root, <<5>>} = CAccountMap.get(accounts, addr(1)) + assert is_binary(root) and byte_size(root) == 32 - assert CMerkleTree.get(storage, slot(1)) == val(1) - assert CMerkleTree.get(storage, slot(2)) == val(2) + expected_root = + CAccountMap.new() + |> CAccountMap.put(addr(1), 5, 1_000, multi_slot.storage_root, <<5>>) + |> CAccountMap.storage_root_hash(addr(1)) + + assert root == expected_root + + assert CAccountMap.storage_get(accounts, addr(1), slot(1)) == val(1) + assert CAccountMap.storage_get(accounts, addr(1), slot(2)) == val(2) end test "clone preserves lazy storage until materialized" do @@ -205,8 +208,7 @@ defmodule ChainStateUncompactTest do assert State.hash(cloned) == State.hash(original) for i <- 1..4 do - acc = State.account(cloned, addr(i)) - assert Account.storage_value(acc, slot(i)) == val(i) + assert State.storage_value(cloned, addr(i), slot(i)) == val(i) end end end @@ -221,29 +223,33 @@ defmodule ChainStateUncompactTest do |> then(fn st -> elem(CAccountMap.uncompact_state(st.accounts), 0) end) end - test "Account.compact stores code_hash matching Account.codehash/1" do + test "State.compact stores code_hash matching Account.codehash/1" do acc = sample_account(3) |> Map.put(:code, :binary.copy(<<0xEE>>, 256)) - compact = Account.compact(acc) + compact = compact_via_state(acc) assert compact.code_hash == Account.codehash(acc) - assert compact.root_hash == Account.root_hash(acc) + + expected_root = + CAccountMap.new() + |> CAccountMap.put(addr(1), acc.nonce, acc.balance, acc.storage_root, acc.code) + |> CAccountMap.storage_root_hash(addr(1)) + + assert compact.root_hash == expected_root end test "put overwrites lazy account without prior get" do - compact = %{addr(1) => sample_account(1) |> Account.compact()} - {accounts, _, _} = CAccountMap.uncompact_state(compact) + compact = %{addr(1) => compact_via_state(sample_account(1))} + {accounts, _} = CAccountMap.uncompact_state(compact) - new_storage = - CMerkleTree.insert_items(CMerkleTree.new(), [ - {slot(99), val(99)} - ]) + new_storage = [{slot(99), val(99)}] accounts = CAccountMap.put(accounts, addr(1), 9, 9_000, new_storage, <<9>>) - assert {9, 9_000, storage, <<9>>} = CAccountMap.get(accounts, addr(1)) - assert CMerkleTree.get(storage, slot(99)) == val(99) - refute CMerkleTree.get(storage, slot(1)) == val(1) + assert {9, 9_000, root, <<9>>} = CAccountMap.get(accounts, addr(1)) + assert is_binary(root) and byte_size(root) == 32 + assert CAccountMap.storage_get(accounts, addr(1), slot(99)) == val(99) + refute CAccountMap.storage_get(accounts, addr(1), slot(1)) == val(1) end test "to_list materializes lazy storage for every account" do @@ -257,10 +263,18 @@ defmodule ChainStateUncompactTest do assert map_size(listed) == 4 for i <- 1..4 do - {_nonce, _balance, storage, code} = Map.fetch!(listed, addr(i)) + {_nonce, _balance, root, code} = Map.fetch!(listed, addr(i)) assert code == <> - assert CMerkleTree.get(storage, slot(i)) == val(i) - assert CMerkleTree.root_hash(storage) == Account.root_hash(sample_account(i)) + assert is_binary(root) and byte_size(root) == 32 + assert CAccountMap.storage_get(accounts, addr(i), slot(i)) == val(i) + + expected_root = + CAccountMap.new() + |> CAccountMap.put(addr(i), i, i * 1_000, sample_account(i).storage_root, <>) + |> CAccountMap.storage_root_hash(addr(i)) + + assert root == expected_root + assert CAccountMap.storage_root_hash(accounts, addr(i)) == root end end @@ -276,21 +290,21 @@ defmodule ChainStateUncompactTest do test "clone GC after lazy uncompact leaves parent storage readable" do restored = live_state(6) |> State.compact() |> State.uncompact() - _fork = CAccountMap.clone(restored.accounts) - assert CAccountMap.size(_fork) == 6 - _fork = nil + fork = CAccountMap.clone(restored.accounts) + assert CAccountMap.size(fork) == 6 + _ = fork force_gc() for i <- 1..6 do - assert Account.storage_value(State.account(restored, addr(i)), slot(i)) == val(i) + assert State.storage_value(restored, addr(i), slot(i)) == val(i) end end test "fork put on lazy map isolates parent account data" do compact = compact_accounts_map(3) - {accounts, _, _} = CAccountMap.uncompact_state(compact) + {accounts, _} = CAccountMap.uncompact_state(compact) - new_storage = CMerkleTree.insert_items(CMerkleTree.new(), [{slot(50), val(50)}]) + new_storage = [{slot(50), val(50)}] fork = accounts @@ -309,19 +323,16 @@ defmodule ChainStateUncompactTest do fork = Enum.reduce(1..4, fork, fn i, state -> - acc = - state - |> State.account(addr(i)) - |> Account.storage_set_value(slot(100 + i), val(100 + i)) - - State.set_account(state, addr(i), acc) + State.storage_put_map(state, %{ + addr(i) => %{slot(100 + i) => val(100 + i)} + }) end) for i <- 1..4 do - assert Account.storage_value(State.account(fork, addr(i)), slot(100 + i)) == + assert State.storage_value(fork, addr(i), slot(100 + i)) == val(100 + i) - assert Account.storage_value(State.account(restored, addr(i)), slot(100 + i)) == + assert State.storage_value(restored, addr(i), slot(100 + i)) == <<0::unsigned-size(256)>> end end @@ -343,7 +354,7 @@ defmodule ChainStateUncompactTest do restored = State.uncompact(state) assert CAccountMap.size(restored.accounts) == @account_count - assert restored.store != nil + assert is_binary(Chain.State.hash(restored)) end @tag :slow @@ -351,36 +362,24 @@ defmodule ChainStateUncompactTest do state = compact_state(@account_count) assert map_size(state.accounts) == @account_count - {reduce_us, _accounts} = - :timer.tc(fn -> - Enum.reduce(state.accounts, CAccountMap.new(), fn {id, acc}, accounts -> - CAccountMap.put_account(accounts, id, Account.uncompact(acc)) - end) - end) - - {tree_us, _tree} = - :timer.tc(fn -> - state.accounts - |> Enum.map(fn {id, acc} -> {id, Account.hash(Account.uncompact(acc))} end) - |> Map.new() - |> CMerkleTree.from_map() - end) + {nif_us, {accounts, hash}} = + :timer.tc(fn -> CAccountMap.uncompact_state(state.accounts) end) {full_us, restored} = :timer.tc(fn -> State.uncompact(state) end) + assert CAccountMap.size(accounts) == @account_count + assert is_binary(hash) assert CAccountMap.size(restored.accounts) == @account_count assert is_binary(State.hash(restored)) - assert restored.store != nil + assert is_binary(Chain.State.hash(restored)) - reduce_ms = Float.round(reduce_us / 1000, 1) - tree_ms = Float.round(tree_us / 1000, 1) + nif_ms = Float.round(nif_us / 1000, 1) full_ms = Float.round(full_us / 1000, 1) IO.puts(""" State.uncompact performance (@account_count=#{@account_count}): - reduce+put loop: #{reduce_ms} ms - tree(from_map) estimate: #{tree_ms} ms - full uncompact: #{full_ms} ms + CAccountMap.uncompact_state: #{nif_ms} ms + full State.uncompact: #{full_ms} ms """) assert full_us < 150_000, diff --git a/test/chain_test.exs b/test/chain_test.exs index 763c3d9..35d2f64 100644 --- a/test/chain_test.exs +++ b/test/chain_test.exs @@ -36,8 +36,8 @@ defmodule ChainTest do code: Rlpx.bin2addr(state["code"]), nonce: Rlpx.bin2num(state["nonce"]), storage_root: - Enum.reduce(state["storage"], CMerkleTree.new(), fn {key, value}, tree -> - CMerkleTree.insert(tree, Rlpx.hex2num(key), Rlpx.bin2num(value)) + Enum.map(state["storage"] || %{}, fn {key, value} -> + {Hash.to_bytes32(Rlpx.hex2num(key)), Hash.to_bytes32(Rlpx.bin2num(value))} end) }} end) @@ -259,10 +259,19 @@ defmodule ChainTest do assert Account.nonce(result) == Account.nonce(account) assert Account.code(result) == Account.code(account) - # for {key, value} <- to_list(result.storage_root) do - # assert {key, value} == {key, Account.storageInteger(account, key)} - # end - assert to_list(Account.tree(result)) == to_list(Account.tree(account)) + # result is map-backed (no live trie); compare via State.storage_to_list. + ref_list = + case account do + %Account{storage_root: pairs} when is_list(pairs) -> + pairs + |> Enum.map(fn {key, value} -> {compress(key), compress(value)} end) + |> Enum.sort() + + _ -> + [] + end + + assert storage_list(state, addr) == ref_list end end @@ -271,8 +280,8 @@ defmodule ChainTest do assert post_keys == reference_keys end - defp to_list(tree) do - CMerkleTree.to_list(tree) + defp storage_list(state, addr) do + State.storage_to_list(state, addr) |> Enum.map(fn {key, value} -> {compress(key), compress(value)} end) |> Enum.sort() end diff --git a/test/cmerkle_account_map_diff_test.exs b/test/cmerkle_account_map_diff_test.exs index faf66a0..347b620 100644 --- a/test/cmerkle_account_map_diff_test.exs +++ b/test/cmerkle_account_map_diff_test.exs @@ -14,8 +14,7 @@ defmodule CMerkleAccountMapDiffTest do defp build_map(n, mutate \\ nil) do Enum.reduce(1..n, CAccountMap.new(), fn i, acc -> - storage = - CMerkleTree.insert(CMerkleTree.new(), slot(i), <>) + storage = [{slot(i), <>}] nonce = if mutate == {:nonce, i}, do: i + 1000, else: i balance = if mutate == {:balance, i}, do: i * 9_999, else: i * 1_000 @@ -25,63 +24,56 @@ defmodule CMerkleAccountMapDiffTest do end) end - defp legacy_diff(map_a, map_b) do - CMerkleTree.list_difference( - CAccountMap.to_account_list(map_a), - CAccountMap.to_account_list(map_b) - ) + defp full_addrs(map_a, map_b) do + CAccountMap.difference_full(map_a, map_b) + |> Enum.map(fn {addr, _, _, _, _, _} -> addr end) + |> Enum.sort() end - defp assert_diff_equivalent(map_a, map_b) do - native = CAccountMap.list_difference(map_a, map_b) - legacy = legacy_diff(map_a, map_b) - - assert Map.keys(native) |> Enum.sort() == Map.keys(legacy) |> Enum.sort() - - for key <- Map.keys(native) do - {na, nb} = native[key] - {la, lb} = legacy[key] - assert account_equal?(na, la) - assert account_equal?(nb, lb) + defp assert_difference_full_shape(map_a, map_b) do + full = CAccountMap.difference_full(map_a, map_b) + assert is_list(full) + + for {addr, _side_a, _side_b, storage_diff, root_a, root_b} <- full do + assert byte_size(addr) == 20 + assert is_list(storage_diff) or is_map(storage_diff) + assert root_a == nil or match?(<<_::binary-size(32)>>, root_a) + assert root_b == nil or match?(<<_::binary-size(32)>>, root_b) + in_a = CAccountMap.get(map_a, addr) != :undefined + in_b = CAccountMap.get(map_b, addr) != :undefined + assert in_a or in_b end - end - defp account_equal?(nil, nil), do: true - - defp account_equal?(%Account{} = a, %Account{} = b) do - a.nonce == b.nonce && a.balance == b.balance && a.code == b.code && - Account.root_hash(a) == Account.root_hash(b) + full end - defp account_equal?(a, b), do: a == b - - describe "native vs legacy list_difference equivalence" do + describe "difference_full" do test "empty maps" do a = CAccountMap.new() b = CAccountMap.new() - assert_diff_equivalent(a, b) - assert CAccountMap.list_difference(a, b) == %{} + assert CAccountMap.difference_full(a, b) == [] end test "identical maps" do a = build_map(40) b = CAccountMap.clone(a) - assert_diff_equivalent(a, b) - assert CAccountMap.list_difference(a, b) == %{} + assert CAccountMap.difference_full(a, b) == [] end test "same shared map resource" do a = build_map(10) - assert CAccountMap.list_difference(a, a) == %{} + assert CAccountMap.difference_full(a, a) == [] end test "add-only and delete-only accounts" do a = build_map(30) b = build_map(35) - assert_diff_equivalent(a, b) + assert full_addrs(a, b) == Enum.map(31..35, &addr/1) |> Enum.sort() + assert_difference_full_shape(a, b) c = build_map(20) - assert_diff_equivalent(a, c) + assert full_addrs(a, c) == Enum.map(21..30, &addr/1) |> Enum.sort() + assert_difference_full_shape(a, c) end test "scalar field mutations" do @@ -90,7 +82,7 @@ defmodule CMerkleAccountMapDiffTest do for mutate <- [{:nonce, 3}, {:balance, 7}, {:code, 11}] do fork = CAccountMap.clone(base) {kind, i} = mutate - storage = CMerkleTree.insert(CMerkleTree.new(), slot(i), <>) + storage = [{slot(i), <>}] fork = case kind do @@ -99,7 +91,8 @@ defmodule CMerkleAccountMapDiffTest do :code -> CAccountMap.put(fork, addr(i), i, i * 1_000, storage, <<99, 88>>) end - assert_diff_equivalent(base, fork) + assert full_addrs(base, fork) == [addr(i)] + assert_difference_full_shape(base, fork) end end @@ -107,24 +100,20 @@ defmodule CMerkleAccountMapDiffTest do a = build_map(25) b = CAccountMap.clone(a) id = addr(5) - {nonce, balance, storage, code} = CAccountMap.get(b, id) - storage = - CMerkleTree.insert( - CMerkleTree.clone(storage), - slot(99_999), - <<99_999::unsigned-size(256)>> - ) + b = + CAccountMap.storage_put_map(b, %{ + id => %{slot(99_999) => <<99_999::unsigned-size(256)>>} + }) - b = CAccountMap.put(b, id, nonce, balance, storage, code) - assert_diff_equivalent(a, b) + assert full_addrs(a, b) == [id] + assert_difference_full_shape(a, b) + assert CAccountMap.storage_get(b, id, slot(99_999)) == <<99_999::unsigned-size(256)>> + assert CAccountMap.storage_get(a, id, slot(99_999)) == nil end test "shared storage trie across accounts" do - storage = - CMerkleTree.insert_items(CMerkleTree.new(), [ - {slot(1), <<1::unsigned-size(256)>>} - ]) + storage = [{slot(1), <<1::unsigned-size(256)>>}] a = Enum.reduce(1..12, CAccountMap.new(), fn i, acc -> @@ -132,11 +121,11 @@ defmodule CMerkleAccountMapDiffTest do end) b = CAccountMap.clone(a) - assert_diff_equivalent(a, b) + assert CAccountMap.difference_full(a, b) == [] b = CAccountMap.put(b, addr(3), 99, 999, storage, <<1>>) - refute CAccountMap.list_difference(a, b) == %{} - assert_diff_equivalent(a, b) + assert full_addrs(a, b) == [addr(3)] + assert_difference_full_shape(a, b) end test "compact maps via State.compact" do @@ -144,8 +133,15 @@ defmodule CMerkleAccountMapDiffTest do State.new() |> then(fn st -> Enum.reduce(1..80, st, fn i, acc -> - storage = CMerkleTree.insert(CMerkleTree.new(), slot(i), <>) - acc0 = Account.put_tree(Account.new(nonce: i, balance: i), storage) + storage = [{slot(i), <>}] + + acc0 = %{ + Account.new(nonce: i, balance: i) + | storage_root: storage, + map_backed: false, + root_hash: nil + } + State.set_account(acc, addr(i), acc0) end) end) @@ -156,22 +152,17 @@ defmodule CMerkleAccountMapDiffTest do fork = live |> State.clone() - |> then(fn st -> - acc = State.account(st, addr(10)) + |> State.storage_put_map(%{ + addr(10) => %{slot(50_000) => <<50_000::unsigned-size(256)>>} + }) - tree = - Account.tree(acc) - |> CMerkleTree.insert(slot(50_000), <<50_000::unsigned-size(256)>>) - - State.set_account(st, addr(10), Account.put_tree(acc, tree)) - end) - - assert_diff_equivalent(compact_nif, fork.accounts) - assert_diff_equivalent(compact_nif, live.accounts) + assert full_addrs(compact_nif, fork.accounts) == [addr(10)] + assert_difference_full_shape(compact_nif, fork.accounts) + assert CAccountMap.difference_full(compact_nif, live.accounts) == [] end @tag :slow - test "randomized equivalence property" do + test "randomized difference_full consistency" do for seed <- 1..50 do :rand.seed(:exsss, {seed, seed, seed}) n = :rand.uniform(120) + 5 @@ -190,30 +181,29 @@ defmodule CMerkleAccountMapDiffTest do CAccountMap.delete(acc, id) 2 -> - storage = - CMerkleTree.insert( - CMerkleTree.new(), - slot(i + 10_000), - <> - ) - + storage = [{slot(i + 10_000), <>}] CAccountMap.put(acc, id, i + 1, i * 2_000, storage, <>) _ -> - {nonce, balance, storage, code} = CAccountMap.get(acc, id) - - storage = - CMerkleTree.insert( - CMerkleTree.clone(storage), - slot(i + 20_000), - <> - ) - - CAccountMap.put(acc, id, nonce, balance, storage, code) + case CAccountMap.get(acc, id) do + :undefined -> + acc + + {_nonce, _balance, _root, _code} -> + CAccountMap.storage_put_map(acc, %{ + id => %{slot(i + 20_000) => <>} + }) + end end end) - assert_diff_equivalent(a, b) + full = assert_difference_full_shape(a, b) + + if CAccountMap.root_hash(a) == CAccountMap.root_hash(b) do + assert full == [] + else + assert full != [] + end end end end @@ -224,23 +214,23 @@ defmodule CMerkleAccountMapDiffTest do State.new() |> then(fn st -> Enum.reduce(1..30, st, fn i, acc -> - storage = CMerkleTree.insert(CMerkleTree.new(), slot(i), <>) - State.set_account(acc, addr(i), Account.put_tree(Account.new(nonce: i), storage)) + storage = [{slot(i), <>}] + + State.set_account(acc, addr(i), %{ + Account.new(nonce: i) + | storage_root: storage, + map_backed: false, + root_hash: nil + }) end) end) next = prev |> State.clone() - |> then(fn st -> - acc = State.account(st, addr(7)) - - tree = - Account.tree(acc) - |> CMerkleTree.insert(slot(77_777), <<77_777::unsigned-size(256)>>) - - State.set_account(st, addr(7), Account.put_tree(acc, tree)) - end) + |> State.storage_put_map(%{ + addr(7) => %{slot(77_777) => <<77_777::unsigned-size(256)>>} + }) delta = State.difference(prev, next) diff --git a/test/cmerkle_lock_clone_regression_test.exs b/test/cmerkle_lock_clone_regression_test.exs new file mode 100644 index 0000000..88151d5 --- /dev/null +++ b/test/cmerkle_lock_clone_regression_test.exs @@ -0,0 +1,275 @@ +# Diode Server +# Copyright 2021-2024 Diode +# Licensed under the Diode License, Version 1.1 +# +# Regressions for lock/clone/storage_put_map after clone_lazy removal: +# 1) Cached/locked peak must use State.clone/1 for speculative forks. +# 2) State.lock freezes the account map (storage_put_map / put / delete raise). +# 3) Shared-storage lock must not deadlock. +# 4) difference_full is the sole NIF diff path. +# 5) State.storage_put_map works unlocked and raises on locked maps. +# 6) CAccountMap.get/2 returns a 32-byte storage root hash (never a live trie). +defmodule CMerkleLockCloneRegressionTest do + use ExUnit.Case, async: false + + alias Chain.{Account, State} + + @moduletag timeout: 120_000 + + defp addr(i), do: <> + defp slot(i), do: <> + defp val(i), do: <> + + defp sample_account(i) do + %Account{ + nonce: i, + balance: i * 1_000, + storage_root: [{slot(i), val(i)}], + code: <>, + map_backed: false + } + end + + defp locked_peak_like_state(n \\ 3) do + state = + Enum.reduce(1..n, State.new(), fn i, st -> + State.set_account(st, addr(i), sample_account(i)) + end) + + # BlockProcess.cache_block/1 locks the cached state before RPC/Edge/Shell read it. + State.lock(state) + state + end + + describe "cached peak speculative path (RPC/EdgeV2/Shell regression)" do + test "lock then clone/1 succeeds and isolates parent" do + peak = locked_peak_like_state() + + fork = State.clone(peak) + + updated = + State.storage_put_map(fork, %{ + addr(1) => %{slot(999) => val(999)} + }) + + assert State.storage_value(updated, addr(1), slot(999)) == val(999) + + assert State.storage_value(peak, addr(1), slot(999)) == + <<0::unsigned-size(256)>> + end + + test "set_account and put on locked peak raise without mutating parent" do + peak = locked_peak_like_state(1) + before_hash = State.hash(peak) + before_slot = State.storage_value(peak, addr(1), slot(1)) + + acc = + State.account(peak, addr(1)) + |> Map.put(:nonce, 99) + + assert_raise ArgumentError, fn -> + State.set_account(peak, addr(1), acc) + end + + assert_raise ArgumentError, fn -> + CAccountMap.put( + peak.accounts, + addr(1), + 99, + 1, + [], + <<>> + ) + end + + assert_raise ArgumentError, fn -> + CAccountMap.delete(peak.accounts, addr(1)) + end + + assert State.hash(peak) == before_hash + assert State.storage_value(peak, addr(1), slot(1)) == before_slot + end + + test "storage_put_map on unlocked works; on locked raises" do + unlocked = + State.new() + |> State.set_account(addr(1), sample_account(1)) + + updated = + State.storage_put_map(unlocked, %{ + addr(1) => %{slot(42) => val(42)} + }) + + assert CAccountMap.storage_get(updated.accounts, addr(1), slot(42)) == val(42) + assert CAccountMap.storage_get(updated.accounts, addr(1), slot(1)) == val(1) + + peak = State.lock(unlocked) + + assert_raise ArgumentError, fn -> + State.storage_put_map(peak, %{addr(1) => %{slot(43) => val(43)}}) + end + + assert CAccountMap.storage_get(peak.accounts, addr(1), slot(43)) == nil + assert CAccountMap.storage_get(peak.accounts, addr(1), slot(1)) == val(1) + end + end + + describe "lock storage immutability" do + test "get returns 32-byte root hash; storage_put_map on locked raises" do + peak = locked_peak_like_state(1) + {_n, _b, root, _c} = CAccountMap.get(peak.accounts, addr(1)) + assert is_binary(root) and byte_size(root) == 32 + assert root == CAccountMap.storage_root_hash(peak.accounts, addr(1)) + before = CAccountMap.storage_get(peak.accounts, addr(1), slot(1)) + + assert_raise ArgumentError, fn -> + CAccountMap.storage_put_map(peak.accounts, %{addr(1) => %{slot(42) => val(42)}}) + end + + assert CAccountMap.storage_get(peak.accounts, addr(1), slot(1)) == before + assert CAccountMap.storage_get(peak.accounts, addr(1), slot(42)) == nil + end + + test "put and storage_put_map on frozen map raise; state_root_hashes stay stable" do + peak = locked_peak_like_state(1) + before = State.state_root_hashes(peak) + + assert_raise ArgumentError, fn -> + State.storage_put_map(peak, %{addr(1) => %{slot(9) => val(9)}}) + end + + assert_raise ArgumentError, fn -> + CAccountMap.put(peak.accounts, addr(9), 0, 0, [], <<>>) + end + + assert State.state_root_hashes(peak) == before + assert length(before) == 16 + end + + test "apply_difference on locked map raises; clone then apply succeeds" do + prev = + State.new() + |> State.set_account(addr(1), sample_account(1)) + + next = + prev + |> State.clone() + |> State.storage_put_map(%{addr(1) => %{slot(7) => val(7)}}) + + delta = State.difference(prev, next) + + State.lock(prev) + + assert_raise ArgumentError, fn -> + State.apply_difference(prev, delta) + end + + restored = + prev + |> State.clone() + |> State.apply_difference(delta) + + assert State.storage_value(restored, addr(1), slot(7)) == val(7) + + assert State.storage_value(prev, addr(1), slot(7)) == + <<0::unsigned-size(256)>> + end + end + + describe "shared storage lock (apply_canonical_lock regression)" do + test "lock with many accounts sharing one storage completes and stays forkable" do + shared = Enum.map(1..8, fn i -> {slot(i), val(i)} end) + + accounts = + Enum.reduce(1..40, CAccountMap.new(), fn i, map -> + CAccountMap.put(map, addr(i), i, i * 100, shared, <>) + end) + + base = %State{State.new() | accounts: accounts} + + {lock_us, _} = :timer.tc(fn -> State.lock(base) end) + assert lock_us < 2_000_000, "shared-storage lock hung or was too slow: #{lock_us}µs" + + {clone_us, fork} = :timer.tc(fn -> State.clone(base) end) + assert clone_us < 2_000_000 + + updated = + State.storage_put_map(fork, %{ + addr(1) => %{slot(999) => val(999)} + }) + + assert State.storage_value(updated, addr(1), slot(999)) == val(999) + + assert State.storage_value(base, addr(1), slot(999)) == + <<0::unsigned-size(256)>> + + # Sibling account still sees original shared slots on the frozen parent. + assert State.storage_value(base, addr(2), slot(1)) == val(1) + end + + test "concurrent locks on distinct maps that share storage roots do not hang" do + # Same root hash via identical slot lists. Concurrent lock+clone must complete + # without hanging. + storage = [{slot(1), val(1)}, {slot(2), val(2)}] + + maps = + for i <- 1..8 do + CAccountMap.new() + |> CAccountMap.put(addr(i), i, i * 10, storage, <>) + |> CAccountMap.put(addr(100 + i), i, i * 10, storage, <>) + end + + forks = + maps + |> Task.async_stream( + fn map -> + CAccountMap.lock(map) + CAccountMap.clone(map) + end, + max_concurrency: 8, + timeout: 5_000, + ordered: true + ) + |> Enum.map(fn {:ok, fork} -> fork end) + + assert length(forks) == 8 + + Enum.each(forks, fn fork -> + assert CAccountMap.size(fork) == 2 + end) + end + end + + describe "difference_full sole NIF path" do + test "State.difference on locked peak vs mutated clone round-trips via clone+apply" do + peak = locked_peak_like_state(4) + + next = + peak + |> State.clone() + |> State.storage_put_map(%{addr(2) => %{slot(50) => val(50)}}) + |> then(fn st -> + State.set_account(st, addr(9), sample_account(9)) + end) + + delta = State.difference(peak, next) + assert is_list(delta) + assert delta != [] + + full_ids = + CAccountMap.difference_full(peak.accounts, next.accounts) + |> Enum.map(fn {id, _, _, _, _, _} -> id end) + |> Enum.sort() + + assert full_ids == Enum.sort([addr(2), addr(9)]) + + restored = + peak + |> State.clone() + |> State.apply_difference(delta) + |> State.normalize() + + assert State.hash(restored) == State.hash(State.normalize(next)) + end + end +end diff --git a/test/cmerkle_lock_concurrency_test.exs b/test/cmerkle_lock_concurrency_test.exs index e77c7e2..07bf7b4 100644 --- a/test/cmerkle_lock_concurrency_test.exs +++ b/test/cmerkle_lock_concurrency_test.exs @@ -8,179 +8,41 @@ defmodule CMerkleLockConcurrencyTest do @moduletag timeout: 300_000 @tasks 24 - @ops 30 @timeout_ms 120_000 - describe "lock + difference concurrency (production deadlock regression)" do - test "concurrent lock/1 on clones with identical root (enter_lock dedup)" do - data = - Enum.map(1..80, fn i -> - {String.pad_leading("L#{i}", 32), CMerkleTree.hash("lock#{i}")} - end) - - base = CMerkleTree.new() |> CMerkleTree.insert_items(data) - - run_parallel(@tasks, fn _ -> - _ = - base - |> CMerkleTree.clone() - |> CMerkleTree.lock() - - :ok - end) - end - - test "concurrent difference while locking cloned trees with shared roots" do - ta = - CMerkleTree.new() - |> CMerkleTree.insert_items( - Enum.map(1..120, fn i -> - {String.pad_leading("da#{i}", 32), CMerkleTree.hash("da#{i}")} - end) - ) - - tb = - CMerkleTree.new() - |> CMerkleTree.insert_items( - Enum.map(1..120, fn i -> - {String.pad_leading("db#{i}", 32), CMerkleTree.hash("db#{i}")} - end) - ) - - base = - CMerkleTree.new() - |> CMerkleTree.insert_items( - Enum.map(1..100, fn i -> - {String.pad_leading("sh#{i}", 32), CMerkleTree.hash("sh#{i}")} - end) - ) - - run_parallel(@tasks, fn w -> - if rem(w, 3) == 0 do - _ = CMerkleTree.difference(ta, tb) - else - _ = - base - |> CMerkleTree.clone() - |> CMerkleTree.lock() - - Enum.each(1..@ops, fn j -> - k = String.pad_leading("m#{w}_#{j}", 32) - _ = CMerkleTree.insert(ta, k, CMerkleTree.hash("v#{w}#{j}")) - _ = CMerkleTree.difference(ta, tb) - end) - end - - :ok - end) - end - - test "interleaved lock, difference, and mutate (P5 + P6 combined)" do - ta = - CMerkleTree.new() - |> CMerkleTree.insert_items( - Enum.map(1..100, fn i -> - {String.pad_leading("ia#{i}", 32), CMerkleTree.hash("ia#{i}")} - end) - ) - - tb = - CMerkleTree.new() - |> CMerkleTree.insert_items( - Enum.map(1..100, fn i -> - {String.pad_leading("ib#{i}", 32), CMerkleTree.hash("ib#{i}")} - end) - ) - - mutators = div(@tasks, 3) |> max(1) - lockers = div(@tasks, 3) |> max(1) - differ = @tasks - mutators - lockers - - run_parallel(mutators, fn w -> - Enum.each(1..@ops, fn j -> - k = String.pad_leading("mut#{w}_#{j}", 32) - _ = CMerkleTree.insert(ta, k, CMerkleTree.hash("mut#{w}#{j}")) - end) - - :ok - end) - - run_parallel(lockers, fn _ -> - _ = - ta - |> CMerkleTree.clone() - |> CMerkleTree.lock() - - :ok - end) - - run_parallel(differ, fn _ -> - _ = CMerkleTree.difference(ta, tb) - _ = CMerkleTree.difference(tb, ta) - :ok - end) - end - - test "lock dedup still shares canonical root across clones" do - data = - Enum.map(1..40, fn i -> - {String.pad_leading("d#{i}", 32), CMerkleTree.hash("d#{i}")} - end) - - base = CMerkleTree.new() |> CMerkleTree.insert_items(data) - expected_root = CMerkleTree.root_hash(base) - - locked = - 1..8 - |> Enum.map(fn _ -> - base |> CMerkleTree.clone() |> CMerkleTree.lock() - end) - - for tree <- locked do - assert CMerkleTree.root_hash(tree) == expected_root - end - end - end - describe "account_map_lock NIF concurrency" do test "concurrent CAccountMap.lock on maps with deduped shared storage tries" do map = lock_test_shared_storage_map(60, 5) - store = - CMerkleTree.insert_items(CMerkleTree.new(), [ - {String.pad_leading("store", 32), CMerkleTree.hash("store")} - ]) - run_parallel(@tasks, fn _ -> - _ = map |> CAccountMap.clone() |> CAccountMap.lock(store) + _ = map |> CAccountMap.clone() |> CAccountMap.lock() :ok end) end - test "concurrent account_map_lock and storage difference" do + test "concurrent account_map_lock and storage root / list reads" do map = lock_test_shared_storage_map(48, 4) - {_, _, storage_a, _} = CAccountMap.get(map, <<1::unsigned-size(160)>>) - {_, _, storage_b, _} = CAccountMap.get(map, <<2::unsigned-size(160)>>) - lockers = div(@tasks, 2) |> max(1) - differ = @tasks - lockers + readers = @tasks - lockers run_parallel(lockers, fn _ -> - _ = map |> CAccountMap.clone() |> CAccountMap.lock(nil) + _ = map |> CAccountMap.clone() |> CAccountMap.lock() :ok end) - run_parallel(differ, fn _ -> - _ = CMerkleTree.difference(storage_a, storage_b) - _ = CMerkleTree.difference(storage_b, storage_a) + run_parallel(readers, fn _ -> + _ = CAccountMap.storage_root_hash(map, <<1::unsigned-size(160)>>) + _ = CAccountMap.storage_root_hash(map, <<2::unsigned-size(160)>>) + _ = CAccountMap.storage_to_list(map, <<1::unsigned-size(160)>>) + _ = CAccountMap.storage_to_list(map, <<2::unsigned-size(160)>>) :ok end) end end - describe "D-C7 native list_difference vs account_map_get" do - test "concurrent list_difference and get materialize" do + describe "D-C7 native difference_full vs account_map_get" do + test "concurrent difference_full and get materialize" do map = lock_test_shared_storage_map(60, 5) fork = CAccountMap.clone(map) @@ -193,28 +55,28 @@ defmodule CMerkleLockConcurrencyTest do end) run_parallel(differ, fn _ -> - _ = CAccountMap.list_difference(map, fork) + _ = CAccountMap.difference_full(map, fork) :ok end) end end - describe "D-C8 dual-map list_difference ordering" do - test "list_difference(A,B) concurrent with list_difference(B,A)" do + describe "D-C8 dual-map difference_full ordering" do + test "difference_full(A,B) concurrent with difference_full(B,A)" do a = lock_test_shared_storage_map(40, 4) b = CAccountMap.clone(a) - b = CAccountMap.put(b, <<3::unsigned-size(160)>>, 99, 99_000, CMerkleTree.new(), <<99>>) + b = CAccountMap.put(b, <<3::unsigned-size(160)>>, 99, 99_000, [], <<99>>) ab = div(@tasks, 2) |> max(1) ba = @tasks - ab run_parallel(ab, fn _ -> - _ = CAccountMap.list_difference(a, b) + _ = CAccountMap.difference_full(a, b) :ok end) run_parallel(ba, fn _ -> - _ = CAccountMap.list_difference(b, a) + _ = CAccountMap.difference_full(b, a) :ok end) end @@ -222,11 +84,8 @@ defmodule CMerkleLockConcurrencyTest do defp lock_test_shared_storage_map(account_count, group_size) do Enum.reduce(1..account_count, CAccountMap.new(), fn i, map -> - storage = - CMerkleTree.insert_items(CMerkleTree.new(), [ - {String.pad_leading("g#{div(i - 1, group_size)}", 32), - CMerkleTree.hash("g#{div(i - 1, group_size)}")} - ]) + g = div(i - 1, group_size) + storage = [{String.pad_leading("g#{g}", 32), Diode.hash("g#{g}")}] CAccountMap.put(map, <>, i, i * 1_000, storage, <>) end) diff --git a/test/cmerkle_nif_deadlock_test.exs b/test/cmerkle_nif_deadlock_test.exs index 72d58f9..9c5cf8b 100644 --- a/test/cmerkle_nif_deadlock_test.exs +++ b/test/cmerkle_nif_deadlock_test.exs @@ -11,90 +11,8 @@ defmodule CMerkleNifDeadlockTest do @moduletag :cmerkle_concurrency @tasks 32 - @ops 25 @timeout_ms 180_000 - @p9_tasks 48 - @p9_ops 40 - - describe "D-A1/P9 phased lock_and_difference (nightly regression)" do - @describetag timeout: 180_000 - - test "phase-1 locked clone drop then phase-2 insert and difference load" do - lockers = div(@p9_tasks, 3) |> max(1) - differ = @p9_tasks - lockers - - shared = prefilled_tree("s", 100) - other = prefilled_tree("o", 100) - - run_parallel(lockers, fn _ -> - _ = - shared - |> CMerkleTree.clone() - |> CMerkleTree.lock() - - :ok - end) - - :erlang.garbage_collect() - - run_parallel(differ, fn w -> - Enum.each(1..@p9_ops, fn j -> - k = String.pad_leading("p9#{w}_#{j}", 32) - _ = CMerkleTree.insert(shared, k, CMerkleTree.hash("p9#{w}#{j}")) - _ = CMerkleTree.difference(shared, other) - _ = CMerkleTree.difference(other, shared) - end) - - :ok - end) - end - end - - describe "D-A2 leave_lock (GC) vs difference on same tree" do - test "concurrent disposable trees, GC, and difference on anchor" do - anchor = - CMerkleTree.new() - |> CMerkleTree.insert_items( - Enum.map(1..80, fn i -> - {String.pad_leading("a2a#{i}", 32), CMerkleTree.hash("a2a#{i}")} - end) - ) - - other = - CMerkleTree.new() - |> CMerkleTree.insert_items( - Enum.map(1..80, fn i -> - {String.pad_leading("a2o#{i}", 32), CMerkleTree.hash("a2o#{i}")} - end) - ) - - run_parallel(@tasks, fn w -> - Enum.each(1..@ops, fn j -> - _ = - CMerkleTree.new() - |> CMerkleTree.insert_items( - Enum.map(1..12, fn k -> - {String.pad_leading("d#{w}_#{j}_#{k}", 32), CMerkleTree.hash("d#{w}#{j}#{k}")} - end) - ) - - _ = CMerkleTree.difference(anchor, other) - - if rem(j, 7) == 0 do - _ = - anchor - |> CMerkleTree.clone() - |> CMerkleTree.lock() - end - end) - - if rem(w, 5) == 0, do: :erlang.garbage_collect() - :ok - end) - end - end - describe "D-C2 uncompact_state vs storage difference" do test "parallel uncompact and per-account storage diffs" do n = 200 @@ -103,22 +21,36 @@ defmodule CMerkleNifDeadlockTest do run_parallel(workers, fn i -> if rem(i, 2) == 0 do - {accounts, _store, _hash} = CAccountMap.uncompact_state(compact) + {accounts, _hash} = CAccountMap.uncompact_state(compact) if CAccountMap.size(accounts) != n, do: raise("size mismatch") else a = - CMerkleTree.insert_items(CMerkleTree.new(), [ - {slot(i), <>}, - {slot(i + 7000), <>} - ]) + CAccountMap.new() + |> CAccountMap.put( + addr(i), + i, + i * 100, + [ + {slot(i), <>}, + {slot(i + 7000), <>} + ], + <> + ) b = - CMerkleTree.insert_items(CMerkleTree.new(), [ - {slot(i), <>}, - {slot(i + 8000), <>} - ]) + CAccountMap.new() + |> CAccountMap.put( + addr(i), + i, + i * 100, + [ + {slot(i), <>}, + {slot(i + 8000), <>} + ], + <> + ) - _ = CMerkleTree.difference(a, b) + _ = CAccountMap.difference_full(a, b) end :ok @@ -137,13 +69,10 @@ defmodule CMerkleNifDeadlockTest do |> then(fn st -> Enum.reduce(1..40, st, fn i, acc -> id = addr(rem(i, n) + 1) - acc0 = State.account(acc, id) - tree = Account.tree(acc0) - tree = - CMerkleTree.insert(tree, slot(i + 200_000), <>) - - State.set_account(acc, id, Account.put_tree(acc0, tree)) + State.storage_put_map(acc, %{ + id => %{slot(i + 200_000) => <>} + }) end) end) @@ -176,23 +105,16 @@ defmodule CMerkleNifDeadlockTest do map = Enum.reduce(1..n, CAccountMap.new(), fn i, acc -> - storage = - CMerkleTree.insert(CMerkleTree.new(), slot(i), <>) - + storage = [{slot(i), <>}] CAccountMap.put(acc, addr(i), i, i * 1_000, storage, <>) end) - store = - CMerkleTree.insert_items(CMerkleTree.new(), [ - {slot(99_999), <<99_999::unsigned-size(256)>>} - ]) - lockers = div(@tasks, 3) |> max(1) cloners = div(@tasks, 3) |> max(1) differ = @tasks - lockers - cloners run_parallel(lockers, fn _ -> - _ = CAccountMap.lock(map, store) + _ = CAccountMap.lock(map) :ok end) @@ -205,7 +127,7 @@ defmodule CMerkleNifDeadlockTest do addr(rem(w, n) + 1), 9_999, 9_999, - CMerkleTree.new(), + [], <<9_999>> ) @@ -214,115 +136,20 @@ defmodule CMerkleNifDeadlockTest do end) run_parallel(differ, fn i -> - a = CAccountMap.get(map, addr(rem(i, n) + 1)) - b = CAccountMap.get(map, addr(rem(i + 1, n) + 1)) - - case {a, b} do - {{_, _, sa, _}, {_, _, sb, _}} -> - _ = CMerkleTree.difference(sa, sb) - - _ -> - :ok - end - - :ok - end) - end - end - - describe "D-B2 three-tree difference overlap" do - test "difference(A,B) concurrent with difference(A,C)" do - shared = - CMerkleTree.new() - |> CMerkleTree.insert_items( - Enum.map(1..100, fn i -> - {String.pad_leading("b2s#{i}", 32), CMerkleTree.hash("b2s#{i}")} - end) - ) - - tb = - CMerkleTree.new() - |> CMerkleTree.insert_items( - Enum.map(1..100, fn i -> - {String.pad_leading("b2b#{i}", 32), CMerkleTree.hash("b2b#{i}")} - end) - ) - - tc = - CMerkleTree.new() - |> CMerkleTree.insert_items( - Enum.map(1..100, fn i -> - {String.pad_leading("b2c#{i}", 32), CMerkleTree.hash("b2c#{i}")} - end) - ) - - ab = div(@tasks, 2) |> max(1) - ac = @tasks - ab - - run_parallel(ab, fn _ -> - Enum.each(1..@ops, fn _ -> - _ = CMerkleTree.difference(shared, tb) - end) - - :ok - end) - - run_parallel(ac, fn _ -> - Enum.each(1..@ops, fn _ -> - _ = CMerkleTree.difference(shared, tc) - _ = CMerkleTree.difference(tb, tc) - end) - - :ok - end) - end - end - - describe "D-F2 enter_lock canonical ref vs concurrent leave_lock" do - test "many clones lock same root under difference load" do - data = - Enum.map(1..100, fn i -> - {String.pad_leading("f2#{i}", 32), CMerkleTree.hash("f2#{i}")} - end) - - base = CMerkleTree.new() |> CMerkleTree.insert_items(data) - expected_root = CMerkleTree.root_hash(base) - - other = - CMerkleTree.new() - |> CMerkleTree.insert_items( - Enum.map(1..100, fn i -> - {String.pad_leading("f2o#{i}", 32), CMerkleTree.hash("f2o#{i}")} - end) - ) - - lockers = div(@tasks, 2) |> max(1) - differ = @tasks - lockers - - run_parallel(lockers, fn _ -> - locked = - base - |> CMerkleTree.clone() - |> CMerkleTree.lock() - - if CMerkleTree.root_hash(locked) != expected_root, do: raise("root mismatch") - :ok - end) - - run_parallel(differ, fn w -> - Enum.each(1..@ops, fn j -> - k = String.pad_leading("f2m#{w}_#{j}", 32) - _ = CMerkleTree.insert(base, k, CMerkleTree.hash("m#{w}#{j}")) - _ = CMerkleTree.difference(base, other) - end) - + a = addr(rem(i, n) + 1) + b = addr(rem(i + 1, n) + 1) + _ = CAccountMap.get(map, a) + _ = CAccountMap.get(map, b) + _ = CAccountMap.storage_root_hash(map, a) + _ = CAccountMap.storage_root_hash(map, b) + _ = CAccountMap.storage_to_list(map, a) :ok end) end end describe "D-D7 prepare_state composite (us1-shaped)" do - test "concurrent list_difference, to_list, and State.lock" do + test "concurrent difference_full, to_list, and State.lock" do n = 100 prev = build_live_state(n) |> State.normalize() @@ -332,20 +159,17 @@ defmodule CMerkleNifDeadlockTest do |> then(fn st -> Enum.reduce(1..20, st, fn i, acc -> id = addr(rem(i, n) + 1) - acc0 = State.account(acc, id) - tree = Account.tree(acc0) - - tree = - CMerkleTree.insert(tree, slot(i + 300_000), <>) - State.set_account(acc, id, Account.put_tree(acc0, tree)) + State.storage_put_map(acc, %{ + id => %{slot(i + 300_000) => <>} + }) end) end) differ = div(@tasks, 4) |> max(1) lockers = div(@tasks, 4) |> max(1) - legacy = div(@tasks, 4) |> max(1) - native = @tasks - differ - lockers - legacy + listers = div(@tasks, 4) |> max(1) + native = @tasks - differ - lockers - listers run_parallel(differ, fn _ -> _ = State.difference(prev, block) @@ -357,13 +181,13 @@ defmodule CMerkleNifDeadlockTest do :ok end) - run_parallel(legacy, fn _ -> + run_parallel(listers, fn _ -> _ = CAccountMap.to_list(block.accounts) :ok end) run_parallel(native, fn _ -> - _ = CAccountMap.list_difference(prev.accounts, block.accounts) + _ = CAccountMap.difference_full(prev.accounts, block.accounts) :ok end) end @@ -373,33 +197,31 @@ defmodule CMerkleNifDeadlockTest do defp slot(i), do: <> - defp prefilled_tree(prefix, n) do - CMerkleTree.new() - |> CMerkleTree.insert_items( - Enum.map(1..n, fn i -> - {String.pad_leading("#{prefix}#{i}", 32), CMerkleTree.hash("#{prefix}#{i}")} - end) - ) - end - defp build_compact_accounts(n) do - for i <- 1..n, into: %{} do - tree = - CMerkleTree.insert(CMerkleTree.new(), slot(i), <>) + Enum.reduce(1..n, State.new(), fn i, st -> + acc = %Account{ + nonce: i, + balance: i * 1_000, + storage_root: [{slot(i), <>}], + code: <>, + map_backed: false + } - acc = %Account{nonce: i, balance: i * 1_000, storage_root: tree, code: <>} - {addr(i), Account.compact(acc)} - end + State.set_account(st, addr(i), acc) + end) + |> State.compact() + |> Map.fetch!(:accounts) end defp build_live_state(n) do Enum.reduce(1..n, State.new(), fn i, st -> - tree = - CMerkleTree.insert_items(CMerkleTree.new(), [ - {slot(i), <>} - ]) + acc = %Account{ + nonce: i, + balance: i * 1_000, + storage_root: [{slot(i), <>}], + code: <> + } - acc = %Account{nonce: i, balance: i * 1_000, storage_root: tree, code: <>} State.set_account(st, addr(i), acc) end) end diff --git a/test/cmerkle_nif_leak_test.exs b/test/cmerkle_nif_leak_test.exs index 0322a40..14693b6 100644 --- a/test/cmerkle_nif_leak_test.exs +++ b/test/cmerkle_nif_leak_test.exs @@ -33,32 +33,27 @@ defmodule CMerkleNifLeakTest do end describe "canonical lock dedup must not leak SharedState" do - test "empty trie lock storm" do + test "empty account map lock storm" do baseline = rss_kb() for _ <- 1..300 do - CMerkleTree.new() |> CMerkleTree.lock() + CAccountMap.new() |> CAccountMap.lock() end force_gc() {_locked, orphans, _shared, _res} = CMerkleTree.nif_stats() - assert orphans == 0 + assert orphans <= 2 assert rss_kb() - baseline < 50_000 end test "account_map_lock with identical storage roots" do baseline = rss_kb() n = 60 + storage = [{slot(1), val(1)}, {slot(2), val(2)}] for _ <- 1..30 do map = Enum.reduce(1..n, CAccountMap.new(), fn i, acc -> - storage = - CMerkleTree.insert_items(CMerkleTree.new(), [ - {slot(1), val(1)}, - {slot(2), val(2)} - ]) - CAccountMap.put(acc, addr(i), i, i * 1_000, storage, <>) end) @@ -73,22 +68,20 @@ defmodule CMerkleNifLeakTest do test "locked clone GC retains canonical map until last registration" do shared = - CMerkleTree.new() - |> CMerkleTree.insert_items( - Enum.map(1..80, fn i -> - {String.pad_leading("lc#{i}", 32), CMerkleTree.hash("lc#{i}")} - end) - ) + Enum.reduce(1..80, CAccountMap.new(), fn i, acc -> + storage = [{String.pad_leading("lc#{i}", 32), Diode.hash("lc#{i}")}] + CAccountMap.put(acc, addr(i), i, i * 1_000, storage, <>) + end) for _ <- 1..300 do - _ = shared |> CMerkleTree.clone() |> CMerkleTree.lock() + _ = shared |> CAccountMap.clone() |> CAccountMap.lock() end force_gc() {locked, orphans, shared_count, _res} = CMerkleTree.nif_stats() assert orphans == 0 assert locked <= 80 - assert shared_count < 500 + assert shared_count < 2000 end test "block sync shaped compact uncompact lock loop" do @@ -101,10 +94,7 @@ defmodule CMerkleNifLeakTest do acc = %Account{ nonce: i, balance: i * 1_000, - storage_root: - CMerkleTree.insert_items(CMerkleTree.new(), [ - {slot(i), val(i)} - ]), + storage_root: [{slot(i), val(i)}], code: <> } @@ -124,29 +114,27 @@ defmodule CMerkleNifLeakTest do assert rss_kb() - baseline < 100_000 end - test "account_map list_difference loop does not grow shared_states" do + test "account_map difference_full loop does not grow shared_states" do n = 80 base = build_diff_map(n) fork = CAccountMap.clone(base) |> then(fn map -> - {nonce, balance, storage, code} = CAccountMap.get(map, addr(3)) - - storage = - CMerkleTree.insert( - CMerkleTree.clone(storage), - slot(99_999), - <<99_999::unsigned-size(256)>> - ) - - CAccountMap.put(map, addr(3), nonce + 1, balance, storage, code) + map + |> CAccountMap.storage_put_map(%{ + addr(3) => %{slot(99_999) => <<99_999::unsigned-size(256)>>} + }) + |> then(fn m -> + {nonce, balance, _root, code} = CAccountMap.get(m, addr(3)) + CAccountMap.put_meta(m, addr(3), nonce + 1, balance, code) + end) end) {_locked0, _orphans0, shared0, _res0} = CMerkleTree.nif_stats() for _ <- 1..100 do - _ = CAccountMap.list_difference(base, fork) + _ = CAccountMap.difference_full(base, fork) end force_gc() @@ -158,7 +146,7 @@ defmodule CMerkleNifLeakTest do defp build_diff_map(n) do Enum.reduce(1..n, CAccountMap.new(), fn i, acc -> - storage = CMerkleTree.insert_items(CMerkleTree.new(), [{slot(i), val(i)}]) + storage = [{slot(i), val(i)}] CAccountMap.put(acc, addr(i), i, i * 1_000, storage, <>) end) end diff --git a/test/cmerkle_storage_map_test.exs b/test/cmerkle_storage_map_test.exs new file mode 100644 index 0000000..d54d026 --- /dev/null +++ b/test/cmerkle_storage_map_test.exs @@ -0,0 +1,107 @@ +# Diode Server +# Copyright 2021-2024 Diode +# Licensed under the Diode License, Version 1.1 +# +# Focused coverage for CAccountMap / Chain.State storage_* and put_meta APIs. +defmodule CMerkleStorageMapTest do + use ExUnit.Case, async: false + + alias Chain.State + + @moduletag timeout: 60_000 + + defp addr(i), do: <> + defp slot(i), do: <> + defp val(i), do: <> + + test "storage_put_map multi-account multi-slot then get / to_list / root_hash" do + map = CAccountMap.new() + + map = + CAccountMap.storage_put_map(map, %{ + addr(1) => %{slot(10) => val(10), slot(11) => val(11)}, + addr(2) => %{slot(20) => val(20), slot(21) => val(21), slot(22) => val(22)} + }) + + assert CAccountMap.storage_get(map, addr(1), slot(10)) == val(10) + assert CAccountMap.storage_get(map, addr(1), slot(11)) == val(11) + assert CAccountMap.storage_get(map, addr(2), slot(20)) == val(20) + assert CAccountMap.storage_get(map, addr(2), slot(22)) == val(22) + assert CAccountMap.storage_get(map, addr(1), slot(99)) == nil + + list1 = CAccountMap.storage_to_list(map, addr(1)) |> Enum.sort() + assert list1 == Enum.sort([{slot(10), val(10)}, {slot(11), val(11)}]) + + list2 = CAccountMap.storage_to_list(map, addr(2)) + assert length(list2) == 3 + assert CAccountMap.storage_size(map, addr(1)) == 2 + assert CAccountMap.storage_size(map, addr(2)) == 3 + + root1 = CAccountMap.storage_root_hash(map, addr(1)) + root2 = CAccountMap.storage_root_hash(map, addr(2)) + assert is_binary(root1) and byte_size(root1) == 32 + assert is_binary(root2) and byte_size(root2) == 32 + assert root1 != root2 + + # State helpers mirror the same values. + state = %State{accounts: map} + assert State.storage_value(state, addr(1), slot(10)) == val(10) + assert State.storage_size(state, addr(2)) == 3 + assert State.storage_root_hash(state, addr(1)) == root1 + end + + test "storage_put_map on frozen map raises" do + map = + CAccountMap.new() + |> CAccountMap.storage_put_map(%{addr(1) => %{slot(1) => val(1)}}) + + CAccountMap.lock(map) + + assert_raise ArgumentError, fn -> + CAccountMap.storage_put_map(map, %{addr(1) => %{slot(2) => val(2)}}) + end + + assert CAccountMap.storage_get(map, addr(1), slot(1)) == val(1) + assert CAccountMap.storage_get(map, addr(1), slot(2)) == nil + end + + test "put_meta updates nonce without wiping storage" do + map = + CAccountMap.new() + |> CAccountMap.storage_put_map(%{ + addr(1) => %{slot(5) => val(5), slot(6) => val(6)} + }) + + before_root = CAccountMap.storage_root_hash(map, addr(1)) + before_list = CAccountMap.storage_to_list(map, addr(1)) |> Enum.sort() + + map = CAccountMap.put_meta(map, addr(1), 42, 9_999, <<1, 2, 3>>) + + {nonce, balance, _storage, code} = CAccountMap.get(map, addr(1)) + assert nonce == 42 + assert balance == 9_999 + assert code == <<1, 2, 3>> + + assert CAccountMap.storage_get(map, addr(1), slot(5)) == val(5) + assert CAccountMap.storage_get(map, addr(1), slot(6)) == val(6) + assert CAccountMap.storage_to_list(map, addr(1)) |> Enum.sort() == before_list + assert CAccountMap.storage_root_hash(map, addr(1)) == before_root + end + + test "get_proofs and storage_get_proofs return terms without crashing" do + map = + CAccountMap.new() + |> CAccountMap.put(addr(1), 1, 100, [], <<>>) + |> CAccountMap.storage_put_map(%{addr(1) => %{slot(7) => val(7)}}) + + account_proof = CAccountMap.get_proofs(map, addr(1)) + assert account_proof != nil + + storage_proof = CAccountMap.storage_get_proofs(map, addr(1), slot(7)) + assert storage_proof != nil + + state = %State{accounts: map} + assert State.get_proofs(state, addr(1)) != nil + assert State.storage_get_proofs(state, addr(1), slot(7)) != nil + end +end diff --git a/test/cmerkletree_test.exs b/test/cmerkletree_test.exs deleted file mode 100644 index 60a8281..0000000 --- a/test/cmerkletree_test.exs +++ /dev/null @@ -1,541 +0,0 @@ -# Diode Server -# Copyright 2021-2024 Diode -# Licensed under the Diode License, Version 1.1 -defmodule CMerkleTreeTest do - use ExUnit.Case - - # Reference: count zero bytes (same semantics as legacy Niffler / EVM gas calc). - defp ref_count_zeros(bin), do: Enum.count(:binary.bin_to_list(bin), &(&1 == 0)) - - describe "golden root_hash regression" do - test "pairs dataset matches fixed roots (Elixir pairs/1 format)" do - for {n, hex} <- [ - {20, "b043342d7f3104673d8a0aa10e85ac17dd3edddb71754490247a0b945be71521"}, - {128, "852d365bfbad6c28924a672e304d62a118ca6adbe48aa2471122e54d586b8f37"}, - {1000, "ee3b68fee8188459a39e190e0dfcff4c7ea1711fdf62b4cb553f0b8c0ed350b4"} - ] do - tree = - Enum.reduce(pairs(n), new(), fn item, acc -> - CMerkleTree.insert_item(acc, item) - end) - - assert Base.encode16(CMerkleTree.root_hash(tree), case: :lower) == hex - end - end - end - - describe "memory introspection NIFs" do - test "struct_sizes and memory_stats are consistent" do - {ib, pb, _plb, _tb, stripe} = CMerkleTree.struct_sizes() - assert stripe >= 1 - assert ib > 0 and pb > 0 - - tree = - Enum.reduce(pairs(30), new(), fn item, acc -> - CMerkleTree.insert_item(acc, item) - end) - - {nodes, pairs_n, approx} = CMerkleTree.memory_stats(tree) - assert pairs_n == 30 - assert nodes >= 1 - assert approx == nodes * ib + pairs_n * pb - end - - test "malloc_info returns XML on GNU libc" do - case CMerkleTree.malloc_info() do - :unsupported -> assert true - bin when is_binary(bin) -> assert String.contains?(bin, "malloc") - end - end - end - - describe "count_zeros" do - test "matches reference for edge and random payloads" do - bins = [ - <<>>, - <<0>>, - <<255>>, - <<0, 0, 0>>, - <<0, 1, 2, 0>>, - :crypto.strong_rand_bytes(256) - ] - - for bin <- bins do - assert CMerkleTree.count_zeros(bin) == ref_count_zeros(bin) - end - end - end - - defp new() do - CMerkleTree.new() - end - - test "initialize" do - tree = new() - assert CMerkleTree.size(tree) == 0 - assert CMerkleTree.bucket_count(tree) == 1 - end - - test "inserts" do - tree = new() - - size = 16 - - tree = - Enum.reduce(pairs(size), tree, fn item, acc -> - CMerkleTree.insert_item(acc, item) - end) - - assert CMerkleTree.size(tree) == size - assert CMerkleTree.bucket_count(tree) == 1 - end - - test "difference" do - [a, b, c, d] = pairs(20) |> Enum.chunk_every(5) - [a2, _b2, _c2, _d2] = pairs(20, "2") |> Enum.chunk_every(5) - - tree_a = CMerkleTree.from_list(a ++ b ++ c) - tree_b = CMerkleTree.from_list(a2 ++ b ++ d) - - diff = CMerkleTree.difference(tree_a, tree_b) - - assert diff == %{ - " 1" => - {<<107, 134, 178, 115, 255, 52, 252, 225, 157, 107, 128, 78, 255, 90, 63, 87, 71, - 173, 164, 234, 162, 47, 29, 73, 192, 30, 82, 221, 183, 135, 91, 75>>, - <<107, 81, 212, 49, 223, 93, 127, 20, 28, 190, 206, 204, 247, 158, 223, 61, 216, - 97, 195, 180, 6, 159, 11, 17, 102, 26, 62, 239, 172, 187, 169, 24>>}, - " 2" => - {<<212, 115, 94, 58, 38, 94, 22, 238, 224, 63, 89, 113, 139, 155, 93, 3, 1, 156, 7, - 216, 182, 197, 31, 144, 218, 58, 102, 110, 236, 19, 171, 53>>, - <<120, 95, 62, 199, 235, 50, 243, 11, 144, 205, 15, 207, 54, 87, 211, 136, 181, - 255, 66, 151, 242, 249, 113, 111, 246, 110, 155, 105, 192, 93, 221, 9>>}, - " 3" => - {<<78, 7, 64, 133, 98, 190, 219, 139, 96, 206, 5, 193, 222, 207, 227, 173, 22, 183, - 34, 48, 150, 125, 224, 31, 100, 11, 126, 71, 41, 180, 159, 206>>, - <<226, 156, 156, 24, 12, 98, 121, 176, 176, 42, 189, 106, 24, 1, 199, 192, 64, - 130, 207, 72, 110, 192, 39, 170, 19, 81, 94, 79, 56, 132, 187, 107>>}, - " 4" => - {<<75, 34, 119, 119, 212, 221, 31, 198, 28, 111, 136, 79, 72, 100, 29, 2, 180, 209, - 33, 211, 253, 50, 140, 176, 139, 85, 49, 252, 172, 218, 191, 138>>, - <<115, 71, 92, 180, 10, 86, 142, 141, 168, 160, 69, 206, 209, 16, 19, 126, 21, - 159, 137, 10, 196, 218, 136, 59, 107, 23, 220, 101, 27, 58, 128, 73>>}, - " 5" => - {<<239, 45, 18, 125, 227, 123, 148, 43, 170, 208, 97, 69, 229, 75, 12, 97, 154, 31, - 34, 50, 123, 46, 187, 207, 190, 199, 143, 85, 100, 175, 227, 157>>, - <<65, 207, 192, 209, 242, 209, 39, 176, 69, 85, 183, 36, 109, 132, 1, 155, 77, 39, - 113, 10, 63, 58, 255, 110, 119, 100, 55, 91, 30, 6, 224, 93>>}, - " 11" => - {<<79, 200, 43, 38, 174, 203, 71, 210, 134, 140, 78, 251, 227, 88, 23, 50, 163, - 231, 203, 204, 108, 46, 251, 50, 6, 44, 8, 23, 10, 5, 238, 184>>, nil}, - " 12" => - {<<107, 81, 212, 49, 223, 93, 127, 20, 28, 190, 206, 204, 247, 158, 223, 61, 216, - 97, 195, 180, 6, 159, 11, 17, 102, 26, 62, 239, 172, 187, 169, 24>>, nil}, - " 13" => - {<<63, 219, 163, 95, 4, 220, 140, 70, 41, 134, 201, 146, 188, 248, 117, 84, 98, 87, - 17, 48, 114, 169, 9, 193, 98, 247, 228, 112, 229, 129, 226, 120>>, nil}, - " 14" => - {<<133, 39, 168, 145, 226, 36, 19, 105, 80, 255, 50, 202, 33, 43, 69, 188, 147, - 246, 159, 187, 128, 28, 59, 30, 190, 218, 197, 39, 117, 249, 158, 97>>, nil}, - " 15" => - {<<230, 41, 250, 101, 152, 215, 50, 118, 143, 124, 114, 107, 75, 98, 18, 133, 249, - 195, 184, 83, 3, 144, 10, 169, 18, 1, 125, 183, 97, 125, 139, 219>>, nil}, - " 16" => - {nil, - <<177, 126, 246, 209, 156, 122, 91, 30, 232, 59, 144, 124, 89, 85, 38, 220, 177, - 235, 6, 219, 130, 39, 214, 80, 213, 221, 160, 169, 244, 206, 140, 217>>}, - " 17" => - {nil, - <<69, 35, 84, 15, 21, 4, 205, 23, 16, 12, 72, 53, 232, 91, 126, 239, 212, 153, 17, - 88, 15, 142, 255, 240, 89, 154, 143, 40, 59, 230, 185, 227>>}, - " 18" => - {nil, - <<78, 201, 89, 159, 194, 3, 209, 118, 163, 1, 83, 108, 46, 9, 26, 25, 188, 133, - 39, 89, 178, 85, 189, 104, 24, 129, 10, 66, 197, 254, 209, 74>>}, - " 19" => - {nil, - <<148, 0, 241, 178, 28, 181, 39, 215, 250, 61, 62, 171, 186, 147, 85, 122, 24, - 235, 231, 162, 202, 78, 71, 28, 254, 94, 76, 91, 76, 167, 247, 103>>}, - " 20" => - {nil, - <<245, 202, 56, 247, 72, 161, 214, 234, 247, 38, 184, 164, 47, 181, 117, 195, 199, - 31, 24, 100, 168, 20, 51, 1, 120, 45, 225, 61, 162, 217, 32, 43>>} - } - end - - test "number conversion" do - data = %{ - <<0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0>> => - <<0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 1>>, - <<0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 1>> => - <<0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 1>>, - <<0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 3>> => - <<0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 169, 79, 83, 116, 252, 229, 237, 188, 142, 42, 134, - 151, 193, 83, 49, 103, 126, 110, 191, 11>>, - <<0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 1, 7>> => - <<0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 69, 254>>, - <<110, 54, 152, 54, 72, 124, 35, 75, 158, 85, 62, 243, 247, 135, 194, 216, 134, 85, 32, 115, - 157, 52, 12, 103, 179, 210, 81, 163, 57, 134, 229, 141>> => - <<0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 1>> - } - - tree = - Enum.reduce(data, new(), fn {key, value}, tree -> - value = :binary.decode_unsigned(value) - value = <> - key = :binary.decode_unsigned(key) - CMerkleTree.insert(tree, key, value) - end) - - for {key, value} <- data do - key = :binary.decode_unsigned(key) - assert CMerkleTree.get(tree, key) == value - end - end - - test "no duplicate" do - tree = - new() - |> CMerkleTree.insert_item({"a", 1}) - |> CMerkleTree.insert_item({"a", 2}) - - assert CMerkleTree.size(tree) == 1 - - assert CMerkleTree.to_list(tree) == [{"a", <<0::unsigned-size(248), 2>>}] - end - - test "clone" do - {data1, data2} = Enum.sort(pairs(128)) |> Enum.split(64) - - tree1 = new() |> CMerkleTree.insert_items(data1) - assert CMerkleTree.size(tree1) == 64 - - tree2 = CMerkleTree.clone(tree1) - assert CMerkleTree.size(tree2) == 64 - assert CMerkleTree.root_hash(tree1) == CMerkleTree.root_hash(tree2) - - tree2 = CMerkleTree.insert_items(tree2, data2) - assert CMerkleTree.size(tree2) == 128 - assert CMerkleTree.size(tree1) == 64 - assert CMerkleTree.root_hash(tree1) != CMerkleTree.root_hash(tree2) - end - - test "no nulls" do - tree = - new() - |> CMerkleTree.insert_item({"a", 1}) - |> CMerkleTree.insert_item({"a", 0}) - - assert CMerkleTree.size(tree) == 0 - end - - test "proof" do - size = 20 - tree0 = new() - - tree20 = - Enum.reduce(pairs(size), tree0, fn item, acc -> - CMerkleTree.insert_item(acc, item) - end) - - roots = CMerkleTree.root_hashes(tree20) - - for {{k, v}, idx} <- Enum.with_index(pairs(10 * size)) do - proofs = CMerkleTree.get_proofs(tree20, k) - - if idx == 0 do - assert proofs == - {[ - <<0::size(1)>>, - 15, - {" 1", - <<107, 134, 178, 115, 255, 52, 252, 225, 157, 107, 128, 78, 255, 90, 63, 87, - 71, 173, 164, 234, 162, 47, 29, 73, 192, 30, 82, 221, 183, 135, 91, 75>>} - ], - <<213, 245, 42, 124, 119, 38, 179, 149, 177, 91, 192, 217, 115, 78, 97, 97, 159, - 59, 6, 21, 61, 126, 252, 97, 248, 135, 154, 180, 92, 208, 105, 18>>} - end - - proof = proof(proofs) - - [prefix, pos | values] = value(proofs) - x = bit_size(prefix) - <> = hash(k) - <> = binary_part(hash(k), byte_size(hash(k)), -1) - - # Checking that this proof connects to the root - assert Enum.member?(roots, proof) - - # Checking that the provided range is for the given keys prefix - assert key_prefix == prefix - - # Checking that the provided leaf matches the given key - assert rem(last_byte, 16) == pos - - if idx < size do - assert :proplists.get_value(k, values) == v - else - assert :proplists.get_value(k, values) == :undefined - end - - # IO.puts("#{byte_size(BertExt.encode!(proofs))} (#{length(values)})") - end - end - - test "equality" do - size = 20 - tree = new() - - tree20 = - Enum.reduce(pairs(size), tree, fn item, acc -> - CMerkleTree.insert_item(acc, item) - end) - - tree20r = - Enum.reduce(Enum.reverse(pairs(size)), tree, fn item, acc -> - CMerkleTree.insert_item(acc, item) - end) - - assert CMerkleTree.root_hash(tree20) == CMerkleTree.root_hash(tree20r) - assert tree20 == tree20r - end - - test "deletes" do - size = 20 - sizeh = div(size, 2) - - tree0 = new() - - tree20 = - Enum.reduce(pairs(size), tree0, fn item, acc -> - CMerkleTree.insert_item(acc, item) - end) - - assert CMerkleTree.size(tree20) == size - assert CMerkleTree.bucket_count(tree20) == 2 - - tree10 = - Enum.reduce(pairs(sizeh), tree20, fn {key, _}, acc -> - CMerkleTree.delete(acc, key) - end) - - assert CMerkleTree.size(tree10) == sizeh - assert CMerkleTree.bucket_count(tree10) == 1 - - tree0v2 = - Enum.reduce(pairs(size), tree20, fn {key, _}, acc -> - CMerkleTree.delete(acc, key) - end) - - assert CMerkleTree.size(tree0v2) == 0 - assert CMerkleTree.bucket_count(tree10) == 1 - assert CMerkleTree.root_hash(tree0v2) == CMerkleTree.root_hash(tree0) - end - - describe "high load: COW clone, fork, difference" do - test "512-key base: two divergent clones and difference" do - data = - Enum.map(1..512, fn i -> {String.pad_leading("#{i}", 32), CMerkleTree.hash("hl#{i}")} end) - - {d_a, d_rest} = Enum.split(data, 300) - {d_b, d_c} = Enum.split(d_rest, 120) - - base = new() |> CMerkleTree.insert_items(d_a) - - t2 = - base - |> CMerkleTree.clone() - |> CMerkleTree.insert_items(d_b ++ d_c) - - t_alt = - base - |> CMerkleTree.clone() - |> CMerkleTree.insert_items(d_b) - |> CMerkleTree.insert_items(Enum.take(d_c, 40)) - - diff = CMerkleTree.difference(t2, t_alt) - assert map_size(diff) >= 1 - assert CMerkleTree.size(t2) == 512 - assert CMerkleTree.size(t_alt) == 300 + 120 + 40 - _ = CMerkleTree.root_hash(t2) - _ = CMerkleTree.root_hash(t_alt) - end - - test "U256 sequential keys: clone then parallel extensions + difference (COW + diff)" do - slots = - Enum.map(0..420, fn i -> {<>, <>} end) - - base = new() |> CMerkleTree.insert_items(slots) - - left = - base - |> CMerkleTree.clone() - |> CMerkleTree.insert_items( - Enum.map(421..500, fn i -> - {<>, <>} - end) - ) - - right = - base - |> CMerkleTree.clone() - |> CMerkleTree.insert_items( - Enum.map(501..590, fn i -> - {<>, <>} - end) - ) - - diff = CMerkleTree.difference(left, right) - assert map_size(diff) >= 1 - assert CMerkleTree.root_hash(left) != CMerkleTree.root_hash(right) - _ = CMerkleTree.root_hash(base) - end - - test "U256 sequential keys: clone then partial overwrite + difference (stress)" do - slots = - Enum.map(0..400, fn i -> {<>, <>} end) - - base = new() |> CMerkleTree.insert_items(slots) - - left = - base - |> CMerkleTree.clone() - |> CMerkleTree.insert_items( - Enum.map(401..450, fn i -> - {<>, <>} - end) - ) - - right = - base - |> CMerkleTree.clone() - |> then(fn t -> - Enum.reduce(200..280, t, fn i, acc -> - CMerkleTree.insert(acc, <>, <>) - end) - end) - - diff = CMerkleTree.difference(left, right) - assert map_size(diff) >= 1 - assert CMerkleTree.root_hash(left) != CMerkleTree.root_hash(right) - end - end - - describe "get_range" do - test "dense sequential keys" do - base = 0x100 - - tree = - CMerkleTree.new() - |> CMerkleTree.insert(base, 1) - |> CMerkleTree.insert(base + 1, 2) - |> CMerkleTree.insert(base + 2, 3) - - assert CMerkleTree.get_range(tree, base, 3) == [ - {<>, <<1::unsigned-size(256)>>}, - {<>, <<2::unsigned-size(256)>>}, - {<>, <<3::unsigned-size(256)>>} - ] - end - - test "sparse range returns nil for missing slots" do - base = 0x200 - - tree = - CMerkleTree.new() - |> CMerkleTree.insert(base, 1) - |> CMerkleTree.insert(base + 2, 3) - - assert CMerkleTree.get_range(tree, base, 3) == [ - {<>, <<1::unsigned-size(256)>>}, - {<>, nil}, - {<>, <<3::unsigned-size(256)>>} - ] - end - - test "stops at uint256 overflow" do - max_key = <> - tree = CMerkleTree.insert(CMerkleTree.new(), max_key, 42) - - assert length(CMerkleTree.get_range(tree, max_key, 3)) == 1 - assert CMerkleTree.get(tree, max_key) == <<42::unsigned-size(256)>> - end - - test "max count returns at most 255 entries" do - base = 0x4000 - count = 255 - - tree = - CMerkleTree.new() - |> then(fn t -> - Enum.reduce(0..(count - 1), t, fn i, acc -> - CMerkleTree.insert(acc, base + i, i + 1) - end) - end) - - range = CMerkleTree.get_range(tree, base, count) - assert length(range) == 255 - assert length(range) <= 255 - end - end - - defp pairs(num, variant \\ "") do - Enum.map(1..num, fn idx -> - {String.pad_leading("#{idx}", 32), CMerkleTree.hash("#{idx}" <> variant)} - end) - end - - defp value(list) when is_list(list) do - list - end - - defp value({left, right}) do - value(left) || value(right) - end - - defp value(hash) when is_binary(hash) do - false - end - - defp proof(list) when is_list(list) do - # :io.format("Erlang: ~p~nBert: ~p~n", [list, BertExt.encode!(list)]) - hash(BertExt.encode!(list)) - end - - defp proof({left, right}) do - list = [proof(left), proof(right)] - hash(BertExt.encode!(list)) - end - - defp proof(hash) when is_binary(hash) do - hash - end - - defp hash(value) when is_binary(value) do - Diode.hash(value) - end - - # defp encode(mixed) when is_tuple(mixed) do - # List.to_tuple(encode(Tuple.to_list(mixed))) - # end - - # defp encode(hashes) when is_list(hashes) do - # Enum.map(hashes, &encode/1) - # end - - # defp encode(hash = <<_::binary-size(32)>>) do - # :base64.encode(hash) - # end - - # defp encode(term) do - # term - # end -end diff --git a/test/count_zeros_test.exs b/test/count_zeros_test.exs new file mode 100644 index 0000000..2c71dde --- /dev/null +++ b/test/count_zeros_test.exs @@ -0,0 +1,26 @@ +# Diode Server +# Copyright 2021-2024 Diode +# Licensed under the Diode License, Version 1.1 +defmodule CountZerosTest do + use ExUnit.Case + + # Reference: count zero bytes (same semantics as legacy Niffler / EVM gas calc). + defp ref_count_zeros(bin), do: Enum.count(:binary.bin_to_list(bin), &(&1 == 0)) + + describe "count_zeros" do + test "matches reference for edge and random payloads" do + bins = [ + <<>>, + <<0>>, + <<255>>, + <<0, 0, 0>>, + <<0, 1, 2, 0>>, + :crypto.strong_rand_bytes(256) + ] + + for bin <- bins do + assert CMerkleTree.count_zeros(bin) == ref_count_zeros(bin) + end + end + end +end diff --git a/test/evm_storage_readahead_test.exs b/test/evm_storage_readahead_test.exs index ca0d7bc..bc19fb3 100644 --- a/test/evm_storage_readahead_test.exs +++ b/test/evm_storage_readahead_test.exs @@ -25,16 +25,15 @@ defmodule EvmStorageReadaheadTest do base = 0x3000 count = gs_range_count() + addr = <<1::unsigned-size(160)>> - tree = - CMerkleTree.new() - |> then(fn t -> - Enum.reduce(0..(count - 1), t, fn i, acc -> - CMerkleTree.insert(acc, base + i, i + 1) - end) + slots = + Enum.map(0..(count - 1), fn i -> + {<>, <>} end) - range = CMerkleTree.get_range(tree, base, count) + map = CAccountMap.put(CAccountMap.new(), addr, 1, 100, slots, <<>>) + range = CAccountMap.storage_get_range(map, addr, <>, count) assert length(range) == 255 assert length(range) <= 255 end) @@ -90,8 +89,7 @@ defmodule EvmStorageReadaheadTest do {:ok, state, %TransactionReceipt{msg: :ok}} = Transaction.apply(ctx, block, state) contract = Transaction.new_contract_address(ctx) - acc = Chain.State.account(state, contract) - assert Chain.Account.storage_value(acc, 0) |> :binary.decode_unsigned() == 0 + assert Chain.State.storage_value(state, contract, 0) |> :binary.decode_unsigned() == 0 tx = %{ @@ -108,7 +106,6 @@ defmodule EvmStorageReadaheadTest do {:ok, state, %TransactionReceipt{msg: :ok}} = Transaction.apply(tx, block, state) - acc = Chain.State.account(state, contract) - assert Chain.Account.storage_value(acc, 0) |> :binary.decode_unsigned() == 1 + assert Chain.State.storage_value(state, contract, 0) |> :binary.decode_unsigned() == 1 end end diff --git a/test/evm_test.exs b/test/evm_test.exs index b945970..6667804 100644 --- a/test/evm_test.exs +++ b/test/evm_test.exs @@ -52,19 +52,22 @@ defmodule EvmTest do # Fail test 1: Too little balance ctx_fail = %{ctx | gasLimit: Transaction.gas_limit(ctx) * 1_000_000} |> Transaction.sign(priv) - assert {:error, :not_enough_balance} == Transaction.apply(ctx_fail, block, state) - # Fail test 2: Too little gas + assert {:error, :not_enough_balance} == + Transaction.apply(ctx_fail, block, Chain.State.clone(state)) + + # Fail test 2: Too little gas (clone — apply mutates state / nonce in place) ctx_fail = %{ctx | gasLimit: 1} |> Transaction.sign(priv) {:ok, _state, %TransactionReceipt{msg: :evmc_out_of_gas}} = - Transaction.apply(ctx_fail, block, state) + Transaction.apply(ctx_fail, block, Chain.State.clone(state)) {:ok, state, %TransactionReceipt{msg: :ok}} = Transaction.apply(ctx, block, state) - # Checking value of i at position 0 - acc = Chain.State.account(state, Transaction.new_contract_address(ctx)) - value = Chain.Account.storage_value(acc, 0) |> :binary.decode_unsigned() + value = + Chain.State.storage_value(state, Transaction.new_contract_address(ctx), 0) + |> :binary.decode_unsigned() + assert value == 0 # Method call increment @@ -79,11 +82,11 @@ defmodule EvmTest do assert Wallet.pubkey!(Transaction.origin(tx)) == Wallet.pubkey!(from_wallet) - # Fail test 3: value on non_payable method + # Fail test 3: value on non_payable method (clone — apply mutates state / nonce) tx_fail = %{tx | value: 1} |> Transaction.sign(priv) {:ok, _state, %TransactionReceipt{msg: :evmc_revert}} = - Transaction.apply(tx_fail, block, state) + Transaction.apply(tx_fail, block, Chain.State.clone(state)) {:ok, state, %TransactionReceipt{msg: :ok, evmout: evmout}} = Transaction.apply(tx, block, state) @@ -91,8 +94,10 @@ defmodule EvmTest do assert evmout == "" # Checking value of i at position 0 - acc = Chain.State.account(state, Transaction.new_contract_address(ctx)) - value = Chain.Account.storage_value(acc, 0) |> :binary.decode_unsigned() + value = + Chain.State.storage_value(state, Transaction.new_contract_address(ctx), 0) + |> :binary.decode_unsigned() + assert value == 1 end diff --git a/test/state_diff_perf_contract_test.exs b/test/state_diff_perf_contract_test.exs new file mode 100644 index 0000000..3740898 --- /dev/null +++ b/test/state_diff_perf_contract_test.exs @@ -0,0 +1,184 @@ +# Diode Server +# Copyright 2021-2024 Diode +# Licensed under the Diode License, Version 1.1 +# +# Contract tests for docs/specs/change-state-diff-perf.md (Phases A–E). +defmodule StateDiffPerfContractTest do + use ExUnit.Case, async: false + + alias Chain.{Account, State} + + defp addr(i), do: <> + defp slot(i), do: <> + + defp build_live(n, slots_per) do + Enum.reduce(1..n, State.new(), fn i, st -> + storage = + for s <- 1..slots_per do + {slot(i * 10_000 + s), <>} + end + + State.set_account(st, addr(i), %Account{ + nonce: i, + balance: i * 100, + storage_root: storage, + code: <>, + map_backed: false + }) + end) + |> State.normalize() + end + + defp peak_from_compact(n, slots_per) do + build_live(n, slots_per) + |> State.lock() + |> State.compact() + |> State.uncompact() + |> State.lock() + end + + defp bump_nonce(state, id) do + meta = State.ensure_account(state, id) + State.set_account(state, id, %{meta | nonce: meta.nonce + 1}) + end + + describe "cached_root_after_uncompact" do + test "compact→uncompact preserves storage roots used by difference" do + live = build_live(16, 2) |> State.lock() + ids = Enum.map(1..16, &addr/1) + + roots_before = + Map.new(ids, fn id -> {id, CAccountMap.storage_root_hash(live.accounts, id)} end) + + compact = State.compact(live) + sample = Map.fetch!(compact.accounts, addr(1)) + assert match?(<<_::binary-size(32)>>, sample.root_hash) + assert sample.root_hash == roots_before[addr(1)] + + restored = State.uncompact(compact) |> State.lock() + + for id <- ids do + assert CAccountMap.storage_root_hash(restored.accounts, id) == roots_before[id] + end + end + end + + describe "root_invalidated_after_storage_put" do + test "storage_put_map changes storage root and difference reports it" do + prev = build_live(12, 2) |> State.lock() + id = addr(3) + before = CAccountMap.storage_root_hash(prev.accounts, id) + + next = + prev + |> State.clone() + |> State.storage_put_map(%{id => %{slot(30_001) => <<999::unsigned-size(256)>>}}) + |> State.normalize() + + after_root = CAccountMap.storage_root_hash(next.accounts, id) + assert after_root != before + + assert Enum.any?(State.difference(prev, next), fn {^id, report} -> + match?( + %{state: state, root_hash: {^before, ^after_root}} when map_size(state) > 0, + report + ) + end) + end + end + + describe "cow_unique_after_write" do + test "clone then write does not change parent storage or state root" do + peak = peak_from_compact(20, 2) + parent_hash = State.hash(peak) + parent_root = CAccountMap.storage_root_hash(peak.accounts, addr(1)) + parent_slot = CAccountMap.storage_get(peak.accounts, addr(1), slot(10_001)) + + _fork = + peak + |> State.clone() + |> State.storage_put_map(%{addr(1) => %{slot(10_001) => <<42::unsigned-size(256)>>}}) + |> State.normalize() + + assert State.hash(peak) == parent_hash + assert CAccountMap.storage_root_hash(peak.accounts, addr(1)) == parent_root + assert CAccountMap.storage_get(peak.accounts, addr(1), slot(10_001)) == parent_slot + end + + test "clone of compact peak does not roughly double RSS (shared_ptr COW)" do + # Phase D acceptance: clone shares CompactStorage; growth should be far below + # a full slot-vector duplication (~accounts * slots * ~64 bytes). + peak = peak_from_compact(400, 16) + before = read_rss_kb() + _fork = State.clone(peak) + after_clone = read_rss_kb() + growth_kb = max(after_clone - before, 0) + # Full deep copy of 400*16 slots would be hundreds of KB of payload alone; + # allow generous overhead for map/trie wrapper fork but fail on ~2x slot dump. + assert growth_kb < 4_000 + end + end + + defp read_rss_kb do + case File.read("/proc/self/status") do + {:ok, status} -> + case Regex.run(~r/VmRSS:\s+(\d+)/, status) do + [_, kb] -> String.to_integer(kb) + _ -> 0 + end + + _ -> + 0 + end + end + + describe "difference_full tuple shape" do + test "difference_full returns addr/sides/storage_diff/roots sextuples" do + prev = + CAccountMap.new() + |> CAccountMap.put(addr(1), 1, 100, [{slot(1), <<1::unsigned-size(256)>>}], <<1>>) + + next = + CAccountMap.clone(prev) + |> CAccountMap.storage_put_map(%{addr(1) => %{slot(1) => <<2::unsigned-size(256)>>}}) + + full = CAccountMap.difference_full(prev, next) + assert full != [] + assert Enum.all?(full, &(tuple_size(&1) == 6)) + + assert Enum.all?(full, fn {_addr, _a, _b, _diff, root_a, root_b} -> + match?(<<_::binary-size(32)>>, root_a) and match?(<<_::binary-size(32)>>, root_b) + end) + end + end + + describe "compact_small_delta prepare_state shape" do + test "few changed accounts on compact-uncompact peak round-trip via difference" do + changed = 3 + peak = peak_from_compact(40, 2) + + next = + Enum.reduce(1..changed, State.clone(peak), fn i, acc -> + id = addr(i) + + acc + |> State.storage_put_map(%{ + id => %{slot(i * 10_000 + 1) => <>} + }) + |> bump_nonce(id) + end) + |> State.normalize() + + delta = State.difference(peak, next) + assert length(delta) == changed + + restored = + peak + |> State.clone() + |> State.apply_difference(delta) + |> State.normalize() + + assert State.hash(restored) == State.hash(next) + end + end +end