Skip to main content

sidestr_round/
round.rs

1//! Level 2's round (`proposals/level-2.md`; `siding/lib/round.mjs`), as a
2//! pure state machine.
3//!
4//! In Melvin Carvalho's words, adapted: the proposer for a height builds
5//! the block and publishes it as a kind 23510 event; each other signer
6//! checks it against its own chain and mempool and answers with a kind
7//! 23511 partial signature; with `k` the proposer seals the block, adds it,
8//! publishes it as a kind 23514 event for the others and announces it.
9//! Signer keys are Nostr keys, so an event's author is the signer. One
10//! signature per height per signer — unless that proposal has had
11//! `proposeAfter` seconds to seal and has not: then a later, entitled
12//! proposer may have mine too, or a stalled height (its proposer gone after
13//! collecting fewer than `k`) would never move. And a proposer drops its
14//! own proposal after `proposeAfter × n` seconds.
15//!
16//! # No I/O, no clock
17//!
18//! [`Round::tick`] and [`Round::on_event`] take the time as an argument
19//! (unix **milliseconds**, as `Date.now()` is) and return a list of [`Action`]s — events to publish,
20//! blocks that entered the chain, lines to log — for the caller to act on.
21//! The chain is a port ([`ChainView`]): the in-memory state in a test, the
22//! file-backed chain in a signer. Nothing here opens a socket or reads a
23//! clock, so every branch of the protocol is testable with a fixed `now`.
24//!
25//! # Timing, to the millisecond
26//!
27//! `round.mjs` measures in `Date.now()` milliseconds and the same
28//! comparisons hold here, with `propose_after` = `P` seconds and `n`
29//! signers:
30//!
31//! | rule | reference | here |
32//! |---|---|---|
33//! | one signature per height relaxes (`mayReSign`) | `now − signed.at > P·1000` | strictly more than `P` s after the signature's intent: at 30 001 ms for `P = 30`, not at 30 000 |
34//! | my proposal is dropped | `now − pending.at > P·1000·n` | at 90 001 ms for `P = 30, n = 3` |
35//! | a replayed proposal is ignored | `now/1000 − created_at > P·n` | `now − created_at·1000 > P·n·1000`: the same instant, 90 001 ms after the event's second |
36//! | lateness (`entitled`) | `⌊(at − base) / 1000 / P⌋` | `(at − base) / (P·1000)`, integer; `base` = when the block became due, else the event's `created_at·1000` |
37//! | the "signed … s ago" log | `Math.round(ms / 1000)` | rounded, the same |
38//!
39//! Events carry `created_at` in whole seconds (`now / 1000`), as NIP-01
40//! requires; nothing on the wire changes.
41//!
42//! # Hardening, all behind options with upstream's behaviour as default
43//!
44//! - **The vote journal** ([`VoteJournal`]): the intent is journalled,
45//!   durably, before the custody signer is asked, and the signature after
46//!   it answers, before the `Publish` action is returned; a failed intent
47//!   write means the signer is not called, a failed signature write means
48//!   nothing is published. On restart the journal is loaded and the
49//!   one-signature rule is applied against every entry.
50//! - **`resign_after`** ([`RoundConfig::resign_after`]): upstream's
51//!   relaxation (`Some(propose_after)`) by default, for interop; `None`
52//!   never re-signs a height (ADR-2101). With `None`, a height whose
53//!   proposal stranded stays stranded until the proposer returns — that is
54//!   the trade the record chose, and it is the operator's to make.
55//! - **A sealed block is a candidate**, ingested through the validator
56//!   ([`ChainView::add_block`]) and never treated as final (review §9):
57//!   nothing here decides finality, and a caller must not either.
58//! - **Deterministic rules first, mempool policy second**: a proposal is
59//!   judged under the chain's own rules (`StateOf::judge`, minus the two
60//!   that cannot hold for an unsealed template: the block signature and the
61//!   proof of work that sealing grinds) before any transaction reaches the
62//!   mempool. Refusals are logged as `round.mjs` logs them; the one new
63//!   refusal names the rules.
64//!
65//! # Honest limits
66//!
67//! This is upstream's protocol: it tolerates `n − k` signers being *down*
68//! and nothing being *wrong*. A faulty proposer can strand a height; a
69//! relay can delay a proposal past its window; two subsets of `k` can seal
70//! one template to two hashes (`sidestr-core`'s template-versus-sealed
71//! note). Byzantine tolerance is a separate protocol above the signature
72//! (ADR-2101), not a setting here.
73
74use std::collections::BTreeMap;
75
76use bitcoin::secp256k1::{schnorr::Signature, XOnlyPublicKey};
77use bitcoin::BlockHash;
78use sidestr_core::block::{
79    block_height, block_sighash_for, template_id, HeaderFamily, SidestrBlock, SpendPath,
80};
81use sidestr_core::federation::{seal_federated, verify_partial, Federation};
82use sidestr_core::state::{ClaimRequest, NextBlock};
83use sidestr_nostr::event::{pubkey_from_hex, Event};
84use sidestr_nostr::kinds::{KIND_BLOCK_PROPOSAL, KIND_PARTIAL_SIGNATURE, KIND_SEALED_BLOCK};
85use sidestr_nostr::relay::Follower;
86use sidestr_nostr::round::{sign_partial, sign_proposal, sign_sealed, Partial, Proposal};
87use sidestr_nostr::tags::{first, height_tag, TAG_E, TAG_H};
88
89use crate::chain::ChainView;
90use crate::error::{Error, Result};
91use crate::journal::{VoteEntry, VoteJournal, VoteRole, VoteScope, VoteStage};
92use crate::signer::{PartialRequest, RoundSigner};
93
94/// The rules that cannot hold for an unsealed template and are therefore
95/// not held against a proposal: the solution is not there yet, and the
96/// nonce is ground at sealing.
97pub const RULES_NOT_JUDGED_ON_A_TEMPLATE: [&str; 2] =
98    ["sidestr:rule-block-signature", "btc:rule-header-pow"];
99
100/// The round's options.
101#[derive(Debug, Clone, PartialEq, Eq)]
102pub struct RoundConfig {
103    /// Seconds without a block before the next signer in the ring may
104    /// propose (`--propose-after`, upstream default 30).
105    pub propose_after: u64,
106    /// After how many seconds a signed-but-unsealed height may be signed
107    /// again for another proposal (strictly more than this many, to the
108    /// millisecond). `Some(propose_after)` is upstream's rule; `None` never
109    /// re-signs.
110    pub resign_after: Option<u64>,
111}
112
113impl RoundConfig {
114    /// Upstream's behaviour for a given `propose_after`.
115    pub fn upstream(propose_after: u64) -> Self {
116        Self {
117            propose_after,
118            resign_after: Some(propose_after),
119        }
120    }
121}
122
123impl Default for RoundConfig {
124    /// `propose_after = 30`, re-signing after 30: `round.mjs`'s defaults.
125    fn default() -> Self {
126        Self::upstream(30)
127    }
128}
129
130/// A block that entered the chain through the round.
131#[derive(Debug, Clone, PartialEq, Eq)]
132pub struct SealedBlock {
133    /// Its height.
134    pub height: u32,
135    /// Its hash.
136    pub hash: BlockHash,
137    /// Transactions, coinbase included.
138    pub txs: usize,
139    /// Fees the coinbase collected (known for my own proposal, 0 otherwise).
140    pub fees: u64,
141    /// Claims it made (known for my own proposal, 0 otherwise).
142    pub claims: usize,
143    /// The signer whose 23514 carried it, or `None` when sealed here.
144    pub from: Option<String>,
145}
146
147/// What the caller does after a [`Round::tick`] or [`Round::on_event`].
148#[derive(Debug, Clone, PartialEq, Eq)]
149pub enum Action {
150    /// Send this event to every relay (`relay.mjs publish`).
151    Publish(Event),
152    /// A block was validated and applied through [`ChainView::add_block`]:
153    /// announce the tip, reset the block timer.
154    Sealed(SealedBlock),
155    /// A line for the signer's log, worded as `round.mjs` words it.
156    Log(String),
157}
158
159/// The optional check a signer with a parent view runs on a proposal's
160/// claims (`bin/siding.mjs checkClaims`): `Some(reason)` refuses it.
161pub trait ClaimChecker<F: HeaderFamily> {
162    /// Why the block's claims are unacceptable, or `None`.
163    fn check(&self, block: &F::Block) -> Option<String>;
164}
165
166/// My proposal in flight (`round.mjs pending`).
167#[derive(Debug, Clone)]
168pub struct Pending<B> {
169    /// The 23510's id.
170    pub id: String,
171    /// The height.
172    pub height: u32,
173    /// The template.
174    pub block: B,
175    /// Signatures gathered so far, mine included.
176    pub sigs: BTreeMap<XOnlyPublicKey, Signature>,
177    /// When it was proposed, unix milliseconds.
178    pub at: u64,
179    /// The fees it collects.
180    pub fees: u64,
181    /// The claims it makes.
182    pub claims: usize,
183}
184
185#[derive(Debug, Clone)]
186struct SignedAt {
187    id: String,
188    at: u64,
189}
190
191/// The round for one signer of one chain.
192pub struct Round<F: HeaderFamily> {
193    cfg: RoundConfig,
194    family: F,
195    chain_id: String,
196    genesis_hash: BlockHash,
197    fed: Federation,
198    me: XOnlyPublicKey,
199    me_hex: String,
200    signer: Box<dyn RoundSigner>,
201    journal: Box<dyn VoteJournal>,
202    pending: Option<Pending<F::Block>>,
203    signed: BTreeMap<u32, SignedAt>,
204    due_since: Option<u64>,
205    claims_wanted: Vec<ClaimRequest>,
206    check_claims: Option<Box<dyn ClaimChecker<F>>>,
207    proposals: Follower,
208    partials: Follower,
209    sealed: Follower,
210}
211
212impl<F: HeaderFamily> core::fmt::Debug for Round<F> {
213    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
214        f.debug_struct("Round")
215            .field("chain_id", &self.chain_id)
216            .field("me", &self.me_hex)
217            .field("cfg", &self.cfg)
218            .field("pending", &self.pending.as_ref().map(|p| p.height))
219            .field("signed", &self.signed.keys().collect::<Vec<_>>())
220            .finish_non_exhaustive()
221    }
222}
223
224fn short(s: &str, n: usize) -> &str {
225    &s[..s.len().min(n)]
226}
227
228impl<F: HeaderFamily> Round<F> {
229    /// A round for the chain `state` holds, as the signer `signer` — which
230    /// must be one of the document's signers — with its journal loaded.
231    pub fn new(
232        state: &sidestr_core::state::StateOf<F>,
233        signer: Box<dyn RoundSigner>,
234        journal: Box<dyn VoteJournal>,
235        cfg: RoundConfig,
236    ) -> Result<Self> {
237        let fed = state.federation().cloned().ok_or_else(|| {
238            Error::Federation("not a federated chain: no signers/threshold".into())
239        })?;
240        let me = signer.pubkey();
241        if !fed.signers.contains(&me) {
242            return Err(Error::Key("this key is not one of the signers".into()));
243        }
244        let chain_id = state.document().id.clone();
245        let mut signed = BTreeMap::new();
246        for e in journal.entries()? {
247            if let VoteScope::Height(h) = e.scope {
248                signed.insert(
249                    h,
250                    SignedAt {
251                        id: e.subject,
252                        at: e.at,
253                    },
254                );
255            }
256        }
257        Ok(Self {
258            cfg,
259            family: *state.family(),
260            genesis_hash: state.genesis_hash(),
261            fed,
262            me,
263            me_hex: hex::encode(me.serialize()),
264            signer,
265            journal,
266            pending: None,
267            signed,
268            due_since: None,
269            claims_wanted: Vec::new(),
270            check_claims: None,
271            proposals: Follower::new(KIND_BLOCK_PROPOSAL, &chain_id),
272            partials: Follower::new(KIND_PARTIAL_SIGNATURE, &chain_id),
273            sealed: Follower::new(KIND_SEALED_BLOCK, &chain_id),
274            chain_id,
275        })
276    }
277
278    /// Install the claims check a parent view provides.
279    pub fn with_claim_checker(mut self, checker: Box<dyn ClaimChecker<F>>) -> Self {
280        self.check_claims = Some(checker);
281        self
282    }
283
284    /// The signer set.
285    pub fn federation(&self) -> &Federation {
286        &self.fed
287    }
288    /// My key.
289    pub fn me(&self) -> &XOnlyPublicKey {
290        &self.me
291    }
292    /// My slot in the ring (`fed.signers.indexOf(pub)`).
293    pub fn slot(&self) -> usize {
294        self.fed
295            .signers
296            .iter()
297            .position(|k| *k == self.me)
298            .expect("checked in new")
299    }
300    /// The options.
301    pub fn config(&self) -> &RoundConfig {
302        &self.cfg
303    }
304    /// My proposal in flight.
305    pub fn pending(&self) -> Option<&Pending<F::Block>> {
306        self.pending.as_ref()
307    }
308    /// The heights I have signed a proposal for, with the proposal id and
309    /// when (unix milliseconds) — the journal's view after this run's
310    /// additions.
311    pub fn signed(&self) -> impl Iterator<Item = (u32, &str, u64)> {
312        self.signed.iter().map(|(h, s)| (*h, s.id.as_str(), s.at))
313    }
314    /// What the producer wants claimed in the next block it proposes
315    /// (`round.wantClaims`). Empty included: a claim sealed by another
316    /// signer must leave the list, or every proposal of mine throws.
317    pub fn want_claims(&mut self, claims: Vec<ClaimRequest>) {
318        self.claims_wanted = claims;
319    }
320
321    fn n(&self) -> u64 {
322        self.fed.signers.len() as u64
323    }
324
325    /// `round.mjs mayReSign`: no signature at this height, or the one there
326    /// is has had its window to seal — strictly more than `resign_after`
327    /// seconds, measured in milliseconds.
328    fn may_resign(&self, height: u32, now_ms: u64) -> bool {
329        match (self.signed.get(&height), self.cfg.resign_after) {
330            (None, _) => true,
331            (Some(_), None) => false,
332            (Some(prev), Some(after)) => now_ms.saturating_sub(prev.at) > after * 1000,
333        }
334    }
335
336    /// `propose_after × n`, in milliseconds: the proposal's life.
337    fn ring_ms(&self) -> u64 {
338        self.cfg.propose_after * 1000 * self.n()
339    }
340
341    /// `round.mjs entitled`: the proposer for a height is `height mod n`;
342    /// every `propose_after` seconds of lateness lets the next signer in
343    /// the ring propose too. Lateness counts from when the block became
344    /// due (`due_since`), not from the last block. Never negative: another
345    /// signer's clock may run a little ahead of mine. `at_ms` and
346    /// `due_since` are milliseconds, as upstream's.
347    fn entitled(&self, signer: &XOnlyPublicKey, height: u32, at_ms: u64) -> bool {
348        let Some(slot) = self.fed.signers.iter().position(|k| k == signer) else {
349            return false;
350        };
351        let n = self.n();
352        let turn = u64::from(height) % n;
353        let base = self.due_since.unwrap_or(at_ms);
354        let late = at_ms.saturating_sub(base) / (self.cfg.propose_after.max(1) * 1000);
355        (slot as u64 + n - turn) % n <= late
356    }
357
358    fn template_id(&self, block: &F::Block) -> Result<[u8; 32]> {
359        Ok(template_id(
360            &self.family,
361            block,
362            &self.chain_id,
363            Some(self.genesis_hash),
364        )?)
365    }
366
367    fn partial(&self, block: &F::Block, height: u32, tid: [u8; 32]) -> Result<Signature> {
368        let digest = block_sighash_for(
369            &self.family,
370            block,
371            &self.fed.challenge(),
372            &SpendPath::ScriptPath {
373                leaf_hash: self.fed.leaf_hash,
374                annex: None,
375                codesep_pos: 0xffff_ffff,
376            },
377        )?;
378        self.signer
379            .sign_partial(&PartialRequest::new(&self.chain_id, height, tid, digest))
380    }
381
382    /// One journal record. The intent goes in before the custody signer is
383    /// asked, the signature after it answers.
384    fn journal(
385        &mut self,
386        tid: [u8; 32],
387        height: u32,
388        role: VoteRole,
389        subject: &str,
390        at_ms: u64,
391        signature: Option<&Signature>,
392    ) -> Result<()> {
393        self.journal.record(&VoteEntry {
394            scope: VoteScope::Height(height),
395            role,
396            subject: subject.to_string(),
397            digest: hex::encode(tid),
398            at: at_ms,
399            stage: if signature.is_some() {
400                VoteStage::Signed
401            } else {
402                VoteStage::Intent
403            },
404            signature: signature.map(|s| hex::encode(s.as_ref())),
405        })
406    }
407
408    /// Intent, then the custody signer, then the signature: the order the
409    /// journal guarantees. `Err` before the signer was asked means no
410    /// signature exists; `Err` after means one exists, is journalled, and
411    /// is not published.
412    fn authorise(
413        &mut self,
414        block: &F::Block,
415        height: u32,
416        role: VoteRole,
417        subject: &str,
418        now_ms: u64,
419    ) -> core::result::Result<Signature, (bool, Error)> {
420        let tid = self.template_id(block).map_err(|e| (false, e))?;
421        self.journal(tid, height, role, subject, now_ms, None)
422            .map_err(|e| (false, e))?;
423        // journalled: from here the height counts as signed whatever happens next
424        self.signed.insert(
425            height,
426            SignedAt {
427                id: subject.to_string(),
428                at: now_ms,
429            },
430        );
431        let sig = self.partial(block, height, tid).map_err(|e| (true, e))?;
432        self.journal(tid, height, role, subject, now_ms, Some(&sig))
433            .map_err(|e| (true, e))?;
434        Ok(sig)
435    }
436
437    /// Called every second by the producer (`round.tick({ due })`): drop a
438    /// proposal nobody sealed, and propose when a block is due and it is my
439    /// turn. `now_ms` is unix milliseconds; `due` is the producer's block
440    /// timer, as upstream computes it.
441    pub fn tick(&mut self, now_ms: u64, chain: &mut dyn ChainView<F>, due: bool) -> Vec<Action> {
442        let now = now_ms;
443        let mut out = Vec::new();
444        if let Some(p) = &self.pending {
445            if p.height <= chain.state().height() {
446                // the height moved under my proposal: a sealed block from elsewhere took it
447                self.pending = None;
448            } else if now.saturating_sub(p.at) > self.ring_ms() {
449                out.push(Action::Log(format!(
450                    "round: my proposal h{} got {} signature(s); dropping it",
451                    p.height,
452                    p.sigs.len()
453                )));
454                self.pending = None;
455                return out;
456            } else {
457                return out;
458            }
459        }
460        if !due {
461            self.due_since = None;
462            return out;
463        }
464        if self.due_since.is_none() {
465            self.due_since = Some(now);
466        }
467        let height = chain.state().height().saturating_add(1);
468        if !self.may_resign(height, now) {
469            return out;
470        }
471        if self.entitled(&self.me, height, now) {
472            match self.propose(now, chain, &mut out) {
473                Ok(()) => {}
474                Err(e) => out.push(Action::Log(format!("round: {e}"))),
475            }
476        }
477        out
478    }
479
480    /// `round.mjs propose`. `now` is milliseconds. The 23510 envelope is
481    /// signed first — a Nostr signature over an unsealed template, whose
482    /// id is the intent's subject — then the intent is journalled, then
483    /// the custody signer makes the partial, then the signature record;
484    /// only then is the event handed out.
485    fn propose(
486        &mut self,
487        now: u64,
488        chain: &mut dyn ChainView<F>,
489        out: &mut Vec<Action>,
490    ) -> Result<()> {
491        let secs = now / 1000;
492        let state = chain.state();
493        // never propose a claim the chain already has
494        let wanted: Vec<ClaimRequest> = self
495            .claims_wanted
496            .iter()
497            .filter(|c| !state.claimed(&c.txid, c.vout))
498            .cloned()
499            .collect();
500        let (block, fees, claims) = state.build_next(&NextBlock {
501            time: u32::try_from(secs).unwrap_or(u32::MAX),
502            claims: wanted,
503        })?;
504        let height = block_height(&self.family, &block)?;
505        let ev = sign_proposal(
506            self.signer.as_ref(),
507            &Proposal {
508                chain_id: self.chain_id.clone(),
509                height,
510                block_hex: hex::encode(block.encode()),
511            },
512            secs,
513        )?;
514        // the intent is journalled before the custody key is asked, the signature before anything is handed out
515        let sig = self
516            .authorise(&block, height, VoteRole::Proposed, &ev.id, now)
517            .map_err(|(_, e)| e)?;
518        let mut sigs = BTreeMap::new();
519        sigs.insert(self.me, sig);
520        self.pending = Some(Pending {
521            id: ev.id.clone(),
522            height,
523            block: block.clone(),
524            sigs,
525            at: now,
526            fees,
527            claims,
528        });
529        out.push(Action::Log(format!(
530            "round: proposing h{height} {}… ({} txs, {claims} claim(s))",
531            short(&ev.id, 12),
532            block.txdata().len() - 1
533        )));
534        out.push(Action::Publish(ev));
535        self.maybe_seal(now, chain, out);
536        Ok(())
537    }
538
539    /// An event from a relay (`relay.mjs subscribe` → `onProposal`,
540    /// `onPartial`, `onSealed`). The on-receipt checks — kind, unseen,
541    /// tagged for this chain, signature — are applied here, so the caller
542    /// may hand over everything the relay sends. `now_ms` is unix
543    /// milliseconds.
544    pub fn on_event(
545        &mut self,
546        now_ms: u64,
547        chain: &mut dyn ChainView<F>,
548        ev: &Event,
549    ) -> Vec<Action> {
550        let now = now_ms;
551        let mut out = Vec::new();
552        match ev.kind {
553            KIND_BLOCK_PROPOSAL => {
554                if self.proposals.accept(ev).is_some() {
555                    self.on_proposal(now, chain, ev, &mut out);
556                }
557            }
558            KIND_PARTIAL_SIGNATURE => {
559                if self.partials.accept(ev).is_some() {
560                    self.on_partial(now, chain, ev, &mut out);
561                }
562            }
563            KIND_SEALED_BLOCK if self.sealed.accept(ev).is_some() => {
564                self.on_sealed(now, chain, ev, &mut out);
565            }
566            _ => {}
567        }
568        out
569    }
570
571    /// `round.mjs onProposal`, with the deterministic rules judged before
572    /// the mempool.
573    fn on_proposal(
574        &mut self,
575        now: u64,
576        chain: &mut dyn ChainView<F>,
577        ev: &Event,
578        out: &mut Vec<Action>,
579    ) {
580        let Ok(height) = height_tag(&ev.tags, TAG_H) else {
581            return;
582        };
583        if ev.pubkey == self.me_hex {
584            return;
585        }
586        // a relay replaying an old proposal: its proposer has moved on
587        if now.saturating_sub(ev.created_at.saturating_mul(1000)) > self.ring_ms() {
588            return;
589        }
590        let secs = now / 1000;
591        let log = |s: String| Action::Log(s);
592        let my = chain.state().height();
593        if u64::from(height) != u64::from(my) + 1 {
594            out.push(log(format!(
595                "round: proposal h{height} from {}… ignored (my tip is {my})",
596                short(&ev.pubkey, 8)
597            )));
598            return;
599        }
600        let proposer = pubkey_from_hex(&ev.pubkey).ok();
601        if !proposer.is_some_and(|p| self.entitled(&p, height, ev.created_at.saturating_mul(1000)))
602        {
603            out.push(log(format!(
604                "round: proposal h{height} from {}… refused: not its turn",
605                short(&ev.pubkey, 8)
606            )));
607            return;
608        }
609        if !self.may_resign(height, now) {
610            let prev = self
611                .signed
612                .get(&height)
613                .expect("may_resign is false only with an entry");
614            out.push(log(format!(
615                "round: proposal h{height} from {}… refused: I signed {}… for this height {} s ago",
616                short(&ev.pubkey, 8),
617                short(&prev.id, 8),
618                (now.saturating_sub(prev.at) + 500) / 1000
619            )));
620            return;
621        }
622        let block = match hex::decode(ev.content.trim())
623            .ok()
624            .and_then(|b| F::Block::decode(&b).ok())
625        {
626            Some(b) => b,
627            None => {
628                out.push(log("round: proposal is not a block".into()));
629                return;
630            }
631        };
632        let tip = chain.state().tip();
633        let header = block.header();
634        if self.family.prev(header) != tip.hash || self.family.time(header) <= tip.time {
635            out.push(log(format!(
636                "round: proposal h{height} refused: does not build on my tip"
637            )));
638            return;
639        }
640        // deterministic chain rules first (hardening: the template judged as a block would be,
641        // minus the two rules sealing satisfies), local mempool policy second
642        let (verdict, _) = chain.state().judge(
643            height,
644            &block,
645            Some(u32::try_from(secs).unwrap_or(u32::MAX)),
646        );
647        let failed: Vec<String> = verdict
648            .failed()
649            .into_iter()
650            .filter(|r| !RULES_NOT_JUDGED_ON_A_TEMPLATE.contains(&r.as_str()))
651            .collect();
652        if !failed.is_empty() {
653            out.push(log(format!(
654                "round: proposal h{height} refused: rules {}",
655                failed.join(", ")
656            )));
657            return;
658        }
659        // every transaction must be one my mempool accepts (or already holds): the same checks a producer makes
660        for tx in &block.txdata()[1..] {
661            let txid = tx.compute_txid();
662            if chain.state().mempool().any(|m| m.compute_txid() == txid) {
663                continue;
664            }
665            if let Err(e) = chain.submit(tx.clone()) {
666                out.push(log(format!(
667                    "round: proposal h{height} refused: tx {}… {e}",
668                    short(&txid.to_string(), 12)
669                )));
670                return;
671            }
672        }
673        if let Some(why) = self.check_claims.as_ref().and_then(|c| c.check(&block)) {
674            out.push(log(format!("round: proposal h{height} refused: {why}")));
675            return;
676        }
677        let sig = match self.authorise(&block, height, VoteRole::Signed, &ev.id, now) {
678            Ok(s) => s,
679            Err((false, e)) => {
680                out.push(log(format!("round: proposal h{height} not signed: {e}")));
681                return;
682            }
683            Err((true, e)) => {
684                out.push(log(format!(
685                    "round: proposal h{height} signed but not published: {e}"
686                )));
687                return;
688            }
689        };
690        let pev = match sign_partial(
691            self.signer.as_ref(),
692            &Partial {
693                chain_id: self.chain_id.clone(),
694                height,
695                proposal: ev.id.clone(),
696                signature_hex: hex::encode(sig.as_ref()),
697            },
698            secs,
699        ) {
700            Ok(p) => p,
701            Err(e) => {
702                out.push(log(format!("round: {e}")));
703                return;
704            }
705        };
706        out.push(Action::Publish(pev));
707        out.push(log(format!(
708            "round: signed h{height} {}… from {}…",
709            short(&ev.id, 12),
710            short(&ev.pubkey, 8)
711        )));
712    }
713
714    /// `round.mjs onPartial`.
715    fn on_partial(
716        &mut self,
717        now: u64,
718        chain: &mut dyn ChainView<F>,
719        ev: &Event,
720        out: &mut Vec<Action>,
721    ) {
722        let Some(p) = &self.pending else {
723            return;
724        };
725        if first(&ev.tags, TAG_E) != Some(p.id.as_str()) || ev.pubkey == self.me_hex {
726            return;
727        }
728        let Ok(pk) = pubkey_from_hex(&ev.pubkey) else {
729            return;
730        };
731        if !self.fed.signers.contains(&pk) {
732            return;
733        }
734        let sig = hex::decode(ev.content.trim())
735            .ok()
736            .and_then(|b| Signature::from_slice(&b).ok());
737        let ok = sig.is_some_and(|s| verify_partial(&self.family, &p.block, &self.fed, &pk, &s));
738        if !ok {
739            out.push(Action::Log(format!(
740                "round: bad partial from {}…",
741                short(&ev.pubkey, 8)
742            )));
743            return;
744        }
745        let p = self.pending.as_mut().expect("checked");
746        p.sigs.insert(pk, sig.expect("checked"));
747        out.push(Action::Log(format!(
748            "round: {}/{} signatures for h{}",
749            p.sigs.len(),
750            self.fed.threshold,
751            p.height
752        )));
753        self.maybe_seal(now, chain, out);
754    }
755
756    /// `round.mjs maybeSeal`: with `k`, seal, add through the validator,
757    /// publish the sealed block.
758    fn maybe_seal(&mut self, now: u64, chain: &mut dyn ChainView<F>, out: &mut Vec<Action>) {
759        let Some(p) = &self.pending else {
760            return;
761        };
762        if p.sigs.len() < usize::from(self.fed.threshold) {
763            return;
764        }
765        let p = self.pending.take().expect("checked");
766        let sealed = match seal_federated(&self.family, &p.block, &self.fed, &p.sigs) {
767            Ok(s) => s,
768            Err(e) => {
769                out.push(Action::Log(format!(
770                    "round: sealed block refused by my own validator: {e}"
771                )));
772                return;
773            }
774        };
775        let secs = now / 1000;
776        match chain.add_block(&sealed, secs) {
777            Ok(r) => {
778                self.claims_wanted.clear();
779                self.due_since = None;
780                out.push(Action::Log(format!(
781                    "block {} {}… sealed by {} of {}, {} txs, fees {}{}",
782                    r.height,
783                    short(&r.hash.to_string(), 16),
784                    self.fed.threshold,
785                    self.n(),
786                    r.txs - 1,
787                    p.fees,
788                    if p.claims > 0 {
789                        format!(", claims {}", p.claims)
790                    } else {
791                        String::new()
792                    }
793                )));
794                match sign_sealed(
795                    self.signer.as_ref(),
796                    &Proposal {
797                        chain_id: self.chain_id.clone(),
798                        height: r.height,
799                        block_hex: hex::encode(sealed.encode()),
800                    },
801                    secs,
802                ) {
803                    Ok(ev) => out.push(Action::Publish(ev)),
804                    Err(e) => out.push(Action::Log(format!("round: {e}"))),
805                }
806                out.push(Action::Sealed(SealedBlock {
807                    height: r.height,
808                    hash: r.hash,
809                    txs: r.txs,
810                    fees: p.fees,
811                    claims: p.claims,
812                    from: None,
813                }));
814            }
815            Err(e) => out.push(Action::Log(format!(
816                "round: sealed block refused by my own validator: {e}"
817            ))),
818        }
819    }
820
821    /// `round.mjs onSealed`: a block another signer sealed is a candidate
822    /// for the validator, nothing more.
823    fn on_sealed(
824        &mut self,
825        now: u64,
826        chain: &mut dyn ChainView<F>,
827        ev: &Event,
828        out: &mut Vec<Action>,
829    ) {
830        let Ok(height) = height_tag(&ev.tags, TAG_H) else {
831            return;
832        };
833        let from_signer = pubkey_from_hex(&ev.pubkey).is_ok_and(|p| self.fed.signers.contains(&p));
834        if ev.pubkey == self.me_hex
835            || !from_signer
836            || u64::from(height) != u64::from(chain.state().height()) + 1
837        {
838            return;
839        }
840        let refused = |e: String| {
841            Action::Log(format!(
842                "round: sealed block h{height} from {}… refused: {e}",
843                short(&ev.pubkey, 8)
844            ))
845        };
846        let block = match hex::decode(ev.content.trim())
847            .map_err(|e| e.to_string())
848            .and_then(|b| F::Block::decode(&b).map_err(|e| e.to_string()))
849        {
850            Ok(b) => b,
851            Err(e) => {
852                out.push(refused(e));
853                return;
854            }
855        };
856        match chain.add_block(&block, now / 1000) {
857            Ok(r) => {
858                self.due_since = None;
859                if self.pending.as_ref().is_some_and(|p| p.height <= r.height) {
860                    self.pending = None;
861                }
862                out.push(Action::Log(format!(
863                    "block {} {}… from {}… (sealed by the federation)",
864                    r.height,
865                    short(&r.hash.to_string(), 16),
866                    short(&ev.pubkey, 8)
867                )));
868                out.push(Action::Sealed(SealedBlock {
869                    height: r.height,
870                    hash: r.hash,
871                    txs: r.txs,
872                    fees: 0,
873                    claims: 0,
874                    from: Some(ev.pubkey.clone()),
875                }));
876            }
877            Err(e) => out.push(refused(e.to_string())),
878        }
879    }
880}