1use 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
94pub const RULES_NOT_JUDGED_ON_A_TEMPLATE: [&str; 2] =
98 ["sidestr:rule-block-signature", "btc:rule-header-pow"];
99
100#[derive(Debug, Clone, PartialEq, Eq)]
102pub struct RoundConfig {
103 pub propose_after: u64,
106 pub resign_after: Option<u64>,
111}
112
113impl RoundConfig {
114 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 fn default() -> Self {
126 Self::upstream(30)
127 }
128}
129
130#[derive(Debug, Clone, PartialEq, Eq)]
132pub struct SealedBlock {
133 pub height: u32,
135 pub hash: BlockHash,
137 pub txs: usize,
139 pub fees: u64,
141 pub claims: usize,
143 pub from: Option<String>,
145}
146
147#[derive(Debug, Clone, PartialEq, Eq)]
149pub enum Action {
150 Publish(Event),
152 Sealed(SealedBlock),
155 Log(String),
157}
158
159pub trait ClaimChecker<F: HeaderFamily> {
162 fn check(&self, block: &F::Block) -> Option<String>;
164}
165
166#[derive(Debug, Clone)]
168pub struct Pending<B> {
169 pub id: String,
171 pub height: u32,
173 pub block: B,
175 pub sigs: BTreeMap<XOnlyPublicKey, Signature>,
177 pub at: u64,
179 pub fees: u64,
181 pub claims: usize,
183}
184
185#[derive(Debug, Clone)]
186struct SignedAt {
187 id: String,
188 at: u64,
189}
190
191pub 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 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 pub fn with_claim_checker(mut self, checker: Box<dyn ClaimChecker<F>>) -> Self {
280 self.check_claims = Some(checker);
281 self
282 }
283
284 pub fn federation(&self) -> &Federation {
286 &self.fed
287 }
288 pub fn me(&self) -> &XOnlyPublicKey {
290 &self.me
291 }
292 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 pub fn config(&self) -> &RoundConfig {
302 &self.cfg
303 }
304 pub fn pending(&self) -> Option<&Pending<F::Block>> {
306 self.pending.as_ref()
307 }
308 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 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 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 fn ring_ms(&self) -> u64 {
338 self.cfg.propose_after * 1000 * self.n()
339 }
340
341 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}