Weaver NetworkAttestations vec never grows, validator-set growth locks out new authorities and halts the bridge

Hi team

Finding: the attestation vec is sized once and never grows. In pallets weaver lib dot rs, extend_warp at lines 336 to 341 resizes NetworkAttestations only inside if attestations dot is_empty, then enforces authority_index less than attestations dot len with TooManyAttestations. on_initialize at lines 533 to 537 removes the vec only when it is longer than the current authority count. There is no grow path anywhere in the pallet: when the authority set grows, the stale short vec persists.

Executed at HEAD, both passing. Five authorities attest, vec length 5. on_new_session rotates to eight authorities. Authority 7 extend_warp fails TooManyAttestations. on_initialize sees 5 votes against byzantium threshold of 8 which is 6, so no TapestryDraft is ever reached, weave_weft stays gated, no new loom roots are produced, and sessions cannot advance. Deposits made after the last good root can never be pulled. A second run at 5 to 7 stays alive but degraded, pinning the death cliff exactly at threshold of N exceeding the stale length. Full weaver suite 26 of 26 passing after the run.

Trigger, stated honestly: routine honest validator-set growth. Genesis runs 37 with a max of 500 under NPoS elections, so growth is the expected deployment path, not an attack. No attacker is needed.

Suggested fix: resize the vec on growth in on_initialize or in extend_warp.

Shahmeer

PoC for this finding.

Checkout: ghost-node at 0162da4604d9931c16d12a0cec40dd85951368c5.

Append these tests to pallets/weaver/src/tests.rs, then run:

cargo test -p ghost-weaver --offline --lib poc_attestation_vec

Remove the appended tests afterwards to leave the tree clean.

fn w2a_seed(i: u32) -> AuthorityPair {
    AuthorityPair::from_string(&format!("//W2A{}", i), None).expect("valid seed")
}

fn w2a_rotate_to(n: u32) {
    let grown: Vec<AuthorityPair> = (0..n).map(w2a_seed).collect();
    let validators: Vec<(sp_runtime::AccountId32, crate::sr25519::AuthorityId)> = grown
        .iter()
        .enumerate()
        .map(|(i, p)| {
            let mut id = [0u8; 32];
            id[0..8].copy_from_slice(&((i + 1) as u64).to_le_bytes());
            (sp_runtime::AccountId32::from(id), p.public())
        })
        .collect();
    <GhostWeaver as frame_support::traits::OneSessionHandler<sp_runtime::AccountId32>>::on_new_session(
        false,
        validators.iter().map(|(a, k)| (a, k.clone())).collect::<Vec<(&sp_runtime::AccountId32, crate::sr25519::AuthorityId)>>().into_iter(),
        validators.iter().map(|(a, k)| (a, k.clone())).collect::<Vec<(&sp_runtime::AccountId32, crate::sr25519::AuthorityId)>>().into_iter(),
    );
}

#[test]
fn poc_attestation_vec_never_grows_bricks_consensus_after_authority_growth() {
    let initial: Vec<AuthorityPair> = (0..5).map(w2a_seed).collect();
    let network_id = 1u32;
    let network_data = NetworkDataBuilder::default().with_block_deviation(5).build();
    let networks = vec![(network_id, network_data, 0u64)];

    new_test_ext(initial.clone(), networks).execute_with(|| {
        System::set_block_number(2);

        // Five authorities attest; the vec is sized to 5.
        for (i, pair) in initial.iter().enumerate() {
            let att = AttestationContext::default()
                .with_current_block(1)
                .with_network_id(network_id)
                .with_authority_index(i as u16)
                .build_block_attestation(100 + i as u64);
            let sig = pair.sign(&att.encode());
            assert!(GhostWeaver::extend_warp(RuntimeOrigin::none(), att, sig).is_ok());
        }
        assert_eq!(GhostWeaver::network_attestations(network_id).len(), 5);

        // Honest session rotation grows the validator set 5 -> 8.
        w2a_rotate_to(8);
        assert_eq!(GhostWeaver::authorities().len(), 8);

        // A new authority's attestation is rejected: the vec was never resized.
        System::set_block_number(3);
        let new_pair = w2a_seed(7);
        let att_new = AttestationContext::default()
            .with_current_block(2)
            .with_network_id(network_id)
            .with_authority_index(7u16)
            .build_block_attestation(200);
        let sig_new = new_pair.sign(&att_new.encode());
        let res = GhostWeaver::extend_warp(RuntimeOrigin::none(), att_new, sig_new);
        assert_eq!(res, Err(Error::<TestRuntime>::TooManyAttestations.into()));

        // on_initialize cannot reach consensus: 5 votes < byzantium threshold of 8 (= 6).
        GhostWeaver::on_initialize(4);
        assert!(GhostWeaver::tapestry_drafts(network_id).is_none());
        // The stale vec is still there, still short -- nothing healed it.
        assert_eq!(GhostWeaver::network_attestations(network_id).len(), 5);
    });
}

#[test]
fn poc_attestation_vec_stale_len_survives_at_seven_authorities() {
    let initial: Vec<AuthorityPair> = (0..5).map(w2a_seed).collect();
    let network_id = 1u32;
    let network_data = NetworkDataBuilder::default().with_block_deviation(5).build();
    let networks = vec![(network_id, network_data, 0u64)];

    new_test_ext(initial.clone(), networks).execute_with(|| {
        System::set_block_number(2);

        for (i, pair) in initial.iter().enumerate() {
            let att = AttestationContext::default()
                .with_current_block(1)
                .with_network_id(network_id)
                .with_authority_index(i as u16)
                .build_block_attestation(100 + i as u64);
            let sig = pair.sign(&att.encode());
            assert!(GhostWeaver::extend_warp(RuntimeOrigin::none(), att, sig).is_ok());
        }

        // Grow 5 -> 7: threshold(7) = 5, so the 5 old votes still reach consensus
        // (degraded -- new authorities are voiceless -- but alive).
        w2a_rotate_to(7);

        System::set_block_number(3);
        let new_pair = w2a_seed(6);
        let att_new = AttestationContext::default()
            .with_current_block(2)
            .with_network_id(network_id)
            .with_authority_index(6u16)
            .build_block_attestation(200);
        let sig_new = new_pair.sign(&att_new.encode());
        let res = GhostWeaver::extend_warp(RuntimeOrigin::none(), att_new, sig_new);
        assert_eq!(res, Err(Error::<TestRuntime>::TooManyAttestations.into()));

        GhostWeaver::on_initialize(4);
        assert!(GhostWeaver::tapestry_drafts(network_id).is_some());
    });
}

Observed: both ok. First test pins the death cliff: 5 to 8 bricks consensus, no TapestryDraft, vec stuck at 5. Second test shows 5 to 7 survives degraded.

Shahmeer

Thank you for your report!

That actually makes sense. The happy path currently only checks:

if attestation_len.unwrap_or(0) > len {
    weight.saturating_accrue(T::DbWeight::get().writes(1));
    NetworkAttestations::<T>::remove(&network_id);
    return weight;
}

Which is not sufficient. So your recommendation is:

Suggested fix: resize the vec on growth in on_initialize or in extend_warp.

But which do you think is preferable? As a small addition, it would be nice to add a check during validate_unsigned that the authority index already has an available slot allocated for it, in my opinion.

Hey @Sands - it looks like the dev team has fixed the issue you reported. Could you confirm whether it’s resolved on your end now, or continue the thread if it still persists?

We decided to resize the NetworkAttestations during the on_initialize if the sizes does not match. Maybe not the best ever solution but it should work.

Hi team

Confirmed on my end. The on_initialize resize matches the fix I recommended: resizing NetworkAttestations when the sizes do not match restores the growth path for new authorities and unhalts the bridge. Two follow-ups worth keeping: grow-on-short in extend_warp covers the shrink direction the on_initialize path does not see, and the validate_unsigned slot check is hardening rather than a liveness fix, so it can land separately.

Shahmeer