Expand description
sidestr-core — user-activated sidechains beside a Bitcoin-family parent,
in Rust: the chain document, the parents table, signed blocks in either
header family, the peg-in claim and peg-out burn rules, the block file and
an in-memory validating chain.
A sidestr chain runs beside a Bitcoin-family chain with Bitcoin’s transaction rules, blocks that are valid because they are signed rather than because they were mined, no subsidy, and every coin on it a coin locked on the parent. The name is the chain beside the chain. “User activated” is a claim about who enforces the rules: a chain has signers, and signers decide the order of blocks. They do not decide the rules. A node applies a rule because its operator adopted the document, and a block that breaks an adopted rule is invalid to that node whatever signature it carries. The signers can stall the chain. They cannot change it.
This crate is a port of siding, the reference implementation by
Melvin Carvalho (https://github.com/sidestr/spec, AGPL-3.0), ported from
commit 2de40bdac4cba01be0864156a553d8287c22e279 and brought to SPEC 0.0.4
(@sidestr/spec 0.0.6) at fa86dac83d47b8f70195132e91e9dc083e1d9228 (the peg output is the one the peg
holders own, or pays the script the signer announces; signatures follow
the parent’s family), together with the parts of
the engine it loads — bitcoin-desktop/schema (the block, header and
spending checks) and bitcoin-blake/blaketestnode (the block file) — and
carries the same licence, AGPL-3.0-only. SPEC.md in that repository is
the design; section numbers below are its. Where a function ports a
siding function its documentation names it, so the two can be read side
by side.
§What is here
| module | what | SPEC | ported from |
|---|---|---|---|
parents | the parents a chain can sit beside: alias, long id, header family, genesis and fork block | 3.2 | siding/lib/parents.mjs |
document | the chain document: id, parent, challenge, prefix, peg and fee parameters, pegs, genesisHash, a level-2 signers/threshold; the magic siding new derives | 3, 5 | siding/bin/siding.mjs new, lib/engine.mjs, lib/overlay.mjs checkFederation |
block | the header family boundary (HeaderFamily, Stock, FamilyBlock); building a block; the signed block data (BIP 325 over this chain’s header); the solution push in the coinbase; the BIP 34 height; sign, seal, verify | 3.2, 4 | siding/lib/block.mjs |
sighash | the signature hashes a spend is judged by: BIP 341, and Knots’ unified opt-in sighash beside a BLAKE2b parent; the taproot key-path verifier | 3 | schema/codec/interpreter.js |
parent | the parent chain behind parent::ParentRpc / parent::PegWallet: peg-ins found in decoded blocks, peg status, what to claim and lock, the burn payment and checkpoint as send outputs, reconciliation; Bitcoin Core’s JSON-RPC behind feature rpc | 6, 7, 11 | siding/lib/parent.mjs, checkpoint.mjs, bin/siding.mjs produce |
federation | level 2, the pure parts: the NUMS internal key, the multi_a(k, …) leaf, output key and control block, partial signatures, witness assembly, sealing, and the verifier for exactly that leaf | level-2 | siding/lib/federation.mjs; schema/codec/interpreter.js (tapscript) |
marker | the OP_RETURN grammar: pegin:, claim:, pegout:, ckpt:, and text records | 6, 7, 11 | siding/lib/marker.mjs, overlay.mjs, parent.mjs, checkpoint.mjs, records.mjs |
rules | the rules in phases with the sidestr overlay: zero subsidy, the signature challenge, the claim rule, the burn rule; the family’s own rules; the extension point for more | 4, 6, 7, 12 | schema/codec/blocks.js, headers.js; siding/lib/overlay.mjs |
state | the chain in memory, generic over the family (StateOf, State for stock): headers, UTXO set, the overlay’s records, a mempool with the producer’s policy, block production | 4, 5, 11 | siding/lib/chain.mjs, blaketestnode/lib/node.mjs |
blockfile | [u32 height][u32 size][block] with a JSON index (feature std) | 11 | blaketestnode/lib/blockfile.mjs |
chain | the chain on disk (chain::ChainOf, chain::Chain for stock): replay, genesis when absent, every accepted block written (feature std) | 5, 11 | siding/lib/chain.mjs |
address | bech32 / bech32m both ways, any prefix | 3 | siding/lib/address.mjs |
§How the pieces talk (SPEC section 11)
- Blocks are served as a file,
[u32 height][u32 size][block], with a JSON index andchain.json, from any mirror: a directory on a web server, nothing more.chain::Chainreads and writes that file;state::Stateis the same chain fed blocks by whoever fetched them. - Peg-ins (§6): an output on the parent to the chain’s peg wallet with
OP_RETURN pegin:<chain id>:<sidechain script bytes>; the producer claims it atpegConfirmationswith a coinbase payout followed byclaim:<txid>:<vout>(state::ClaimRequest,marker::parse_claims). - Peg-outs (§7): a sidechain output
OP_RETURN pegout:<parent script hex>with a value of at leastpegoutMin; the value leaves the supply, the chain records the burn (state::State::pegouts) and the peg holders owe it on the parent. - Transactions reach a producer and are included when they validate
(
state::State::submit): the mempool’s policy is the document’sminFeeRateandpegoutMin, published so a wallet can compute it. - Tips and relays are not in this crate: the tip announcement (kind
33333) and transactions as events (kind 23500) are
sidestr-nostr’s.
§A chain, end to end
use bitcoin::consensus::encode::serialize;
use sidestr_core::block::{challenge_for, pubkey_of};
use sidestr_core::document::{ChainDocument, Peg};
use sidestr_core::state::{NextBlock, State};
// a signer key: in siding a 32-byte hex file, never an argument
let key = bitcoin::secp256k1::SecretKey::from_slice(&[7u8; 32]).unwrap();
let me = challenge_for(&pubkey_of(&key));
// the document is the chain's identity: the genesis is derived from it
let mut doc = ChainDocument::from_json(&r#"{
"id": "sidestr:example", "name": "example", "parent": "tbtc4", "challenge": "", "signer": "",
"powLimit": "7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
"addressPrefix": "ex", "genesisTime": 1790000000, "pegs": []
}"#.replace("\"challenge\": \"\"", &format!("\"challenge\": \"{}\"", me.to_hex_string()))
.replace("\"signer\": \"\"", &format!("\"signer\": \"{}\"", pubkey_of(&key)))).unwrap();
doc.pegs.push(Peg { txid: "a".repeat(64), vout: 0, amount: 100_000_000, script: me.to_hex_string(), extra: Default::default() });
// SPEC 5: the genesis mints exactly the pegs, sealed by the signer, deterministically
let mut state = State::with_key(doc.clone(), &key).unwrap();
assert_eq!(state.coins(&me)[0].value, 100_000_000);
let genesis = State::genesis_block_for(&doc, &key).unwrap();
assert_eq!(state.genesis_hash(), genesis.header.block_hash());
// SPEC 4: a block is valid because it is signed; the producer makes one on the tip
let (added, block) = state.produce(&key, &NextBlock { time: 1790000100, claims: vec![] }, None).unwrap();
assert_eq!((added.height, block.txdata.len()), (1, 1));
// a validator with no key replays the same bytes to the same tip
let mut validator = State::from_genesis(doc, &genesis, None).unwrap();
validator.add_block_bytes(&serialize(&block), Some(added.hash), None).unwrap();
assert_eq!(validator.tip(), state.tip());
// and refuses the block again, or one the rules fail, by name
assert!(validator.add_block(&block, None, None).unwrap_err().to_string().contains("apply 1 at height 1"));§Conventions that matter
- Keys are files, never arguments.
block::key_from_hextakes the file’s text; nothing here prints a key. A block is a pure function of its inputs and the key (zero BIP 340 auxiliary randomness), so two producers with the same key and mempool make the same block. - A chain id is a name, not a proof. The document’s
genesisHashis what a validator holds a block file to (state::State::from_genesis,chain::Chain::open) once block 0 has passed the rules; a mirror is held to the announced tip. - The header format and proof-of-work hash follow the parent (§3).
Nothing in the document names them;
parents::resolve_parentdecides, andblock::HeaderFamilyis the seam: the rules,StateOfandchain::ChainOfare generic over it. This crate carries the stock family (Stock:btc,tbtc4, the block isbitcoin::Block);sidestr-headerimplements the trait for Knots’ 164-byte v2 header (xbt,txbt4) withFamilyBlockas its block, and depends on this crate, never the reverse. A state instantiated for one family refuses a document whose parent hands down the other (Error::UnsupportedFamily). Beside a BLAKE2b parent the chain also inherits Knots’ unified opt-in sighash from height 0 (sighash), which every spend on the livesidestr:txbt4-sidingchain uses. - A federation signs a template; consensus is elsewhere. A level-2
document derives its challenge from
signersandthreshold(federation::Federation); anykpartial signatures seal a block (federation::seal_federated), andblock::template_idis the identity they authorise, which sealing does not change — the sealed hash does. The co-signing round itself (round.mjs,pegoutround.mjs) is not here: it issidestr-round, a pure state machine over this crate’s federation andsidestr-nostr’s envelopes, with upstream’s timeout re-signing as an option that defaults on and can be switched off (the ADR-2101 review found it unsafe); the Byzantine-tolerant protocol above the signature is a later crate still. - Nothing in the rules does I/O.
document,block,marker,rules,stateandaddresstake bytes and return verdicts; the filesystem and the clock are behind featurestdinblockfileandchain. The crate is notno_std;stdnames what touches the operating system. - Amounts are sats,
u64. Txids in markers are display-order hex strings, as the markers carry them; structural txids arebitcoin::Txid.
§Where this port departs from siding
Each is deliberate and small; the byte-for-byte genesis and the interop
tests in tests/ are what say they are harmless.
- The genesis is judged, not trusted.
siding/lib/chain.mjs #applyapplies block 0 on its hash alone: if it matches the document’sgenesisHash(or the mirror’s index) it is the chain’s base, signed or not.StateOf::from_genesisruns every rule that applies at height 0 first — the family’s header rules,sidestr:rule-block-signatureagainst the challenge, the block-context rules with the pegs as the one subsidy, andsidestr:rule-genesis-document(the block’s signed data is that ofStateOf::build_genesis_forand itsbitsthe document’spowLimit) — and only then holds the hash to the pin. A hash pin says which block 0 you hold, not that it is well-formed; there is no trusted import. Shown to pass on the vendored fixtures, the two live reference chains (sidestr:txbt4-siding,sidestr:melchain) and the estate’s sealedsidestr:dreamlabgenesis — not asserted for every genesis the reference has ever produced. Kept deliberately stricter than the reference; the self-containedtests/audit_regressions.rsholds it. - A stock header with version bit 31 set is refused everywhere. The
kernel’s
structVariantswould read such bytes as a Knots v2 header, and itsbtc:rule-header-versionfails because a stockversionisi32leand the word is negative.Stock::decode_headerand the stock block decoder refuse the bytes by name; on the typed pathHeaderFamily::version_numbercarries the codec’s signedness into the rule, soStateOf::add_blockrefuses it asbtc:rule-header-version, the rule the reference names on the same block. - The block file’s record framing is checked.
blockfile.mjs readBlockreads through the index and never looks at a record’s own[u32 height][u32 size]prefix.blockfile::read_blockholds the index entry to the file’s length with checked arithmetic and the prefix to the entry (Error::BlockFile), so a mirror whoseblocks.datandblocks.jsondisagree is refused rather than replayed. - Script verification fails closed. The reference kernel verifies every
script type and reports a witness version it does not know as
“unverifiable”, which lets the block through. This crate verifies
taproot key-path spends — the only spends a level-1 chain with a
5120…challenge and bech32m wallets makes — and refuses anything else (sighash::verify_taproot_key_path). A block spending by script path is invalid here and valid there; there is no general interpreter. - The solution’s witness decoder is strict.
decodeWitnessreads what it can and ignores the rest;block::decode_witnessrefuses a truncated item, trailing bytes, a non-minimal CompactSize and more than 256 items, since the solution is consensus data. - The script path is one template, verified exactly. The reference
executes any tapscript; this crate verifies the taproot commitment,
the leaf version and then exactly the
multi_a(k, pk_1 … pk_n)leaf under BIP 342 (federation::verify_multi_a_input), refusing any other leaf by name (federation::ScriptPathError::NotMultiA) and an unknown leaf version too (federation::ScriptPathError::LeafVersion), where the kernel and Bitcoin Core treat the latter as a success. Within that template the two agree case for case (tests/consensus_oracle.rs, Core’s interpreter behind theconsensus-oraclefeature). - A marker’s push is written canonically and read as the reference reads
it.
overlay.mjs opReturnDatatakes6a, an optional4c, one length byte and that many bytes: the byte is a length whatever opcode it is to Bitcoin, and anOP_PUSHDATA1prefix is accepted for any length.marker::op_return_datadoes exactly that — it is the burn rule’s grammar, so a burn a reference wallet wrote as6a 57 …(OP_7to an interpreter:pegoutMarkerwrites a bare length byte even above 75) is recorded here as it is there. What this crate writes differs:marker::pegout_markerandmarker::record_scriptemitOP_PUSHDATA1above 75 bytes, the one form both engines and Bitcoin’s script parser read alike (the encoder has written that since 0.2.0). 0.2.0 recognised only the direct-push form as a burn (looks_like_pegoutread thepegout:prefix at byte 2), so a burn to a 35–40-byte parent script was silently unpaid and a malformedOP_PUSHDATA1burn was accepted where the reference refuses the block; 0.2.1 changed recognition and the burn-loop guard, pinned against the reference intests/audit_regressions.rs. - A marker’s text is decoded as the reference decodes it — not a
departure, but easy to get wrong: siding text-decodes with a WHATWG
TextDecoder, whose default drops one leading UTF-8 byte-order mark, soEF BB BF pegout:abcdnamesabcdthere.marker::parse_pegout,marker::looks_like_pegout,marker::parse_claims, the hex-form decision ofmarker::parse_peg_markerandmarker::record_textdrop it too;marker::parse_pegout_markerandmarker::parse_checkpointcompare bytes, as the reference does, and do not. Pinned intests/audit_regressions_records.rs(0.2.1). - The mempool judges signatures by the block rules. siding’s
submitpassesunifiedSighash: trueon every family while its block rule applies it only from the fork height, so on a stock chain the reference mempool would admit a spend its own block rule refuses;StateOf::submitasks the family, as the block rule does. - Overlay records commit on apply. siding’s claim and burn checks write
into their maps while validating, so a block that later fails another
rule still leaves its claims recorded at its height. Here
rules::validate_block_contextreturns the records a block would leave andstate::State::applycommits them only when every rule passed. record_textchecks the push length. The reference’s check is commented out;marker::record_textrefuses a record whose bytes do not match its push length, or whose push is not minimal, whererecordTextreads the text anyway. This is the only derived-record difference the differential intests/audit_regressions_records.rsallows.- Zero auxiliary randomness everywhere, not only for the genesis. Both are valid BIP 340; only reproducibility differs.
- A document naming
assets,poolorevmis refused atdocument::ChainDocument::validate, asloadEnginerefuses a rule it does not have: those overlays are not carried, and a validator must not run a chain it would misjudge. The Knots overlay’s RDTS weight cap (knots:rule-blockctx-weight-rdts) is not carried either: on a sidestr chainrdtsExpiryTimeis 0, so it is never active.
Re-exports§
pub use block::FamilyBlock;pub use block::HeaderFamily;pub use block::SidestrBlock;pub use block::Stock;pub use document::ChainDocument;pub use error::Error;pub use error::Result;pub use parents::resolve_parent;pub use parents::Family;pub use parents::Parent;pub use state::State;pub use state::StateOf;
Modules§
- address
- Segwit addresses under any prefix: bech32 (BIP 173) for witness version 0,
bech32m (BIP 350) for 1 and up (
siding/lib/address.mjs). - assets
- The
assetsrule (SPEC 12.2) as a view: what each unspent output carries, derived from the chain one block at a time. A port of the check insiding/lib/overlays/assets.mjs(AGPL-3.0, Melvin Carvalho), without the pool rule. - block
- Blocks on a sidestr chain (SPEC 4): building, the block data that is
signed, the virtual transactions the challenge is evaluated against, the
solution’s place in the coinbase, and the height a stock header does not
carry. A port of
siding/lib/block.mjs. - blockfile
- The block file: blocks appended as
[u32le height][u32le size][block bytes], with a JSON index{network, from, to, blocks: [{height, hash, offset, size}]}beside it, so a client can Range-fetch exactly the tail it lacks and verify every block’s hash (SPEC 11). A port ofbitcoin-blake/blaketestnodelib/blockfile.mjs(AGPL-3.0). Featurestd. - chain
- A sidestr chain on disk (feature
std): theStateOfreplayed from the block file in a directory, created with the genesis when absent, and every accepted block written down (siding/lib/chain.mjs open,addBlock,produce). The clock is the system’s. - document
- The chain document (SPEC 3, 5): the overlay that defines a sidestr chain beside its parent, and the chain’s identity.
- error
- The one error type every fallible function in this crate returns.
- federation
- Level 2 (
proposals/level-2.md), the pure parts: a chain withnsigners and a thresholdk. The challenge is a taproot output whose internal key is provably unspendable (BIP 341’s NUMS point tweaked by the chain id) and whose single leaf ismulti_a(k, pk_1 … pk_n). A block’s solution is the script-path witness:nsignature slots in leaf order (empty for a signer who did not sign), the leaf script, the control block. A port ofsiding/lib/federation.mjs(Melvin Carvalho, AGPL-3.0). - marker
- The marker grammar: the
OP_RETURNtexts by which pegs, claims, burns and checkpoints are written on the sidechain and on the parent (SPEC 6, 7, 11). - mirror
- A mirror’s block file read from memory: what a client holds after one
GET <mirror>/blocks.dat(SPEC 11), replayed into a validatedStateOfwithout a file system. The framing is the block file’s,[u32le height][u32le size][block bytes]repeated (bitcoin-blake/blaketestnodelib/blockfile.mjs);blockfile(featurestd) writes and reads the same records on disk. - parent
- The parent chain as a producer or a signer sees it (SPEC 6, 7, 11; the
level-2 view): peg-ins found in the parent’s blocks, whether one is still
unspent and how deep, the peg wallet’s payment of a burn, the producer’s
checkpoint, and the reconciliation of burns against what the wallet has
already paid. A port of
siding/lib/parent.mjsandsiding/lib/checkpoint.mjs(Melvin Carvalho, AGPL-3.0). - parents
- The parents a chain can sit beside (SPEC 3.2): a short alias per chain, the long kernel id it resolves to, and the header family the chain inherits.
- records
- Records (SPEC 12.1):
OP_RETURNoutputs whose data is UTF-8 text of at most 255 bytes in a single minimal push.issue:,tally:andpool:are parsed here; theassetsview (crate::assets) decides what they mean. A port ofsiding/lib/records.mjs(AGPL-3.0, Melvin Carvalho). - rules
- The rules (SPEC 4, 6, 7): Bitcoin’s block rules as the reference kernel
runs them for a
btc:regtest-derived network, with the sidestr overlay — zero subsidy, the signature challenge, the claim rule and the burn rule — and, beside a BLAKE2b parent, the Knots overlay’s header and block rules. - sighash
- The signature hashes a spend on a sidestr chain is judged by, and the taproot key-path verifier over them.
- state
- The chain in memory (SPEC 4, 5, 11): headers and hashes from the genesis
up, the UTXO set, the overlay’s records, a mempool with the producer’s
policy, and block production. A port of
siding/lib/chain.mjsSidingand the state-machine half ofbitcoin-blake/blaketestnodelib/node.mjsChainNode, for level 1: one signer, no reorgs.