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;