Skip to main content

decdn_cli/commands/
register.rs

1//! `decdn node register` — on-chain node registration (ADR 019).
2//!
3//! Unlike the other `node` subcommands (which talk to a running node's
4//! loopback admin RPC), this performs the on-chain operator-registration
5//! step of onboarding: it loads the local iroh node key and Ethereum keystore,
6//! reads the operator's on-chain nonces, builds the EIP-712
7//! `bindingSignature` and the ed25519 ownership signature locally, and
8//! submits `CapacityBond.registerNode`.
9//!
10//! Bonding (`approve` + `bond` + `declareMbps`) is a precondition:
11//! the contract reverts `BondBelowMinimum` / `BondBelowCurve` if the bond
12//! is not already posted (run `decdn node bond` first). `--dry-run` builds
13//! and prints everything (it still reads the chain for the nonces the
14//! signatures depend on) but does not submit.
15//!
16//! The signing + submit path is factored into `submit_registration` so
17//! `decdn setup` (#933) can drive registration with the same already-loaded
18//! signer + provider it used for bonding, decrypting the keystore once.
19
20use std::io;
21use std::path::Path;
22
23use alloy::primitives::{Address, B256, Bytes};
24use alloy::providers::Provider;
25use alloy::signers::SignerSync;
26use alloy::signers::local::PrivateKeySigner;
27use anyhow::Context;
28use decdn_common::cli;
29use decdn_common::identity;
30use decdn_incentive::capacity_bond::CapacityBond;
31use decdn_incentive::{bind_sig, node_register};
32
33use crate::commands::{chain_ctx, terms};
34
35/// Entry point for `decdn node register`.
36pub async fn run(args: &cli::RegisterArgs, global_config: Option<&Path>) -> anyhow::Result<()> {
37    let config_path = args.chain.common.config.as_deref().or(global_config);
38    let file = chain_ctx::load_optional_config(config_path)?;
39    let resolved = chain_ctx::resolve(&args.chain, &file)?;
40    let cb_addr = resolved.capacity_bond_address;
41
42    let signer = chain_ctx::load_operator_signer(&args.chain.common, &resolved.keystore).await?;
43    let provider = decdn_client::provider::build_provider(&resolved.rpc_url, &signer)?;
44
45    // ADR 019 § Terms Acceptance — read the network's current terms hash, then
46    // require the operator to accept the matching embedded terms before we sign.
47    // Enforced on `--dry-run` too: a dry run still produces a real, submit-able
48    // signature that commits to `termsHash`, so it must not be generated over
49    // terms the operator hasn't seen / a stale client's hash.
50    let terms_hash = CapacityBond::new(cb_addr, &provider)
51        .currentTermsHash()
52        .call()
53        .await
54        .with_context(|| {
55            format!("failed to read currentTermsHash from CapacityBond at {cb_addr}")
56        })?;
57    terms::ensure_accepted_async(terms_hash, args.accept_terms).await?;
58
59    // `decdn node register` submits a single transaction and has no partial
60    // report to print, so the slot is only consulted through the error message.
61    let mut tx_slot = None;
62    let multiaddrs = registration_multiaddrs(&args.multiaddrs, &file);
63    let outcome = submit_registration(
64        &provider,
65        &signer,
66        &resolved.data_dir,
67        cb_addr,
68        resolved.chain_id,
69        &args.region,
70        &multiaddrs,
71        terms_hash,
72        args.chain.common.dry_run,
73        &mut tx_slot,
74    )
75    .await?;
76
77    let mut out = io::stdout().lock();
78    let label = if outcome.tx.is_some() {
79        "failed to write result"
80    } else {
81        "failed to write dry-run output"
82    };
83    write_outcome(&mut out, &outcome, args.chain.common.json).context(label)?;
84    Ok(())
85}
86
87/// The multiaddrs to register (ADR 001 § Node Discovery): `explicit` when the
88/// operator passed any, else one `/ip{4,6}/<ip>/udp/<bind_port>/quic-v1` per
89/// public address on the host's default route. A host behind NAT gets none,
90/// since its route source is not dialable; its operator passes the forwarded
91/// address with `--multiaddr` instead. The chosen default is reported on
92/// stderr, so `--json` stdout stays machine-readable.
93pub(crate) fn registration_multiaddrs(
94    explicit: &[String],
95    file: &chain_ctx::FileConfig,
96) -> Vec<String> {
97    if !explicit.is_empty() {
98        return explicit.to_vec();
99    }
100    let bind_port = file.bind_port();
101    let chosen = default_multiaddrs(&decdn_common::net::route_public_ips(), bind_port);
102    if chosen.is_empty() {
103        eprintln!(
104            "note: no --multiaddr given and no public address on the default route (behind \
105             NAT); registering none. Peers reach the node through the relay and its pkarr \
106             record. If UDP {bind_port} is forwarded to this host, pass the forwarded address \
107             with --multiaddr."
108        );
109    } else {
110        eprintln!(
111            "note: no --multiaddr given; registering this host's public address: {}",
112            chosen.join(", ")
113        );
114    }
115    chosen
116}
117
118fn default_multiaddrs(public: &[std::net::IpAddr], bind_port: u16) -> Vec<String> {
119    public
120        .iter()
121        .map(|ip| decdn_common::net::quic_multiaddr(*ip, bind_port))
122        .collect()
123}
124
125/// Registration parameters + result, owned so the formatter and `decdn setup`
126/// can read them after the signer/provider go out of scope. `tx` is `Some`
127/// after a successful submit, `None` for a dry run.
128pub(crate) struct RegisterOutcome {
129    pub(crate) node_id: B256,
130    pub(crate) operator: Address,
131    pub(crate) chain_id: u64,
132    pub(crate) capacity_bond: Address,
133    pub(crate) region: String,
134    pub(crate) binding_nonce: u64,
135    pub(crate) registration_nonce: u64,
136    pub(crate) multiaddr_count: usize,
137    pub(crate) binding_sig: Vec<u8>,
138    pub(crate) ed25519_sig: Vec<u8>,
139    pub(crate) tx: Option<B256>,
140}
141
142/// Build the binding + ownership signatures and submit `registerNode`
143/// (ADR 019) against an already-built `provider` and `signer`.
144/// `dry_run` reads the chain for the nonces the signatures depend on but does
145/// not submit. Shared by `run` and `decdn setup` so the keystore is decrypted
146/// once across bond + register.
147///
148/// `tx_slot` is caller-owned for the same reason `bond::execute`'s `Outcome` is
149/// (#1355): on an unreadable receipt the transaction is broadcast and may take
150/// effect, and the returned [`RegisterOutcome`] is gone with the `Err`. See
151/// [`decdn_incentive::tx::send`] for the slot's contract.
152#[allow(clippy::too_many_arguments)]
153pub(crate) async fn submit_registration<P: Provider + Clone>(
154    provider: &P,
155    signer: &PrivateKeySigner,
156    data_dir: &Path,
157    cb_addr: Address,
158    chain_id: u64,
159    region: &str,
160    multiaddrs: &[String],
161    terms_hash: B256,
162    dry_run: bool,
163    tx_slot: &mut Option<B256>,
164) -> anyhow::Result<RegisterOutcome> {
165    // Load the iroh node key. Require it to already exist — `load_or_generate`
166    // would otherwise mint a *fresh* identity and register that, silently
167    // diverging from the key the daemon serves under. Operators create it with
168    // `decdn key-gen` (or `decdn setup`).
169    let key_path = identity::key_path(data_dir);
170    anyhow::ensure!(
171        key_path.exists(),
172        "no node key at {}; run `decdn key-gen` first (register binds the existing iroh identity)",
173        key_path.display(),
174    );
175    let node_secret = identity::load_or_generate(data_dir)
176        .with_context(|| format!("failed to load node key from {}", data_dir.display()))?;
177    let node_id = B256::from_slice(node_secret.public().as_bytes());
178
179    let operator = signer.address();
180    let bond = CapacityBond::new(cb_addr, provider);
181
182    // Nonces feed both signature digests, so they are read even on a dry run.
183    let binding_nonce: u64 =
184        bond.bindingNonce(operator).call().await.with_context(|| {
185            format!("failed to read bindingNonce from CapacityBond at {cb_addr}")
186        })?;
187    let registration_nonce: u64 =
188        bond.registrationNonce(node_id)
189            .call()
190            .await
191            .with_context(|| {
192                format!("failed to read registrationNonce from CapacityBond at {cb_addr}")
193            })?;
194
195    // ADR 019 § Terms Acceptance — `terms_hash` is the accepted (and staleness-
196    // checked) `currentTermsHash` the caller obtained via `terms::ensure_accepted`
197    // before we sign. The binding signature commits to it, and the on-chain
198    // contract enforces `termsHash == currentTermsHash`.
199
200    // EIP-712 RegisterNode signature (Ethereum key). `sign_hash_sync` yields a
201    // low-s, 27/28-`v` 65-byte signature accepted by the on-chain OZ
202    // `SignatureChecker` (same path as `decdn_incentive::bind_sig`).
203    let domain = bind_sig::bind_node_id_domain(chain_id, cb_addr);
204    let bind_hash =
205        bind_sig::register_node_signing_hash(node_id, binding_nonce, terms_hash, &domain);
206    let binding_sig = signer.sign_hash_sync(&bind_hash)?.as_bytes().to_vec();
207
208    // ed25519 ownership signature (iroh node key) over the contract's
209    // ownership digest.
210    let digest =
211        node_register::ownership_message_digest(node_id, operator, chain_id, registration_nonce);
212    let ed25519_sig = node_secret.sign(digest.as_slice()).to_bytes().to_vec();
213
214    let packed_multiaddrs = node_register::pack_multiaddrs(multiaddrs)?;
215
216    let mut outcome = RegisterOutcome {
217        node_id,
218        operator,
219        chain_id,
220        capacity_bond: cb_addr,
221        region: region.to_string(),
222        binding_nonce,
223        registration_nonce,
224        multiaddr_count: multiaddrs.len(),
225        binding_sig: binding_sig.clone(),
226        ed25519_sig: ed25519_sig.clone(),
227        tx: None,
228    };
229
230    if dry_run {
231        return Ok(outcome);
232    }
233
234    // Via the shared `send` (#1355) so a receipt-fetch timeout still names the
235    // in-flight tx, into the CALLER's `tx_slot`. Writing it into the local
236    // `outcome` instead would not survive: `outcome` is returned by value, so
237    // the `?` below drops it and the caller — which reports the partial summary
238    // — would see nothing.
239    //
240    // The hint reaches both the send and revert arms, which matters here:
241    // `registerNode`'s failure modes are pre-flight gas-estimate reverts, so it
242    // is the SEND arm that fires in practice.
243    let tx = chain_ctx::send(
244        bond.registerNode(
245            node_id,
246            Bytes::from(packed_multiaddrs),
247            region.to_string(),
248            terms_hash,
249            Bytes::from(binding_sig),
250            Bytes::from(ed25519_sig),
251        ),
252        "registerNode",
253        Some(
254            "most likely the bond does not cover minBond / the declared-capacity curve, the \
255             nodeId/address is already bound, or a signature was rejected",
256        ),
257        tx_slot,
258    )
259    .await?;
260    outcome.tx = Some(tx);
261    Ok(outcome)
262}
263
264/// Write the outcome as JSON or grep-friendly `key=value` lines. `tx` is
265/// `Some` after a successful submit, `None` for a dry run. Pure (`&mut impl
266/// Write`) so the output shape is unit-testable without a chain.
267pub(crate) fn write_outcome(
268    w: &mut impl io::Write,
269    o: &RegisterOutcome,
270    json: bool,
271) -> io::Result<()> {
272    if json {
273        let value = serde_json::json!({
274            "submitted": o.tx.is_some(),
275            "tx": o.tx.map(|h| format!("{h:#x}")),
276            "node_id": format!("{:#x}", o.node_id),
277            "operator": format!("{:#x}", o.operator),
278            "chain_id": o.chain_id,
279            "capacity_bond": format!("{:#x}", o.capacity_bond),
280            "region": o.region,
281            "binding_nonce": o.binding_nonce,
282            "registration_nonce": o.registration_nonce,
283            "multiaddrs": o.multiaddr_count,
284            "binding_sig": format!("0x{}", alloy::hex::encode(&o.binding_sig)),
285            "ed25519_sig": format!("0x{}", alloy::hex::encode(&o.ed25519_sig)),
286        });
287        return writeln!(w, "{value}");
288    }
289    writeln!(w, "node_id={:#x}", o.node_id)?;
290    writeln!(w, "operator={:#x}", o.operator)?;
291    writeln!(w, "chain_id={}", o.chain_id)?;
292    writeln!(w, "capacity_bond={:#x}", o.capacity_bond)?;
293    writeln!(w, "region={}", o.region)?;
294    writeln!(w, "binding_nonce={}", o.binding_nonce)?;
295    writeln!(w, "registration_nonce={}", o.registration_nonce)?;
296    writeln!(w, "multiaddrs={}", o.multiaddr_count)?;
297    writeln!(w, "binding_sig=0x{}", alloy::hex::encode(&o.binding_sig))?;
298    writeln!(w, "ed25519_sig=0x{}", alloy::hex::encode(&o.ed25519_sig))?;
299    match o.tx {
300        Some(h) => writeln!(w, "submitted=true tx={h:#x}"),
301        None => writeln!(w, "submitted=false dry_run=true"),
302    }
303}
304
305#[cfg(test)]
306#[allow(clippy::unwrap_used, clippy::expect_used)]
307mod tests;