Skip to main content

decdn_cli/commands/
update_multiaddrs.rs

1//! `decdn node update-multiaddrs` — (re)publish the operator's dialable QUIC
2//! addresses on-chain (ADR 019 § Multiaddr encoding, #1908).
3//!
4//! An operator who ran `decdn node register` with no `--multiaddr` published an
5//! empty set and is stranded on the iroh relay path: client→node transfers pin
6//! at relay throughput and never upgrade to a direct QUIC connection, even when
7//! the node is directly dialable. `register` cannot fix it (a second call
8//! reverts `NodeAlreadyRegistered`), and the only other route is a hand-encoded
9//! `cast send … updateMultiaddrs(bytes)` reproducing `pack_multiaddrs`'s framing
10//! by hand. This command is the one-command fix: it packs the addresses with the
11//! exact `node_register::pack_multiaddrs` encoding `register` uses and submits
12//! `CapacityBond.updateMultiaddrs` from the operator Ethereum key.
13//!
14//! The set fully REPLACES what is on-chain — the contract stores the field
15//! verbatim rather than merging.
16//!
17//! Like `decdn node deregister`, the contract's guardrails are read and checked
18//! before the send so they surface as named errors rather than a bare
19//! `execution reverted`: `NodeNotActive` (the operator must be registered), the
20//! `maxMultiaddrSize` byte ceiling (`MultiaddrsTooLarge`), and the
21//! `multiaddrUpdateCooldown` between updates (`MultiaddrCooldownActive`). The
22//! cooldown is compared against the head block timestamp — the same clock the
23//! contract uses — so the pre-check and the on-chain check agree.
24
25use std::io;
26use std::path::Path;
27
28use alloy::primitives::{Address, B256, Bytes};
29use alloy::providers::Provider;
30use anyhow::Context;
31use decdn_common::cli;
32use decdn_incentive::capacity_bond::CapacityBond;
33use decdn_incentive::node_register;
34
35use crate::commands::chain_ctx;
36
37/// Entry point for `decdn node update-multiaddrs`.
38pub async fn run(
39    args: &cli::UpdateMultiaddrsArgs,
40    global_config: Option<&Path>,
41) -> anyhow::Result<()> {
42    let config_path = args.chain.common.config.as_deref().or(global_config);
43    let file = chain_ctx::load_optional_config(config_path)?;
44    let resolved = chain_ctx::resolve(&args.chain, &file)?;
45    let cb_addr = resolved.capacity_bond_address;
46
47    // Reject empty / whitespace-only entries before packing. `--multiaddr` is
48    // required, but clap accepts `--multiaddr ""`, and a blank string packs to a
49    // zero-length entry — an on-chain address no peer can dial, which recreates
50    // the exact relay-pinned footgun this command exists to fix.
51    reject_blank_multiaddrs(&args.multiaddrs)?;
52
53    // Pack next: an oversized single entry (past the `uint16` length prefix)
54    // is a local encoding error, caught before any keystore decrypt or chain
55    // read. The total-size ceiling is a separate, chain-read guardrail below.
56    let packed = node_register::pack_multiaddrs(&args.multiaddrs)?;
57
58    let signer = chain_ctx::load_operator_signer(&args.chain.common, &resolved.keystore).await?;
59    let operator = signer.address();
60    let provider = decdn_client::provider::build_provider(&resolved.rpc_url, &signer)?;
61    let bond = CapacityBond::new(cb_addr, &provider);
62
63    let plan = build_plan(&bond, operator, cb_addr, args.multiaddrs.len(), &packed).await?;
64
65    let json = args.chain.common.json;
66    if args.chain.common.dry_run {
67        let mut out = io::stdout().lock();
68        write_plan(&mut out, &plan, json, None, true).context("failed to write dry-run output")?;
69        return Ok(());
70    }
71
72    // Pre-flight rather than a bare revert: name which guardrail would trip and
73    // report its exact chain-read bound. The check order mirrors the contract's
74    // (active → size → cooldown) so the first failing gate is the one reported.
75    ensure_submittable(&plan)?;
76
77    // Caller-owned slot (#1355): from the send onward the transaction is
78    // broadcast and may take effect, so an unreadable receipt must still print
79    // the hash rather than read as "nothing happened".
80    let mut tx = None;
81    let result = chain_ctx::send(
82        bond.updateMultiaddrs(Bytes::from(packed)),
83        "updateMultiaddrs",
84        Some(
85            "the node must be active and within the maxMultiaddrSize ceiling, and the \
86             multiaddr-update cooldown must have elapsed",
87        ),
88        &mut tx,
89    )
90    .await;
91
92    let mut out = io::stdout().lock();
93    write_plan(&mut out, &plan, json, tx.as_ref(), false).context("failed to write result")?;
94    drop(out);
95
96    result.map(|_| ())
97}
98
99/// What `updateMultiaddrs` will publish and the guardrails it must clear, read
100/// from the chain before anything is sent. Owned so [`write_plan`] and
101/// [`ensure_submittable`] are testable without a chain.
102#[derive(Clone, Debug, PartialEq, Eq)]
103pub(crate) struct Plan {
104    /// The `CapacityBond` the transaction targets.
105    pub(crate) capacity_bond: Address,
106    /// The operator Ethereum address signing the update.
107    pub(crate) operator: Address,
108    /// The node id whose addresses are being (re)published. Zero when the
109    /// operator has no registration (which also makes `active` false).
110    pub(crate) node_id: B256,
111    /// Whether the operator is in the active set — the gate `updateMultiaddrs`
112    /// enforces first.
113    pub(crate) active: bool,
114    /// Number of multiaddr strings being published.
115    pub(crate) multiaddr_count: usize,
116    /// Packed byte length of the `multiaddrs` field the transaction carries.
117    pub(crate) packed_size: u64,
118    /// Governable `maxMultiaddrSize` ceiling (bytes) the packed size must not
119    /// exceed.
120    pub(crate) max_multiaddr_size: u64,
121    /// `lastMultiaddrUpdate` timestamp (unix seconds); `0` when never updated.
122    pub(crate) last_update: u64,
123    /// Governable `multiaddrUpdateCooldown` (seconds) between updates.
124    pub(crate) cooldown_secs: u64,
125    /// Earliest timestamp a new update is accepted: `last_update +
126    /// cooldown_secs`.
127    pub(crate) ready_at: u64,
128    /// Head block timestamp — the clock the contract compares `ready_at`
129    /// against, read here so the pre-check and the on-chain check agree.
130    pub(crate) head_timestamp: u64,
131}
132
133/// Read the operator's registry state and the two governable guardrails, plus
134/// the head block timestamp the cooldown is measured against.
135async fn build_plan<P: Provider + Clone>(
136    bond: &CapacityBond::CapacityBondInstance<P>,
137    operator: Address,
138    capacity_bond: Address,
139    multiaddr_count: usize,
140    packed: &[u8],
141) -> anyhow::Result<Plan> {
142    let ctx = |what: &str| format!("failed to read {what} from CapacityBond at {capacity_bond}");
143
144    let info = bond
145        .getNodeByAddress(operator)
146        .call()
147        .await
148        .with_context(|| ctx("getNodeByAddress"))?;
149    let max_multiaddr_size = bond
150        .maxMultiaddrSize()
151        .call()
152        .await
153        .with_context(|| ctx("maxMultiaddrSize"))?;
154    let cooldown = bond
155        .multiaddrUpdateCooldown()
156        .call()
157        .await
158        .with_context(|| ctx("multiaddrUpdateCooldown"))?;
159    let head_timestamp = chain_ctx::head_timestamp(bond.provider()).await?;
160
161    // Saturating rather than `try_into`: both are governance parameters bounded
162    // far below `u64::MAX`, so a value past `u64` is unreachable — and failing
163    // the whole command on an unreachable read would be worse than clamping.
164    let max_multiaddr_size: u64 = max_multiaddr_size.saturating_to();
165    let cooldown_secs: u64 = cooldown.saturating_to();
166    let ready_at = info.lastMultiaddrUpdate.saturating_add(cooldown_secs);
167    // Packed length is a handful of KB in practice; `u64::MAX` on the
168    // impossible overflow keeps the ceiling check erring toward "too large".
169    let packed_size = u64::try_from(packed.len()).unwrap_or(u64::MAX);
170
171    Ok(Plan {
172        capacity_bond,
173        operator,
174        node_id: info.nodeId,
175        active: info.active,
176        multiaddr_count,
177        packed_size,
178        max_multiaddr_size,
179        last_update: info.lastMultiaddrUpdate,
180        cooldown_secs,
181        ready_at,
182        head_timestamp,
183    })
184}
185
186/// Reject empty or whitespace-only multiaddrs. clap enforces "at least one
187/// `--multiaddr`", but not that each is non-blank: `--multiaddr ""` (or `"  "`)
188/// packs to a zero-length on-chain entry no peer can dial — the relay-pinned
189/// footgun this command fixes, re-created. Pure so the guard is testable without
190/// a chain; runs before packing so nothing blank ever reaches the wire.
191pub(crate) fn reject_blank_multiaddrs(multiaddrs: &[String]) -> anyhow::Result<()> {
192    for (i, ma) in multiaddrs.iter().enumerate() {
193        anyhow::ensure!(
194            !ma.trim().is_empty(),
195            "--multiaddr #{} is empty or whitespace — publish a real QUIC address like \
196             `/ip4/203.0.113.10/udp/4433/quic-v1`, or omit it (at least one non-blank \
197             address is required).",
198            i + 1,
199        );
200    }
201    Ok(())
202}
203
204/// Refuse a run the contract would revert, naming the guardrail and its exact
205/// bound. Split from `run` so every branch is testable without a chain. The
206/// order matches the contract: `NodeNotActive`, then `MultiaddrsTooLarge`, then
207/// `MultiaddrCooldownActive`.
208pub(crate) fn ensure_submittable(plan: &Plan) -> anyhow::Result<()> {
209    anyhow::ensure!(
210        plan.active,
211        "this operator is not in the active node set — `updateMultiaddrs` would revert \
212         NodeNotActive. A registered node re-enters with `decdn node register`. An ejected \
213         operator cannot register (it would revert too) and instead exits the bond with \
214         `decdn node unbond --all`."
215    );
216    anyhow::ensure!(
217        plan.packed_size <= plan.max_multiaddr_size,
218        "packed multiaddrs are {} bytes, over the maxMultiaddrSize ceiling of {} bytes — \
219         `updateMultiaddrs` would revert MultiaddrsTooLarge. Publish fewer or shorter \
220         addresses.",
221        plan.packed_size,
222        plan.max_multiaddr_size,
223    );
224    if plan.head_timestamp < plan.ready_at {
225        let wait = plan.ready_at.saturating_sub(plan.head_timestamp);
226        anyhow::bail!(
227            "the multiaddr-update cooldown is active — `updateMultiaddrs` would revert \
228             MultiaddrCooldownActive. Last update was at {} (unix); the next is accepted at \
229             {} (unix), ~{} s from the current head at {}.",
230            plan.last_update,
231            plan.ready_at,
232            wait,
233            plan.head_timestamp,
234        );
235    }
236    Ok(())
237}
238
239/// Write the plan + outcome as JSON or grep-friendly `key=value` lines. Pure
240/// (`&mut impl Write`) so the output shape is unit-testable without a chain.
241///
242/// `dry_run` is carried explicitly rather than inferred from `tx.is_none()`,
243/// for the reason `deregister::write_plan` carries it: a real run whose send
244/// failed also has no tx, so the absence alone cannot mean "preview".
245pub(crate) fn write_plan(
246    w: &mut impl io::Write,
247    p: &Plan,
248    json: bool,
249    tx: Option<&B256>,
250    dry_run: bool,
251) -> io::Result<()> {
252    let tx_hex = tx.map(|v| format!("{v:#x}"));
253    if json {
254        let value = serde_json::json!({
255            "submitted": tx.is_some(),
256            "dry_run": dry_run,
257            "capacity_bond": format!("{:#x}", p.capacity_bond),
258            "operator": format!("{:#x}", p.operator),
259            "node_id": format!("{:#x}", p.node_id),
260            "active": p.active,
261            "multiaddrs": p.multiaddr_count,
262            "packed_bytes": p.packed_size,
263            "max_multiaddr_size": p.max_multiaddr_size,
264            "last_update": p.last_update,
265            "cooldown_secs": p.cooldown_secs,
266            "ready_at": p.ready_at,
267            "head_timestamp": p.head_timestamp,
268            "update_tx": tx_hex,
269        });
270        return writeln!(w, "{value}");
271    }
272    writeln!(w, "capacity_bond={:#x}", p.capacity_bond)?;
273    writeln!(w, "operator={:#x}", p.operator)?;
274    writeln!(w, "node_id={:#x}", p.node_id)?;
275    writeln!(w, "active={}", p.active)?;
276    writeln!(w, "multiaddrs={}", p.multiaddr_count)?;
277    writeln!(w, "packed_bytes={}", p.packed_size)?;
278    writeln!(w, "max_multiaddr_size={}", p.max_multiaddr_size)?;
279    writeln!(w, "last_update={}", p.last_update)?;
280    writeln!(w, "cooldown_secs={}", p.cooldown_secs)?;
281    writeln!(w, "ready_at={}", p.ready_at)?;
282    writeln!(w, "head_timestamp={}", p.head_timestamp)?;
283    match tx_hex {
284        Some(h) => writeln!(w, "update_tx={h}")?,
285        None => writeln!(w, "update_tx=skipped")?,
286    }
287    writeln!(w, "submitted={} dry_run={dry_run}", tx.is_some())
288}
289
290#[cfg(test)]
291#[allow(clippy::unwrap_used, clippy::expect_used)]
292mod tests;