urma-runtime 0.1.0

URMA publication planning, network transports and recovery
Documentation
use crate::config;
use crate::error::{Context, Error, ensure};
use crate::plan::PublicationPlan;
use crate::publication::PublicPlan;
use crate::transaction::decode;
use bitcoin::Transaction;
use urma_chain::observation::Chain;

fn transaction(raw: &str) -> Result<Transaction, Error> {
    decode(raw, usize::MAX).map_err(|cause| match cause {
        Error::Context { cause, .. } => *cause,
        cause => cause,
    })
}

fn verify_commit(
    commit: &Transaction,
    previous: &bitcoin::TxOut,
    author: &str,
) -> Result<(), Error> {
    ensure!(
        commit.input.len() == 1 && commit.output.len() == 2,
        "invalid commit transaction shape"
    );
    let input = &commit.input[0];
    ensure!(
        input.script_sig.is_empty() && input.witness.len() == 2,
        "funding must be signed native P2WPKH"
    );
    let mut witness = input.witness.iter();
    let signature = bitcoin::ecdsa::Signature::from_slice(
        witness.next().context("missing funding signature")?,
    )?;
    let public =
        bitcoin::PublicKey::from_slice(witness.next().context("missing funding public key")?)?;
    ensure!(
        signature.sighash_type == bitcoin::sighash::EcdsaSighashType::All,
        "funding signature must commit all outputs"
    );
    ensure!(
        public.inner.x_only_public_key().0.to_string() == author,
        "author and funding identities differ"
    );
    let compressed = bitcoin::CompressedPublicKey::try_from(public)?;
    urma_wallet::signing::verify_p2wpkh_signature(commit, 0, previous, &signature, &compressed)
        .map_err(|cause| match cause {
            urma_wallet::signing::FundingSignatureError::SighashType => {
                Error::Invalid("funding signature must commit all outputs".into())
            }
            urma_wallet::signing::FundingSignatureError::PublicKeyMismatch => {
                Error::Invalid("funding public key mismatch".into())
            }
            urma_wallet::signing::FundingSignatureError::Sighash(cause) => cause.into(),
            urma_wallet::signing::FundingSignatureError::Signature(cause) => cause.into(),
        })?;
    Ok(())
}

pub(crate) fn validate(plan: &PublicationPlan) -> Result<(), Error> {
    ensure!(
        plan.version == 1
            && !plan.records.is_empty()
            && plan.records.len() <= usize::try_from(PublicationPlan::MAX_RECORDS)?,
        "invalid publication plan version or size"
    );
    ensure!(
        plan.total_fee <= plan.maximum_fee && plan.maximum_fee > 0,
        "plan fee budget exceeded"
    );
    let mut state = Validation::new();
    for pair in &plan.records {
        state.append(pair, plan.chain, &plan.author)?;
    }
    state.finish(plan.total_fee, &plan.root_txid)
}

pub(crate) struct Validation {
    count: u32,
    total: u64,
    previous_commit: String,
    last: String,
}

impl Validation {
    pub(crate) fn new() -> Self {
        Self {
            count: 0,
            total: 0,
            previous_commit: String::new(),
            last: String::new(),
        }
    }

    pub(crate) fn append(
        &mut self,
        pair: &PublicPlan,
        chain: Chain,
        author: &str,
    ) -> Result<Vec<u8>, Error> {
        ensure!(
            pair.chain.genesis()? == chain.genesis()?
                && pair.version == urma_core::format::Urma::VERSION,
            "plan record network or version mismatch"
        );
        ensure!(
            (1..=100).contains(&pair.fee_rate),
            "invalid record fee rate"
        );
        if self.count > 0 {
            ensure!(
                pair.funding.raw_transaction == self.previous_commit && pair.funding.vout == 1,
                "broken publication funding chain"
            );
        }
        let (outpoint, previous) = pair.funding.prevout()?;
        let commit = transaction(&pair.commit)?;
        let reveal = transaction(&pair.reveal)?;
        verify_commit(&commit, &previous, author)?;
        ensure!(
            commit.input[0].previous_output == outpoint,
            "commit does not spend declared funding"
        );
        let parsed = urma_core::envelope::verify_reveal(&reveal, &commit)?;
        verify_policy(&commit, &reveal, chain)?;
        ensure!(
            parsed.author.to_string() == author,
            "record author mismatch"
        );
        ensure!(
            commit.output[1].script_pubkey == previous.script_pubkey
                && reveal.output[0].script_pubkey == previous.script_pubkey,
            "publication change destination mismatch"
        );
        let committed = commit.output[0]
            .value
            .to_sat()
            .checked_add(commit.output[1].value.to_sat())
            .context("commit output sum overflow")?;
        ensure!(
            committed <= previous.value.to_sat(),
            "commit spends more than declared funding"
        );
        let outputs = commit.output[1]
            .value
            .to_sat()
            .checked_add(reveal.output[0].value.to_sat())
            .context("output sum overflow")?;
        let fee = previous
            .value
            .to_sat()
            .checked_sub(outputs)
            .context("negative publication fee")?;
        self.total = self.total.checked_add(fee).context("fee sum overflow")?;
        self.previous_commit = pair.commit.clone();
        self.last = reveal.compute_txid().to_string();
        self.count = self.count.checked_add(1).context("record count overflow")?;
        Ok(parsed.record)
    }

    pub(crate) fn last_txid(&self) -> Result<bitcoin::Txid, Error> {
        Ok(self.last.parse()?)
    }

    pub(crate) fn finish(&self, total_fee: u64, root_txid: &str) -> Result<(), Error> {
        ensure!(
            self.total == total_fee && self.last == root_txid,
            "plan fee or locator mismatch"
        );
        Ok(())
    }
}

fn verify_policy(commit: &Transaction, reveal: &Transaction, chain: Chain) -> Result<(), Error> {
    ensure!(
        commit.weight().to_wu() <= config::STANDARD_TX_WEIGHT
            && reveal.weight().to_wu() <= config::STANDARD_TX_WEIGHT,
        "publication exceeds standard transaction weight"
    );
    let retained = config::publication_return(chain);
    ensure!(
        commit.output[0].value.to_sat() >= retained
            && commit.output[1].value.to_sat() >= retained
            && reveal.output[0].value.to_sat() >= retained,
        "publication output below configured dust-safe retained value"
    );
    Ok(())
}