Weaver pull_thread claims rejected by validate_unsigned, and the empty-leaf arbitrary mint they gate

Hi team

This is one report with two linked legs, plus a pointer to the WeavingStates report already filed. The three must ship in one fix, because fixing the pool gate alone turns the second leg into a permissionless Critical.

Leg one, pool gate. In weaver lib dot rs, validate_unsigned at line 589 matches only extend_warp and weave_weft before the catch-all arm returns the invalid call error. The pull_thread dispatchable at line 430 opens with an ensure none origin check. An honest pull_thread claim is therefore rejected by every transaction pool, and a signed claim fails the origin check in dispatch. The only production inclusion path at HEAD is a block author placing the unsigned extrinsic into its own block, because block execution does not re-run pool validation. Honest users are locked out while a single block author keeps a private claim path.

let validity = GhostWeaver::validate_unsigned(TransactionSource::External, &call);
assert_eq!(
    validity,
    Err(TransactionValidityError::Invalid(InvalidTransaction::Call).into())

Leg two, empty-leaf mint. In weaver types dot rs, get_preimage_slice at line 365 replaces the slot at the given index with the computed arguments hash when the index is in range, and otherwise returns the empty hash. The arguments hash at line 413 binds amount, receiver, slot index and network, but on the out-of-range branch that binding is computed nowhere, so receiver and amount are never bound to a leaf. get_unique_key at line 383 mixes session, slot index, proof index and network and places no bound on the slot index. I installed the root of a fully empty 256-leaf tree, claimed with an empty slots vector, slot index 999 and an arbitrary receiver, and minted 1,000,000 units with no deposit anywhere. A second claim with slot index 1000 minted 2,000,000 more, because the unique key changes with the unbounded slot index.

let proof1 = ThreadProof::EvmThreadProof(EvmThreadProof::new(
    999, 5, receiver.clone(), 1_000_000u64, proof.clone(), Vec::new()));
assert!(GhostWeaver::pull_thread(RuntimeOrigin::none(), network_id, session, proof1).is_ok());
assert_eq!(Balances::free_balance(&receiver), 1_000_000);

let proof2 = ThreadProof::EvmThreadProof(EvmThreadProof::new(
    1000, 5, receiver.clone(), 2_000_000u64, proof, Vec::new()));
assert!(GhostWeaver::pull_thread(RuntimeOrigin::none(), network_id, session, proof2).is_ok());
assert_eq!(Balances::free_balance(&receiver), 3_000_000);

Severity, stated honestly. As shipped this is High, gated by leg one to block-author inclusion. It becomes Critical the moment claim submission is repaired without the companion preimage fix, because the branch fix for the pool gate calls the same proof verification that still contains the empty-leaf branch. No victim cooperation is needed; the attacker controls slot index, proof index, receiver, amount and the slots vector, and every partially filled gatekeeper tree supplies empty leaves publicly.

Suggested fix, all in one change: bind the supplied slot against the computed arguments hash first, reset WeavingStates at session finalization second, open the pool gate last. The WeavingStates leg is filed separately and must ship in the same change.

Shahmeer

Thank you for report!

The team member observed similar issue ~1 week ago and did a commit. Can you please confirm is it the same issue and does the commit resolved it for you based on your local tests?

Moreover, in the development branch you can find additional validation for the pull_thread call as well. Because we haven’t deployed the smart contracts yet, there is nothing for validators to watch on their side, so we can do a forkless upgrade right before adding external EVM gatekeepers.

If not please continue this thread in order to elevate it.