urma-runtime 0.3.0

URMA publication planning, network transports and recovery
Documentation
use crate::config;
use crate::error::{Context, Error, bail, ensure};
use crate::node::Node;
use crate::publish::{MempoolCheck, test_accept};
use crate::transaction::{signed_vsize, spending};
use crate::wallet::{FundingInput, Selection};
use bitcoin::{Amount, OutPoint, ScriptBuf, Transaction, TxOut, Txid, consensus::serialize};
use serde_json::json;
use std::num::NonZeroU64;
use urma_wallet::{
    account::{Account, Branch, KeyPath},
    wallet::{FeeBudget, FeeRate, SpendRequest},
};

pub struct TransferRequest {
    pub destination: ScriptBuf,
    pub amount: u64,
    pub rate: NonZeroU64,
    pub max_fee: u64,
}

pub struct Change {
    pub value: u64,
    pub path: KeyPath,
}

pub struct Transfer {
    pub transaction: Transaction,
    pub fee: u64,
    pub change: Option<Change>,
    pub inputs: Vec<FundingInput>,
}

fn output(value: u64, script: &ScriptBuf) -> TxOut {
    TxOut {
        value: Amount::from_sat(value),
        script_pubkey: script.clone(),
    }
}

fn quote(
    request: &TransferRequest,
    inputs: usize,
    change: Option<&ScriptBuf>,
) -> Result<u64, Error> {
    let mut draft = spending(
        &vec![OutPoint::null(); inputs],
        output(request.amount, &request.destination),
    );
    for script in change.iter() {
        draft.output.push(output(0, script));
    }
    signed_vsize(&draft)?
        .checked_mul(request.rate.get())
        .context("transfer fee overflow")
}

fn settle(
    request: &TransferRequest,
    account: &Account,
    selection: &Selection,
    retained: u64,
) -> Result<(u64, Option<Change>), Error> {
    let inputs = selection.inputs.len();
    let rest = selection
        .total
        .checked_sub(request.amount)
        .context("insufficient funding")?;
    let path = KeyPath::new(Branch::Internal, selection.next_change);
    let fee = quote(request, inputs, Some(&account.script(path)?))?;
    if rest >= fee.checked_add(retained).context("transfer fee overflow")? {
        let value = rest - fee;
        return Ok((fee, Some(Change { value, path })));
    }
    ensure!(
        rest >= quote(request, inputs, None)?,
        "insufficient funding"
    );
    Ok((rest, None))
}

pub fn prepare(
    node: &Node,
    account: &Account,
    request: &TransferRequest,
) -> Result<Transfer, Error> {
    let retained = config::publication_return(node.chain());
    ensure!(
        request.amount >= retained,
        "amount below the dust-safe minimum of {retained} base units"
    );
    ensure!(
        !request.destination.is_op_return(),
        "destination cannot be a data output"
    );
    let single = quote(request, 1, None)?;
    let per_input = quote(request, 2, None)?
        .checked_sub(single)
        .context("transfer fee underflow")?;
    let minimum = request
        .amount
        .checked_add(single)
        .context("transfer amount overflow")?;
    let selection = node.select_coins(account, minimum, per_input)?;
    let (fee, change) = settle(request, account, &selection, retained)?;
    ensure!(
        fee <= request.max_fee,
        "transfer fee {fee} exceeds the approved maximum {}",
        request.max_fee
    );
    let mut outpoints = Vec::new();
    let mut prevouts = Vec::new();
    let mut paths = Vec::new();
    for input in &selection.inputs {
        let (outpoint, previous) = input.funding.prevout()?;
        outpoints.push(outpoint);
        prevouts.push(previous);
        paths.push(input.path);
    }
    let mut unsigned = spending(&outpoints, output(request.amount, &request.destination));
    for change in change.iter() {
        unsigned
            .output
            .push(output(change.value, &account.script(change.path)?));
    }
    let signed = account.sign(
        &SpendRequest {
            transaction: &unsigned,
            prevouts: &prevouts,
            budget: FeeBudget {
                chain: node.chain().genesis()?,
                rate: FeeRate(request.rate),
                maximum_base_units: request.max_fee,
            },
        },
        &paths,
    )?;
    Ok(Transfer {
        transaction: signed,
        fee,
        change,
        inputs: selection.inputs,
    })
}

pub fn submit(node: &Node, transfer: &Transfer) -> Result<Txid, Error> {
    let raw = hex::encode(serialize(&transfer.transaction));
    match test_accept(node, &raw)? {
        MempoolCheck::Allowed => {}
        MempoolCheck::Rejected(reason) => {
            bail!("node policy rejected the transfer: {reason}; nothing was broadcast")
        }
        MempoolCheck::Unavailable(reason) => {
            tracing::warn!(%reason, "transfer broadcast without mempool preflight");
        }
    }
    let txid = transfer.transaction.compute_txid();
    let answer = node.call("sendrawtransaction", &[json!(raw)])?;
    ensure!(
        answer == json!(txid),
        "broadcast returned an unexpected TXID"
    );
    Ok(txid)
}