decdn-cli 0.0.4

User-facing deCDN CLI (decdn binary): probe, fetch, pools, publishing, origin import, node admin
//! `decdn node register` — on-chain node registration (ADR 019).
//!
//! Unlike the other `node` subcommands (which talk to a running node's
//! loopback admin RPC), this performs the on-chain operator-registration
//! step of onboarding: it loads the local iroh node key and Ethereum keystore,
//! reads the operator's on-chain nonces, builds the EIP-712
//! `bindingSignature` and the ed25519 ownership signature locally, and
//! submits `CapacityBond.registerNode`.
//!
//! Bonding (`approve` + `bond` + `declareMbps`) is a precondition:
//! the contract reverts `BondBelowMinimum` / `BondBelowCurve` if the bond
//! is not already posted (run `decdn node bond` first). `--dry-run` builds
//! and prints everything (it still reads the chain for the nonces the
//! signatures depend on) but does not submit.
//!
//! The signing + submit path is factored into `submit_registration` so
//! `decdn setup` (#933) can drive registration with the same already-loaded
//! signer + provider it used for bonding, decrypting the keystore once.

use std::io;
use std::path::Path;

use alloy::primitives::{Address, B256, Bytes};
use alloy::providers::Provider;
use alloy::signers::SignerSync;
use alloy::signers::local::PrivateKeySigner;
use anyhow::Context;
use decdn_common::cli;
use decdn_common::identity;
use decdn_incentive::capacity_bond::CapacityBond;
use decdn_incentive::{bind_sig, node_register};

use crate::commands::{chain_ctx, terms};

/// Entry point for `decdn node register`.
pub async fn run(args: &cli::RegisterArgs, global_config: Option<&Path>) -> anyhow::Result<()> {
    let config_path = args.chain.common.config.as_deref().or(global_config);
    let file = chain_ctx::load_optional_config(config_path)?;
    let resolved = chain_ctx::resolve(&args.chain, &file)?;
    let cb_addr = resolved.capacity_bond_address;

    let signer = chain_ctx::load_operator_signer(&args.chain.common, &resolved.keystore).await?;
    let provider = decdn_client::provider::build_provider(&resolved.rpc_url, &signer)?;

    // ADR 019 § Terms Acceptance — read the network's current terms hash, then
    // require the operator to accept the matching embedded terms before we sign.
    // Enforced on `--dry-run` too: a dry run still produces a real, submit-able
    // signature that commits to `termsHash`, so it must not be generated over
    // terms the operator hasn't seen / a stale client's hash.
    let terms_hash = CapacityBond::new(cb_addr, &provider)
        .currentTermsHash()
        .call()
        .await
        .with_context(|| {
            format!("failed to read currentTermsHash from CapacityBond at {cb_addr}")
        })?;
    terms::ensure_accepted_async(terms_hash, args.accept_terms).await?;

    // `decdn node register` submits a single transaction and has no partial
    // report to print, so the slot is only consulted through the error message.
    let mut tx_slot = None;
    let multiaddrs = registration_multiaddrs(&args.multiaddrs, &file);
    let outcome = submit_registration(
        &provider,
        &signer,
        &resolved.data_dir,
        cb_addr,
        resolved.chain_id,
        &args.region,
        &multiaddrs,
        terms_hash,
        args.chain.common.dry_run,
        &mut tx_slot,
    )
    .await?;

    let mut out = io::stdout().lock();
    let label = if outcome.tx.is_some() {
        "failed to write result"
    } else {
        "failed to write dry-run output"
    };
    write_outcome(&mut out, &outcome, args.chain.common.json).context(label)?;
    Ok(())
}

/// The multiaddrs to register (ADR 001 § Node Discovery): `explicit` when the
/// operator passed any, else one `/ip{4,6}/<ip>/udp/<bind_port>/quic-v1` per
/// public address on the host's default route. A host behind NAT gets none,
/// since its route source is not dialable; its operator passes the forwarded
/// address with `--multiaddr` instead. The chosen default is reported on
/// stderr, so `--json` stdout stays machine-readable.
pub(crate) fn registration_multiaddrs(
    explicit: &[String],
    file: &chain_ctx::FileConfig,
) -> Vec<String> {
    if !explicit.is_empty() {
        return explicit.to_vec();
    }
    let bind_port = file.bind_port();
    let chosen = default_multiaddrs(&decdn_common::net::route_public_ips(), bind_port);
    if chosen.is_empty() {
        eprintln!(
            "note: no --multiaddr given and no public address on the default route (behind \
             NAT); registering none. Peers reach the node through the relay and its pkarr \
             record. If UDP {bind_port} is forwarded to this host, pass the forwarded address \
             with --multiaddr."
        );
    } else {
        eprintln!(
            "note: no --multiaddr given; registering this host's public address: {}",
            chosen.join(", ")
        );
    }
    chosen
}

fn default_multiaddrs(public: &[std::net::IpAddr], bind_port: u16) -> Vec<String> {
    public
        .iter()
        .map(|ip| decdn_common::net::quic_multiaddr(*ip, bind_port))
        .collect()
}

/// Registration parameters + result, owned so the formatter and `decdn setup`
/// can read them after the signer/provider go out of scope. `tx` is `Some`
/// after a successful submit, `None` for a dry run.
pub(crate) struct RegisterOutcome {
    pub(crate) node_id: B256,
    pub(crate) operator: Address,
    pub(crate) chain_id: u64,
    pub(crate) capacity_bond: Address,
    pub(crate) region: String,
    pub(crate) binding_nonce: u64,
    pub(crate) registration_nonce: u64,
    pub(crate) multiaddr_count: usize,
    pub(crate) binding_sig: Vec<u8>,
    pub(crate) ed25519_sig: Vec<u8>,
    pub(crate) tx: Option<B256>,
}

/// Build the binding + ownership signatures and submit `registerNode`
/// (ADR 019) against an already-built `provider` and `signer`.
/// `dry_run` reads the chain for the nonces the signatures depend on but does
/// not submit. Shared by `run` and `decdn setup` so the keystore is decrypted
/// once across bond + register.
///
/// `tx_slot` is caller-owned for the same reason `bond::execute`'s `Outcome` is
/// (#1355): on an unreadable receipt the transaction is broadcast and may take
/// effect, and the returned [`RegisterOutcome`] is gone with the `Err`. See
/// [`decdn_incentive::tx::send`] for the slot's contract.
#[allow(clippy::too_many_arguments)]
pub(crate) async fn submit_registration<P: Provider + Clone>(
    provider: &P,
    signer: &PrivateKeySigner,
    data_dir: &Path,
    cb_addr: Address,
    chain_id: u64,
    region: &str,
    multiaddrs: &[String],
    terms_hash: B256,
    dry_run: bool,
    tx_slot: &mut Option<B256>,
) -> anyhow::Result<RegisterOutcome> {
    // Load the iroh node key. Require it to already exist — `load_or_generate`
    // would otherwise mint a *fresh* identity and register that, silently
    // diverging from the key the daemon serves under. Operators create it with
    // `decdn key-gen` (or `decdn setup`).
    let key_path = identity::key_path(data_dir);
    anyhow::ensure!(
        key_path.exists(),
        "no node key at {}; run `decdn key-gen` first (register binds the existing iroh identity)",
        key_path.display(),
    );
    let node_secret = identity::load_or_generate(data_dir)
        .with_context(|| format!("failed to load node key from {}", data_dir.display()))?;
    let node_id = B256::from_slice(node_secret.public().as_bytes());

    let operator = signer.address();
    let bond = CapacityBond::new(cb_addr, provider);

    // Nonces feed both signature digests, so they are read even on a dry run.
    let binding_nonce: u64 =
        bond.bindingNonce(operator).call().await.with_context(|| {
            format!("failed to read bindingNonce from CapacityBond at {cb_addr}")
        })?;
    let registration_nonce: u64 =
        bond.registrationNonce(node_id)
            .call()
            .await
            .with_context(|| {
                format!("failed to read registrationNonce from CapacityBond at {cb_addr}")
            })?;

    // ADR 019 § Terms Acceptance — `terms_hash` is the accepted (and staleness-
    // checked) `currentTermsHash` the caller obtained via `terms::ensure_accepted`
    // before we sign. The binding signature commits to it, and the on-chain
    // contract enforces `termsHash == currentTermsHash`.

    // EIP-712 RegisterNode signature (Ethereum key). `sign_hash_sync` yields a
    // low-s, 27/28-`v` 65-byte signature accepted by the on-chain OZ
    // `SignatureChecker` (same path as `decdn_incentive::bind_sig`).
    let domain = bind_sig::bind_node_id_domain(chain_id, cb_addr);
    let bind_hash =
        bind_sig::register_node_signing_hash(node_id, binding_nonce, terms_hash, &domain);
    let binding_sig = signer.sign_hash_sync(&bind_hash)?.as_bytes().to_vec();

    // ed25519 ownership signature (iroh node key) over the contract's
    // ownership digest.
    let digest =
        node_register::ownership_message_digest(node_id, operator, chain_id, registration_nonce);
    let ed25519_sig = node_secret.sign(digest.as_slice()).to_bytes().to_vec();

    let packed_multiaddrs = node_register::pack_multiaddrs(multiaddrs)?;

    let mut outcome = RegisterOutcome {
        node_id,
        operator,
        chain_id,
        capacity_bond: cb_addr,
        region: region.to_string(),
        binding_nonce,
        registration_nonce,
        multiaddr_count: multiaddrs.len(),
        binding_sig: binding_sig.clone(),
        ed25519_sig: ed25519_sig.clone(),
        tx: None,
    };

    if dry_run {
        return Ok(outcome);
    }

    // Via the shared `send` (#1355) so a receipt-fetch timeout still names the
    // in-flight tx, into the CALLER's `tx_slot`. Writing it into the local
    // `outcome` instead would not survive: `outcome` is returned by value, so
    // the `?` below drops it and the caller — which reports the partial summary
    // — would see nothing.
    //
    // The hint reaches both the send and revert arms, which matters here:
    // `registerNode`'s failure modes are pre-flight gas-estimate reverts, so it
    // is the SEND arm that fires in practice.
    let tx = chain_ctx::send(
        bond.registerNode(
            node_id,
            Bytes::from(packed_multiaddrs),
            region.to_string(),
            terms_hash,
            Bytes::from(binding_sig),
            Bytes::from(ed25519_sig),
        ),
        "registerNode",
        Some(
            "most likely the bond does not cover minBond / the declared-capacity curve, the \
             nodeId/address is already bound, or a signature was rejected",
        ),
        tx_slot,
    )
    .await?;
    outcome.tx = Some(tx);
    Ok(outcome)
}

/// Write the outcome as JSON or grep-friendly `key=value` lines. `tx` is
/// `Some` after a successful submit, `None` for a dry run. Pure (`&mut impl
/// Write`) so the output shape is unit-testable without a chain.
pub(crate) fn write_outcome(
    w: &mut impl io::Write,
    o: &RegisterOutcome,
    json: bool,
) -> io::Result<()> {
    if json {
        let value = serde_json::json!({
            "submitted": o.tx.is_some(),
            "tx": o.tx.map(|h| format!("{h:#x}")),
            "node_id": format!("{:#x}", o.node_id),
            "operator": format!("{:#x}", o.operator),
            "chain_id": o.chain_id,
            "capacity_bond": format!("{:#x}", o.capacity_bond),
            "region": o.region,
            "binding_nonce": o.binding_nonce,
            "registration_nonce": o.registration_nonce,
            "multiaddrs": o.multiaddr_count,
            "binding_sig": format!("0x{}", alloy::hex::encode(&o.binding_sig)),
            "ed25519_sig": format!("0x{}", alloy::hex::encode(&o.ed25519_sig)),
        });
        return writeln!(w, "{value}");
    }
    writeln!(w, "node_id={:#x}", o.node_id)?;
    writeln!(w, "operator={:#x}", o.operator)?;
    writeln!(w, "chain_id={}", o.chain_id)?;
    writeln!(w, "capacity_bond={:#x}", o.capacity_bond)?;
    writeln!(w, "region={}", o.region)?;
    writeln!(w, "binding_nonce={}", o.binding_nonce)?;
    writeln!(w, "registration_nonce={}", o.registration_nonce)?;
    writeln!(w, "multiaddrs={}", o.multiaddr_count)?;
    writeln!(w, "binding_sig=0x{}", alloy::hex::encode(&o.binding_sig))?;
    writeln!(w, "ed25519_sig=0x{}", alloy::hex::encode(&o.ed25519_sig))?;
    match o.tx {
        Some(h) => writeln!(w, "submitted=true tx={h:#x}"),
        None => writeln!(w, "submitted=false dry_run=true"),
    }
}

#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests;