urma-runtime 0.3.4

URMA publication planning, network transports and recovery
Documentation
use crate::config;
use crate::error::{Context, Error, bail, ensure};
use crate::plan::PublicationPlan;
use crate::publication::PublicPlan;
use crate::transaction::decode;
use bitcoin::{CompressedPublicKey, OutPoint, ScriptBuf, Transaction, TxOut, ecdsa};
use std::collections::HashSet;
use urma_chain::observation::Chain;
use urma_core::{envelope, format::Urma};
use urma_wallet::{
    account::Account,
    signing::{FundingSignatureError, verify_p2wpkh_signature},
};

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

pub(crate) fn return_script(hex_script: &str) -> Result<ScriptBuf, Error> {
    let script = ScriptBuf::from(hex::decode(hex_script)?);
    ensure!(
        script.is_p2wpkh(),
        "publication return requires native P2WPKH"
    );
    Ok(script)
}

pub(crate) fn funded_by(hex_script: &str, funder: &Account) -> Result<bool, Error> {
    Ok(return_script(hex_script)? == funder.receive_script()?)
}

fn funding_error(cause: FundingSignatureError) -> Error {
    match cause {
        FundingSignatureError::SighashType => {
            Error::Invalid("funding signature must commit all outputs".into())
        }
        FundingSignatureError::PublicKeyMismatch => {
            Error::Invalid("funding public key mismatch".into())
        }
        FundingSignatureError::Sighash(cause) => cause.into(),
        FundingSignatureError::Signature(cause) => cause.into(),
    }
}

fn verify_input(commit: &Transaction, index: usize, previous: &TxOut) -> Result<(), Error> {
    let input = &commit.input[index];
    ensure!(
        input.script_sig.is_empty() && input.witness.len() == 2,
        "funding must be signed native P2WPKH"
    );
    let mut witness = input.witness.iter();
    let signature =
        ecdsa::Signature::from_slice(witness.next().context("missing funding signature")?)?;
    let key = witness.next().context("missing funding public key")?;
    ensure!(key.len() == 33, "funding public key must be compressed");
    let public = CompressedPublicKey::from_slice(key)?;
    verify_p2wpkh_signature(commit, index, previous, &signature, &public).map_err(funding_error)
}

fn verify_commit(commit: &Transaction, prevouts: &[(OutPoint, TxOut)]) -> Result<(), Error> {
    ensure!(
        commit.input.len() == prevouts.len() && commit.output.len() == 2,
        "invalid commit transaction shape"
    );
    let mut seen = HashSet::new();
    for (index, (input, (outpoint, previous))) in commit.input.iter().zip(prevouts).enumerate() {
        ensure!(
            input.previous_output == *outpoint && seen.insert(*outpoint),
            "commit does not spend declared funding"
        );
        verify_input(commit, index, previous)?;
    }
    Ok(())
}

fn verify_first_funding(pair: &PublicPlan) -> Result<(), Error> {
    ensure!(
        (1..=config::WALLET_MAX_INPUTS).contains(&pair.funding.len()),
        "first publication record needs 1..{} funding inputs",
        config::WALLET_MAX_INPUTS
    );
    Ok(())
}

fn verify_chained_funding(pair: &PublicPlan, previous_commit: &str) -> Result<(), Error> {
    let [chained] = pair.funding.as_slice() else {
        bail!("broken publication funding chain");
    };
    ensure!(
        chained.raw_transaction == previous_commit && chained.vout == 1,
        "broken publication funding chain"
    );
    Ok(())
}

fn declared_funding(pair: &PublicPlan) -> Result<Vec<(OutPoint, TxOut)>, Error> {
    let mut prevouts = Vec::new();
    for funding in &pair.funding {
        prevouts.push(funding.prevout()?);
    }
    Ok(prevouts)
}

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 return_script = return_script(&plan.return_script)?;
    let mut state = Validation::new();
    for pair in &plan.records {
        state.append(pair, plan.chain, &plan.author, &return_script)?;
    }
    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,
        return_script: &ScriptBuf,
    ) -> Result<Vec<u8>, Error> {
        ensure!(
            pair.chain.genesis()? == chain.genesis()? && pair.version == Urma::VERSION,
            "plan record network or version mismatch"
        );
        ensure!(
            (1..=100).contains(&pair.fee_rate),
            "invalid record fee rate"
        );
        match self.count {
            0 => verify_first_funding(pair)?,
            _ => verify_chained_funding(pair, &self.previous_commit)?,
        }
        let prevouts = declared_funding(pair)?;
        let commit = transaction(&pair.commit)?;
        let reveal = transaction(&pair.reveal)?;
        verify_commit(&commit, &prevouts)?;
        let parsed = 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 == *return_script
                && reveal.output[0].script_pubkey == *return_script,
            "publication return destination mismatch"
        );
        let mut available = 0u64;
        for (_, previous) in &prevouts {
            available = available
                .checked_add(previous.value.to_sat())
                .context("funding sum overflow")?;
        }
        let committed = commit.output[0]
            .value
            .to_sat()
            .checked_add(commit.output[1].value.to_sat())
            .context("commit output sum overflow")?;
        ensure!(
            committed <= available,
            "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 = available
            .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(())
}