Skip to main content

decdn_cli/commands/
chain_ctx.rs

1//! Shared plumbing for the on-chain `node` subcommands (`register`, `bond`).
2//!
3//! Resolves the blockchain coordinates (flag > `[blockchain]`/`[identity]`
4//! TOML config > default) and loads the operator's keystore signer. Commands
5//! then build a wallet-filled provider with
6//! [`decdn_client::provider::build_provider`] and construct their
7//! `CapacityBond` instance against it.
8
9use std::io;
10use std::path::{Path, PathBuf};
11
12use alloy::primitives::Address;
13use alloy::providers::Provider;
14use alloy::signers::local::PrivateKeySigner;
15use anyhow::Context;
16use decdn_common::cli;
17use decdn_common::cli::common::expand_tilde;
18use decdn_common::config::DEFAULT_CHAIN_ID;
19use decdn_incentive::eth_identity::{self, PasswordSource, PasswordUse, ResolvedPassword};
20use serde::Deserialize;
21
22/// Partial deserializer for the TOML config — only the `[blockchain]`,
23/// `[identity]`, and `network.bind_port` fields these commands need.
24/// Deliberately partial (like `node.rs`'s `AdminPortConfig`) so an operator's
25/// typo in an unrelated section can't block onboarding; serde-toml ignores
26/// unknown fields.
27#[derive(Debug, Default, Deserialize)]
28pub struct FileConfig {
29    blockchain: Option<FileBlockchain>,
30    identity: Option<FileIdentity>,
31    network: Option<FileNetwork>,
32}
33
34impl FileConfig {
35    /// The daemon's QUIC bind port, resolved as the daemon resolves it (see
36    /// [`decdn_common::config::configured_bind_port`]).
37    pub fn bind_port(&self) -> u16 {
38        decdn_common::config::configured_bind_port(self.network.as_ref().and_then(|n| n.bind_port))
39    }
40}
41
42#[derive(Debug, Default, Deserialize)]
43struct FileNetwork {
44    bind_port: Option<u16>,
45}
46
47#[derive(Debug, Default, Deserialize)]
48struct FileBlockchain {
49    rpc_url: Option<String>,
50    chain_id: Option<u64>,
51    capacity_bond_address: Option<String>,
52    eth_keystore: Option<PathBuf>,
53    publisher_registry_address: Option<String>,
54    origin_assignment_address: Option<String>,
55    slash_appeal_address: Option<String>,
56}
57
58#[derive(Debug, Default, Deserialize)]
59struct FileIdentity {
60    data_dir: Option<PathBuf>,
61}
62
63/// Coordinates resolved from flags > config file > defaults.
64#[derive(Debug)]
65pub struct Resolved {
66    /// JSON-RPC endpoint the command talks to.
67    pub rpc_url: String,
68    /// EVM chain id, checked against what the endpoint reports.
69    pub chain_id: u64,
70    /// `CapacityBond` address the bond and unbond commands act on.
71    pub capacity_bond_address: Address,
72    /// Path to the operator's Ethereum keystore file.
73    pub keystore: PathBuf,
74    /// Node data directory; the keystore and node key default to paths
75    /// under it.
76    pub data_dir: PathBuf,
77}
78
79/// Load the partial config from `config_path` (or the `--config` flag folded
80/// in by the caller). An explicit path that is missing/unreadable is an error;
81/// the default path simply falls through to an empty config (every field can
82/// also come from a flag).
83pub fn load_optional_config(config_path: Option<&Path>) -> anyhow::Result<FileConfig> {
84    let (path, explicit) = match config_path {
85        Some(p) => (expand_tilde(p), true),
86        None => match cli::common::default_config_path() {
87            Some(p) => (p, false),
88            None => return Ok(FileConfig::default()),
89        },
90    };
91    match std::fs::read_to_string(&path) {
92        Ok(contents) => toml::from_str(&contents)
93            .with_context(|| format!("failed to parse config file {}", path.display())),
94        Err(e) if e.kind() == io::ErrorKind::NotFound && !explicit => Ok(FileConfig::default()),
95        Err(e) => {
96            Err(anyhow::Error::new(e)
97                .context(format!("failed to read config file {}", path.display())))
98        }
99    }
100}
101
102/// Resolve the `rpc_url` / `chain_id` / `data_dir` / `keystore` fields shared by
103/// every on-chain command with the same flag > config > default precedence.
104/// Kept separate so [`resolve`] and [`resolve_appeal`] can't drift on this
105/// chain. `expand_tilde` is applied to whichever explicit path wins (flag OR
106/// config); the `default_data_dir` fallback is already absolute.
107fn resolve_common(
108    chain: &cli::CommonChainArgs,
109    file: &FileConfig,
110) -> anyhow::Result<(String, u64, PathBuf, PathBuf)> {
111    let bc = file.blockchain.as_ref();
112    let rpc_url = chain
113        .rpc_url
114        .clone()
115        .or_else(|| bc.and_then(|b| b.rpc_url.clone()))
116        .ok_or_else(|| {
117            anyhow::anyhow!("rpc_url not set (pass --rpc-url or set blockchain.rpc_url)")
118        })?;
119    let chain_id = chain
120        .chain_id
121        .or_else(|| bc.and_then(|b| b.chain_id))
122        .unwrap_or(DEFAULT_CHAIN_ID);
123    let data_dir = chain
124        .data_dir
125        .clone()
126        .or_else(|| file.identity.as_ref().and_then(|i| i.data_dir.clone()))
127        .map(|p| expand_tilde(&p))
128        .or_else(cli::default_data_dir)
129        .ok_or_else(|| {
130            anyhow::anyhow!("data_dir not set and no default available (pass --data-dir)")
131        })?;
132    let keystore = chain
133        .keystore
134        .clone()
135        .or_else(|| bc.and_then(|b| b.eth_keystore.clone()))
136        .map_or_else(
137            || eth_identity::keystore_path(&data_dir),
138            |p| expand_tilde(&p),
139        );
140    Ok((rpc_url, chain_id, data_dir, keystore))
141}
142
143/// Resolve the chain coordinates with flag > config > default precedence.
144/// Pure so the precedence is unit-testable.
145pub fn resolve(chain: &cli::ChainArgs, file: &FileConfig) -> anyhow::Result<Resolved> {
146    let bc = file.blockchain.as_ref();
147    // `resolve_common` first so `rpc_url` is the first missing-field reported
148    // (preserves the original error priority before the shared extraction).
149    let (rpc_url, chain_id, data_dir, keystore) = resolve_common(&chain.common, file)?;
150    let capacity_bond_raw = chain
151        .capacity_bond_address
152        .clone()
153        .or_else(|| bc.and_then(|b| b.capacity_bond_address.clone()))
154        .ok_or_else(|| {
155            anyhow::anyhow!(
156                "capacity_bond_address not set (pass --capacity-bond-address or set \
157                 blockchain.capacity_bond_address)"
158            )
159        })?;
160    let capacity_bond_address = parse_nonzero_address(&capacity_bond_raw, "capacity_bond_address")?;
161    Ok(Resolved {
162        rpc_url,
163        chain_id,
164        capacity_bond_address,
165        keystore,
166        data_dir,
167    })
168}
169
170/// Coordinates for `decdn appeal slash`, resolved from flags > config >
171/// defaults. Requires `rpc_url` + `slash_appeal_address`; unlike [`Resolved`]
172/// it does *not* require `capacity_bond_address` (the appeal path doesn't touch
173/// `CapacityBond` directly).
174#[derive(Debug)]
175pub struct ResolvedAppeal {
176    /// JSON-RPC endpoint the command talks to.
177    pub rpc_url: String,
178    /// EVM chain id, checked against what the endpoint reports.
179    pub chain_id: u64,
180    /// `SlashAppeal` address the appeal is filed against.
181    pub slash_appeal_address: Address,
182    /// Path to the operator's Ethereum keystore file.
183    pub keystore: PathBuf,
184    /// Node data directory; the keystore and node key default to paths
185    /// under it.
186    pub data_dir: PathBuf,
187}
188
189/// Resolve appeal-command coordinates. Pure so precedence is unit-testable.
190/// `slash_appeal_flag` is the command's `--slash-appeal-address` override.
191pub fn resolve_appeal(
192    chain: &cli::CommonChainArgs,
193    slash_appeal_flag: Option<&str>,
194    file: &FileConfig,
195) -> anyhow::Result<ResolvedAppeal> {
196    let bc = file.blockchain.as_ref();
197    let slash_appeal_raw = slash_appeal_flag
198        .map(str::to_string)
199        .or_else(|| bc.and_then(|b| b.slash_appeal_address.clone()))
200        .ok_or_else(|| {
201            anyhow::anyhow!(
202                "slash_appeal_address not set (pass --slash-appeal-address or set \
203                 blockchain.slash_appeal_address)"
204            )
205        })?;
206    // Reject the zero address via the shared guard (#1153) — see
207    // `parse_nonzero_address`; each of the four addresses resolved here and in
208    // `resolve` / `resolve_publish` gets the same early, labelled rejection.
209    let slash_appeal_address = parse_nonzero_address(&slash_appeal_raw, "slash_appeal_address")?;
210    let (rpc_url, chain_id, data_dir, keystore) = resolve_common(chain, file)?;
211    Ok(ResolvedAppeal {
212        rpc_url,
213        chain_id,
214        slash_appeal_address,
215        keystore,
216        data_dir,
217    })
218}
219
220// Address parsing lives in `decdn-common` so the zero-address guard is shared,
221// not duplicated, across the daemon, this CLI, and `decdn-incentive` (#1219).
222// Re-exported here so the `chain_ctx::parse_address` / `chain_ctx::parse_nonzero_address`
223// call sites across the CLI keep resolving unchanged.
224pub use decdn_common::address::{parse_address, parse_nonzero_address};
225
226/// Load the operator's Ethereum keystore signer, sourcing the password per
227/// `password_sources` — the same precedence the daemon uses.
228///
229/// `load_signer` runs the keystore's scrypt KDF, which is CPU-heavy
230/// (hundreds of ms); it is offloaded to `spawn_blocking` so it doesn't stall
231/// the async executor, matching `decdn-node`'s runtime keystore load.
232pub async fn load_operator_signer(
233    chain: &cli::CommonChainArgs,
234    keystore: &Path,
235) -> anyhow::Result<PrivateKeySigner> {
236    load_signer_with_password_file(chain.keystore_password_file.as_deref(), keystore).await
237}
238
239/// CLI-boundary wrapper over [`decdn_incentive::eth_identity::standard_sources`],
240/// the shared keystore password source list. Precedence, presence semantics,
241/// and `usage` are documented there; the `decdn-node` daemon reaches the same
242/// builder directly.
243///
244/// The delta this wrapper adds is tilde expansion, so callers passing the raw
245/// clap value (`key-gen`, the operator commands) need not do it themselves.
246/// `fetch` and `pool` expand at their own `resolve_chain`, and `expand_tilde`
247/// leaves an already-expanded path alone, so the second pass costs them
248/// nothing. Expansion rewrites a leading `~` and nothing else: a relative path
249/// stays relative and resolves against the working directory when read.
250pub(crate) fn password_sources(
251    password_file: Option<&Path>,
252    usage: PasswordUse,
253) -> Vec<PasswordSource> {
254    eth_identity::standard_sources(password_file.map(expand_tilde), usage)
255}
256
257/// Resolve the keystore password and print any source warnings to stderr. The
258/// shared CLI entry point to [`eth_identity::read_password`]: `decdn-incentive`
259/// denies `print_stderr`, so it returns the warnings for a caller to surface,
260/// and the CLI is where a mistyped `--keystore-password-file` is typed.
261///
262/// Returns the full [`ResolvedPassword`] so a caller that must inspect the
263/// winning source — `key-gen`'s empty-password guard — still can; callers that
264/// only need the secret take [`ResolvedPassword::into_secret`].
265pub(crate) fn read_keystore_password(
266    sources: &[PasswordSource],
267    prompt_label: &str,
268) -> anyhow::Result<ResolvedPassword> {
269    let resolved = eth_identity::read_password(sources, prompt_label)?;
270    for warning in resolved.warnings() {
271        eprintln!("warning: {warning}");
272    }
273    Ok(resolved)
274}
275
276/// Load an Ethereum keystore signer, sourcing the password per
277/// `password_sources`. The scrypt KDF is offloaded to `spawn_blocking` so it
278/// doesn't stall the async executor.
279///
280/// `password_file` is the raw `--keystore-password-file` value; expansion is
281/// `password_sources`' job. Most callers arrive through
282/// [`load_operator_signer`], which pulls that path off the shared
283/// [`cli::CommonChainArgs`].
284pub async fn load_signer_with_password_file(
285    password_file: Option<&Path>,
286    keystore: &Path,
287) -> anyhow::Result<PrivateKeySigner> {
288    let password = read_keystore_password(
289        &password_sources(password_file, PasswordUse::Unlock),
290        "eth keystore password",
291    )?
292    .into_secret();
293    let keystore = keystore.to_path_buf();
294    let display = keystore.display().to_string();
295    tokio::task::spawn_blocking(move || eth_identity::load_signer(&keystore, &password))
296        .await
297        .context("keystore decryption task panicked")?
298        .with_context(|| format!("failed to load keystore at {display}"))
299}
300
301/// Publisher-command coordinates resolved from flags > config > defaults.
302/// The two contract addresses are optional here; each subcommand requires
303/// only the one it targets and errors with a specific message if it is unset.
304#[derive(Debug)]
305pub struct ResolvedPublish {
306    /// JSON-RPC endpoint the command talks to.
307    pub rpc_url: String,
308    /// EVM chain id, checked against what the endpoint reports.
309    pub chain_id: u64,
310    /// `PublisherRegistry` address. `None` when neither
311    /// `--publisher-registry-address` nor `blockchain.publisher_registry_address`
312    /// is set; a subcommand that needs it then errors naming the missing key.
313    pub publisher_registry_address: Option<Address>,
314    /// `OriginAssignment` address, optional on the same terms.
315    pub origin_assignment_address: Option<Address>,
316    /// Path to the operator's Ethereum keystore file.
317    pub keystore: PathBuf,
318    /// Node data directory; the keystore and node key default to paths
319    /// under it.
320    pub data_dir: PathBuf,
321}
322
323/// Resolve publisher-command coordinates. Pure so precedence is unit-testable.
324pub fn resolve_publish(
325    args: &cli::PublishChainArgs,
326    file: &FileConfig,
327) -> anyhow::Result<ResolvedPublish> {
328    let bc = file.blockchain.as_ref();
329    // Shared with `resolve` / `resolve_appeal` so the rpc_url/chain_id/
330    // data_dir/keystore precedence can't drift between the `node` and
331    // `publish` commands. Only the two contract addresses are publish-specific.
332    let (rpc_url, chain_id, data_dir, keystore) = resolve_common(&args.common, file)?;
333    let publisher_registry_address = args
334        .publisher_registry_address
335        .clone()
336        .or_else(|| bc.and_then(|b| b.publisher_registry_address.clone()))
337        .map(|raw| parse_nonzero_address(&raw, "publisher_registry_address"))
338        .transpose()?;
339    let origin_assignment_address = args
340        .origin_assignment_address
341        .clone()
342        .or_else(|| bc.and_then(|b| b.origin_assignment_address.clone()))
343        .map(|raw| parse_nonzero_address(&raw, "origin_assignment_address"))
344        .transpose()?;
345    Ok(ResolvedPublish {
346        rpc_url,
347        chain_id,
348        publisher_registry_address,
349        origin_assignment_address,
350        keystore,
351        data_dir,
352    })
353}
354
355/// The shared transaction submitter, re-exported so the on-chain `node`
356/// subcommands keep their `chain_ctx::send(..)` call shape. It lives in
357/// `decdn-incentive` so every on-chain write in that crate shares it (#1355). See
358/// [`decdn_incentive::tx::send`] for the `landed` contract, which callers must
359/// read carefully: an `Err` does **not** imply nothing landed.
360pub(crate) use decdn_incentive::tx::send;
361
362/// Head block timestamp: the clock a contract compares `block.timestamp`
363/// against. A pre-check that reads it, rather than the local clock, agrees
364/// with the contract even when this machine's clock drifts.
365///
366/// # Errors
367///
368/// Errors when the latest block cannot be read.
369pub(crate) async fn head_timestamp<P: Provider>(provider: &P) -> anyhow::Result<u64> {
370    let block = provider
371        .get_block(alloy::eips::BlockId::latest())
372        .await
373        .context("failed to read the latest block")?
374        .ok_or_else(|| anyhow::anyhow!("no latest block"))?;
375    Ok(block.header.timestamp)
376}
377
378#[cfg(test)]
379#[allow(clippy::unwrap_used, clippy::expect_used)]
380mod tests;