urma-cli 0.2.1

Command-line program for URMA identities, wallets, Wire, archives and Git
use crate::{
    approve_publication,
    config::{self, IndexChoice},
    node_cli::chain_name,
    print_report, progress,
};
use bitcoin::{Transaction, Txid, consensus::deserialize};
use serde_json::Value;
use std::path::{Path, PathBuf};
use urma_core::{envelope, format::PublicRecord};
use urma_names::{error::NamesError, index::NamesIndex, payload::Payload};
use urma_runtime::{
    error::{Context, Error, bail, ensure},
    node::{Node, Presence},
    plan::PublicationPlan,
    publish::{self, PublishReport, RevealTiming},
};

pub(crate) struct Publication<'a> {
    pub(crate) plan: &'a Path,
    pub(crate) journal: &'a Path,
    pub(crate) index: Option<PathBuf>,
    pub(crate) label: &'a str,
    pub(crate) yes: bool,
    pub(crate) watch: bool,
}

enum RevealWindow {
    Unchecked,
    Blocks(u64),
}

pub(crate) fn names_error(cause: NamesError) -> Error {
    match cause {
        NamesError::Protocol(cause) => Error::Protocol(cause),
        NamesError::Block(cause) => Error::from(cause),
        NamesError::Io(cause) => Error::from(cause),
        NamesError::Json(cause) => Error::Json(cause),
        NamesError::Integer(cause) => Error::Integer(cause),
        NamesError::Hash(cause) => Error::Hash(cause),
        NamesError::Invalid(message) => Error::Invalid(message),
        NamesError::Missing(message) => Error::Missing(message),
    }
}

pub(crate) fn names_record(bytes: &[u8]) -> Result<Payload, Error> {
    Ok(Payload::from_record(&PublicRecord::decode(bytes)?)?)
}

pub(crate) fn registry_of(payload: &Payload) -> Result<Txid, Error> {
    match payload {
        Payload::Genesis(..) => Err(Error::Missing("genesis records have no registry".into())),
        Payload::Claim(op) | Payload::Update(op) | Payload::Renew(op) => Ok(op.registry),
        Payload::Approve(approval) => Ok(approval.registry),
        Payload::Suspend(op) | Payload::Restore(op) => Ok(op.registry),
    }
}

fn planned(plan: &PublicationPlan) -> Result<(Transaction, Transaction), Error> {
    ensure!(
        plan.records.len() == 1,
        "names plans carry exactly one record"
    );
    let commit: Transaction = deserialize(&hex::decode(&plan.records[0].commit)?)?;
    let reveal: Transaction = deserialize(&hex::decode(&plan.records[0].reveal)?)?;
    Ok((commit, reveal))
}

impl RevealWindow {
    fn of(node: &Node, plan: &PublicationPlan, index: Option<PathBuf>) -> Result<Self, Error> {
        let reveal = planned(plan)?.1;
        let payload = names_record(&envelope::extract_reveal(&reveal)?.record)?;
        let Payload::Genesis(..) = payload else {
            let registry = registry_of(&payload)?;
            let network = chain_name(node.chain())?;
            let path = config::names_index(
                IndexChoice {
                    path: index,
                    registry: Some(registry),
                },
                &network,
            )?;
            return Self::from_index(&path, registry);
        };
        Ok(Self::Unchecked)
    }

    fn from_index(path: &Path, registry: Txid) -> Result<Self, Error> {
        if !path.try_exists()? {
            tracing::warn!(index = %path.display(), "no names index; the reveal window is not checked");
            return Ok(Self::Unchecked);
        }
        let index = NamesIndex::load(path).map_err(names_error)?;
        if index.registry.genesis() != registry {
            tracing::warn!(index = %path.display(), "names index belongs to another registry; the reveal window is not checked");
            return Ok(Self::Unchecked);
        }
        Ok(Self::Blocks(u64::from(
            index.registry.rules().reveal_max_blocks,
        )))
    }

    fn check(&self, node: &Node, plan: &PublicationPlan) -> Result<(), Error> {
        let Self::Blocks(window) = self else {
            return Ok(());
        };
        let (commit, reveal) = planned(plan)?;
        match node.presence(reveal.compute_txid())? {
            Presence::Missing => {}
            Presence::Mempool | Presence::Confirmed { .. } => return Ok(()),
        }
        let Presence::Confirmed { height, .. } = node.presence(commit.compute_txid())? else {
            return Ok(());
        };
        let age = node
            .tip_height()?
            .checked_add(1)
            .context("height overflow")?
            .checked_sub(height)
            .context("commit above the tip")?;
        ensure!(
            age <= *window,
            "commit confirmed at {height} is too old for the reveal window ({age} > {window}); encode and plan a new record"
        );
        Ok(())
    }
}

fn waiting(report: &PublishReport) -> bool {
    report.blocked_reason.starts_with("awaiting")
        || report.blocked_reason == "mempool full"
        || report.blocked_reason == "transaction missing after submission; resume exact plan"
}

pub(crate) fn follow(node: &Node, publication: Publication<'_>) -> Result<Value, Error> {
    publish::ensure_journal_distinct(publication.plan, publication.journal)?;
    let plan = PublicationPlan::load(publication.plan)?;
    let id = plan.id()?;
    approve_publication(publication.label, &id, plan.total_fee, publication.yes)?;
    let window = RevealWindow::of(node, &plan, publication.index)?;
    loop {
        window.check(node, &plan)?;
        let report = publish::publish_with(
            node,
            &plan,
            &id,
            publication.journal,
            RevealTiming::AfterConfirmation,
        )?;
        if report.complete {
            print_report(serde_json::to_value(&report)?)?;
            return Ok(Value::Null);
        }
        if !publication.watch || !waiting(&report) {
            print_report(serde_json::to_value(&report)?)?;
            bail!(
                "publication paused: {}; inspect report and resume exact plan",
                report.blocked_reason
            );
        }
        progress(format!(
            "{}; checking again in {}s",
            report.blocked_reason,
            config::names_watch_interval().as_secs()
        ));
        std::thread::sleep(config::names_watch_interval());
    }
}