urma-cli 0.2.1

Command-line program for URMA identities, wallets, Wire, archives and Git
use crate::{
    config::{self, IndexChoice, StoreChoice},
    key_cli::VaultAccess,
    names_cli::{load_index, plan_record, sync_index, write_record},
    names_flow::{self, Publication, names_record},
    node_cli::{NodeArgs, chain_name},
    print_report,
    web_cli::{store_publication, web_error},
};
use bitcoin::{Txid, XOnlyPublicKey};
use clap::Args;
use serde_json::{Value, json};
use sha2::{Digest, Sha256};
use std::path::{Path, PathBuf};
use urma_core::{envelope, multipart::MultipartRecord};
use urma_names::{
    index::NamesIndex,
    name::Name,
    payload::{Approval, Mode, OwnerOp, Payload},
    state::{PendingKind, PendingRecord},
};
use urma_runtime::{
    error::{Context, Error, bail, ensure},
    node::Node,
    plan::PlanLimits,
    recovery,
};
use urma_web::{config::Limits, package::Package, store::Store};

#[derive(Args)]
pub(crate) struct Fees {
    #[arg(long, default_value_t = 1)]
    fee_rate: u64,
    #[arg(long, default_value_t = 100_000)]
    max_fee: u64,
    #[arg(short, long, help = "Approve the displayed exact plan and fee")]
    yes: bool,
    #[arg(
        long,
        help = "Stay in the foreground until the commit and the reveal are confirmed"
    )]
    watch: bool,
}

#[derive(Args)]
pub(crate) struct RequestArgs {
    #[command(flatten)]
    access: VaultAccess,
    #[command(flatten)]
    node: NodeArgs,
    #[arg(long, help = "Registry genesis TXID")]
    registry: Txid,
    #[arg(long)]
    name: String,
    #[arg(long, help = "Root TXID of the published URMAWEB1 site")]
    target: Txid,
    #[arg(
        long,
        default_value = "names-request",
        help = "New directory for the record, plan and journal"
    )]
    output: PathBuf,
    #[command(flatten)]
    fees: Fees,
}

#[derive(Args)]
pub(crate) struct Pending {
    #[arg(long, help = "Registry genesis TXID")]
    registry: Txid,
    #[arg(long, help = "Reveal TXID of the pending request or update")]
    request: Txid,
    #[arg(long, help = "Names index file; defaults to the registry's local index")]
    index: Option<PathBuf>,
    #[arg(long, help = "Verified store; defaults to the browser's store")]
    store: Option<PathBuf>,
    #[arg(long, default_value_t = Limits::DEFAULT.max_package_bytes)]
    max_bytes: usize,
}

#[derive(Args)]
pub(crate) struct ReviewArgs {
    #[command(flatten)]
    node: NodeArgs,
    #[command(flatten)]
    pending: Pending,
    #[arg(
        long,
        default_value = "names-review",
        help = "New directory receiving every declared file of the requested site"
    )]
    output: PathBuf,
}

#[derive(Args)]
pub(crate) struct ApproveArgs {
    #[command(flatten)]
    access: VaultAccess,
    #[command(flatten)]
    node: NodeArgs,
    #[command(flatten)]
    pending: Pending,
    #[arg(
        long,
        default_value = "names-approval",
        help = "New directory for the reviewed files, the approval record, plan and journal"
    )]
    output: PathBuf,
    #[command(flatten)]
    fees: Fees,
}

struct Reviewed {
    record: PendingRecord,
    summary: Value,
}

fn limits(fees: &Fees) -> PlanLimits {
    PlanLimits {
        fee_rate: fees.fee_rate,
        max_fee: fees.max_fee,
        max_records: 1,
    }
}

fn publish_record(
    node: &Node,
    directory: &Path,
    label: &str,
    fees: &Fees,
) -> Result<Value, Error> {
    names_flow::follow(
        node,
        Publication {
            plan: &directory.join("plan.json"),
            journal: &directory.join("progress.json"),
            index: None,
            label,
            yes: fees.yes,
            watch: fees.watch,
        },
    )
}

pub(crate) fn request(args: RequestArgs) -> Result<Value, Error> {
    let node = args.node.connect()?;
    let root = recovery::verified_record(&node, args.target)?;
    let MultipartRecord::Root(manifest) = root.decode()? else {
        bail!("{} is not a publication root", args.target);
    };
    ensure!(
        manifest.profile == Package::PROFILE,
        "{} is not an URMAWEB1 site",
        args.target
    );
    let name = Name::normalize(&args.name)?;
    urma_io::create_private_directory(&args.output)?;
    let record = args.output.join("request.record");
    write_record(
        &Payload::Claim(OwnerOp {
            registry: args.registry,
            salt: rand::random(),
            name: name.clone(),
            target: args.target,
        }),
        &record,
    )?;
    print_report(plan_record(
        &node,
        &args.access,
        &record,
        &args.output.join("plan.json"),
        limits(&args.fees),
    )?)?;
    let label = format!("Request {name} -> {} (site by {})", args.target, root.author());
    publish_record(&node, &args.output, &label, &args.fees)
}

fn synced_index(node: &Node, pending: &Pending) -> Result<NamesIndex, Error> {
    let network = chain_name(node.chain())?;
    let path = config::names_index(
        IndexChoice {
            path: pending.index.clone(),
            registry: Some(pending.registry),
        },
        &network,
    )?;
    let report = sync_index(node, pending.registry, &path, 10_000)?;
    ensure!(
        report.complete_to_tip,
        "names index stopped at {} of {}; run names scan until it reaches the tip",
        report.height,
        report.tip
    );
    load_index(&network, &path)
}

fn verify_request(node: &Node, registry: Txid, txid: Txid, record: &PendingRecord) -> Result<(), Error> {
    let reveal = node.transaction(txid)?;
    let spent = reveal.input.first().context("request reveal without input")?;
    let commit = node.transaction(spent.previous_output.txid)?;
    let parsed = envelope::verify_reveal(&reveal, &commit)?;
    ensure!(
        <[u8; 32]>::from(Sha256::digest(&parsed.record)) == record.sha256
            && parsed.author == record.author,
        "request {txid} on chain differs from the indexed record"
    );
    let (Payload::Claim(op) | Payload::Update(op)) = names_record(&parsed.record)? else {
        bail!("{txid} is not a request or an update");
    };
    ensure!(
        op.registry == registry && op.name == record.name && op.target == record.target,
        "request {txid} names another registry, name or target"
    );
    Ok(())
}

fn export_site(node: &Node, store: &Path, target: Txid, output: &Path, max_bytes: usize) -> Result<Value, Error> {
    let summary = store_publication(node, target, store, max_bytes)?;
    let opened = Store::open(store).map_err(web_error)?;
    let verified = opened
        .publication(&chain_name(node.chain())?, &target.to_string(), max_bytes)
        .map_err(web_error)?;
    let site = output.join("site");
    for file in &verified.package.files {
        write_exported(&site, &file.path, &file.bytes)?;
    }
    for pin in &verified.package.pinned {
        let bytes = opened
            .object(&hex::encode(pin.payload_sha256), Store::MAX_OBJECT_BYTES)
            .map_err(web_error)?;
        write_exported(&site, &pin.path, &bytes)?;
    }
    Ok(json!({"directory": site, "publication": serde_json::to_value(summary)?}))
}

fn write_exported(site: &Path, path: &str, bytes: &[u8]) -> Result<(), Error> {
    let target = site.join(path);
    let parent = target.parent().context("exported path without parent")?;
    std::fs::create_dir_all(parent)?;
    urma_io::write_new(&target, bytes)?;
    Ok(())
}

fn reviewed(node: &Node, pending: &Pending, output: &Path) -> Result<Reviewed, Error> {
    let index = synced_index(node, pending)?;
    let Some(record) = index.registry.pending().get(&pending.request) else {
        bail!(
            "{} is not a pending request or update of registry {}",
            pending.request,
            pending.registry
        );
    };
    ensure!(!record.completed, "{} already completed", pending.request);
    verify_request(node, pending.registry, pending.request, record)?;
    urma_io::create_private_directory(output)?;
    let store = config::web_store(StoreChoice(pending.store.clone()))?;
    let site = export_site(node, &store, record.target, output, pending.max_bytes)?;
    let rules = index.registry.rules();
    let summary = json!({
        "registry": pending.registry.to_string(),
        "mode": rules.mode,
        "threshold": rules.threshold,
        "request": pending.request.to_string(),
        "kind": record.kind,
        "name": record.name.as_str(),
        "requester": record.author.to_string(),
        "target": record.target.to_string(),
        "requested_at_height": record.height,
        "approvals": record.approvals.iter().map(XOnlyPublicKey::to_string).collect::<Vec<_>>(),
        "review": site,
        "broadcast": false,
    });
    urma_io::write_new(&output.join("review.json"), &serde_json::to_vec_pretty(&summary)?)?;
    Ok(Reviewed {
        record: record.clone(),
        summary,
    })
}

pub(crate) fn review(args: ReviewArgs) -> Result<Value, Error> {
    let node = args.node.connect()?;
    Ok(reviewed(&node, &args.pending, &args.output)?.summary)
}

pub(crate) fn approve(args: ApproveArgs) -> Result<Value, Error> {
    let node = args.node.connect()?;
    let approver = args.access.open()?.keyring().active()?.author().0;
    let reviewed = reviewed(&node, &args.pending, &args.output)?;
    let index = synced_index(&node, &args.pending)?;
    let rules = index.registry.rules();
    ensure!(
        rules.mode == Mode::Administered && rules.is_approver(&approver),
        "the active identity {approver} is not an approver of registry {}",
        args.pending.registry
    );
    ensure!(
        !reviewed.record.approvals.contains(&approver),
        "{approver} already approved {}",
        args.pending.request
    );
    print_report(reviewed.summary)?;
    let record = args.output.join("approval.record");
    write_record(
        &Payload::Approve(Approval {
            registry: args.pending.registry,
            record_txid: args.pending.request,
            record_sha256: reviewed.record.sha256,
        }),
        &record,
    )?;
    print_report(plan_record(
        &node,
        &args.access,
        &record,
        &args.output.join("plan.json"),
        limits(&args.fees),
    )?)?;
    let action = match reviewed.record.kind {
        PendingKind::Request => "Approve name",
        PendingKind::Update => "Approve update of",
    };
    let label = format!(
        "{action} {} -> {} requested by {}; reviewed files in {}",
        reviewed.record.name,
        reviewed.record.target,
        reviewed.record.author,
        args.output.join("site").display()
    );
    publish_record(&node, &args.output, &label, &args.fees)
}