Skip to main content

anvil/eth/backend/tempo_consensus/
certificate.rs

1//! The private wire adapter for Tempo's Simplex finalization certificates.
2//!
3//! The pinned format is `UInt(epoch) || UInt(view) || UInt(parent) || digest || vote || seed`,
4//! with two compressed 48-byte MinSig signatures. The vote signs the whole proposal, while the
5//! seed signs only its epoch and view. Integers use Commonware's unsigned varints, not fixed-width
6//! Ethereum quantities. Keep this adapter aligned with the pinned upstream definitions:
7//!
8//! - [Commonware 2026.9.0 `Proposal` and `Finalization`][proposal].
9//! - [Commonware 2026.9.0 threshold VRF signature pair][vrf] and [namespaces][namespaces].
10//! - [Tempo's pinned `TEMPO` namespace][tempo].
11//!
12//! [proposal]: https://github.com/commonwarexyz/monorepo/blob/d476a2361ce6840d2b9d0aa6fb30a924429046d4/consensus/src/simplex/types.rs
13//! [vrf]: https://github.com/commonwarexyz/monorepo/blob/d476a2361ce6840d2b9d0aa6fb30a924429046d4/consensus/src/simplex/scheme/bls12381_threshold/vrf.rs
14//! [namespaces]: https://github.com/commonwarexyz/monorepo/blob/d476a2361ce6840d2b9d0aa6fb30a924429046d4/consensus/src/simplex/scheme/mod.rs
15//! [tempo]: https://github.com/tempoxyz/tempo/blob/2c25f7cd8267b205a6b95e08bbc73baf887bea88/crates/consensus/src/config.rs
16
17use alloy_primitives::{B256, Bytes};
18use commonware_codec::{
19    FixedSize, Write,
20    varint::{MAX_U64_VARINT_SIZE, UInt},
21};
22use commonware_cryptography::{
23    bls12381::{
24        dkg::feldman_desmedt::Output,
25        primitives::{
26            group::{G1, Share},
27            ops::threshold,
28            variant::MinSig,
29        },
30    },
31    ed25519::PublicKey,
32};
33use commonware_parallel::Sequential;
34
35// Three UInt<u64> varints, a digest, and two compressed MinSig signatures.
36const MAX_CERTIFICATE_BYTES: usize = 3 * MAX_U64_VARINT_SIZE + B256::len_bytes() + 2 * G1::SIZE;
37
38/// Signs a proposal with the local 1-of-1 committee and encodes its finalization certificate.
39pub(super) fn encode(
40    epoch: u64,
41    view: u64,
42    parent_view: u64,
43    digest: B256,
44    output: &Output<MinSig, PublicKey>,
45    share: &Share,
46) -> Bytes {
47    let mut certificate = Vec::with_capacity(MAX_CERTIFICATE_BYTES);
48    UInt(epoch).write(&mut certificate);
49    UInt(view).write(&mut certificate);
50    let seed = threshold::sign_message::<MinSig>(share, b"TEMPO_SEED", &certificate);
51
52    UInt(parent_view).write(&mut certificate);
53    certificate.extend_from_slice(digest.as_slice());
54    let vote = threshold::sign_message::<MinSig>(share, b"TEMPO_FINALIZE", &certificate);
55    let (vote, seed) = threshold::recover_pair(output.public(), [&vote], [&seed], &Sequential)
56        .expect("the local committee has exactly one share and a threshold of one");
57    vote.write(&mut certificate);
58    seed.write(&mut certificate);
59    certificate.into()
60}
61
62#[cfg(test)]
63mod tests {
64    use super::*;
65    use commonware_codec::DecodeExt;
66    use commonware_cryptography::{
67        Signer,
68        bls12381::{
69            dkg::feldman_desmedt,
70            primitives::{ops, sharing::Mode},
71        },
72        ed25519::PrivateKey,
73    };
74    use commonware_utils::{N3f1, TryFromIterator, ordered::Set};
75    use rand_chacha::{ChaCha20Rng, rand_core::SeedableRng};
76
77    #[test]
78    fn finalization_encodes_and_signs_variable_length_proposals() {
79        let (output, share) = committee(7);
80        // Literal protobuf varints independently specify the proposal's three integer fields.
81        let cases = [
82            ((7, 9, 8), 0x42, &[7, 9, 8][..], 2),
83            ((127, 128, 127), 0, &[0x7f, 0x80, 0x01, 0x7f][..], 3),
84            ((128, 129, 128), 0, &[0x80, 0x01, 0x81, 0x01, 0x80, 0x01][..], 4),
85            ((16_383, 16_384, 16_383), 0, &[0xff, 0x7f, 0x80, 0x80, 0x01, 0xff, 0x7f][..], 5),
86            (
87                (16_384, 16_385, 16_384),
88                0,
89                &[0x80, 0x80, 0x01, 0x81, 0x80, 0x01, 0x80, 0x80, 0x01][..],
90                6,
91            ),
92        ];
93        for ((epoch, view, parent_view), digest_byte, prefix, round_len) in cases {
94            let certificate =
95                encode(epoch, view, parent_view, B256::repeat_byte(digest_byte), &output, &share);
96            let proposal_len = prefix.len() + 32;
97            assert_eq!(&certificate[..prefix.len()], prefix);
98            assert_eq!(&certificate[prefix.len()..proposal_len], &[digest_byte; 32]);
99            assert_eq!(certificate.len(), proposal_len + 96);
100
101            let vote = G1::decode(&certificate[proposal_len..proposal_len + 48]).unwrap();
102            let seed = G1::decode(&certificate[proposal_len + 48..]).unwrap();
103            let public = output.public().public();
104            assert!(
105                ops::verify_message::<MinSig>(
106                    public,
107                    b"TEMPO_FINALIZE",
108                    &certificate[..proposal_len],
109                    &vote,
110                )
111                .is_ok()
112            );
113            assert!(
114                ops::verify_message::<MinSig>(public, b"TEMPO_SEED", &prefix[..round_len], &seed)
115                    .is_ok()
116            );
117        }
118    }
119
120    #[test]
121    fn finalization_seed_depends_only_on_round() {
122        let (output, share) = committee(7);
123        let first = encode(7, 9, 8, B256::repeat_byte(0x42), &output, &share);
124        let second = encode(7, 9, 6, B256::repeat_byte(0x24), &output, &share);
125        assert_ne!(&first[35..83], &second[35..83]);
126        assert_eq!(&first[83..], &second[83..]);
127        assert_eq!(first, encode(7, 9, 8, B256::repeat_byte(0x42), &output, &share));
128    }
129
130    fn committee(seed: u8) -> (Output<MinSig, PublicKey>, Share) {
131        let proposer = PrivateKey::decode(&[seed; 32][..]).unwrap().public_key();
132        let (output, shares) = feldman_desmedt::deal::<MinSig, _, N3f1>(
133            ChaCha20Rng::from_seed([seed; 32]),
134            Mode::NonZeroCounter,
135            Set::try_from_iter([proposer]).unwrap(),
136        )
137        .unwrap();
138        (output, shares.into_iter().next().unwrap().1)
139    }
140}