urma-runtime 0.3.0

URMA publication planning, network transports and recovery
Documentation
use crate::bitcoin_rpc::supported_network;
use crate::config::{BITCOIN_MAX_FEE_RATE, BITCOIN_MAX_FEE_SATS, BITCOIN_RETURN_SATS, Limits};
use crate::container;
use crate::error::{Context, Error, ensure};
use crate::transaction::decode;
use bitcoin::{
    Address, OutPoint, ScriptBuf, Sequence, Transaction, TxOut, Witness, absolute,
    sighash::EcdsaSighashType, transaction::Version,
};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use std::{collections::BTreeSet, str::FromStr};
use urma_core::envelope;
use urma_core::format::Urma;
use urma_wallet::funding::Funding;

#[derive(Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Plan {
    pub version: u8,
    pub network: String,
    pub object_id: String,
    pub start_height: u64,
    pub input_bytes: usize,
    pub fee_sats: u64,
    pub commit_fee_sats: u64,
    pub reveal_fee_sats: u64,
    pub fee_rate_sat_vb: u64,
    pub funding: Funding,
    pub commit: String,
    pub reveals: Vec<String>,
    pub mining_address: String,
}

pub(crate) fn decode_transaction(raw: &str) -> Result<Transaction, Error> {
    decode(raw, 8_000_000)
}

fn validate_shape(tx: &Transaction) -> Result<(), Error> {
    ensure!(
        tx.version == Version::TWO && tx.lock_time == absolute::LockTime::ZERO,
        "unexpected transaction version or locktime"
    );
    ensure!(
        tx.input.len() == 1
            && tx.input[0].script_sig.is_empty()
            && tx.input[0].sequence == Sequence::ENABLE_RBF_NO_LOCKTIME,
        "unexpected transaction input structure"
    );
    ensure!(
        tx.weight().to_wu() <= 400_000,
        "transaction exceeds standard weight"
    );
    Ok(())
}

pub fn validate_plan(plan: &Plan) -> Result<Value, Error> {
    let network = supported_network(&plan.network)?;
    let payout = Address::from_str(&plan.mining_address)?.require_network(network)?;
    ensure!(
        payout.script_pubkey().is_p2tr(),
        "journal payout must be Taproot"
    );
    validate_transparent_plan(plan, &payout.script_pubkey(), true)
}

pub(crate) fn validate_transparent_plan(
    plan: &Plan,
    payout: &ScriptBuf,
    require_signed_commit: bool,
) -> Result<Value, Error> {
    validate_metadata(plan)?;
    let (outpoint, previous) = plan.funding.prevout()?;
    let commit = decode_transaction(&plan.commit)?;
    let commit_fee = validate_commit(
        plan,
        payout,
        &commit,
        outpoint,
        &previous,
        require_signed_commit,
    )?;
    let change = commit.output.last().context("commit change missing")?;
    let commit_id = commit.compute_txid();
    let (reveal_fee, reveal_ids, reveal_sizes) = validate_reveals(plan, payout, &commit)?;
    ensure!(
        reveal_fee == plan.reveal_fee_sats
            && commit_fee.checked_add(reveal_fee) == Some(plan.fee_sats),
        "journal total fees differ from signed transactions"
    );
    Ok(
        json!({"status": "valid", "network": plan.network, "object_id": plan.object_id,
        "input_bytes": plan.input_bytes, "start_height": plan.start_height, "total_chunks": plan.reveals.len(),
        "fee_rate_sat_vb": plan.fee_rate_sat_vb, "fee_sats": plan.fee_sats,
        "commit_fee_sats": commit_fee, "reveal_fee_sats": reveal_fee,
        "commit_txid": commit_id.to_string(), "reveal_txids": reveal_ids,
        "commit_vsize": commit.vsize(), "reveal_vsizes": reveal_sizes,
        "funding_txid": outpoint.txid.to_string(), "funding_vout": outpoint.vout,
        "funding_sats": previous.value.to_sat(), "change_sats": change.value.to_sat(),
        "payout_address": plan.mining_address, "chain_status_checked": false}),
    )
}

fn validate_metadata(plan: &Plan) -> Result<(), Error> {
    if plan.input_bytes > Limits::INPUT_BYTES {
        return Err(Error::Capacity("client input capacity exceeded".into()));
    }

    ensure!(
        plan.version == Urma::VERSION,
        "unsupported publication journal"
    );
    ensure!(
        (1..=Limits::RECORDS).contains(&plan.reveals.len()),
        "invalid reveal count"
    );
    ensure!(
        (1..=Limits::INPUT_BYTES).contains(&plan.input_bytes),
        "invalid input length"
    );
    ensure!(
        plan.input_bytes.div_ceil(Urma::CHUNK_BYTES) == plan.reveals.len(),
        "journal input length disagrees with reveal count"
    );
    ensure!(
        (1..=BITCOIN_MAX_FEE_RATE).contains(&plan.fee_rate_sat_vb),
        "invalid journal fee rate"
    );
    ensure!(
        plan.fee_sats <= BITCOIN_MAX_FEE_SATS,
        "journal exceeds client fee cap"
    );
    ensure!(
        hex::decode(&plan.object_id)?.len() == 32,
        "invalid journal object ID"
    );
    Ok(())
}
fn validate_commit(
    plan: &Plan,
    payout: &ScriptBuf,
    commit: &Transaction,
    outpoint: OutPoint,
    previous: &TxOut,
    require_signed_commit: bool,
) -> Result<u64, Error> {
    validate_shape(commit)?;
    ensure!(
        commit.input[0].previous_output == outpoint,
        "commit does not spend supplied funding"
    );
    ensure!(
        commit.output.len() == plan.reveals.len() + 1,
        "commit has unexpected outputs"
    );
    let change = commit.output.last().context("commit change missing")?;
    ensure!(
        change.script_pubkey == *payout && change.value.to_sat() >= BITCOIN_RETURN_SATS,
        "invalid commit change destination or value"
    );
    let output_sats = commit
        .output
        .iter()
        .try_fold(0u64, |sum, output| sum.checked_add(output.value.to_sat()))
        .context("commit output sum overflow")?;
    let commit_fee = previous
        .value
        .to_sat()
        .checked_sub(output_sats)
        .context("commit spends more than funding")?;
    ensure!(
        commit_fee == plan.commit_fee_sats,
        "journal commit fee differs from signed transaction"
    );
    let mut worst_case = commit.clone();
    worst_case.input[0].witness = Witness::from_slice(&[vec![0; 73], vec![0; 33]]);
    ensure!(
        commit_fee == u64::try_from(worst_case.vsize())? * plan.fee_rate_sat_vb
            && commit_fee >= u64::try_from(commit.vsize())? * plan.fee_rate_sat_vb,
        "commit fee is inconsistent with selected rate"
    );

    verify_funding_signature(commit, previous, require_signed_commit)?;
    Ok(commit_fee)
}
fn verify_funding_signature(
    commit: &Transaction,
    previous: &TxOut,
    require_signed_commit: bool,
) -> Result<(), Error> {
    if require_signed_commit {
        let funding_witness: Vec<_> = commit.input[0].witness.iter().collect();
        ensure!(
            funding_witness.len() == 2,
            "invalid P2WPKH signature witness"
        );
        let signature = bitcoin::ecdsa::Signature::from_slice(funding_witness[0])?;
        ensure!(
            signature.sighash_type == EcdsaSighashType::All,
            "commit must sign all outputs"
        );
        let public = bitcoin::PublicKey::from_slice(funding_witness[1])?;
        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("commit must sign all outputs".into())
                }
                urma_wallet::signing::FundingSignatureError::PublicKeyMismatch => {
                    Error::Invalid("funding key does not match previous output".into())
                }
                urma_wallet::signing::FundingSignatureError::Sighash(cause) => cause.into(),
                urma_wallet::signing::FundingSignatureError::Signature(cause) => Error::Context {
                    message: "invalid commit signature".into(),
                    cause: Box::new(cause.into()),
                },
            })?;
    } else {
        ensure!(
            commit.input[0].witness.is_empty(),
            "draft commit must be unsigned"
        );
    }

    Ok(())
}
fn validate_reveals(
    plan: &Plan,
    payout: &ScriptBuf,
    commit: &Transaction,
) -> Result<(u64, Vec<String>, Vec<usize>), Error> {
    let mut seen = BTreeSet::new();
    let mut seen_chunks = BTreeSet::new();
    let mut discovery_tag = None;
    let mut reveal_fee = 0u64;
    let mut reveal_ids = Vec::new();
    let mut reveal_sizes = Vec::new();
    for raw in &plan.reveals {
        let reveal = decode_transaction(raw)?;
        validate_shape(&reveal)?;
        let source = reveal.input[0].previous_output;
        ensure!(
            source.txid == commit.compute_txid()
                && (usize::try_from(source.vout)?) < plan.reveals.len(),
            "reveal does not spend a commit media output"
        );
        ensure!(
            seen.insert(source.vout),
            "duplicate reveal spends the same commit output"
        );
        ensure!(
            reveal.output.len() == 1
                && reveal.output[0].value.to_sat() == BITCOIN_RETURN_SATS
                && reveal.output[0].script_pubkey == *payout,
            "invalid reveal payout"
        );
        let prevout = &commit.output[usize::try_from(source.vout)?];
        ensure!(
            prevout.script_pubkey.is_p2tr(),
            "commit media output is not Taproot"
        );
        let fee = prevout
            .value
            .to_sat()
            .checked_sub(BITCOIN_RETURN_SATS)
            .context("reveal spends more than commit output")?;
        ensure!(
            fee == u64::try_from(reveal.vsize())? * plan.fee_rate_sat_vb,
            "reveal fee differs from selected rate"
        );
        reveal_fee = reveal_fee
            .checked_add(fee)
            .context("reveal fee sum overflow")?;
        let record = envelope::verify_reveal(&reveal, commit)?.record;
        let header = container::inspect_header(&record)?;
        ensure!(
            header.id.as_slice() == hex::decode(&plan.object_id)?
                && usize::try_from(header.count)? == plan.reveals.len(),
            "reveal metadata differs from journal"
        );
        ensure!(seen_chunks.insert(header.index), "duplicate record index");
        let tag = header.discovery_tag;
        for previous_tag in discovery_tag.iter() {
            ensure!(*previous_tag == tag, "inconsistent discovery tags");
        }
        discovery_tag = Some(tag);
        reveal_ids.push(reveal.compute_txid().to_string());
        reveal_sizes.push(reveal.vsize());
    }
    Ok((reveal_fee, reveal_ids, reveal_sizes))
}