tari-ootle-cli 0.16.2

Tari Ootle Template Development CLI
// Copyright 2024 The Tari Project
// SPDX-License-Identifier: BSD-3-Clause

use std::path::PathBuf;
use std::time::Duration;

use crate::cli::commands::publish::{
    find_metadata_cbor, load_project_config, resolve_active_network, resolve_wallet_daemon_url,
};
use crate::cli::config::Config;
use crate::cli::util::get_default_metadata_server_url;
use anyhow::{Context, anyhow};
use clap::Parser;
use dialoguer::Confirm;
use tari_engine_types::published_template::PublishedTemplateAddress;
use tari_ootle_common_types::Network;
use tari_ootle_publish_lib::NetworkConfig;
use tari_ootle_publish_lib::publisher::{SignedMetadataPayload, TemplatePublisher};
use tari_ootle_template_metadata::TemplateMetadata;
use url::Url;

/// Default retry settings: 6 attempts, 10s initial backoff (10, 20, 40, 80, 160s ≈ ~5 min total).
const DEFAULT_MAX_RETRIES: u32 = 6;
const DEFAULT_INITIAL_BACKOFF_SECS: u64 = 10;

#[derive(Clone, Parser, Debug)]
pub struct PublishMetadataArgs {
    /// Path to the template crate directory.
    /// Defaults to the current directory.
    #[arg(default_value = ".")]
    pub path: PathBuf,

    /// Template address to publish metadata for (e.g. template_bce07f... or raw hex).
    /// If omitted, uses the address saved in tari.config.toml from the last publish.
    #[arg(long, short = 't')]
    pub template_address: Option<PublishedTemplateAddress>,

    /// Metadata server URL. Overrides the value in tari.config.toml and global CLI config.
    #[arg(long)]
    pub metadata_server_url: Option<Url>,

    /// Maximum number of retry attempts for 404 (template not yet synced).
    #[arg(long, default_value_t = DEFAULT_MAX_RETRIES)]
    pub max_retries: u32,

    /// Use author-signed metadata submission.
    /// Signs via the wallet daemon and allows updating metadata without
    /// republishing the template on-chain.
    #[arg(long)]
    pub signed: bool,

    /// Key index for the author signing key (default: 0).
    /// Used with --signed to identify which derived account key to sign with.
    #[arg(long, default_value_t = 0)]
    pub key_index: u64,

    /// Wallet daemon JSON-RPC URL.
    /// Overrides the value in tari.config.toml and global CLI config.
    /// Required with --signed.
    #[arg(long)]
    pub wallet_daemon_url: Option<url::Url>,
}

pub async fn handle(
    config: Config,
    network_override: Option<Network>,
    args: PublishMetadataArgs,
) -> anyhow::Result<()> {
    let cbor_path = find_metadata_cbor(&args.path).await?;
    let mut cbor_bytes = std::fs::read(&cbor_path).context("reading metadata CBOR file")?;

    let project_config = load_project_config(&args.path).await?;
    let network = resolve_active_network(network_override, &project_config, &config);
    let wallet_daemon_url =
        resolve_wallet_daemon_url(args.wallet_daemon_url.as_ref(), &project_config, &config, network);
    println!("🌐 Network: {network}");

    let publisher = TemplatePublisher::new(NetworkConfig::new(wallet_daemon_url));

    // Resolve metadata server URL: CLI flag > project config > global config > default for network
    let metadata_server_url = args
        .metadata_server_url
        .as_ref()
        .or(project_config.metadata_server_url(network))
        .or(config.metadata_server_url(network))
        .cloned();

    let metadata_server_url = match metadata_server_url {
        Some(url) => url,
        None => {
            let default_url = get_default_metadata_server_url(network)
                .ok_or_else(|| anyhow!("no default metadata server for {network}"))?;
            default_url.parse::<Url>().expect("parse default url")
        },
    };

    let mut metadata =
        TemplateMetadata::from_cbor(&cbor_bytes).context("metadata CBOR is invalid — cannot publish corrupt data")?;

    // Check if built metadata matches Cargo.toml
    let cargo_toml_path = args.path.join("Cargo.toml");
    if cargo_toml_path.exists() {
        match tari_ootle_template_metadata::from_cargo_toml(&cargo_toml_path) {
            Ok(current) if current != metadata => {
                println!("⚠️  Built metadata does not match Cargo.toml (metadata may be stale)");
                let rebuild = Confirm::new()
                    .with_prompt("Rebuild to update metadata?")
                    .default(true)
                    .interact()?;
                if rebuild {
                    crate::cli::commands::publish::build_template(&args.path).await?;
                    let new_cbor_path = find_metadata_cbor(&args.path).await?;
                    cbor_bytes = std::fs::read(&new_cbor_path).context("reading rebuilt metadata CBOR")?;
                    metadata = TemplateMetadata::from_cbor(&cbor_bytes).context("rebuilt metadata CBOR is invalid")?;
                    println!("✅ Metadata rebuilt");
                }
            },
            Ok(_) => {},
            Err(e) => {
                println!("⚠️  Could not read Cargo.toml metadata for freshness check: {e}");
            },
        }
    }

    // Resolve template address: CLI flag > project config for selected network
    let addr = args
        .template_address
        .or_else(|| project_config.template_address(network).cloned())
        .ok_or_else(|| {
            anyhow!(
                "No template address provided for network '{network}'. \
                 Use --template-address or publish the template first \
                 (`tari publish`) to save the address in tari.config.toml."
            )
        })?;

    println!(
        "📄 Publishing metadata for {} v{} to {} (template: {})",
        metadata.name, metadata.version, metadata_server_url, addr
    );

    if args.signed {
        let payload = publisher
            .sign_metadata_for_publish(args.key_index, addr.as_template_address(), metadata)
            .await
            .context("signing metadata via wallet daemon")?;

        println!("🔑 Signed as author: {}", payload.public_key);

        publish_metadata_signed(&metadata_server_url, &addr, &payload, args.max_retries).await
    } else {
        publish_metadata_to_server(&metadata_server_url, &addr, &cbor_bytes, args.max_retries).await
    }
}

/// Flow 1: Hash-verified metadata publish (POST raw CBOR).
pub async fn publish_metadata_to_server(
    server_url: &Url,
    template_address: &PublishedTemplateAddress,
    cbor_bytes: &[u8],
    max_retries: u32,
) -> anyhow::Result<()> {
    let addr = template_address.as_template_address();
    let url = server_url
        .join(&format!("/api/templates/{addr}/metadata"))
        .context("building metadata endpoint URL")?;

    let client = reqwest::Client::new();
    let mut backoff = Duration::from_secs(DEFAULT_INITIAL_BACKOFF_SECS);

    for attempt in 0..=max_retries {
        let resp = client
            .post(url.clone())
            .header("Content-Type", "application/cbor")
            .body(cbor_bytes.to_vec())
            .send()
            .await
            .with_context(|| format!("POST {url}"))?;

        let status = resp.status();
        if status.is_success() {
            println!("✅ Metadata published successfully");
            return Ok(());
        }

        let body = resp.text().await.unwrap_or_default();

        if status == reqwest::StatusCode::NOT_FOUND && attempt < max_retries {
            println!(
                "⏳ Template not yet synced by server (attempt {}/{}), retrying in {}s...",
                attempt + 1,
                max_retries + 1,
                backoff.as_secs()
            );
            tokio::time::sleep(backoff).await;
            backoff *= 2;
            continue;
        }

        return Err(anyhow!("Metadata server returned {status}: {body}"));
    }

    unreachable!()
}

/// Flow 2: Author-signed metadata publish (POST JSON with Schnorr signature from walletd).
pub async fn publish_metadata_signed(
    server_url: &Url,
    template_address: &PublishedTemplateAddress,
    payload: &SignedMetadataPayload,
    max_retries: u32,
) -> anyhow::Result<()> {
    let addr = template_address.as_template_address();
    let url = server_url
        .join(&format!("/api/templates/{addr}/metadata/signed"))
        .context("building signed metadata endpoint URL")?;

    let client = reqwest::Client::new();
    let mut backoff = Duration::from_secs(DEFAULT_INITIAL_BACKOFF_SECS);

    for attempt in 0..=max_retries {
        let resp = client
            .post(url.clone())
            .header("Content-Type", "application/json")
            .json(payload)
            .send()
            .await
            .with_context(|| format!("POST {url}"))?;

        let status = resp.status();
        if status.is_success() {
            println!("✅ Signed metadata published successfully");
            return Ok(());
        }

        let resp_body = resp.text().await.unwrap_or_default();

        if status == reqwest::StatusCode::NOT_FOUND && attempt < max_retries {
            println!(
                "⏳ Template not yet synced by server (attempt {}/{}), retrying in {}s...",
                attempt + 1,
                max_retries + 1,
                backoff.as_secs()
            );
            tokio::time::sleep(backoff).await;
            backoff *= 2;
            continue;
        }

        return Err(anyhow!("Metadata server returned {status}: {resp_body}"));
    }

    unreachable!()
}