urma-runtime 0.2.2

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::transaction;
use bitcoin::{Amount, OutPoint, ScriptBuf, Transaction, TxOut, Txid, Witness, consensus::serialize};
use serde_json::json;
use std::num::NonZeroU64;
use urma_identity::identity::IdentitySigner;
use urma_wallet::wallet::{FeeBudget, FeeRate, SpendRequest};

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

pub struct Transfer {
    pub transaction: Transaction,
    pub fee: u64,
    pub change: u64,
}

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

fn quote(request: &TransferRequest, own: &ScriptBuf, retained: u64) -> Result<u64, Error> {
    let mut draft = transaction(OutPoint::null(), output(request.amount, &request.destination));
    draft.output.push(output(retained, own));
    draft.input[0].witness = Witness::from_slice(&[vec![0; 73], vec![0; 33]]);
    let fee = u64::try_from(draft.vsize())?
        .checked_mul(request.rate.get())
        .context("transfer fee overflow")?;
    ensure!(
        fee <= request.max_fee,
        "transfer fee {fee} exceeds the approved maximum {}",
        request.max_fee
    );
    Ok(fee)
}

pub fn prepare(
    node: &Node,
    signer: &impl IdentitySigner,
    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 own = urma_wallet::signing::script(signer)?;
    let fee = quote(request, &own, retained)?;
    let spent = request
        .amount
        .checked_add(fee)
        .context("transfer amount overflow")?;
    let minimum = spent.checked_add(retained).context("transfer amount overflow")?;
    let funding = node.select_funding(signer, minimum)?;
    let (outpoint, previous) = funding.prevout()?;
    let change = previous
        .value
        .to_sat()
        .checked_sub(spent)
        .context("insufficient funding")?;
    let mut unsigned = transaction(outpoint, output(request.amount, &request.destination));
    unsigned.output.push(output(change, &own));
    let signed = urma_wallet::signing::sign(
        signer,
        &SpendRequest {
            transaction: &unsigned,
            prevouts: std::slice::from_ref(&previous),
            budget: FeeBudget {
                chain: node.chain().genesis()?,
                rate: FeeRate(request.rate),
                maximum_base_units: request.max_fee,
            },
        },
    )?;
    Ok(Transfer {
        transaction: signed,
        fee,
        change,
    })
}

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)
}