urma-cli 0.1.0

Command-line tools for URMA identities, wallets, Wire, archives and Git
use crate::{detail, progress, stage};
use urma_chain::observation::Chain;
use urma_identity::identity::IdentitySigner;
use urma_runtime::error::{Context, Error};
use urma_runtime::{node::Node, quote::Quote};

pub(crate) fn currency(chain: Chain) -> &'static str {
    match chain {
        Chain::LitecoinMainnet | Chain::LitecoinTestnet => "LTC",
        Chain::BitcoinTestnet4 | Chain::BitcoinRegtest => "BTC",
    }
}

pub(crate) fn amount(value: u64) -> String {
    format!("{}.{:08}", value / 100_000_000, value % 100_000_000)
}

pub(crate) fn preview(chain: Chain, quote: &Quote, ceiling: u64) {
    let unit = currency(chain);
    detail(
        2,
        format!(
            "Payload: {} bytes; data: {}; leaves: {}; root: 1. Retained value: {} base units per record, plus final change.",
            quote.payload_bytes, quote.parts, quote.leaves, quote.retained_value
        ),
    );
    progress(format!(
        "Multipart: 256 KiB maximum per record; {} records, {} transactions (commit + reveal).",
        quote.records,
        u64::from(quote.records) * 2
    ));
    progress(format!(
        "Estimated network fee: {} {unit}.",
        amount(quote.maximum_fee)
    ));
    progress(format!(
        "Funding required: {} {unit} (includes value returned to your identity, not just fees).",
        amount(quote.funding)
    ));
    if ceiling < quote.maximum_fee {
        progress(format!(
            "Your fee limit is {} {unit}; the estimate exceeds it by {} {unit}.",
            amount(ceiling),
            amount(quote.maximum_fee - ceiling)
        ));
    }
}

pub(crate) fn check(
    node: &Node,
    signer: &impl IdentitySigner,
    quote: &Quote,
    ceiling: u64,
) -> Result<(), Error> {
    let unit = currency(node.chain());
    let address = urma_wallet::address::receive_address(signer, node.chain())?;
    progress(format!(
        "Your funding address ({}): {address}",
        node.chain().label()
    ));
    let outputs = stage("Checking confirmed spendable funds...", || {
        node.available_utxos(signer)
    })?;
    let mut balance = 0u64;
    let mut largest = 0u64;
    for output in &outputs {
        balance = balance
            .checked_add(output.value)
            .context("balance overflow")?;
        largest = largest.max(output.value);
    }
    progress(format!(
        "Confirmed spendable balance: {} {unit} across {} payment(s).",
        amount(balance),
        outputs.len()
    ));
    detail(
        2,
        format!(
            "Largest available payment: {} {unit}. Unconfirmed, immature and spent outputs are excluded.",
            amount(largest)
        ),
    );
    if balance < quote.funding {
        progress(format!(
            "Insufficient funds: you are short by {} {unit}.",
            amount(quote.funding - balance)
        ));
    }
    if largest < quote.funding {
        if balance >= quote.funding {
            progress("Your total balance is sufficient, but it is split across payments this version cannot combine.".into());
        }
        progress(format!(
            "This version needs one confirmed payment of at least {} {unit} to the address above. Adding only the shortfall as a separate payment may not suffice.",
            amount(quote.funding)
        ));
    }
    if ceiling < quote.maximum_fee {
        return Err(Error::Capacity("estimated fee exceeds --max-fee; raise the limit or use git publish for an automatically calculated quote. Nothing was broadcast".into()));
    }
    if largest < quote.funding {
        return Err(Error::Missing("funding is not ready; the amounts and receiving address are shown above. Nothing was broadcast".into()));
    }
    progress("Funding is sufficient for this quote. No broadcast yet.".into());
    Ok(())
}

pub(crate) fn preflight(node: &Node, directory: &std::path::Path) -> Result<(), Error> {
    use urma_runtime::{
        disk_plan::DiskPlan,
        publish::{MempoolCheck, test_accept},
    };
    let plan = DiskPlan::load(&directory.join("publication"))?;
    let first = plan.record(0)?;
    progress(
        "Checking the first funding transaction with testmempoolaccept (no broadcast)...".into(),
    );
    match test_accept(node, &first.commit)? {
        MempoolCheck::Allowed => progress("First commit accepted by node policy. Future dependent transactions are not yet mempool-verified.".into()),
        MempoolCheck::Rejected(reason) => return Err(Error::Invalid(format!("node mempool policy rejected first commit: {reason}; nothing was broadcast"))),
        MempoolCheck::Unavailable(reason) => {
            tracing::warn!(%reason, "prepare preflight unavailable");
            progress("Mempool preflight unavailable from this endpoint; this plan has no live node acceptance proof. Nothing was broadcast.".into());
        }
    }
    Ok(())
}