Skip to main content

decdn_cli/commands/
appeal.rs

1//! `decdn appeal slash` — file a slash appeal and post the appeal bond
2//! (ADR 028 § Slashing Appeals).
3//!
4//! Operator-facing entry to the appeal state machine: read the governable
5//! `appealBond` from `SlashAppeal`, approve that TOKEN to the contract if the
6//! allowance is short, then `openSlashAppeal(slashId, evidenceBundleHash)`. The
7//! contract enforces the operator-only, 30-day-window, one-appeal-per-slash and
8//! 365-day frequency-cap rules; this command surfaces those reverts verbatim.
9//!
10//! The role-gated fast-track / grant / uphold actions are governance/multisig
11//! operations and are deliberately not exposed by the CLI.
12
13use std::io;
14use std::path::Path;
15
16use alloy::primitives::{Address, B256, U256};
17use alloy::providers::Provider;
18use anyhow::Context;
19use decdn_common::cli;
20use decdn_incentive::Erc20;
21use decdn_incentive::slash_appeal::SlashAppeal;
22
23use crate::commands::chain_ctx;
24
25/// Entry point for `decdn appeal slash`.
26pub async fn run(args: &cli::AppealSlashArgs, global_config: Option<&Path>) -> anyhow::Result<()> {
27    let config_path = args.chain.config.as_deref().or(global_config);
28    let file = chain_ctx::load_optional_config(config_path)?;
29    let resolved =
30        chain_ctx::resolve_appeal(&args.chain, args.slash_appeal_address.as_deref(), &file)?;
31    let sa_addr = resolved.slash_appeal_address;
32    let slash_id = parse_slash_id(&args.slash_id)?;
33    let evidence = parse_bytes32(&args.evidence_bundle_hash)?;
34
35    let signer = chain_ctx::load_operator_signer(&args.chain, &resolved.keystore).await?;
36    let operator = signer.address();
37    let provider = decdn_client::provider::build_provider(&resolved.rpc_url, &signer)?;
38    let appeal = SlashAppeal::new(sa_addr, &provider);
39
40    let plan = build_plan(&appeal, operator, slash_id, evidence, sa_addr).await?;
41
42    if args.chain.dry_run {
43        let mut out = io::stdout().lock();
44        write_plan(&mut out, &plan, args.chain.json, &Outcome::default(), true)
45            .context("failed to write dry-run output")?;
46        return Ok(());
47    }
48
49    let outcome = execute(&appeal, &provider, &plan, operator, sa_addr).await?;
50
51    let mut out = io::stdout().lock();
52    write_plan(&mut out, &plan, args.chain.json, &outcome, false)
53        .context("failed to write result")?;
54    Ok(())
55}
56
57/// What `appeal slash` intends to do, computed from chain state.
58pub(crate) struct Plan {
59    pub(crate) slash_id: U256,
60    pub(crate) slash_appeal: Address,
61    pub(crate) token: Address,
62    pub(crate) evidence: B256,
63    /// `appealBond()` under the current governable parameter (base units).
64    pub(crate) bond: U256,
65    /// Whether an `approve` is needed (current allowance below the bond).
66    pub(crate) needs_approve: bool,
67}
68
69/// Transaction hashes from a non-dry run; `None` for skipped steps.
70#[derive(Default)]
71pub(crate) struct Outcome {
72    pub(crate) approve: Option<B256>,
73    pub(crate) open: Option<B256>,
74}
75
76/// Read chain state and compute the appeal plan.
77pub(crate) async fn build_plan<P: Provider + Clone>(
78    appeal: &SlashAppeal::SlashAppealInstance<P>,
79    operator: Address,
80    slash_id: U256,
81    evidence: B256,
82    sa_addr: Address,
83) -> anyhow::Result<Plan> {
84    let ctx = || format!("failed to read SlashAppeal state at {sa_addr}");
85    let bond = appeal.appealBond().call().await.with_context(ctx)?;
86    let token = appeal.token().call().await.with_context(ctx)?;
87
88    let allowance = Erc20::new(token, appeal.provider())
89        .allowance(operator, sa_addr)
90        .call()
91        .await
92        .with_context(|| format!("failed to read TOKEN allowance from {token}"))?;
93
94    Ok(Plan {
95        slash_id,
96        slash_appeal: sa_addr,
97        token,
98        evidence,
99        bond,
100        needs_approve: allowance < bond,
101    })
102}
103
104/// Submit the needed transactions: approve (if allowance short) → openSlashAppeal.
105pub(crate) async fn execute<P: Provider + Clone>(
106    appeal: &SlashAppeal::SlashAppealInstance<P>,
107    provider: P,
108    plan: &Plan,
109    operator: Address,
110    sa_addr: Address,
111) -> anyhow::Result<Outcome> {
112    let mut outcome = Outcome::default();
113
114    let token = Erc20::new(plan.token, provider);
115    // Balance check before any send so a doomed run fails clean.
116    let balance = token
117        .balanceOf(operator)
118        .call()
119        .await
120        .with_context(|| format!("failed to read TOKEN balance from {}", plan.token))?;
121    anyhow::ensure!(
122        balance >= plan.bond,
123        "insufficient TOKEN: the appeal bond is {} base units, hold {}",
124        plan.bond,
125        balance,
126    );
127
128    if plan.needs_approve {
129        decdn_incentive::tx::send(
130            token.approve(sa_addr, plan.bond),
131            "approve",
132            None,
133            &mut outcome.approve,
134        )
135        .await?;
136    }
137
138    decdn_incentive::tx::send(
139        appeal.openSlashAppeal(plan.slash_id, plan.evidence),
140        "openSlashAppeal",
141        Some(
142            "is the caller the slashed operator, within the 30-day window, with no existing \
143             appeal and no active 365-day cap?",
144        ),
145        &mut outcome.open,
146    )
147    .await?;
148
149    Ok(outcome)
150}
151
152/// Parse a `uint256` slash id from a decimal string. Kept as `U256` (not
153/// `u64`) because the on-chain `slashId` can exceed `u64::MAX`.
154pub(crate) fn parse_slash_id(s: &str) -> anyhow::Result<U256> {
155    s.parse::<U256>()
156        .map_err(|e| anyhow::anyhow!("invalid slash id {s:?}: expected a uint256 decimal: {e}"))
157}
158
159/// Parse a 0x-prefixed 32-byte hex string into a `B256` (the evidence bundle
160/// hash). Rejects the wrong length up front with a labelled error.
161pub(crate) fn parse_bytes32(s: &str) -> anyhow::Result<B256> {
162    s.parse::<B256>().map_err(|e| {
163        anyhow::anyhow!(
164            "invalid evidence bundle hash {s:?}: expected a 0x-prefixed 32-byte (64 hex char) \
165             value: {e}"
166        )
167    })
168}
169
170/// Write the plan + outcome as JSON or grep-friendly `key=value` lines. Pure
171/// (`&mut impl Write`) so the output shape is unit-testable without a chain.
172pub(crate) fn write_plan(
173    w: &mut impl io::Write,
174    p: &Plan,
175    json: bool,
176    o: &Outcome,
177    dry_run: bool,
178) -> io::Result<()> {
179    let submitted = o.approve.is_some() || o.open.is_some();
180    let tx_hex = |h: Option<&B256>| h.map(|v| format!("{v:#x}"));
181    if json {
182        let value = serde_json::json!({
183            "submitted": submitted,
184            "dry_run": dry_run,
185            "slash_id": p.slash_id.to_string(),
186            "slash_appeal": format!("{:#x}", p.slash_appeal),
187            "token": format!("{:#x}", p.token),
188            "evidence_bundle_hash": format!("{:#x}", p.evidence),
189            "appeal_bond_base": p.bond.to_string(),
190            "needs_approve": p.needs_approve,
191            "approve_tx": tx_hex(o.approve.as_ref()),
192            "open_tx": tx_hex(o.open.as_ref()),
193        });
194        return writeln!(w, "{value}");
195    }
196    writeln!(w, "slash_id={}", p.slash_id)?;
197    writeln!(w, "slash_appeal={:#x}", p.slash_appeal)?;
198    writeln!(w, "token={:#x}", p.token)?;
199    writeln!(w, "evidence_bundle_hash={:#x}", p.evidence)?;
200    writeln!(w, "appeal_bond_base={}", p.bond)?;
201    writeln!(w, "needs_approve={}", p.needs_approve)?;
202    write_tx_line(w, "approve_tx", o.approve.as_ref())?;
203    write_tx_line(w, "open_tx", o.open.as_ref())?;
204    writeln!(w, "submitted={submitted} dry_run={dry_run}")
205}
206
207/// One `<key>=<tx|skipped>` line. A skipped step (allowance already sufficient)
208/// prints `skipped`.
209fn write_tx_line(w: &mut impl io::Write, key: &str, tx: Option<&B256>) -> io::Result<()> {
210    match tx {
211        Some(h) => writeln!(w, "{key}={h:#x}"),
212        None => writeln!(w, "{key}=skipped"),
213    }
214}
215
216#[cfg(test)]
217#[allow(clippy::unwrap_used, clippy::expect_used)]
218mod tests;