use std::collections::BTreeMap;
use bitcoin::{CompactTarget, ScriptBuf, Target};
use serde::{Deserialize, Serialize};
use crate::error::{Error, Result};
use crate::federation::Federation;
use crate::parents::{resolve_parent, Family, Parent};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum RuleEntry {
Name(String),
Activated {
name: String,
#[serde(default)]
from: u32,
},
}
impl RuleEntry {
pub fn name(&self) -> &str {
match self {
Self::Name(name) | Self::Activated { name, .. } => name,
}
}
pub fn from(&self) -> u32 {
match self {
Self::Name(_) => 0,
Self::Activated { from, .. } => *from,
}
}
}
impl From<&str> for RuleEntry {
fn from(value: &str) -> Self {
Self::Name(value.to_owned())
}
}
impl From<String> for RuleEntry {
fn from(value: String) -> Self {
Self::Name(value)
}
}
impl PartialEq<str> for RuleEntry {
fn eq(&self, other: &str) -> bool {
self.name() == other
}
}
impl PartialEq<&str> for RuleEntry {
fn eq(&self, other: &&str) -> bool {
self.name() == *other
}
}
impl core::fmt::Display for RuleEntry {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.write_str(self.name())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Peg {
pub txid: String,
pub vout: u32,
pub amount: u64,
pub script: String,
#[serde(flatten)]
pub extra: BTreeMap<String, serde_json::Value>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ChainDocument {
pub id: String,
pub name: String,
pub parent: String,
pub challenge: String,
pub pow_limit: String,
pub address_prefix: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub magic: Option<String>,
#[serde(default = "default_peg_confirmations")]
pub peg_confirmations: u32,
#[serde(default = "default_refund_blocks")]
pub refund_blocks: u32,
#[serde(default = "default_pegout_blocks")]
pub pegout_blocks: u32,
#[serde(default = "default_pegout_min")]
pub pegout_min: u64,
#[serde(default = "default_min_fee_rate")]
pub min_fee_rate: u64,
pub genesis_time: u32,
#[serde(default)]
pub pegs: Vec<Peg>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub signer: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub genesis_hash: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rules: Option<Vec<RuleEntry>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub signers: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub threshold: Option<u32>,
#[serde(flatten)]
pub extra: BTreeMap<String, serde_json::Value>,
}
fn default_peg_confirmations() -> u32 {
6
}
fn default_refund_blocks() -> u32 {
10_000
}
fn default_pegout_blocks() -> u32 {
144
}
fn default_pegout_min() -> u64 {
10_000
}
fn default_min_fee_rate() -> u64 {
1
}
pub fn magic_for(name: &str) -> String {
let h = format!("sidestr:{name}")
.bytes()
.fold(7u32, |h, b| h.wrapping_mul(31).wrapping_add(u32::from(b)));
format!("{h:08x}")
}
fn is_hex(s: &str, len: usize) -> bool {
s.len() == len && s.bytes().all(|b| b.is_ascii_hexdigit())
}
impl ChainDocument {
pub fn from_json(text: &str) -> Result<Self> {
Self::from_json_with(text, &[])
}
pub fn from_json_with(text: &str, carried: &[&str]) -> Result<Self> {
let doc: Self = serde_json::from_str(text)?;
doc.validate_with(carried)?;
Ok(doc)
}
pub fn to_json(&self) -> Result<String> {
let mut out = Vec::new();
let fmt = serde_json::ser::PrettyFormatter::with_indent(b" ");
let mut ser = serde_json::Serializer::with_formatter(&mut out, fmt);
self.serialize(&mut ser)?;
let mut s = String::from_utf8(out).map_err(|e| Error::Encoding(e.to_string()))?;
s.push('\n');
Ok(s)
}
pub fn validate(&self) -> Result<()> {
self.validate_with(&[])
}
pub fn validate_with(&self, carried: &[&str]) -> Result<()> {
let bad = |m: String| Err(Error::Document(m));
self.parent()?;
if self.id.is_empty() || self.name.is_empty() {
return bad("id and name are required".into());
}
if self.challenge.len() < 2
|| self.challenge.len() % 2 != 0
|| !is_hex(&self.challenge, self.challenge.len())
{
return bad("challenge must be a non-empty hex script".into());
}
if !is_hex(&self.pow_limit, 64) {
return bad("powLimit must be 64 hex characters".into());
}
if self.address_prefix.is_empty()
|| self.address_prefix.len() > 8
|| !self.address_prefix.bytes().all(|b| b.is_ascii_lowercase())
{
return bad("addressPrefix is 1 to 8 lower-case letters".into());
}
if let Some(m) = &self.magic {
if !is_hex(m, 8) {
return bad("magic must be 8 hex characters".into());
}
}
if let Some(h) = &self.genesis_hash {
if !is_hex(h, 64) {
return bad("genesisHash must be 64 hex characters".into());
}
}
for (i, p) in self.pegs.iter().enumerate() {
if !is_hex(&p.txid, 64)
|| p.script.is_empty()
|| p.script.len() % 2 != 0
|| !is_hex(&p.script, p.script.len())
{
return bad(format!(
"peg {i}: txid must be 64 hex characters and script a hex script"
));
}
}
if let Some(s) = &self.signer {
if !is_hex(s, 64) {
return bad("signer must be a 64-hex x-only public key".into());
}
let c = self.challenge.to_ascii_lowercase();
if c.starts_with("5120") && c.len() == 68 && c[4..] != s.to_ascii_lowercase() {
return bad("signer is not the key the challenge names".into());
}
}
if let Some(rules) = &self.rules {
if let Some(r) = rules
.iter()
.find(|r| !r.name().is_empty() && !carried.contains(&r.name()))
{
return bad(if carried.is_empty() {
format!("chain {} names rule \"{r}\", which this validator does not have (sidestr-core carries the core rules only)", self.id)
} else {
format!(
"chain {} names rule \"{r}\", which this validator does not have (it carries {})",
self.id,
carried.join(", ")
)
});
}
if rules.iter().any(|r| r == "pool") && !rules.iter().any(|r| r == "assets") {
return bad("the pool rule needs the assets rule".into());
}
if rules.iter().any(|r| r == "markets") && !rules.iter().any(|r| r == "assets") {
return bad("the markets rule needs the assets rule".into());
}
}
Federation::for_document(self)?;
Ok(())
}
pub fn parent(&self) -> Result<&'static Parent> {
resolve_parent(&self.parent)
}
pub fn family(&self) -> Result<Family> {
Ok(self.parent()?.family)
}
pub fn challenge_script(&self) -> Result<ScriptBuf> {
Ok(ScriptBuf::from_bytes(
hex::decode(&self.challenge).map_err(|e| Error::Encoding(e.to_string()))?,
))
}
pub fn evm_reserve(&self) -> Result<ScriptBuf> {
let reserve = match self.extra.get("evm") {
None | Some(serde_json::Value::Null) => None,
Some(serde_json::Value::Object(m)) => m.get("reserve").filter(|v| !v.is_null()),
Some(v) => return Err(Error::Document(format!("evm must be an object, not {v}"))),
};
match reserve {
None => self.challenge_script(),
Some(serde_json::Value::String(h)) => hex::decode(h)
.map(ScriptBuf::from_bytes)
.map_err(|e| Error::Document(format!("evm.reserve is not a hex script: {e}"))),
Some(v) => Err(Error::Document(format!(
"evm.reserve must be a hex script, not {v}"
))),
}
}
pub fn pow_limit_target(&self) -> Result<Target> {
let bytes: [u8; 32] = hex::decode(&self.pow_limit)
.map_err(|e| Error::Encoding(e.to_string()))?
.try_into()
.map_err(|_| Error::Document("powLimit must be 32 bytes".into()))?;
Ok(Target::from_be_bytes(bytes))
}
pub fn bits(&self) -> Result<CompactTarget> {
Ok(self.pow_limit_target()?.to_compact_lossy())
}
pub fn derived_magic(&self) -> String {
magic_for(&self.name)
}
}
#[cfg(test)]
mod tests {
use super::*;
const TRIAL: &str = include_str!("../fixtures/trial/chain.json");
const DREAMLAB: &str = include_str!("../fixtures/dreamlab/chain.json");
#[test]
fn fixtures_parse_and_round_trip() {
for (text, name, magic) in [
(TRIAL, "trial", "a8f6706f"),
(DREAMLAB, "dreamlab", "d981eab1"),
] {
let doc = ChainDocument::from_json(text).unwrap();
assert_eq!(doc.name, name);
assert_eq!(doc.magic.as_deref(), Some(magic));
assert_eq!(doc.derived_magic(), magic);
assert_eq!(doc.bits().unwrap().to_consensus(), 0x207f_ffff);
let again: serde_json::Value = serde_json::from_str(&doc.to_json().unwrap()).unwrap();
assert_eq!(
again,
serde_json::from_str::<serde_json::Value>(text).unwrap()
);
}
let d = ChainDocument::from_json(DREAMLAB).unwrap();
assert_eq!(d.extra["containment"]["parent"], "tbtc4");
}
#[test]
fn refusals() {
let base: serde_json::Value = serde_json::from_str(TRIAL).unwrap();
let with = |f: &dyn Fn(&mut serde_json::Value)| {
let mut v = base.clone();
f(&mut v);
ChainDocument::from_json(&v.to_string())
};
let e = with(&|v| v["rules"] = serde_json::json!(["assets", "oracle"]))
.unwrap_err()
.to_string();
assert!(e.contains("does not have"), "{e}");
let named = |rules: serde_json::Value| {
let mut v = base.clone();
v["rules"] = rules;
serde_json::from_value::<ChainDocument>(v).unwrap()
};
assert!(named(serde_json::json!(["evm"]))
.validate_with(&["assets", "evm"])
.is_ok());
assert!(named(serde_json::json!(["assets", "evm"]))
.validate_with(&["assets", "evm"])
.is_ok());
for (rules, want) in [
(
serde_json::json!(["evm", "pool"]),
"\"pool\", which this validator does not have (it carries assets, evm)",
),
(serde_json::json!(["desk"]), "\"desk\""),
(serde_json::json!(["evm"]), "carries the core rules only"),
] {
let carried: &[&str] = if want.contains("core rules") {
&[]
} else {
&["assets", "evm"]
};
let e = named(rules).validate_with(carried).unwrap_err().to_string();
assert!(e.contains(want), "{e}");
}
assert!(named(serde_json::json!(["pool"]))
.validate_with(&["pool"])
.unwrap_err()
.to_string()
.contains("needs the assets rule"));
assert_eq!(
with(&|v| v["parent"] = "txbt4".into())
.unwrap()
.family()
.unwrap(),
Family::Blake2b
);
assert!(matches!(
with(&|v| v["parent"] = "doge".into()),
Err(Error::UnknownParent(_))
));
assert!(with(&|v| v["signers"] = serde_json::json!(["aa"])).is_err());
assert!(with(&|v| {
v["signers"] = serde_json::json!(["aa".repeat(32)]);
v["threshold"] = serde_json::json!(1);
})
.unwrap_err()
.to_string()
.contains("is not the one 1 signers"));
assert!(with(&|v| v["signer"] = serde_json::json!("00".repeat(32))).is_err());
assert!(with(&|v| v["powLimit"] = serde_json::json!("ff")).is_err());
assert!(with(&|v| v["addressPrefix"] = serde_json::json!("TRL")).is_err());
assert!(with(&|v| v["genesisHash"] = serde_json::json!("zz")).is_err());
}
}