urma-runtime 0.3.4

URMA publication planning, network transports and recovery
Documentation
use crate::config;
use crate::error::{Context, Error, ensure};
use crate::reveal;
use crate::transaction::{decode, signed_vsize, spending, transaction};
use crate::wallet::FundingInput;
use bitcoin::{Amount, OutPoint, ScriptBuf, TxOut, consensus::serialize};
use serde::{Deserialize, Serialize};
use urma_chain::observation::Chain;
use urma_core::{
    envelope,
    format::{PublicRecord, Urma},
    multipart::MultipartRecord,
};
use urma_identity::identity::IdentitySigner;
use urma_wallet::{
    account::{Account, KeyPath},
    funding::Funding,
    wallet::{FeeBudget, SpendRequest, WalletError},
};

#[derive(Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PublicPlan {
    pub version: u8,
    pub chain: Chain,
    pub funding: Vec<Funding>,
    pub commit: String,
    pub reveal: String,
    pub fee_rate: u64,
}

pub fn prepare(
    record: &PublicRecord,
    author: &impl IdentitySigner,
    funding: Vec<Funding>,
    return_script: &ScriptBuf,
    chain: Chain,
    fee_rate: u64,
) -> Result<PublicPlan, Error> {
    prepare_bytes(
        &record.encode()?,
        author,
        funding,
        return_script,
        chain,
        fee_rate,
    )
}

pub fn prepare_multipart(
    record: &MultipartRecord,
    author: &impl IdentitySigner,
    funding: Vec<Funding>,
    return_script: &ScriptBuf,
    chain: Chain,
    fee_rate: u64,
) -> Result<PublicPlan, Error> {
    prepare_bytes(
        &record.encode()?,
        author,
        funding,
        return_script,
        chain,
        fee_rate,
    )
}

pub(crate) fn prevouts(funding: &[Funding]) -> Result<(Vec<OutPoint>, Vec<TxOut>, u64), Error> {
    let mut outpoints = Vec::new();
    let mut outputs = Vec::new();
    let mut total = 0u64;
    for source in funding {
        let (outpoint, previous) = source.prevout()?;
        total = total
            .checked_add(previous.value.to_sat())
            .context("funding sum overflow")?;
        outpoints.push(outpoint);
        outputs.push(previous);
    }
    Ok((outpoints, outputs, total))
}

pub fn prepare_bytes(
    record: &[u8],
    author: &impl IdentitySigner,
    funding: Vec<Funding>,
    return_script: &ScriptBuf,
    chain: Chain,
    fee_rate: u64,
) -> Result<PublicPlan, Error> {
    ensure!(
        (1..=100).contains(&fee_rate),
        "client fee rate must be 1..100 base units/vB"
    );
    ensure!(
        (1..=config::WALLET_MAX_INPUTS).contains(&funding.len()),
        "publication funding needs 1..{} inputs",
        config::WALLET_MAX_INPUTS
    );
    ensure!(
        return_script.is_p2wpkh(),
        "publication return requires native P2WPKH"
    );
    let (outpoints, _, available) = prevouts(&funding)?;
    let (script, info) =
        envelope::build_for_author(record, author.public_key().inner.x_only_public_key().0)?;
    let retained = config::publication_return(chain);
    let return_output = TxOut {
        value: Amount::from_sat(retained),
        script_pubkey: return_script.clone(),
    };
    let mut reveal = reveal::preview(return_output.clone(), &script, &info)?;
    let reveal_fee = reveal::fee(&reveal, fee_rate)?;
    let publication_output = TxOut {
        value: Amount::from_sat(retained + reveal_fee),
        script_pubkey: ScriptBuf::new_p2tr_tweaked(info.output_key()),
    };
    let mut commit = spending(&outpoints, publication_output.clone());
    commit.output.push(return_output);
    let commit_fee = signed_vsize(&commit)?
        .checked_mul(fee_rate)
        .context("fee overflow")?;
    let total_fee = commit_fee.checked_add(reveal_fee).context("fee overflow")?;
    ensure!(
        total_fee <= 500_000,
        "client publication fee capacity exceeded"
    );
    let change = available
        .checked_sub(total_fee + retained)
        .context("insufficient funding")?;
    ensure!(change >= retained, "funding must leave native change");
    commit.output[1].value = Amount::from_sat(change);
    reveal.input[0].previous_output = OutPoint {
        txid: commit.compute_txid(),
        vout: 0,
    };
    let signature = author.sign_author(reveal::digest(&reveal, &publication_output, &script)?)?;
    reveal.input[0].witness = envelope::witness(signature.as_ref(), &script, &info)?;
    envelope::verify_reveal(&reveal, &commit)?;
    Ok(PublicPlan {
        version: Urma::VERSION,
        chain,
        funding,
        commit: hex::encode(serialize(&commit)),
        reveal: hex::encode(serialize(&reveal)),
        fee_rate,
    })
}

pub fn quote_record(
    record: &[u8],
    author: bitcoin::XOnlyPublicKey,
    return_script: &ScriptBuf,
    fee_rate: u64,
) -> Result<u64, Error> {
    ensure!((1..=100).contains(&fee_rate), "invalid fee rate");
    ensure!(return_script.is_p2wpkh(), "funding requires native P2WPKH");
    let (script, info) = envelope::build_for_author(record, author)?;
    let output = TxOut {
        value: Amount::from_sat(1000),
        script_pubkey: return_script.clone(),
    };
    let reveal = reveal::preview(output.clone(), &script, &info)?;
    let mut commit = transaction(
        OutPoint::null(),
        TxOut {
            value: Amount::from_sat(1000),
            script_pubkey: ScriptBuf::new_p2tr_tweaked(info.output_key()),
        },
    );
    commit.output.push(output);
    signed_vsize(&commit)?
        .checked_add(u64::try_from(reveal.vsize())?)
        .and_then(|bytes| bytes.checked_mul(fee_rate))
        .context("quote fee overflow")
}

pub struct SignedPublicPair {
    pub plan: PublicPlan,
    pub fee: u64,
}

pub fn prepare_signed_bytes(
    record: &[u8],
    author: &impl IdentitySigner,
    funder: &Account,
    inputs: &[FundingInput],
    budget: FeeBudget,
) -> Result<SignedPublicPair, Error> {
    let chain = funder.chain();
    ensure!(
        budget.chain == chain.genesis()?,
        "funding budget chain mismatch"
    );
    let funding: Vec<Funding> = inputs.iter().map(|input| input.funding.clone()).collect();
    let paths: Vec<KeyPath> = inputs.iter().map(|input| input.path).collect();
    let (_, previous, available) = prevouts(&funding)?;
    let mut plan = prepare_bytes(
        record,
        author,
        funding,
        &funder.receive_script()?,
        chain,
        budget.rate.0.get(),
    )?;
    let commit = decode(
        &plan.commit,
        usize::try_from(config::STANDARD_TX_WEIGHT)? * 2,
    )?;
    let signed = funder.sign(
        &SpendRequest {
            transaction: &commit,
            prevouts: &previous,
            budget,
        },
        &paths,
    )?;
    let reveal = decode(
        &plan.reveal,
        usize::try_from(config::STANDARD_TX_WEIGHT)? * 2,
    )?;
    let outputs = signed.output[1]
        .value
        .to_sat()
        .checked_add(reveal.output[0].value.to_sat())
        .ok_or(WalletError::Overflow)?;
    let fee = available
        .checked_sub(outputs)
        .ok_or(WalletError::InsufficientFunds)?;
    if fee > budget.maximum_base_units {
        return Err(WalletError::BudgetExceeded.into());
    }
    plan.commit = hex::encode(serialize(&signed));
    Ok(SignedPublicPair { plan, fee })
}