Skip to main content

decdn_cli/commands/
update_region.rs

1//! `decdn node update-region` — (re)attest the operator's region on-chain
2//! (ADR 030).
3//!
4//! An operator who ran `decdn node register` with the wrong `--region`, or whose
5//! node has physically relocated, needs to correct the on-chain `regionHint`
6//! every downstream reader (`getRegisteredNodes`, off-chain DHT locality
7//! ranking, ADR 030 blacklist-scope ripening) keys on. `register` cannot fix it
8//! (a second call reverts `NodeAlreadyRegistered`), and the only other route is a
9//! hand-encoded `cast send … updateRegion(string)`. This command is the
10//! one-command fix: it validates the code as ISO 3166-1 alpha-2, normalizes it,
11//! and submits `CapacityBond.updateRegion` from the operator Ethereum key.
12//!
13//! The region is validated CLI-side because the contract only length-checks the
14//! string — an unvalidated code would be stored verbatim and read back as "no
15//! locality information" by every consumer that parses it with
16//! [`Region::parse`]. The value fully REPLACES the on-chain `regionHint`.
17//!
18//! Like `decdn node update-multiaddrs`, the contract's guardrails are read and
19//! checked before the send so they surface as named errors rather than a bare
20//! `execution reverted`: `NodeNotActive` (the operator must be registered) and
21//! the `regionStabilityWindow` cooldown between updates (`RegionCooldownActive`).
22//! The 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. (The
24//! `MAX_REGION_HINT_BYTES` length ceiling needs no pre-check: a validated
25//! two-letter code never nears it.)
26
27use std::io;
28use std::path::Path;
29
30use alloy::primitives::{Address, B256};
31use alloy::providers::Provider;
32use anyhow::Context;
33use decdn_common::cli;
34use decdn_incentive::capacity_bond::CapacityBond;
35use decdn_protocol::Region;
36
37use crate::commands::chain_ctx;
38
39/// Entry point for `decdn node update-region`.
40pub async fn run(args: &cli::UpdateRegionArgs, global_config: Option<&Path>) -> anyhow::Result<()> {
41    let config_path = args.chain.common.config.as_deref().or(global_config);
42    let file = chain_ctx::load_optional_config(config_path)?;
43    let resolved = chain_ctx::resolve(&args.chain, &file)?;
44    let cb_addr = resolved.capacity_bond_address;
45
46    // Validate + normalize before any keystore decrypt or chain read: an invalid
47    // code is a local error, and the contract only length-checks, so a garbage
48    // string would be stored verbatim and read back as "no locality" by every
49    // downstream consumer.
50    let region = validate_region(&args.region)?;
51    let new_region = region.as_str().to_string();
52
53    let signer = chain_ctx::load_operator_signer(&args.chain.common, &resolved.keystore).await?;
54    let operator = signer.address();
55    let provider = decdn_client::provider::build_provider(&resolved.rpc_url, &signer)?;
56    let bond = CapacityBond::new(cb_addr, &provider);
57
58    let plan = build_plan(&bond, operator, cb_addr, new_region.clone()).await?;
59
60    let json = args.chain.common.json;
61    if args.chain.common.dry_run {
62        let mut out = io::stdout().lock();
63        write_plan(&mut out, &plan, json, None, true).context("failed to write dry-run output")?;
64        return Ok(());
65    }
66
67    // Pre-flight rather than a bare revert: name which guardrail would trip and
68    // report its exact chain-read bound. The check order mirrors the contract's
69    // (active → cooldown) so the first failing gate is the one reported.
70    ensure_submittable(&plan)?;
71
72    // Caller-owned slot (#1355): from the send onward the transaction is
73    // broadcast and may take effect, so an unreadable receipt must still print
74    // the hash rather than read as "nothing happened".
75    let mut tx = None;
76    let result = chain_ctx::send(
77        bond.updateRegion(new_region),
78        "updateRegion",
79        Some("the node must be active and the region-stability cooldown must have elapsed"),
80        &mut tx,
81    )
82    .await;
83
84    let mut out = io::stdout().lock();
85    write_plan(&mut out, &plan, json, tx.as_ref(), false).context("failed to write result")?;
86    drop(out);
87
88    result.map(|_| ())
89}
90
91/// What `updateRegion` will attest and the guardrails it must clear, read from
92/// the chain before anything is sent. Owned so [`write_plan`] and
93/// [`ensure_submittable`] are testable without a chain.
94#[derive(Clone, Debug, PartialEq, Eq)]
95pub(crate) struct Plan {
96    /// The `CapacityBond` the transaction targets.
97    pub(crate) capacity_bond: Address,
98    /// The operator Ethereum address signing the update.
99    pub(crate) operator: Address,
100    /// The node id whose region is being (re)attested. Zero when the operator
101    /// has no registration (which also makes `active` false).
102    pub(crate) node_id: B256,
103    /// Whether the operator is in the active set — the gate `updateRegion`
104    /// enforces after the length check.
105    pub(crate) active: bool,
106    /// The operator's current on-chain region (`regionHint`); empty when never
107    /// set.
108    pub(crate) current_region: String,
109    /// The normalized ISO 3166-1 alpha-2 code being attested.
110    pub(crate) new_region: String,
111    /// `regionLastChanged` timestamp (unix seconds); stamped at registration and
112    /// on every change, so `0` only for a never-registered operator.
113    pub(crate) last_changed: u64,
114    /// Governable `regionStabilityWindow` (seconds) between updates.
115    pub(crate) stability_window_secs: u64,
116    /// Earliest timestamp a new update is accepted: `last_changed +
117    /// stability_window_secs`.
118    pub(crate) ready_at: u64,
119    /// Head block timestamp — the clock the contract compares `ready_at`
120    /// against, read here so the pre-check and the on-chain check agree.
121    pub(crate) head_timestamp: u64,
122}
123
124/// Read the operator's registry state and region-attestation state, plus the
125/// head block timestamp the cooldown is measured against.
126async fn build_plan<P: Provider + Clone>(
127    bond: &CapacityBond::CapacityBondInstance<P>,
128    operator: Address,
129    capacity_bond: Address,
130    new_region: String,
131) -> anyhow::Result<Plan> {
132    let ctx = |what: &str| format!("failed to read {what} from CapacityBond at {capacity_bond}");
133
134    let info = bond
135        .getNodeByAddress(operator)
136        .call()
137        .await
138        .with_context(|| ctx("getNodeByAddress"))?;
139    let scope = bond
140        .regionScopeData(operator)
141        .call()
142        .await
143        .with_context(|| ctx("regionScopeData"))?;
144    let head_timestamp = chain_ctx::head_timestamp(bond.provider()).await?;
145
146    // Saturating rather than `try_into`: the stability window is a governance
147    // parameter bounded far below `u64::MAX`, so a value past `u64` is
148    // unreachable — and failing the whole command on an unreachable read would be
149    // worse than clamping.
150    let stability_window_secs: u64 = scope.regionStabilityWindow.saturating_to();
151    let last_changed = scope.regionLastChanged;
152    let ready_at = last_changed.saturating_add(stability_window_secs);
153
154    Ok(Plan {
155        capacity_bond,
156        operator,
157        node_id: info.nodeId,
158        active: info.active,
159        current_region: scope.regionHint,
160        new_region,
161        last_changed,
162        stability_window_secs,
163        ready_at,
164        head_timestamp,
165    })
166}
167
168/// Validate a region string as an ISO 3166-1 alpha-2 code and return the
169/// normalized [`Region`]. Pure so the guard is testable without a chain; runs
170/// before any keystore decrypt so an invalid code never reaches the wire. The
171/// same allowlist [`decdn node register --region`] and the `--region` discovery
172/// filter apply, so a code accepted here is one the network recognizes.
173pub(crate) fn validate_region(raw: &str) -> anyhow::Result<Region> {
174    Region::parse(raw).ok_or_else(|| {
175        anyhow::anyhow!(
176            "invalid --region {raw:?}: not an accepted ISO 3166-1 alpha-2 code (e.g. `DE`, `US`)",
177        )
178    })
179}
180
181/// Refuse a run the contract would revert, naming the guardrail and its exact
182/// bound. Split from `run` so every branch is testable without a chain. The order
183/// matches the contract's active → cooldown gates. (The `RegionHintTooLong`
184/// length ceiling is not checked here: [`validate_region`] guarantees a two-byte
185/// code, which never exceeds the ceiling.)
186pub(crate) fn ensure_submittable(plan: &Plan) -> anyhow::Result<()> {
187    anyhow::ensure!(
188        plan.active,
189        "this operator is not in the active node set — `updateRegion` would revert \
190         NodeNotActive. A registered node re-enters with `decdn node register`. An ejected \
191         operator cannot register (it would revert too) and instead exits the bond with \
192         `decdn node unbond --all`."
193    );
194    if plan.head_timestamp < plan.ready_at {
195        let wait = plan.ready_at.saturating_sub(plan.head_timestamp);
196        anyhow::bail!(
197            "the region-stability cooldown is active — `updateRegion` would revert \
198             RegionCooldownActive. The region last changed at {} (unix); the next change is \
199             accepted at {} (unix), ~{} s from the current head at {}.",
200            plan.last_changed,
201            plan.ready_at,
202            wait,
203            plan.head_timestamp,
204        );
205    }
206    Ok(())
207}
208
209/// Write the plan + outcome as JSON or grep-friendly `key=value` lines. Pure
210/// (`&mut impl Write`) so the output shape is unit-testable without a chain.
211///
212/// `dry_run` is carried explicitly rather than inferred from `tx.is_none()`, for
213/// the reason `update_multiaddrs::write_plan` carries it: a real run whose send
214/// failed also has no tx, so the absence alone cannot mean "preview".
215pub(crate) fn write_plan(
216    w: &mut impl io::Write,
217    p: &Plan,
218    json: bool,
219    tx: Option<&B256>,
220    dry_run: bool,
221) -> io::Result<()> {
222    let tx_hex = tx.map(|v| format!("{v:#x}"));
223    if json {
224        let value = serde_json::json!({
225            "submitted": tx.is_some(),
226            "dry_run": dry_run,
227            "capacity_bond": format!("{:#x}", p.capacity_bond),
228            "operator": format!("{:#x}", p.operator),
229            "node_id": format!("{:#x}", p.node_id),
230            "active": p.active,
231            "current_region": p.current_region,
232            "new_region": p.new_region,
233            "last_changed": p.last_changed,
234            "stability_window_secs": p.stability_window_secs,
235            "ready_at": p.ready_at,
236            "head_timestamp": p.head_timestamp,
237            "update_tx": tx_hex,
238        });
239        return writeln!(w, "{value}");
240    }
241    writeln!(w, "capacity_bond={:#x}", p.capacity_bond)?;
242    writeln!(w, "operator={:#x}", p.operator)?;
243    writeln!(w, "node_id={:#x}", p.node_id)?;
244    writeln!(w, "active={}", p.active)?;
245    // Empty when the operator never had a region set; render a sentinel rather
246    // than a blank value so the line is always present for scripts.
247    let current = if p.current_region.is_empty() {
248        "(unset)"
249    } else {
250        &p.current_region
251    };
252    writeln!(w, "current_region={current}")?;
253    writeln!(w, "new_region={}", p.new_region)?;
254    writeln!(w, "last_changed={}", p.last_changed)?;
255    writeln!(w, "stability_window_secs={}", p.stability_window_secs)?;
256    writeln!(w, "ready_at={}", p.ready_at)?;
257    writeln!(w, "head_timestamp={}", p.head_timestamp)?;
258    match tx_hex {
259        Some(h) => writeln!(w, "update_tx={h}")?,
260        None => writeln!(w, "update_tx=skipped")?,
261    }
262    writeln!(w, "submitted={} dry_run={dry_run}", tx.is_some())
263}
264
265#[cfg(test)]
266#[allow(clippy::unwrap_used, clippy::expect_used)]
267mod tests;