1use 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
25pub 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
57pub(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 pub(crate) bond: U256,
65 pub(crate) needs_approve: bool,
67}
68
69#[derive(Default)]
71pub(crate) struct Outcome {
72 pub(crate) approve: Option<B256>,
73 pub(crate) open: Option<B256>,
74}
75
76pub(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
104pub(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 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
152pub(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
159pub(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
170pub(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
207fn 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;