use std::collections::BTreeMap;
use bitcoin::secp256k1::{schnorr::Signature, XOnlyPublicKey};
use bitcoin::BlockHash;
use sidestr_core::block::{
block_height, block_sighash_for, template_id, HeaderFamily, SidestrBlock, SpendPath,
};
use sidestr_core::federation::{seal_federated, verify_partial, Federation};
use sidestr_core::state::{ClaimRequest, NextBlock};
use sidestr_nostr::event::{pubkey_from_hex, Event};
use sidestr_nostr::kinds::{KIND_BLOCK_PROPOSAL, KIND_PARTIAL_SIGNATURE, KIND_SEALED_BLOCK};
use sidestr_nostr::relay::Follower;
use sidestr_nostr::round::{sign_partial, sign_proposal, sign_sealed, Partial, Proposal};
use sidestr_nostr::tags::{first, height_tag, TAG_E, TAG_H};
use crate::chain::ChainView;
use crate::error::{Error, Result};
use crate::journal::{VoteEntry, VoteJournal, VoteRole, VoteScope, VoteStage};
use crate::signer::{PartialRequest, RoundSigner};
pub const RULES_NOT_JUDGED_ON_A_TEMPLATE: [&str; 2] =
["sidestr:rule-block-signature", "btc:rule-header-pow"];
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RoundConfig {
pub propose_after: u64,
pub resign_after: Option<u64>,
}
impl RoundConfig {
pub fn upstream(propose_after: u64) -> Self {
Self {
propose_after,
resign_after: Some(propose_after),
}
}
}
impl Default for RoundConfig {
fn default() -> Self {
Self::upstream(30)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SealedBlock {
pub height: u32,
pub hash: BlockHash,
pub txs: usize,
pub fees: u64,
pub claims: usize,
pub from: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Action {
Publish(Event),
Sealed(SealedBlock),
Log(String),
}
pub trait ClaimChecker<F: HeaderFamily> {
fn check(&self, block: &F::Block) -> Option<String>;
}
#[derive(Debug, Clone)]
pub struct Pending<B> {
pub id: String,
pub height: u32,
pub block: B,
pub sigs: BTreeMap<XOnlyPublicKey, Signature>,
pub at: u64,
pub fees: u64,
pub claims: usize,
}
#[derive(Debug, Clone)]
struct SignedAt {
id: String,
at: u64,
}
pub struct Round<F: HeaderFamily> {
cfg: RoundConfig,
family: F,
chain_id: String,
genesis_hash: BlockHash,
fed: Federation,
me: XOnlyPublicKey,
me_hex: String,
signer: Box<dyn RoundSigner>,
journal: Box<dyn VoteJournal>,
pending: Option<Pending<F::Block>>,
signed: BTreeMap<u32, SignedAt>,
due_since: Option<u64>,
claims_wanted: Vec<ClaimRequest>,
check_claims: Option<Box<dyn ClaimChecker<F>>>,
proposals: Follower,
partials: Follower,
sealed: Follower,
}
impl<F: HeaderFamily> core::fmt::Debug for Round<F> {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.debug_struct("Round")
.field("chain_id", &self.chain_id)
.field("me", &self.me_hex)
.field("cfg", &self.cfg)
.field("pending", &self.pending.as_ref().map(|p| p.height))
.field("signed", &self.signed.keys().collect::<Vec<_>>())
.finish_non_exhaustive()
}
}
fn short(s: &str, n: usize) -> &str {
&s[..s.len().min(n)]
}
impl<F: HeaderFamily> Round<F> {
pub fn new(
state: &sidestr_core::state::StateOf<F>,
signer: Box<dyn RoundSigner>,
journal: Box<dyn VoteJournal>,
cfg: RoundConfig,
) -> Result<Self> {
let fed = state.federation().cloned().ok_or_else(|| {
Error::Federation("not a federated chain: no signers/threshold".into())
})?;
let me = signer.pubkey();
if !fed.signers.contains(&me) {
return Err(Error::Key("this key is not one of the signers".into()));
}
let chain_id = state.document().id.clone();
let mut signed = BTreeMap::new();
for e in journal.entries()? {
if let VoteScope::Height(h) = e.scope {
signed.insert(
h,
SignedAt {
id: e.subject,
at: e.at,
},
);
}
}
Ok(Self {
cfg,
family: *state.family(),
genesis_hash: state.genesis_hash(),
fed,
me,
me_hex: hex::encode(me.serialize()),
signer,
journal,
pending: None,
signed,
due_since: None,
claims_wanted: Vec::new(),
check_claims: None,
proposals: Follower::new(KIND_BLOCK_PROPOSAL, &chain_id),
partials: Follower::new(KIND_PARTIAL_SIGNATURE, &chain_id),
sealed: Follower::new(KIND_SEALED_BLOCK, &chain_id),
chain_id,
})
}
pub fn with_claim_checker(mut self, checker: Box<dyn ClaimChecker<F>>) -> Self {
self.check_claims = Some(checker);
self
}
pub fn federation(&self) -> &Federation {
&self.fed
}
pub fn me(&self) -> &XOnlyPublicKey {
&self.me
}
pub fn slot(&self) -> usize {
self.fed
.signers
.iter()
.position(|k| *k == self.me)
.expect("checked in new")
}
pub fn config(&self) -> &RoundConfig {
&self.cfg
}
pub fn pending(&self) -> Option<&Pending<F::Block>> {
self.pending.as_ref()
}
pub fn signed(&self) -> impl Iterator<Item = (u32, &str, u64)> {
self.signed.iter().map(|(h, s)| (*h, s.id.as_str(), s.at))
}
pub fn want_claims(&mut self, claims: Vec<ClaimRequest>) {
self.claims_wanted = claims;
}
fn n(&self) -> u64 {
self.fed.signers.len() as u64
}
fn may_resign(&self, height: u32, now_ms: u64) -> bool {
match (self.signed.get(&height), self.cfg.resign_after) {
(None, _) => true,
(Some(_), None) => false,
(Some(prev), Some(after)) => now_ms.saturating_sub(prev.at) > after * 1000,
}
}
fn ring_ms(&self) -> u64 {
self.cfg.propose_after * 1000 * self.n()
}
fn entitled(&self, signer: &XOnlyPublicKey, height: u32, at_ms: u64) -> bool {
let Some(slot) = self.fed.signers.iter().position(|k| k == signer) else {
return false;
};
let n = self.n();
let turn = u64::from(height) % n;
let base = self.due_since.unwrap_or(at_ms);
let late = at_ms.saturating_sub(base) / (self.cfg.propose_after.max(1) * 1000);
(slot as u64 + n - turn) % n <= late
}
fn template_id(&self, block: &F::Block) -> Result<[u8; 32]> {
Ok(template_id(
&self.family,
block,
&self.chain_id,
Some(self.genesis_hash),
)?)
}
fn partial(&self, block: &F::Block, height: u32, tid: [u8; 32]) -> Result<Signature> {
let digest = block_sighash_for(
&self.family,
block,
&self.fed.challenge(),
&SpendPath::ScriptPath {
leaf_hash: self.fed.leaf_hash,
annex: None,
codesep_pos: 0xffff_ffff,
},
)?;
self.signer
.sign_partial(&PartialRequest::new(&self.chain_id, height, tid, digest))
}
fn journal(
&mut self,
tid: [u8; 32],
height: u32,
role: VoteRole,
subject: &str,
at_ms: u64,
signature: Option<&Signature>,
) -> Result<()> {
self.journal.record(&VoteEntry {
scope: VoteScope::Height(height),
role,
subject: subject.to_string(),
digest: hex::encode(tid),
at: at_ms,
stage: if signature.is_some() {
VoteStage::Signed
} else {
VoteStage::Intent
},
signature: signature.map(|s| hex::encode(s.as_ref())),
})
}
fn authorise(
&mut self,
block: &F::Block,
height: u32,
role: VoteRole,
subject: &str,
now_ms: u64,
) -> core::result::Result<Signature, (bool, Error)> {
let tid = self.template_id(block).map_err(|e| (false, e))?;
self.journal(tid, height, role, subject, now_ms, None)
.map_err(|e| (false, e))?;
self.signed.insert(
height,
SignedAt {
id: subject.to_string(),
at: now_ms,
},
);
let sig = self.partial(block, height, tid).map_err(|e| (true, e))?;
self.journal(tid, height, role, subject, now_ms, Some(&sig))
.map_err(|e| (true, e))?;
Ok(sig)
}
pub fn tick(&mut self, now_ms: u64, chain: &mut dyn ChainView<F>, due: bool) -> Vec<Action> {
let now = now_ms;
let mut out = Vec::new();
if let Some(p) = &self.pending {
if p.height <= chain.state().height() {
self.pending = None;
} else if now.saturating_sub(p.at) > self.ring_ms() {
out.push(Action::Log(format!(
"round: my proposal h{} got {} signature(s); dropping it",
p.height,
p.sigs.len()
)));
self.pending = None;
return out;
} else {
return out;
}
}
if !due {
self.due_since = None;
return out;
}
if self.due_since.is_none() {
self.due_since = Some(now);
}
let height = chain.state().height().saturating_add(1);
if !self.may_resign(height, now) {
return out;
}
if self.entitled(&self.me, height, now) {
match self.propose(now, chain, &mut out) {
Ok(()) => {}
Err(e) => out.push(Action::Log(format!("round: {e}"))),
}
}
out
}
fn propose(
&mut self,
now: u64,
chain: &mut dyn ChainView<F>,
out: &mut Vec<Action>,
) -> Result<()> {
let secs = now / 1000;
let state = chain.state();
let wanted: Vec<ClaimRequest> = self
.claims_wanted
.iter()
.filter(|c| !state.claimed(&c.txid, c.vout))
.cloned()
.collect();
let (block, fees, claims) = state.build_next(&NextBlock {
time: u32::try_from(secs).unwrap_or(u32::MAX),
claims: wanted,
})?;
let height = block_height(&self.family, &block)?;
let ev = sign_proposal(
self.signer.as_ref(),
&Proposal {
chain_id: self.chain_id.clone(),
height,
block_hex: hex::encode(block.encode()),
},
secs,
)?;
let sig = self
.authorise(&block, height, VoteRole::Proposed, &ev.id, now)
.map_err(|(_, e)| e)?;
let mut sigs = BTreeMap::new();
sigs.insert(self.me, sig);
self.pending = Some(Pending {
id: ev.id.clone(),
height,
block: block.clone(),
sigs,
at: now,
fees,
claims,
});
out.push(Action::Log(format!(
"round: proposing h{height} {}… ({} txs, {claims} claim(s))",
short(&ev.id, 12),
block.txdata().len() - 1
)));
out.push(Action::Publish(ev));
self.maybe_seal(now, chain, out);
Ok(())
}
pub fn on_event(
&mut self,
now_ms: u64,
chain: &mut dyn ChainView<F>,
ev: &Event,
) -> Vec<Action> {
let now = now_ms;
let mut out = Vec::new();
match ev.kind {
KIND_BLOCK_PROPOSAL => {
if self.proposals.accept(ev).is_some() {
self.on_proposal(now, chain, ev, &mut out);
}
}
KIND_PARTIAL_SIGNATURE => {
if self.partials.accept(ev).is_some() {
self.on_partial(now, chain, ev, &mut out);
}
}
KIND_SEALED_BLOCK if self.sealed.accept(ev).is_some() => {
self.on_sealed(now, chain, ev, &mut out);
}
_ => {}
}
out
}
fn on_proposal(
&mut self,
now: u64,
chain: &mut dyn ChainView<F>,
ev: &Event,
out: &mut Vec<Action>,
) {
let Ok(height) = height_tag(&ev.tags, TAG_H) else {
return;
};
if ev.pubkey == self.me_hex {
return;
}
if now.saturating_sub(ev.created_at.saturating_mul(1000)) > self.ring_ms() {
return;
}
let secs = now / 1000;
let log = |s: String| Action::Log(s);
let my = chain.state().height();
if u64::from(height) != u64::from(my) + 1 {
out.push(log(format!(
"round: proposal h{height} from {}… ignored (my tip is {my})",
short(&ev.pubkey, 8)
)));
return;
}
let proposer = pubkey_from_hex(&ev.pubkey).ok();
if !proposer.is_some_and(|p| self.entitled(&p, height, ev.created_at.saturating_mul(1000)))
{
out.push(log(format!(
"round: proposal h{height} from {}… refused: not its turn",
short(&ev.pubkey, 8)
)));
return;
}
if !self.may_resign(height, now) {
let prev = self
.signed
.get(&height)
.expect("may_resign is false only with an entry");
out.push(log(format!(
"round: proposal h{height} from {}… refused: I signed {}… for this height {} s ago",
short(&ev.pubkey, 8),
short(&prev.id, 8),
(now.saturating_sub(prev.at) + 500) / 1000
)));
return;
}
let block = match hex::decode(ev.content.trim())
.ok()
.and_then(|b| F::Block::decode(&b).ok())
{
Some(b) => b,
None => {
out.push(log("round: proposal is not a block".into()));
return;
}
};
let tip = chain.state().tip();
let header = block.header();
if self.family.prev(header) != tip.hash || self.family.time(header) <= tip.time {
out.push(log(format!(
"round: proposal h{height} refused: does not build on my tip"
)));
return;
}
let (verdict, _) = chain.state().judge(
height,
&block,
Some(u32::try_from(secs).unwrap_or(u32::MAX)),
);
let failed: Vec<String> = verdict
.failed()
.into_iter()
.filter(|r| !RULES_NOT_JUDGED_ON_A_TEMPLATE.contains(&r.as_str()))
.collect();
if !failed.is_empty() {
out.push(log(format!(
"round: proposal h{height} refused: rules {}",
failed.join(", ")
)));
return;
}
for tx in &block.txdata()[1..] {
let txid = tx.compute_txid();
if chain.state().mempool().any(|m| m.compute_txid() == txid) {
continue;
}
if let Err(e) = chain.submit(tx.clone()) {
out.push(log(format!(
"round: proposal h{height} refused: tx {}… {e}",
short(&txid.to_string(), 12)
)));
return;
}
}
if let Some(why) = self.check_claims.as_ref().and_then(|c| c.check(&block)) {
out.push(log(format!("round: proposal h{height} refused: {why}")));
return;
}
let sig = match self.authorise(&block, height, VoteRole::Signed, &ev.id, now) {
Ok(s) => s,
Err((false, e)) => {
out.push(log(format!("round: proposal h{height} not signed: {e}")));
return;
}
Err((true, e)) => {
out.push(log(format!(
"round: proposal h{height} signed but not published: {e}"
)));
return;
}
};
let pev = match sign_partial(
self.signer.as_ref(),
&Partial {
chain_id: self.chain_id.clone(),
height,
proposal: ev.id.clone(),
signature_hex: hex::encode(sig.as_ref()),
},
secs,
) {
Ok(p) => p,
Err(e) => {
out.push(log(format!("round: {e}")));
return;
}
};
out.push(Action::Publish(pev));
out.push(log(format!(
"round: signed h{height} {}… from {}…",
short(&ev.id, 12),
short(&ev.pubkey, 8)
)));
}
fn on_partial(
&mut self,
now: u64,
chain: &mut dyn ChainView<F>,
ev: &Event,
out: &mut Vec<Action>,
) {
let Some(p) = &self.pending else {
return;
};
if first(&ev.tags, TAG_E) != Some(p.id.as_str()) || ev.pubkey == self.me_hex {
return;
}
let Ok(pk) = pubkey_from_hex(&ev.pubkey) else {
return;
};
if !self.fed.signers.contains(&pk) {
return;
}
let sig = hex::decode(ev.content.trim())
.ok()
.and_then(|b| Signature::from_slice(&b).ok());
let ok = sig.is_some_and(|s| verify_partial(&self.family, &p.block, &self.fed, &pk, &s));
if !ok {
out.push(Action::Log(format!(
"round: bad partial from {}…",
short(&ev.pubkey, 8)
)));
return;
}
let p = self.pending.as_mut().expect("checked");
p.sigs.insert(pk, sig.expect("checked"));
out.push(Action::Log(format!(
"round: {}/{} signatures for h{}",
p.sigs.len(),
self.fed.threshold,
p.height
)));
self.maybe_seal(now, chain, out);
}
fn maybe_seal(&mut self, now: u64, chain: &mut dyn ChainView<F>, out: &mut Vec<Action>) {
let Some(p) = &self.pending else {
return;
};
if p.sigs.len() < usize::from(self.fed.threshold) {
return;
}
let p = self.pending.take().expect("checked");
let sealed = match seal_federated(&self.family, &p.block, &self.fed, &p.sigs) {
Ok(s) => s,
Err(e) => {
out.push(Action::Log(format!(
"round: sealed block refused by my own validator: {e}"
)));
return;
}
};
let secs = now / 1000;
match chain.add_block(&sealed, secs) {
Ok(r) => {
self.claims_wanted.clear();
self.due_since = None;
out.push(Action::Log(format!(
"block {} {}… sealed by {} of {}, {} txs, fees {}{}",
r.height,
short(&r.hash.to_string(), 16),
self.fed.threshold,
self.n(),
r.txs - 1,
p.fees,
if p.claims > 0 {
format!(", claims {}", p.claims)
} else {
String::new()
}
)));
match sign_sealed(
self.signer.as_ref(),
&Proposal {
chain_id: self.chain_id.clone(),
height: r.height,
block_hex: hex::encode(sealed.encode()),
},
secs,
) {
Ok(ev) => out.push(Action::Publish(ev)),
Err(e) => out.push(Action::Log(format!("round: {e}"))),
}
out.push(Action::Sealed(SealedBlock {
height: r.height,
hash: r.hash,
txs: r.txs,
fees: p.fees,
claims: p.claims,
from: None,
}));
}
Err(e) => out.push(Action::Log(format!(
"round: sealed block refused by my own validator: {e}"
))),
}
}
fn on_sealed(
&mut self,
now: u64,
chain: &mut dyn ChainView<F>,
ev: &Event,
out: &mut Vec<Action>,
) {
let Ok(height) = height_tag(&ev.tags, TAG_H) else {
return;
};
let from_signer = pubkey_from_hex(&ev.pubkey).is_ok_and(|p| self.fed.signers.contains(&p));
if ev.pubkey == self.me_hex
|| !from_signer
|| u64::from(height) != u64::from(chain.state().height()) + 1
{
return;
}
let refused = |e: String| {
Action::Log(format!(
"round: sealed block h{height} from {}… refused: {e}",
short(&ev.pubkey, 8)
))
};
let block = match hex::decode(ev.content.trim())
.map_err(|e| e.to_string())
.and_then(|b| F::Block::decode(&b).map_err(|e| e.to_string()))
{
Ok(b) => b,
Err(e) => {
out.push(refused(e));
return;
}
};
match chain.add_block(&block, now / 1000) {
Ok(r) => {
self.due_since = None;
if self.pending.as_ref().is_some_and(|p| p.height <= r.height) {
self.pending = None;
}
out.push(Action::Log(format!(
"block {} {}… from {}… (sealed by the federation)",
r.height,
short(&r.hash.to_string(), 16),
short(&ev.pubkey, 8)
)));
out.push(Action::Sealed(SealedBlock {
height: r.height,
hash: r.hash,
txs: r.txs,
fees: 0,
claims: 0,
from: Some(ev.pubkey.clone()),
}));
}
Err(e) => out.push(refused(e.to_string())),
}
}
}