From e6207b3c55ccdc5f7b83acc945204316e29ba35b Mon Sep 17 00:00:00 2001 From: latent-9 <296084221+latent-9@users.noreply.github.com> Date: Thu, 13 Aug 2026 09:43:26 +1200 Subject: [PATCH] fix: truncate every shred to canonical size in duplicate detection --- program/src/duplicate_block_proof.rs | 57 ++++++++++++++++++++++++++++ program/src/shred.rs | 10 +++-- 2 files changed, 64 insertions(+), 3 deletions(-) diff --git a/program/src/duplicate_block_proof.rs b/program/src/duplicate_block_proof.rs index 96b9263..f0607a8 100644 --- a/program/src/duplicate_block_proof.rs +++ b/program/src/duplicate_block_proof.rs @@ -435,6 +435,63 @@ mod tests { ) } + #[test] + fn test_non_canonical_padding_payload_proof_invalid() { + // A non-resigned shred padded with extra trailing bytes must not be + // accepted as a duplicate of the original shred. Both carry the same + // leader signature and merkle root (the fixed-offset merkle_root ignores + // the padding), the same slot, index, and type, so the only difference is + // the payload length. get_payload truncates to the canonical size, so the + // padded and original payloads compare equal and the proof is rejected. + let mut rng = rand::rng(); + let leader = Arc::new(Keypair::new()); + let leader_pubkey = leader.pubkey(); + let shredder = Shredder::new(SLOT, PARENT_SLOT, REFERENCE_TICK, VERSION).unwrap(); + let next_shred_index = rng.random_range(0..32_000); + + // is_last_in_slot = false yields non-resigned merkle shreds (data + coding). + let (data_shreds, coding_shreds) = new_rand_shreds( + &mut rng, + next_shred_index, + next_shred_index, + 10, + true, /* merkle_variant */ + &shredder, + &leader, + false, /* is_last_in_slot */ + ); + + for shred in [data_shreds[0].clone(), coding_shreds[0].clone()] { + let payload = shred.payload().as_ref().to_vec(); + let mut padded = payload.clone(); + padded.push(0u8); + + let merkle_root = Shred::new_from_payload(&payload) + .unwrap() + .merkle_root() + .unwrap(); + let signature: [u8; SIGNATURE_BYTES] = + shred.signature().as_ref().try_into().unwrap(); + let context = DuplicateBlockProofContext { + expected_pubkey: &leader_pubkey, + expected_shred1_merkle_root: &merkle_root, + expected_shred2_merkle_root: &merkle_root, + expected_shred1_signature: &signature, + expected_shred2_signature: &signature, + }; + let proof_data = DuplicateBlockProofData { + shred1: &payload, + shred2: &padded, + }; + assert_eq!( + proof_data + .verify_proof(context, SLOT, &leader_pubkey) + .unwrap_err(), + SlashingError::InvalidPayloadProof, + ); + } + } + #[test] fn test_unpack_context() { let node_pubkey = Pubkey::new_unique(); diff --git a/program/src/shred.rs b/program/src/shred.rs index 90230fe..ab5a125 100644 --- a/program/src/shred.rs +++ b/program/src/shred.rs @@ -340,9 +340,13 @@ impl<'a> Shred<'a> { let Ok((proof_offset, proof_size)) = shred.get_proof_offset_and_size() else { return shred.payload; }; - if !shred.resigned { - return shred.payload; - } + // Truncate to the canonical payload end for every shred, not just + // resigned ones. For a non-resigned shred `proof_offset + proof_size` + // equals the canonical size, so any trailing bytes past it (which the + // fixed-offset `merkle_root()` and header getters ignore) are excluded + // from the comparison. Without this a shred padded with extra trailing + // bytes compares unequal to its unpadded twin and is treated as a + // duplicate even though it is the same shred. let Some(offset) = proof_offset.checked_add(proof_size) else { return shred.payload; };