Component: ghost-node — pallets/weaver (pull_thread, EvmThreadProof::get_unique_key)
Impact: Repeatable, unbounded minting of the native protocol currency from a single valid Merkle proof.
Summary
pull_thread accepts a caller-supplied session and validates the thread proof against
LoomStates[network_id][session] without ever checking that the supplied session is the current
one. For EVM proofs the replay-guard key is hash(session, slot_index, proof_index, network_id) —
it embeds that same caller-chosen session — and loom roots are never pruned. A single valid
proof therefore mints again for every stored session whose root matches the proof.
Impact
- Unbounded supply inflation: one proof mints once per session entry, and the number of entries
only grows (the session counter increments and old entries are never removed). - pull_thread is an unsigned extrinsic (ensure_none), so any account can replay.
- The UTXO path is unaffected, which shows the EVM omission is unintended.
Root Cause
-
pallets/weaver/src/lib.rs, pull_thread:
let root_hash = LoomStates::::get(&network_id, &session); // session = caller input
let pulled_thread_key = thread_proof.get_unique_key(session, network_id);No ensure!(session == CurrentWeavingSession::::get(&network_id)) anywhere.
-
pallets/weaver/src/types.rs, EvmThreadProof::get_unique_key:
let session_bytes = (session as u64).to_be_bytes();
let slot_index_bytes = (self.slot_index as u64).to_be_bytes();
let proof_index_bytes = (self.proof_index as u64).to_be_bytes();
let metadata: [H256; 4] = [H256::from(h1), H256::from(h2), H256::from(h3), H256::from(h4)];
sequential_hash::<SubstrateBlake2Hasher, _>(metadata)Changing session changes the key, so the ThreadAlreadyPulled guard never triggers.
-
LoomStates entries are written in on_initialize for current_session and are never removed
(there is no LoomStates::remove / kill anywhere in the pallet).
For comparison, UtxoThreadProof::get_unique_key(&self, network_id) does NOT take a session,
so its replay guard is stable. The EVM path is the asymmetric one.
Reproduction
cargo test -p ghost-weaver poc_same_evm_proof_mints_in_two_sessions – --nocapture
Output:
running 1 test
balance after first pull (session 0): 69
balance after second pull (session 1): 138
test poc_replay::poc_same_evm_proof_mints_in_two_sessions ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 23 filtered out
The pallet’s 24 existing tests still pass.
Suggested Fix
ensure!(
session == CurrentWeavingSession::<T>::get(&network_id),
Error::<T>::WeavingIsInactive, // or a dedicated StaleSession error
);
Additionally, key the replay guard on the proof identity (root / slot index / proof index) rather
than the bookkeeping session, so a proof can only ever be pulled once.