Skip to main content

sidestr_round/
lib.rs

1//! `sidestr-round` — level 2 of sidestr sidechains, usable now: the
2//! co-signing round of `k`-of-`n` signers as a pure state machine that
3//! interoperates on the wire with the reference signer as it runs today,
4//! the peg-out PSBT round over rust-bitcoin, a durable vote journal, and
5//! `cosign`, a runnable signer.
6//!
7//! # The round, in the reference's words
8//!
9//! Adapted from Melvin Carvalho's `proposals/level-2.md` and
10//! `siding/lib/round.mjs`, AGPL-3.0: a level-2 chain has `n` signers and a
11//! threshold `k`. Nothing changes for a validator — the challenge is still
12//! a script and a block is still valid when its solution satisfies it.
13//! Every signer runs a producer: the same validator, the same mempool, its
14//! own mirror. At each height **the proposer is signer `height mod n`**;
15//! after `proposeAfter` seconds without a block, the next signer in order
16//! may propose, and so on around the ring. The proposer builds the block
17//! without its solution and publishes it as a kind 23510 event; each other
18//! signer validates it against its own chain and rules exactly as it would
19//! a block from the mirror, requires that every transaction is one its
20//! mempool accepts, that the height is its tip plus one, that the proposer
21//! is entitled at this time, and that it has not signed another proposal
22//! for this height; if all hold it publishes a kind 23511 event with its
23//! partial signature. With `k` signatures the proposer assembles the
24//! witness, seals the block, adds it, and publishes it as a kind 23514
25//! event so every signer adds it at once. Signer keys are Nostr keys, so a
26//! proposal or a partial is authenticated by the event itself.
27//!
28//! Two rules the draft did not state, as built upstream: a signer's "one
29//! signature per height" **relaxes once the proposal it signed has had
30//! `proposeAfter` seconds to seal and has not** — otherwise a proposer that
31//! dies after collecting fewer than `k` strands the height — and **a
32//! proposer drops its own proposal after `proposeAfter × n` seconds**. A
33//! peg-out is the same round with the same rule of one signature per burn
34//! per signer: a PSBT the payer publishes as kind 23512, returned signed as
35//! kind 23513, finalised and broadcast by the payer.
36//!
37//! # What is here
38//!
39//! | module | what | ported from |
40//! |---|---|---|
41//! | [`round`] | [`round::Round`]: the block round, pure — `tick(now)` and `on_event(now, event)` return [`round::Action`]s | `siding/lib/round.mjs` |
42//! | [`pegout`] | the PSBT functions over rust-bitcoin and [`pegout::PegoutRound`], pure the same way | `siding/lib/pegoutround.mjs`; Core's wallet RPCs it called |
43//! | [`journal`] | [`journal::VoteJournal`]: what this signer authorised, written before it is published; file and memory | — (ADR-2101, review §7) |
44//! | [`signer`] | [`signer::BlockSigner`] and [`signer::LocalKey`]: the key behind named operations | `siding/lib/sign.mjs`, `schnorr.mjs` |
45//! | [`chain`] | [`chain::ChainView`]: the chain as the round sees it, over `sidestr-core`'s state or its file-backed chain | `siding/lib/chain.mjs` (`submit`, `addSealed`) |
46//! | `relay` (feature `relay`) | the tokio websocket client with reconnection, and a NIP-01 relay stand-in for one box | `siding/lib/relay.mjs` |
47//! | `node` (feature `bin`) | one signer running: chain on disk, both rounds, relays, HTTP for mirrors, the tip announcement, the parent | `bin/siding.mjs produce` |
48//!
49//! The envelopes themselves — the five kinds, their tags and content — are
50//! `sidestr-nostr`'s [`sidestr_nostr::round`]; the federation, the partial
51//! signature and the seal are `sidestr-core`'s [`sidestr_core::federation`].
52//! This crate is the protocol between them.
53//!
54//! # Wire compatibility
55//!
56//! A Rust signer co-signs with reference signers and the reverse, on a live
57//! chain, with no change to what travels:
58//!
59//! - **23510** proposal: tags `["chain", id]`, `["h", height]`; content the
60//!   block hex without its solution. **23511** partial: `chain`, `h`,
61//!   `["e", proposal id]`; content the 64-byte BIP-340 signature as hex
62//!   over the tapscript sighash of the block's virtual transaction for the
63//!   federation's leaf. **23514** sealed: `chain`, `h`; content the sealed
64//!   block hex. **23512** peg-out PSBT: `chain`, `["d", "<txid>:<vout>"]`,
65//!   `h`; content the PSBT, base64. **23513** co-signed: `chain`, `d`,
66//!   `["e", 23512 id]`; content the PSBT with this signer's signatures.
67//! - The proposer ring, the lateness entitlement (from when the block became
68//!   due, never negative), the one-signature rule and its relaxation, the
69//!   `proposeAfter × n` drop, the replay filter on a proposal older than
70//!   `proposeAfter × n`, the `since` window of 600 s, the follow-by-kind
71//!   subscription (relays refuse `#chain`), and the on-receipt checks
72//!   (kind, unseen, chain tag, signature) are `round.mjs`'s to the millisecond.
73//! - Every refusal is logged with `round.mjs`'s wording, so operators read
74//!   one log across engines.
75//!
76//! Proven by `tests/interop_round.rs` and `tests/interop_pegout.rs`: three
77//! signers on one box through an in-process relay, {Rust, JS, JS} and
78//! {Rust, Rust, JS}, on a chain the reference makes; sealed blocks whose
79//! witness carries the other engine's slot in both directions; one signer
80//! down tolerated, two halts, one back resumes; a Rust signer restarted on
81//! its journal; burns paid by PSBTs proposed by JS and co-signed by Rust
82//! and the reverse, every finalised parent transaction verified under
83//! BIP 342 against `tr(NUMS, multi_a(k, …))`.
84//!
85//! # Hardening, behind options with upstream's behaviour as default
86//!
87//! None of it changes the wire.
88//!
89//! | option | default | what |
90//! |---|---|---|
91//! | [`journal::VoteJournal`] | a journal is always given; [`journal::MemoryJournal`] forgets, [`journal::FileJournal`] is append-only, `fsync`ed, validated and repaired on open | two records per authorisation. The **intent** (height or burn, proposal id, template id or unsigned txid, time) is written and synced **before** the custody signer is invoked: if that write fails the signer is not called and no signature exists. The **signature** is recorded after the signer answers and **before** the `Publish` action is returned: if that write fails a signature exists, is not published, and the intent counts. On restart every entry — an intent without its signature included — is applied to the one-signature rule. A torn tail is cut back to the last record boundary on open; a malformed terminated record is an error |
92//! | [`round::RoundConfig::resign_after`], [`pegout::PegoutConfig::resign_after`] | `Some(propose_after)` — upstream's relaxation | `None` never re-signs a height, nor a burn on either path — co-signing another's PSBT, proposing my own, or moving from the one to the other (ADR-2101); a stranded height then waits for its proposer |
93//! | `on_sealed` | always | a 23514 is a *candidate*: it enters through the validator ([`chain::ChainView::add_block`]) and is never treated as finality |
94//! | proposal validation order | always | the chain's deterministic rules first ([`sidestr_core::state::StateOf::judge`], minus the block signature and the proof of work sealing satisfies — [`round::RULES_NOT_JUDGED_ON_A_TEMPLATE`]), the local mempool's policy second; the one new refusal names the rules |
95//! | [`pegout::PegoutConfig::max_fee`] | 100 000 sats | a co-signer refuses a payment whose fee is over the cap (the reference checks outputs and inputs, not the difference) |
96//! | 23513 verification | always | a co-signer's PSBT counts only if it is the proposed transaction with a verifying signature by its author |
97//!
98//! # Timing, to the millisecond
99//!
100//! `round.mjs` measures `Date.now()`; so do [`round::Round::tick`] and
101//! [`round::Round::on_event`] (and the peg-out round's): `now` is unix
102//! milliseconds, and the reference's comparisons hold at the millisecond —
103//! re-signing relaxes at 30 001 ms after the signature for `propose_after
104//! = 30`, a proposal of mine is dropped at 90 001 ms for `n = 3`, a
105//! replayed proposal is ignored past the same instant. Only `created_at`
106//! is seconds, as NIP-01 requires. The table is in [`round`].
107//!
108//! # TLS
109//!
110//! Under `relay`, `wss://` is rustls with the `ring` provider and the
111//! Mozilla root store; `relay::default_connector` names them, and
112//! `relay::follow_with` / `relay::publish_one_with` take another
113//! connector for a private root. Proven by a handshake and a
114//! REQ/EVENT/EOSE exchange against the in-process relay behind a
115//! certificate generated in the test (`tests/audit_regressions_node.rs`).
116//!
117//! # Honest limits
118//!
119//! This is upstream's protocol. It tolerates `n − k` signers being **down**
120//! and nothing being **wrong**: availability tolerance, not Byzantine
121//! tolerance. A faulty proposer can strand a height until the relaxation;
122//! two subsets of `k` can seal one template to two hashes; the entitlement
123//! is clock-based; a relay can delay or replay. The journal stops a restart
124//! from becoming a double signature and is not anti-rollback (a host
125//! restored from a snapshot has an old journal). The consensus protocol
126//! above the signature — views, durable safety state, decision
127//! certificates, `AUTHORISE_TEMPLATE` then `FINALISE_BLOCK` — is ADR-2101's
128//! separate crate, and it replaces [`round::Round`], not the codecs, the
129//! federation or the journal.
130//!
131//! The independent audit of 0.1.0-pre (GPT-6 Astra, 2026-09-22;
132//! `docs/proposals/sovereign-settlement-research/AUDIT-sidestr-round-0.1-gpt6-astra.md`)
133//! set the prior review's checklist for `round.mjs` against this port. The
134//! table is reproduced as the auditor filled it, against commit `f59f000`;
135//! its C1 (torn-tail recovery) and C2 (peg-out self-proposals under
136//! `None`) are closed in this release and regression-tested under
137//! `tests/audit_regressions_*.rs`. Every other row still holds.
138//!
139//! | Prior recommendation for `round.mjs` | This port |
140//! |---|---|
141//! | Delete timeout `mayReSign` | No by default; optional `None` for blocks and, through one shared per-burn guard, for peg-outs (audit C2). Journal recovery (torn tail, measured rollback, one writer per file) closed by audits C1 and the 2026-09-22 verification pass. |
142//! | Replace clock entitlement with views/leaders | No; clock ring retained intentionally. |
143//! | Replace in-memory signed map with durable safety state | Partial: FileJournal restores authorisations; MemoryJournal deliberately forgets. No BFT locks/views; C1 defeats recovery. |
144//! | Remove proposer-exclusive aggregation | No; proposer pending state collects the partials. |
145//! | Require decision proofs before block signatures | No. |
146//! | Treat `onSealed` as candidate ingestion | Yes; core validator is called, no finality decision. |
147//! | Prefer highest finalised compatible history | No finalised-history protocol exists. |
148//! | Deterministic chain/UTXO validation; mempool policy is not consensus | Partial: deterministic judge first; local mempool refusal is retained as upstream signer policy. |
149//! | Historical certificates and dependency fetch by digest | No certificate/fetch protocol. |
150//! | Authenticated peg-out policy and durable payout state machine | Partial: authenticated events, burn/output/fee checks, signature checks, its own journal and paid ledger; no consensus-authorised payment intent or durable broadcast outbox. |
151//!
152//! # Running a signer
153//!
154//! ```text
155//! cosign --chain chain.json --dir ~/.sidestr/fed --key-file ~/.sidestr/fed.key \
156//!        --port 3461 --interval 600 --tx-interval 30 --propose-after 30 \
157//!        --relay wss://nos.lol,wss://relay.primal.net,wss://nostr.mom \
158//!        --announce-mirror https://mirror.example/fed \
159//!        [--parent-rpc http://127.0.0.1:48332/ --parent-cookie ~/.bitcoin/.cookie --parent-wallet fed-peg] \
160//!        [--resign-after upstream|never|<secs>] [--journal DIR/votes.jsonl]   # the peg-out round journals in DIR/votes-pegout.jsonl
161//! ```
162//!
163//! The key is a file, never an argument. The block directory must already
164//! hold the chain's `blocks.dat` and `blocks.json` (copied from a mirror);
165//! a joining signer does not make a genesis. `cosign` serves
166//! `/status.json`, `/chain.json`, `/tip`, `/blocks.json`, `/blocks.dat`
167//! (with `Range`), `/coins/<script hex>`, `/pegouts.json` and `POST /tx`
168//! on 127.0.0.1, announces kind 33333 naming `--announce-mirror` after
169//! every block, and with a parent node claims peg-ins and pays peg-outs
170//! through the PSBT round from the wallet that holds the federation's
171//! descriptor with this key private (`siding peg-wallet`). The feature
172//! `bin` builds it; `relay` alone gives the client and the stand-in.
173//!
174//! # Where this port departs from siding
175//!
176//! - **Pure state machines.** `round.mjs` publishes, subscribes and reads
177//!   the clock inside; here the time is an argument and I/O is an action.
178//! - **The peg-out round does not need Core for the PSBT**: funding,
179//!   signing, combining and finalising are rust-bitcoin; the parent is
180//!   asked only for coins and for broadcast. The PSBT carries what Core
181//!   fills for the descriptor, so Core-backed signers co-sign it.
182//! - **`entitled` in the peg-out round is never negative**, as the block
183//!   round's already is; upstream's peg-out variant would refuse the
184//!   rightful payer's proposal made a moment before the co-signer saw the
185//!   burn.
186//! - **Zero BIP-340 auxiliary randomness** in [`signer::LocalKey`], as the
187//!   sibling crates; upstream draws random aux. Both are valid.
188//! - The hardening table above.
189
190#![forbid(unsafe_code)]
191#![deny(
192    missing_docs,
193    missing_debug_implementations,
194    rustdoc::broken_intra_doc_links
195)]
196
197pub mod chain;
198pub mod error;
199pub mod journal;
200#[cfg(feature = "bin")]
201pub mod node;
202pub mod pegout;
203#[cfg(feature = "relay")]
204pub mod relay;
205pub mod round;
206pub mod signer;
207
208pub use error::{Error, Result};
209
210/// The README's examples, compiled and run as doctests.
211#[cfg(doctest)]
212#[doc = include_str!("../README.md")]
213pub struct Readme;