Skip to main content

decdn_cli/commands/
publish.rs

1//! `decdn publish` — origin-publisher control plane (issues #1029 / #1491).
2//!
3//! Submits the `PublisherRegistry` / `OriginAssignment` writes that create
4//! namespaces and then seat or unseat a publisher's authorized origins. Vetting
5//! is not a CLI step: a `VETTER_ROLE` holder on the installed vetting policy (an
6//! operator, multisig, or governance) vets the wallet out-of-band, after which
7//! `assign` and `revoke` take effect in the transaction that carries them
8//! (ADR 011 § Origin Assignment Authority).
9//! Content is bound to a namespace off-chain by the requester at fetch time
10//! (ADR 002 § Hash-to-namespace association), so there is no per-hash on-chain
11//! claim. Mirrors the
12//! on-chain-write pattern of `super::register`: resolve chain coordinates and
13//! parse the target contract address, then — on the submit path only — load
14//! the keystore signer, build a wallet-filled provider, submit, and print a
15//! JSON or `key=value` receipt. `--dry-run` stops after resolution: it loads
16//! no keystore and sends no transaction, printing just the resolved parameters.
17
18use std::io;
19// For `flush` on the stdout lock: a `LineWriter` can buffer a partial write and
20// fail at drop, where the error is unobservable.
21use std::io::Write as _;
22use std::path::Path;
23
24use alloy::primitives::{Address, B256, U256};
25use alloy::providers::{Provider, ProviderBuilder};
26use alloy::signers::local::PrivateKeySigner;
27use alloy::sol_types::SolEvent;
28use anyhow::Context;
29use decdn_common::cli;
30use decdn_incentive::origin_assignment::OriginAssignment;
31use decdn_incentive::publisher_registry::PublisherRegistry;
32use decdn_incentive::tx::SendOutcome;
33
34use crate::commands::chain_ctx;
35use crate::commands::chain_ctx::ResolvedPublish;
36
37/// Entry point for `decdn publish`.
38pub async fn publish_dispatch(
39    args: &cli::PublishArgs,
40    global_config: Option<&Path>,
41) -> anyhow::Result<()> {
42    match &args.command {
43        cli::PublishCommand::Namespace(ns) => match &ns.command {
44            cli::NamespaceCommand::Create(a) => namespace_create(a, global_config).await,
45        },
46        cli::PublishCommand::Assign(a) => assign(a, global_config).await,
47        cli::PublishCommand::Revoke(a) => revoke(a, global_config).await,
48    }
49}
50
51/// Resolve the publisher coordinates and parse the `PublisherRegistry`
52/// address. Used by `namespace create`; the signer and provider are built on
53/// the submit path by [`signer_and_provider`].
54fn registry_ctx(
55    chain: &cli::PublishChainArgs,
56    global_config: Option<&Path>,
57) -> anyhow::Result<(ResolvedPublish, Address)> {
58    let config_path = chain.common.config.as_deref().or(global_config);
59    let file = chain_ctx::load_optional_config(config_path)?;
60    let resolved = chain_ctx::resolve_publish(chain, &file)?;
61    let registry = resolved.publisher_registry_address.ok_or_else(|| {
62        anyhow::anyhow!(
63            "publisher_registry_address not set (pass \
64             --publisher-registry-address or set \
65             blockchain.publisher_registry_address)"
66        )
67    })?;
68    Ok((resolved, registry))
69}
70
71/// Resolve the publisher coordinates and parse the `OriginAssignment` address.
72/// The `assign` parallel of [`registry_ctx`]; the signer and provider are built
73/// on the submit path by [`signer_and_provider`].
74fn assignment_ctx(
75    chain: &cli::PublishChainArgs,
76    global_config: Option<&Path>,
77) -> anyhow::Result<(ResolvedPublish, Address)> {
78    let config_path = chain.common.config.as_deref().or(global_config);
79    let file = chain_ctx::load_optional_config(config_path)?;
80    let resolved = chain_ctx::resolve_publish(chain, &file)?;
81    let origin_assignment = resolved.origin_assignment_address.ok_or_else(|| {
82        anyhow::anyhow!(
83            "origin_assignment_address not set (pass --origin-assignment-address \
84             or set blockchain.origin_assignment_address)"
85        )
86    })?;
87    Ok((resolved, origin_assignment))
88}
89
90/// Preflight the RPC network, decrypt the keystore signer, and build the
91/// wallet-filled provider — the byte-identical submit preamble shared by every
92/// submitting subcommand. Called only on the submit path
93/// (never a dry run), so it always decrypts. The network is verified *before*
94/// touching secrets, so a wrong `--rpc-url` fails here rather than after an
95/// interactive password prompt + scrypt KDF.
96///
97/// Returns owned `(signer, provider)`: `build_provider` clones the signer into
98/// the wallet (`+ use<>`), so the provider borrows nothing and the caller can
99/// hold both as independent locals.
100async fn signer_and_provider(
101    resolved: &ResolvedPublish,
102    chain: &cli::PublishChainArgs,
103) -> anyhow::Result<(PrivateKeySigner, impl Provider + Clone)> {
104    preflight_chain_id(&resolved.rpc_url, resolved.chain_id).await?;
105    let signer = chain_ctx::load_operator_signer(&chain.common, &resolved.keystore).await?;
106    let provider = decdn_client::provider::build_provider(&resolved.rpc_url, &signer)?;
107    Ok((signer, provider))
108}
109
110/// Reject duplicate operator addresses before submitting — the contract
111/// reverts `DuplicateOperator` (`OriginAssignment.addOrigin`) on an operator
112/// that is already seated, so failing fast on the client saves the gas of a
113/// doomed transaction *and* stops a duplicate from stranding the run half-way
114/// through the per-operator loop.
115fn ensure_unique_operators(ops: &[Address]) -> anyhow::Result<()> {
116    let mut seen = std::collections::HashSet::with_capacity(ops.len());
117    for op in ops {
118        if !seen.insert(*op) {
119            anyhow::bail!("duplicate operator address: {op:#x}");
120        }
121    }
122    Ok(())
123}
124
125/// Fail if the RPC reports a different chain id than the resolved one, so a
126/// mis-pointed `--rpc-url` cannot submit to the wrong network. Pure so it is
127/// unit-testable; [`ensure_rpc_chain_id`] does the single network read.
128fn chain_id_guard(expected: u64, rpc: u64) -> anyhow::Result<()> {
129    anyhow::ensure!(
130        expected == rpc,
131        "chain id mismatch: --chain-id/config expects {expected} but the RPC reports {rpc} \
132         (check --rpc-url points at the right network, or pass --chain-id {rpc})",
133    );
134    Ok(())
135}
136
137/// Read the RPC's chain id and enforce it matches `expected` before submitting.
138async fn ensure_rpc_chain_id<P: alloy::providers::Provider>(
139    provider: &P,
140    expected: u64,
141) -> anyhow::Result<()> {
142    let rpc = provider
143        .get_chain_id()
144        .await
145        .context("failed to read chainId from the RPC (is --rpc-url reachable?)")?;
146    chain_id_guard(expected, rpc)
147}
148
149/// Verify the RPC network matches `expected` using a read-only (wallet-less)
150/// provider, *before* any keystore decryption — so a mis-pointed `--rpc-url`
151/// fails fast without an interactive password prompt or the scrypt KDF. The
152/// `rpc_url` value is never echoed into the parse error (it commonly carries an
153/// API key), matching [`decdn_client::provider::build_provider`].
154async fn preflight_chain_id(rpc_url: &str, expected: u64) -> anyhow::Result<()> {
155    let provider = ProviderBuilder::new().connect_http(rpc_url.parse().with_context(|| {
156        format!(
157            "rpc_url is not a valid URL (<redacted>, {} chars)",
158            rpc_url.len()
159        )
160    })?);
161    ensure_rpc_chain_id(&provider, expected).await
162}
163
164// -------------------------------------------------------------------------
165// namespace create
166// -------------------------------------------------------------------------
167
168/// The outcome of `namespace create`'s single transaction — the five mutually
169/// exclusive states as one value, so text and JSON rendering derive from one
170/// source of truth and cannot contradict each other. Modeled
171/// on [`SeatOutcome`]: an illegal combination (a hash on a dry run, a `created`
172/// with no hash) is unrepresentable rather than merely unreached.
173#[derive(Debug, Clone, Copy, PartialEq, Eq)]
174pub(crate) enum NamespaceStatus {
175    /// `--dry-run`: nothing was sent, and no keystore was loaded.
176    DryRun,
177    /// Nothing was minted: the node rejected the send before broadcast. Distinct
178    /// from `Reverted` (which mined) and `MaybeBroadcast` (whose fate is unknown).
179    Failed,
180    /// A transport-class send failure (timeout, reset, 5xx). The request may have
181    /// reached the node and minted a namespace, but no hash was captured, so the
182    /// outcome is unknown and a blind retry can burn quota against
183    /// `maxNamespacesPerPublisher` (#1577).
184    MaybeBroadcast,
185    /// The transaction was broadcast but its receipt was unreadable, so a
186    /// namespace may or may not exist. Keeps the hash — the only handle on it.
187    InFlight(B256),
188    /// Mined, but the call reverted: it burned gas and minted nothing. Keeps its
189    /// hash as a permanent handle, so a gas-reconciling consumer can find it —
190    /// distinct from `Failed`, which never broadcast (#1550).
191    Reverted(B256),
192    /// `createNamespace` confirmed. `id` is `None` only when the receipt carried
193    /// no decodable `NamespaceCreated` log; the tx hash is then the sole handle
194    /// on the live namespace, so it is never dropped.
195    Created { tx: B256, id: Option<u64> },
196}
197
198impl NamespaceStatus {
199    /// The transaction hash, for any state that has one.
200    const fn tx(self) -> Option<B256> {
201        match self {
202            Self::InFlight(hash) | Self::Reverted(hash) | Self::Created { tx: hash, .. } => {
203                Some(hash)
204            }
205            Self::DryRun | Self::Failed | Self::MaybeBroadcast => None,
206        }
207    }
208
209    /// The minted namespace id, known only when the create confirmed and its
210    /// `NamespaceCreated` log decoded. `Some` therefore implies `status ==
211    /// "created"`, which is the invariant a `--json` consumer relies on.
212    const fn namespace_id(self) -> Option<u64> {
213        match self {
214            Self::Created { id, .. } => id,
215            Self::DryRun
216            | Self::Failed
217            | Self::MaybeBroadcast
218            | Self::InFlight(_)
219            | Self::Reverted(_) => None,
220        }
221    }
222
223    /// Whether the transaction was broadcast, for the `submitted` field:
224    /// `Some(true)`/`Some(false)` when it is known, and `None` for
225    /// `MaybeBroadcast`, where it is exactly what cannot be answered. A flat
226    /// `false` there is the #1577 lie.
227    const fn submitted(self) -> Option<bool> {
228        match self {
229            Self::MaybeBroadcast => None,
230            Self::InFlight(_) | Self::Reverted(_) | Self::Created { .. } => Some(true),
231            Self::DryRun | Self::Failed => Some(false),
232        }
233    }
234
235    /// The `status=` token in the receipt.
236    const fn label(self) -> &'static str {
237        match self {
238            Self::DryRun => "dry_run",
239            Self::Failed => "failed",
240            Self::MaybeBroadcast => "maybe_broadcast",
241            Self::InFlight(_) => "unknown",
242            Self::Reverted(_) => "reverted",
243            Self::Created { .. } => "created",
244        }
245    }
246}
247
248/// The `submitted=` token for a single-tx text receipt whose transaction has no
249/// hash: a definite `true`/`false`, or `unknown` for a transport failure whose
250/// broadcast cannot be determined.
251const fn submitted_token(submitted: Option<bool>) -> &'static str {
252    match submitted {
253        Some(true) => "true",
254        Some(false) => "false",
255        None => "unknown",
256    }
257}
258
259/// Result of `namespace create`. `operator` is `None` on a dry run (which loads
260/// no keystore, so the signer address is unknown).
261pub(crate) struct NamespaceOutcome {
262    pub(crate) operator: Option<Address>,
263    pub(crate) registry: Address,
264    pub(crate) status: NamespaceStatus,
265}
266
267pub(crate) fn write_namespace_outcome(
268    w: &mut impl io::Write,
269    o: &NamespaceOutcome,
270    json: bool,
271) -> io::Result<()> {
272    let tx = o.status.tx();
273    let namespace_id = o.status.namespace_id();
274    if json {
275        let value = serde_json::json!({
276            "submitted": o.status.submitted(),
277            "tx": tx.map(|h| format!("{h:#x}")),
278            "operator": o.operator.map(|a| format!("{a:#x}")),
279            "publisher_registry": format!("{:#x}", o.registry),
280            "namespace_id": namespace_id,
281            "status": o.status.label(),
282        });
283        return writeln!(w, "{value}");
284    }
285    if let Some(op) = o.operator {
286        writeln!(w, "operator={op:#x}")?;
287    }
288    writeln!(w, "publisher_registry={:#x}", o.registry)?;
289    // Its own line, like `ready_at`: the id is absent whenever it could not be
290    // decoded, and `status` + `tx` still have to print without it.
291    if let Some(id) = namespace_id {
292        writeln!(w, "namespace_id={id}")?;
293    }
294    match tx {
295        Some(h) => writeln!(w, "status={} tx={h:#x}", o.status.label()),
296        None => writeln!(
297            w,
298            "status={} submitted={}",
299            o.status.label(),
300            submitted_token(o.status.submitted())
301        ),
302    }
303}
304
305async fn namespace_create(
306    args: &cli::NamespaceCreateArgs,
307    global_config: Option<&Path>,
308) -> anyhow::Result<()> {
309    let (resolved, registry) = registry_ctx(&args.chain, global_config)?;
310    let dry_run = args.chain.common.dry_run;
311    let mut operator = None;
312    let mut status = NamespaceStatus::DryRun;
313    // `Ok` on the dry-run path: nothing was sent, so there is nothing to decode
314    // and nothing to report as failed. The 0 is never read — only `created.err()`
315    // is — which matters because 0 is the registry's reserved "no namespace" id,
316    // so it would be a plausible-looking lie if it ever reached the receipt.
317    let mut created: anyhow::Result<u64> = Ok(0);
318
319    if !dry_run {
320        // The keystore is decrypted only when actually submitting — a dry run
321        // needs no secrets.
322        let (signer, provider) = signer_and_provider(&resolved, &args.chain).await?;
323        operator = Some(signer.address());
324        let contract = PublisherRegistry::new(registry, &provider);
325        let mut recorded = SendOutcome::NotSent;
326        let sent = decdn_incentive::tx::send_for_receipt(
327            contract.createNamespace(),
328            "createNamespace",
329            Some("check the RPC, gas, and the registry address"),
330            &mut recorded,
331        )
332        .await;
333
334        status = match sent {
335            // Every decode failure below happens AFTER the namespace exists
336            // on-chain, so it must not short-circuit the receipt — the tx hash is
337            // the only handle the publisher has on a namespace whose id was lost,
338            // and a blind retry mints a second one against
339            // `maxNamespacesPerPublisher`. The receipt's hash always exists on a
340            // confirmed create, so `Created` carries it unconditionally.
341            Ok(receipt) => {
342                created = decode_created_namespace(&receipt);
343                NamespaceStatus::Created {
344                    tx: receipt.transaction_hash,
345                    id: created.as_ref().ok().copied(),
346                }
347            }
348            Err(err) => {
349                created = Err(err);
350                // `recorded` already distinguishes the failure shapes: a broadcast
351                // whose receipt was unreadable (`InFlight`, keeps its hash), a
352                // transport failure that may still have minted (`MaybeBroadcast`),
353                // a confirmed revert (`Reverted`, minted nothing but kept its
354                // hash), or a clean rejection (`Rejected` → `Failed`).
355                match recorded {
356                    SendOutcome::InFlight(hash) => NamespaceStatus::InFlight(hash),
357                    SendOutcome::Reverted(hash) => NamespaceStatus::Reverted(hash),
358                    SendOutcome::MaybeBroadcast => NamespaceStatus::MaybeBroadcast,
359                    SendOutcome::Rejected | SendOutcome::Confirmed(_) | SendOutcome::NotSent => {
360                        NamespaceStatus::Failed
361                    }
362                }
363            }
364        };
365    }
366
367    let outcome = NamespaceOutcome {
368        operator,
369        registry,
370        status,
371    };
372    let mut out = io::stdout().lock();
373    // `flush` explicitly: `Stdout` is a `LineWriter`, so a partial write can
374    // buffer the tail and fail at `drop`, where the result is unobservable — a
375    // truncated receipt reported as a clean one.
376    let write_err = write_namespace_outcome(&mut out, &outcome, args.chain.common.json)
377        .and_then(|()| out.flush())
378        .err();
379    drop(out);
380    let context = namespace_failure_context(outcome.status);
381    propagate(
382        created.err().map(|err| match context {
383            Some(note) => err.context(note),
384            None => err,
385        }),
386        write_err,
387        "failed to write namespace-create output",
388        &single_tx_receipt(
389            outcome
390                .status
391                .namespace_id()
392                .map(|id| format!("namespace_id={id}")),
393            outcome.status.tx(),
394        ),
395    )
396}
397
398/// Extra guidance to attach to a `namespace create` failure, or `None` when the
399/// underlying error already says everything true.
400///
401/// A namespace cannot be un-created, and creating it already incremented
402/// `namespaceCount` against `maxNamespacesPerPublisher` — so an operator who
403/// retries blind burns quota to mint a second one while the first stays owned
404/// with its id still unrecovered. That warning is only honest once something
405/// reached the chain: a rejected send minted nothing and has no hash to point
406/// at. It is also the modal failure, since `NamespaceCapReached` is
407/// `createNamespace`'s only revert and so surfaces from the pre-flight gas
408/// estimate, before anything is broadcast.
409const fn namespace_failure_context(status: NamespaceStatus) -> Option<&'static str> {
410    match status {
411        NamespaceStatus::InFlight(_) => Some(
412            "the transaction was broadcast — check the tx above before re-running, since a \
413             create that landed already counts against maxNamespacesPerPublisher",
414        ),
415        // A transport failure may have minted too, but has no hash to point at,
416        // so the note sends the operator to this signer's own transactions.
417        NamespaceStatus::MaybeBroadcast => Some(
418            "the request may have reached the node and broadcast the transaction — check this \
419             signer's pending and mined transactions before re-running, since a create that \
420             landed already counts against maxNamespacesPerPublisher",
421        ),
422        // Names a log query rather than the receipt's own log, and deliberately
423        // not `ownerOf`. `ownerOf` maps id → owner, so it needs the very id that
424        // was lost — it can confirm a guess, never produce one. An owner-filtered
425        // `eth_getLogs` can: `createNamespace` emits `NamespaceCreated`
426        // unconditionally and indexes both parameters, so the id arrives in a
427        // topic with no data decode. That holds for every failure this note rides
428        // along with, including a receipt that came back without the log — the
429        // log is still on-chain, it just was not in what the RPC returned.
430        NamespaceStatus::Created { .. } => Some(
431            "the namespace exists and is owned by the signer — recover its id by filtering the \
432             registry's NamespaceCreated logs for this signer rather than re-running, which \
433             mints a second namespace",
434        ),
435        // A rejected send or a confirmed revert minted nothing.
436        NamespaceStatus::DryRun | NamespaceStatus::Failed | NamespaceStatus::Reverted(_) => None,
437    }
438}
439
440/// Pull the minted id out of a confirmed `createNamespace` receipt.
441///
442/// The id comes from the emitted event: the return value is not in a receipt,
443/// and a static pre-call would race a concurrent create (`_nextNamespaceId` is
444/// global across publishers).
445///
446/// Match on `topic0` first, then decode, so a log that IS `NamespaceCreated` but
447/// whose payload does not match reports a decode failure instead of vanishing
448/// into "no such log". That covers an `indexed` change, which leaves the
449/// signature hash untouched; a drift that renames the event or changes a
450/// parameter type changes the hash itself and lands in the missing-log arm
451/// below, which says so.
452///
453/// Split out so the caller can print its receipt before propagating any of these
454/// failures, all of which happen after the namespace has already been minted.
455fn decode_created_namespace(
456    receipt: &alloy::rpc::types::TransactionReceipt,
457) -> anyhow::Result<u64> {
458    let created = receipt
459        .inner
460        .logs()
461        .iter()
462        .find(|log| log.topic0() == Some(&PublisherRegistry::NamespaceCreated::SIGNATURE_HASH))
463        .context(
464            "createNamespace succeeded on-chain but its receipt carried no NamespaceCreated \
465             log — either the RPC returned an incomplete receipt, or an ABI drift changed the \
466             event signature (the namespace id could not be recovered; check the tx on a block \
467             explorer)",
468        )?
469        .log_decode::<PublisherRegistry::NamespaceCreated>()
470        .context(
471            "createNamespace emitted a NamespaceCreated log that failed to decode (ABI \
472             mismatch between this CLI and the deployed PublisherRegistry?)",
473        )?;
474    u64::try_from(created.inner.data.namespaceId)
475        .context("namespace id exceeds u64 (unexpected on this chain)")
476}
477
478/// Return the on-chain failure if there was one, otherwise the stdout-write
479/// failure, otherwise success.
480///
481/// The order is the point: a broken pipe must never outrank — and so hide — a
482/// chain error the operator has to act on. Printing the receipt first is what
483/// makes a partial or ambiguous on-chain effect recoverable, and that guarantee
484/// is worthless if the write's own `?` returns before the real error is built.
485///
486/// `receipt` carries that same guarantee across the one case where printing
487/// first is not enough: the write itself failed, so whatever the command learned
488/// on-chain never reached stdout. It rides in the error context instead, which
489/// goes to stderr — a different descriptor, still writable when stdout is a
490/// closed pipe or a full disk. Without it, `cmd | head -1` can mint a namespace
491/// and report only that the output could not be written.
492fn propagate(
493    chain_err: Option<anyhow::Error>,
494    write_err: Option<io::Error>,
495    write_context: &'static str,
496    receipt: &str,
497) -> anyhow::Result<()> {
498    match (chain_err, write_err) {
499        (Some(err), None) => Err(err),
500        // `anyhow` renders the outermost context first, so this wording has to
501        // lead with the chain failure — the write error is the aside, not the
502        // headline.
503        (Some(err), Some(w)) => Err(err.context(format!(
504            "the command failed on-chain (cause below), and its receipt could not be written \
505             to stdout ({receipt}): {w}"
506        ))),
507        (None, Some(w)) => {
508            Err(anyhow::Error::from(w).context(format!("{write_context} ({receipt})")))
509        }
510        (None, None) => Ok(()),
511    }
512}
513
514/// The receipt facts for a command that submits exactly ONE transaction, in the
515/// shape [`propagate`] needs them: what landed, and the hash that is its handle.
516///
517/// `decoded` is the command's own decoded value (`namespace_id=9`,
518/// `ready_at=…`), already rendered, or `None` when there was nothing to decode
519/// or the decode is what failed.
520fn single_tx_receipt(decoded: Option<String>, tx: Option<B256>) -> String {
521    match (decoded, tx) {
522        (Some(d), Some(h)) => format!("{d} tx={h:#x}"),
523        (None, Some(h)) => format!("tx={h:#x}"),
524        (Some(d), None) => d,
525        (None, None) => "nothing was sent".to_string(),
526    }
527}
528
529// -------------------------------------------------------------------------
530// assign (instant, one addOrigin per operator)
531// -------------------------------------------------------------------------
532
533/// The `addOrigin` failure hint: every guard the contract applies, led by the one
534/// a publisher hits first. `removeOrigin` has its own, since none of the operator
535/// guards below apply to unseating.
536const ADD_ORIGIN_HINT: &str = "the signer must be a vetted publisher (a VETTER_ROLE holder on \
537     the installed vetting policy vets it out-of-band) and own the namespace; the operator must \
538     be an active bonded node, not blacklisted, not already seated, and must fit under \
539     maxOriginsPerNamespace";
540
541/// What happened to one operator in the seating loop.
542///
543/// Seating is one transaction per operator, so a run that stops half-way leaves
544/// the namespace genuinely part-seated. The receipt has to say which operators
545/// are live, which definitely are not, and — the case that matters most — which
546/// one was broadcast without a readable outcome, because that one must not be
547/// blindly re-sent.
548#[derive(Debug, Clone, Copy, PartialEq, Eq)]
549pub(crate) enum SeatOutcome {
550    /// The `addOrigin` transaction confirmed. The operator is authorized.
551    Seated(B256),
552    /// The transaction was broadcast but its outcome could not be read (the
553    /// receipt fetch failed, or no receipt arrived within
554    /// `decdn_incentive::tx::RECEIPT_TIMEOUT`). It may still confirm. `send_for_receipt` records
555    /// the hash before awaiting the receipt precisely so this case can carry it.
556    InFlight(B256),
557    /// Mined, but the transaction reverted: it burned gas and did not seat the
558    /// operator. Keeps its hash as a permanent handle — distinct from `Failed`,
559    /// which never broadcast (#1550).
560    Reverted(B256),
561    /// The send failed with a transport-class error, so whether it broadcast is
562    /// unknown and no hash was captured. Re-sending can double-submit, so this is
563    /// distinct from `Failed` (#1577).
564    MaybeBroadcast,
565    /// The node rejected the send — nothing was ever broadcast. The usual shape,
566    /// since the `addOrigin` guards surface from the pre-flight gas estimate.
567    Failed,
568    /// The loop stopped before this operator was tried.
569    NotAttempted,
570}
571
572impl SeatOutcome {
573    /// Derive a seat's outcome from the `send_for_receipt` result and the
574    /// [`SendOutcome`] it recorded. `Ok` always carries the confirmed hash from
575    /// the receipt; the `Err` arms map straight across from `send`.
576    const fn from_send(
577        sent: &anyhow::Result<alloy::rpc::types::TransactionReceipt>,
578        recorded: SendOutcome,
579    ) -> Self {
580        match sent {
581            Ok(receipt) => Self::Seated(receipt.transaction_hash),
582            Err(_) => match recorded {
583                SendOutcome::InFlight(h) => Self::InFlight(h),
584                SendOutcome::Reverted(h) => Self::Reverted(h),
585                SendOutcome::MaybeBroadcast => Self::MaybeBroadcast,
586                // A rejected send, or the unreachable `Confirmed`/`NotSent` on an
587                // `Err`: nothing the operator can chase, so `Failed`.
588                SendOutcome::Rejected | SendOutcome::Confirmed(_) | SendOutcome::NotSent => {
589                    Self::Failed
590                }
591            },
592        }
593    }
594
595    /// The transaction hash, for any state that captured one.
596    const fn tx(self) -> Option<B256> {
597        match self {
598            Self::Seated(hash) | Self::InFlight(hash) | Self::Reverted(hash) => Some(hash),
599            Self::MaybeBroadcast | Self::Failed | Self::NotAttempted => None,
600        }
601    }
602
603    /// The `state=` token in the receipt.
604    const fn label(self) -> &'static str {
605        match self {
606            Self::Seated(_) => "seated",
607            Self::InFlight(_) => "in_flight",
608            Self::Reverted(_) => "reverted",
609            Self::MaybeBroadcast => "maybe_broadcast",
610            Self::Failed => "failed",
611            Self::NotAttempted => "not_attempted",
612        }
613    }
614
615    /// Whether this seat's outcome is uncertain — a broadcast whose receipt could
616    /// not be read, or a send whose broadcast is unknown. Either is a state the
617    /// operator must resolve before re-running, so it outranks every other status
618    /// in [`AssignOutcome::status`].
619    const fn is_uncertain(self) -> bool {
620        matches!(self, Self::InFlight(_) | Self::MaybeBroadcast)
621    }
622}
623
624/// Receipt for `publish assign`.
625///
626/// `seats` carries every requested operator in the order given, each paired with
627/// what happened to it — one entry per operator, always, so the receipt cannot
628/// misattribute a transaction or silently omit an operator. `dry_run` is a field
629/// rather than inferred from the absence of transactions because "nothing was
630/// sent" and "the first send failed" are different answers that both leave zero
631/// seats — the same reason `RevokeOutcome` carries it too.
632pub(crate) struct AssignOutcome {
633    pub(crate) operator: Option<Address>,
634    pub(crate) origin_assignment: Address,
635    pub(crate) namespace_id: u64,
636    pub(crate) seats: Vec<(Address, SeatOutcome)>,
637    pub(crate) dry_run: bool,
638}
639
640impl AssignOutcome {
641    fn seated_count(&self) -> usize {
642        self.seats
643            .iter()
644            .filter(|(_, s)| matches!(s, SeatOutcome::Seated(_)))
645            .count()
646    }
647
648    /// `dry_run` before anything is sent; `unknown` whenever a transaction was
649    /// broadcast without a readable outcome (it outranks everything else — it is
650    /// the state the operator must resolve before re-running); then `seated`,
651    /// `failed`, or `partial` by how many seats landed.
652    fn status(&self) -> &'static str {
653        if self.dry_run {
654            return "dry_run";
655        }
656        if self.seats.iter().any(|(_, s)| s.is_uncertain()) {
657            return "unknown";
658        }
659        // `0` first: an empty `seats` would otherwise satisfy `n == len` and
660        // report every operator seated when none were.
661        match self.seated_count() {
662            0 => "failed",
663            n if n == self.seats.len() => "seated",
664            _ => "partial",
665        }
666    }
667}
668
669pub(crate) fn write_assign_outcome(
670    w: &mut impl io::Write,
671    o: &AssignOutcome,
672    json: bool,
673) -> io::Result<()> {
674    if json {
675        let value = serde_json::json!({
676            "submitted": o.seats.iter().any(|(_, s)| s.tx().is_some()),
677            "operator": o.operator.map(|a| format!("{a:#x}")),
678            "origin_assignment": format!("{:#x}", o.origin_assignment),
679            "namespace_id": o.namespace_id,
680            "operators": o.seats.iter().map(|(a, _)| format!("{a:#x}")).collect::<Vec<_>>(),
681            "origins": o.seats.iter().map(|(a, s)| serde_json::json!({
682                "operator": format!("{a:#x}"),
683                "tx": s.tx().map(|h| format!("{h:#x}")),
684                "state": s.label(),
685            })).collect::<Vec<_>>(),
686            "status": o.status(),
687        });
688        return writeln!(w, "{value}");
689    }
690    if let Some(op) = o.operator {
691        writeln!(w, "operator={op:#x}")?;
692    }
693    writeln!(w, "origin_assignment={:#x}", o.origin_assignment)?;
694    writeln!(w, "namespace_id={}", o.namespace_id)?;
695    writeln!(w, "operators={}", o.seats.len())?;
696    for (a, seat) in &o.seats {
697        match seat.tx() {
698            Some(tx) => writeln!(w, "  operator={a:#x} tx={tx:#x} state={}", seat.label())?,
699            None => writeln!(w, "  operator={a:#x} state={}", seat.label())?,
700        }
701    }
702    if o.dry_run {
703        writeln!(w, "status=dry_run submitted=false")
704    } else {
705        writeln!(w, "status={} seated={}", o.status(), o.seated_count())
706    }
707}
708
709async fn assign(args: &cli::AssignArgs, global_config: Option<&Path>) -> anyhow::Result<()> {
710    let (resolved, oa_addr) = assignment_ctx(&args.chain, global_config)?;
711    let operators = args
712        .operators
713        .iter()
714        .map(|s| chain_ctx::parse_address(s, "operator"))
715        .collect::<anyhow::Result<Vec<Address>>>()?;
716    ensure_unique_operators(&operators)?;
717
718    let mut outcome = AssignOutcome {
719        operator: None,
720        origin_assignment: oa_addr,
721        namespace_id: args.namespace,
722        seats: Vec::with_capacity(operators.len()),
723        dry_run: args.chain.common.dry_run,
724    };
725
726    // Seed one slot per requested operator up front, so "one entry per operator,
727    // in order" is a property of this line rather than of the loop's bookkeeping.
728    // The loop only ever OVERWRITES a slot — it cannot drop, duplicate, or
729    // reorder an operator, and everything it never reaches stays `NotAttempted`.
730    outcome.seats = operators
731        .iter()
732        .map(|a| (*a, SeatOutcome::NotAttempted))
733        .collect();
734
735    // Seating is a per-operator delta, so a set of N operators is N
736    // transactions. They run in the order given and stop at the first failure:
737    // the remaining operators are usually doomed for the same reason (an
738    // unvetted signer, a namespace the signer does not own), and continuing
739    // would burn gas to collect the identical error N times.
740    let mut failure = None;
741    if !outcome.dry_run {
742        let (signer, provider) = signer_and_provider(&resolved, &args.chain).await?;
743        outcome.operator = Some(signer.address());
744        let contract = OriginAssignment::new(oa_addr, &provider);
745        for (operator, seat) in &mut outcome.seats {
746            let mut recorded = SendOutcome::NotSent;
747            let sent = decdn_incentive::tx::send_for_receipt(
748                contract.addOrigin(U256::from(args.namespace), *operator),
749                "addOrigin",
750                Some(ADD_ORIGIN_HINT),
751                &mut recorded,
752            )
753            .await;
754            // `from_send` keeps the two uncertain shapes (`in_flight`,
755            // `maybe_broadcast`) and the mined revert distinct from a clean
756            // rejection, so the receipt never tells an operator to blindly
757            // re-send a transaction that may still be pending (#1550, #1577).
758            *seat = SeatOutcome::from_send(&sent, recorded);
759            if let Err(err) = sent {
760                failure = Some((*operator, err));
761                break;
762            }
763        }
764    }
765
766    // Print BEFORE propagating: the seats that already landed are on-chain, and
767    // an operator who only sees the error has no way to tell which.
768    let mut out = io::stdout().lock();
769    let write_err = write_assign_outcome(&mut out, &outcome, args.chain.common.json)
770        .and_then(|()| out.flush())
771        .err();
772    drop(out);
773
774    // Every seat that reached the chain, so a lost receipt still names them: this
775    // command's whole hazard is not knowing which operators are already live.
776    let seats = outcome
777        .seats
778        .iter()
779        .filter_map(|(operator, seat)| seat.tx().map(|h| format!("{operator:#x}={h:#x}")))
780        .collect::<Vec<_>>()
781        .join(" ");
782    let receipt = if seats.is_empty() {
783        "nothing was sent".to_string()
784    } else {
785        seats
786    };
787
788    let chain_err = failure.map(|(operator, err)| {
789        err.context(format!(
790            "addOrigin failed for operator {operator:#x} after seating {} of {} (the seated \
791             operators are live; once the cause is fixed, re-run with the operators marked \
792             failed or not_attempted, and check any marked in_flight or maybe_broadcast — the \
793             first on a block explorer, the second among this signer's own pending and mined \
794             transactions — before re-sending them)",
795            outcome.seated_count(),
796            outcome.seats.len(),
797        ))
798    });
799    propagate(
800        chain_err,
801        write_err,
802        "failed to write assign output",
803        &receipt,
804    )
805}
806
807// -------------------------------------------------------------------------
808// revoke
809// -------------------------------------------------------------------------
810
811/// The outcome of `publish revoke`'s single transaction — the four mutually
812/// exclusive states as one value, the `removeOrigin` parallel of
813/// [`NamespaceStatus`]. It has no decoded payload, so `Revoked` is a bare hash
814/// rather than a struct.
815#[derive(Debug, Clone, Copy, PartialEq, Eq)]
816pub(crate) enum RevokeStatus {
817    /// `--dry-run`: nothing was sent, and no keystore was loaded.
818    DryRun,
819    /// Nothing was unseated: the node rejected the send before broadcast.
820    /// Distinct from `Reverted` (mined) and `MaybeBroadcast` (fate unknown).
821    Failed,
822    /// A transport-class send failure (timeout, reset, 5xx). The request may have
823    /// reached the node and unseated the operator, but no hash was captured, so
824    /// the outcome is unknown and re-running can double-submit (#1577).
825    MaybeBroadcast,
826    /// The transaction was broadcast but its receipt was unreadable, so the
827    /// operator may or may not still be seated. Keeps the hash — the only handle
828    /// on it.
829    InFlight(B256),
830    /// Mined, but the call reverted: it burned gas and unseated nothing. Keeps
831    /// its hash — distinct from `Failed`, which never broadcast (#1550).
832    Reverted(B256),
833    /// `removeOrigin` confirmed. The operator is unseated.
834    Revoked(B256),
835}
836
837impl RevokeStatus {
838    /// The transaction hash, for any state that has one.
839    const fn tx(self) -> Option<B256> {
840        match self {
841            Self::InFlight(hash) | Self::Reverted(hash) | Self::Revoked(hash) => Some(hash),
842            Self::DryRun | Self::Failed | Self::MaybeBroadcast => None,
843        }
844    }
845
846    /// Whether the transaction was broadcast, for the `submitted` field. `None`
847    /// for `MaybeBroadcast`, where it cannot be answered — a flat `false` there
848    /// is the #1577 lie.
849    const fn submitted(self) -> Option<bool> {
850        match self {
851            Self::MaybeBroadcast => None,
852            Self::InFlight(_) | Self::Reverted(_) | Self::Revoked(_) => Some(true),
853            Self::DryRun | Self::Failed => Some(false),
854        }
855    }
856
857    /// The `status=` token in the receipt.
858    const fn label(self) -> &'static str {
859        match self {
860            Self::DryRun => "dry_run",
861            Self::Failed => "failed",
862            Self::MaybeBroadcast => "maybe_broadcast",
863            Self::InFlight(_) => "unknown",
864            Self::Reverted(_) => "reverted",
865            Self::Revoked(_) => "revoked",
866        }
867    }
868}
869
870/// Receipt for `publish revoke`. One transaction, so the shape is simpler than
871/// [`AssignOutcome`] — but it needs the same honesty about a broadcast whose
872/// outcome could not be read.
873pub(crate) struct RevokeOutcome {
874    pub(crate) operator: Option<Address>,
875    pub(crate) origin_assignment: Address,
876    pub(crate) namespace_id: u64,
877    /// The operator being unseated.
878    pub(crate) revoked: Address,
879    pub(crate) status: RevokeStatus,
880}
881
882pub(crate) fn write_revoke_outcome(
883    w: &mut impl io::Write,
884    o: &RevokeOutcome,
885    json: bool,
886) -> io::Result<()> {
887    let tx = o.status.tx();
888    if json {
889        let value = serde_json::json!({
890            "submitted": o.status.submitted(),
891            "tx": tx.map(|h| format!("{h:#x}")),
892            "operator": o.operator.map(|a| format!("{a:#x}")),
893            "origin_assignment": format!("{:#x}", o.origin_assignment),
894            "namespace_id": o.namespace_id,
895            "revoked_operator": format!("{:#x}", o.revoked),
896            "status": o.status.label(),
897        });
898        return writeln!(w, "{value}");
899    }
900    if let Some(op) = o.operator {
901        writeln!(w, "operator={op:#x}")?;
902    }
903    writeln!(w, "origin_assignment={:#x}", o.origin_assignment)?;
904    writeln!(w, "namespace_id={}", o.namespace_id)?;
905    writeln!(w, "revoked_operator={:#x}", o.revoked)?;
906    match tx {
907        Some(h) => writeln!(w, "status={} tx={h:#x}", o.status.label()),
908        None => writeln!(
909            w,
910            "status={} submitted={}",
911            o.status.label(),
912            submitted_token(o.status.submitted())
913        ),
914    }
915}
916
917async fn revoke(args: &cli::RevokeArgs, global_config: Option<&Path>) -> anyhow::Result<()> {
918    let (resolved, oa_addr) = assignment_ctx(&args.chain, global_config)?;
919    let revoked = chain_ctx::parse_address(&args.operator, "operator")?;
920
921    let dry_run = args.chain.common.dry_run;
922    let mut operator = None;
923    let mut status = RevokeStatus::DryRun;
924
925    // Print before propagating, for the same reason `assign` does: a broadcast
926    // whose receipt could not be read leaves the hash set, and a `--json`
927    // consumer that only sees the error loses its one handle on the removal.
928    let mut chain_err = None;
929    if !dry_run {
930        let (signer, provider) = signer_and_provider(&resolved, &args.chain).await?;
931        operator = Some(signer.address());
932        let contract = OriginAssignment::new(oa_addr, &provider);
933        let mut send = SendOutcome::NotSent;
934        match decdn_incentive::tx::send_for_receipt(
935            contract.removeOrigin(U256::from(args.namespace), revoked),
936            "removeOrigin",
937            Some(
938                "the signer must own the namespace (or hold GOVERNANCE_ROLE) and the operator \
939                 must currently be seated as an authorized origin for it",
940            ),
941            &mut send,
942        )
943        .await
944        {
945            Ok(receipt) => status = RevokeStatus::Revoked(receipt.transaction_hash),
946            Err(err) => {
947                // `send` distinguishes a broadcast whose receipt was unreadable
948                // (`InFlight`), a transport failure that may have unseated
949                // (`MaybeBroadcast`), a confirmed revert (`Reverted`, unseated
950                // nothing), and a clean rejection (`Rejected` → `Failed`).
951                status = match send {
952                    SendOutcome::InFlight(hash) => RevokeStatus::InFlight(hash),
953                    SendOutcome::Reverted(hash) => RevokeStatus::Reverted(hash),
954                    SendOutcome::MaybeBroadcast => RevokeStatus::MaybeBroadcast,
955                    SendOutcome::Rejected | SendOutcome::Confirmed(_) | SendOutcome::NotSent => {
956                        RevokeStatus::Failed
957                    }
958                };
959                chain_err = Some(match revoke_failure_context(status) {
960                    Some(note) => err.context(note),
961                    None => err,
962                });
963            }
964        }
965    }
966
967    let outcome = RevokeOutcome {
968        operator,
969        origin_assignment: oa_addr,
970        namespace_id: args.namespace,
971        revoked,
972        status,
973    };
974    let mut out = io::stdout().lock();
975    let write_err = write_revoke_outcome(&mut out, &outcome, args.chain.common.json)
976        .and_then(|()| out.flush())
977        .err();
978    drop(out);
979    propagate(
980        chain_err,
981        write_err,
982        "failed to write revoke output",
983        &single_tx_receipt(None, outcome.status.tx()),
984    )
985}
986
987/// Extra guidance to attach to a `revoke` failure, or `None` when the underlying
988/// error already says everything true. The parallel of
989/// [`namespace_failure_context`].
990///
991/// Only the broadcast case needs it: a removal that landed makes the retry
992/// revert `NotAuthorizedOrigin`, which reads like a permissions problem rather
993/// than "this already worked". A rejected send unseated nothing and has no hash
994/// to cite, so `send_for_receipt`'s own error is already complete.
995const fn revoke_failure_context(status: RevokeStatus) -> Option<&'static str> {
996    match status {
997        RevokeStatus::InFlight(_) => Some(
998            "the transaction was broadcast — check the tx above before re-running, since a \
999             removal that landed makes the retry revert NotAuthorizedOrigin",
1000        ),
1001        // A transport failure may have unseated too, but has no hash to cite.
1002        RevokeStatus::MaybeBroadcast => Some(
1003            "the request may have reached the node and broadcast the transaction — check this \
1004             signer's pending and mined transactions before re-running, since a removal that \
1005             landed makes the retry revert NotAuthorizedOrigin",
1006        ),
1007        // A rejected send or a confirmed revert unseated nothing.
1008        RevokeStatus::DryRun
1009        | RevokeStatus::Failed
1010        | RevokeStatus::Reverted(_)
1011        | RevokeStatus::Revoked(_) => None,
1012    }
1013}
1014
1015#[cfg(test)]
1016#[allow(clippy::unwrap_used, clippy::indexing_slicing)]
1017mod tests;