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). It may still confirm. `send_for_receipt` records
554    /// the hash before awaiting the receipt precisely so this case can carry it.
555    InFlight(B256),
556    /// Mined, but the transaction reverted: it burned gas and did not seat the
557    /// operator. Keeps its hash as a permanent handle — distinct from `Failed`,
558    /// which never broadcast (#1550).
559    Reverted(B256),
560    /// The send failed with a transport-class error, so whether it broadcast is
561    /// unknown and no hash was captured. Re-sending can double-submit, so this is
562    /// distinct from `Failed` (#1577).
563    MaybeBroadcast,
564    /// The node rejected the send — nothing was ever broadcast. The usual shape,
565    /// since the `addOrigin` guards surface from the pre-flight gas estimate.
566    Failed,
567    /// The loop stopped before this operator was tried.
568    NotAttempted,
569}
570
571impl SeatOutcome {
572    /// Derive a seat's outcome from the `send_for_receipt` result and the
573    /// [`SendOutcome`] it recorded. `Ok` always carries the confirmed hash from
574    /// the receipt; the `Err` arms map straight across from `send`.
575    const fn from_send(
576        sent: &anyhow::Result<alloy::rpc::types::TransactionReceipt>,
577        recorded: SendOutcome,
578    ) -> Self {
579        match sent {
580            Ok(receipt) => Self::Seated(receipt.transaction_hash),
581            Err(_) => match recorded {
582                SendOutcome::InFlight(h) => Self::InFlight(h),
583                SendOutcome::Reverted(h) => Self::Reverted(h),
584                SendOutcome::MaybeBroadcast => Self::MaybeBroadcast,
585                // A rejected send, or the unreachable `Confirmed`/`NotSent` on an
586                // `Err`: nothing the operator can chase, so `Failed`.
587                SendOutcome::Rejected | SendOutcome::Confirmed(_) | SendOutcome::NotSent => {
588                    Self::Failed
589                }
590            },
591        }
592    }
593
594    /// The transaction hash, for any state that captured one.
595    const fn tx(self) -> Option<B256> {
596        match self {
597            Self::Seated(hash) | Self::InFlight(hash) | Self::Reverted(hash) => Some(hash),
598            Self::MaybeBroadcast | Self::Failed | Self::NotAttempted => None,
599        }
600    }
601
602    /// The `state=` token in the receipt.
603    const fn label(self) -> &'static str {
604        match self {
605            Self::Seated(_) => "seated",
606            Self::InFlight(_) => "in_flight",
607            Self::Reverted(_) => "reverted",
608            Self::MaybeBroadcast => "maybe_broadcast",
609            Self::Failed => "failed",
610            Self::NotAttempted => "not_attempted",
611        }
612    }
613
614    /// Whether this seat's outcome is uncertain — a broadcast whose receipt could
615    /// not be read, or a send whose broadcast is unknown. Either is a state the
616    /// operator must resolve before re-running, so it outranks every other status
617    /// in [`AssignOutcome::status`].
618    const fn is_uncertain(self) -> bool {
619        matches!(self, Self::InFlight(_) | Self::MaybeBroadcast)
620    }
621}
622
623/// Receipt for `publish assign`.
624///
625/// `seats` carries every requested operator in the order given, each paired with
626/// what happened to it — one entry per operator, always, so the receipt cannot
627/// misattribute a transaction or silently omit an operator. `dry_run` is a field
628/// rather than inferred from the absence of transactions because "nothing was
629/// sent" and "the first send failed" are different answers that both leave zero
630/// seats — the same reason `RevokeOutcome` carries it too.
631pub(crate) struct AssignOutcome {
632    pub(crate) operator: Option<Address>,
633    pub(crate) origin_assignment: Address,
634    pub(crate) namespace_id: u64,
635    pub(crate) seats: Vec<(Address, SeatOutcome)>,
636    pub(crate) dry_run: bool,
637}
638
639impl AssignOutcome {
640    fn seated_count(&self) -> usize {
641        self.seats
642            .iter()
643            .filter(|(_, s)| matches!(s, SeatOutcome::Seated(_)))
644            .count()
645    }
646
647    /// `dry_run` before anything is sent; `unknown` whenever a transaction was
648    /// broadcast without a readable outcome (it outranks everything else — it is
649    /// the state the operator must resolve before re-running); then `seated`,
650    /// `failed`, or `partial` by how many seats landed.
651    fn status(&self) -> &'static str {
652        if self.dry_run {
653            return "dry_run";
654        }
655        if self.seats.iter().any(|(_, s)| s.is_uncertain()) {
656            return "unknown";
657        }
658        // `0` first: an empty `seats` would otherwise satisfy `n == len` and
659        // report every operator seated when none were.
660        match self.seated_count() {
661            0 => "failed",
662            n if n == self.seats.len() => "seated",
663            _ => "partial",
664        }
665    }
666}
667
668pub(crate) fn write_assign_outcome(
669    w: &mut impl io::Write,
670    o: &AssignOutcome,
671    json: bool,
672) -> io::Result<()> {
673    if json {
674        let value = serde_json::json!({
675            "submitted": o.seats.iter().any(|(_, s)| s.tx().is_some()),
676            "operator": o.operator.map(|a| format!("{a:#x}")),
677            "origin_assignment": format!("{:#x}", o.origin_assignment),
678            "namespace_id": o.namespace_id,
679            "operators": o.seats.iter().map(|(a, _)| format!("{a:#x}")).collect::<Vec<_>>(),
680            "origins": o.seats.iter().map(|(a, s)| serde_json::json!({
681                "operator": format!("{a:#x}"),
682                "tx": s.tx().map(|h| format!("{h:#x}")),
683                "state": s.label(),
684            })).collect::<Vec<_>>(),
685            "status": o.status(),
686        });
687        return writeln!(w, "{value}");
688    }
689    if let Some(op) = o.operator {
690        writeln!(w, "operator={op:#x}")?;
691    }
692    writeln!(w, "origin_assignment={:#x}", o.origin_assignment)?;
693    writeln!(w, "namespace_id={}", o.namespace_id)?;
694    writeln!(w, "operators={}", o.seats.len())?;
695    for (a, seat) in &o.seats {
696        match seat.tx() {
697            Some(tx) => writeln!(w, "  operator={a:#x} tx={tx:#x} state={}", seat.label())?,
698            None => writeln!(w, "  operator={a:#x} state={}", seat.label())?,
699        }
700    }
701    if o.dry_run {
702        writeln!(w, "status=dry_run submitted=false")
703    } else {
704        writeln!(w, "status={} seated={}", o.status(), o.seated_count())
705    }
706}
707
708async fn assign(args: &cli::AssignArgs, global_config: Option<&Path>) -> anyhow::Result<()> {
709    let (resolved, oa_addr) = assignment_ctx(&args.chain, global_config)?;
710    let operators = args
711        .operators
712        .iter()
713        .map(|s| chain_ctx::parse_address(s, "operator"))
714        .collect::<anyhow::Result<Vec<Address>>>()?;
715    ensure_unique_operators(&operators)?;
716
717    let mut outcome = AssignOutcome {
718        operator: None,
719        origin_assignment: oa_addr,
720        namespace_id: args.namespace,
721        seats: Vec::with_capacity(operators.len()),
722        dry_run: args.chain.common.dry_run,
723    };
724
725    // Seed one slot per requested operator up front, so "one entry per operator,
726    // in order" is a property of this line rather than of the loop's bookkeeping.
727    // The loop only ever OVERWRITES a slot — it cannot drop, duplicate, or
728    // reorder an operator, and everything it never reaches stays `NotAttempted`.
729    outcome.seats = operators
730        .iter()
731        .map(|a| (*a, SeatOutcome::NotAttempted))
732        .collect();
733
734    // Seating is a per-operator delta, so a set of N operators is N
735    // transactions. They run in the order given and stop at the first failure:
736    // the remaining operators are usually doomed for the same reason (an
737    // unvetted signer, a namespace the signer does not own), and continuing
738    // would burn gas to collect the identical error N times.
739    let mut failure = None;
740    if !outcome.dry_run {
741        let (signer, provider) = signer_and_provider(&resolved, &args.chain).await?;
742        outcome.operator = Some(signer.address());
743        let contract = OriginAssignment::new(oa_addr, &provider);
744        for (operator, seat) in &mut outcome.seats {
745            let mut recorded = SendOutcome::NotSent;
746            let sent = decdn_incentive::tx::send_for_receipt(
747                contract.addOrigin(U256::from(args.namespace), *operator),
748                "addOrigin",
749                Some(ADD_ORIGIN_HINT),
750                &mut recorded,
751            )
752            .await;
753            // `from_send` keeps the two uncertain shapes (`in_flight`,
754            // `maybe_broadcast`) and the mined revert distinct from a clean
755            // rejection, so the receipt never tells an operator to blindly
756            // re-send a transaction that may still be pending (#1550, #1577).
757            *seat = SeatOutcome::from_send(&sent, recorded);
758            if let Err(err) = sent {
759                failure = Some((*operator, err));
760                break;
761            }
762        }
763    }
764
765    // Print BEFORE propagating: the seats that already landed are on-chain, and
766    // an operator who only sees the error has no way to tell which.
767    let mut out = io::stdout().lock();
768    let write_err = write_assign_outcome(&mut out, &outcome, args.chain.common.json)
769        .and_then(|()| out.flush())
770        .err();
771    drop(out);
772
773    // Every seat that reached the chain, so a lost receipt still names them: this
774    // command's whole hazard is not knowing which operators are already live.
775    let seats = outcome
776        .seats
777        .iter()
778        .filter_map(|(operator, seat)| seat.tx().map(|h| format!("{operator:#x}={h:#x}")))
779        .collect::<Vec<_>>()
780        .join(" ");
781    let receipt = if seats.is_empty() {
782        "nothing was sent".to_string()
783    } else {
784        seats
785    };
786
787    let chain_err = failure.map(|(operator, err)| {
788        err.context(format!(
789            "addOrigin failed for operator {operator:#x} after seating {} of {} (the seated \
790             operators are live; once the cause is fixed, re-run with the operators marked \
791             failed or not_attempted, and check any marked in_flight or maybe_broadcast — the \
792             first on a block explorer, the second among this signer's own pending and mined \
793             transactions — before re-sending them)",
794            outcome.seated_count(),
795            outcome.seats.len(),
796        ))
797    });
798    propagate(
799        chain_err,
800        write_err,
801        "failed to write assign output",
802        &receipt,
803    )
804}
805
806// -------------------------------------------------------------------------
807// revoke
808// -------------------------------------------------------------------------
809
810/// The outcome of `publish revoke`'s single transaction — the four mutually
811/// exclusive states as one value, the `removeOrigin` parallel of
812/// [`NamespaceStatus`]. It has no decoded payload, so `Revoked` is a bare hash
813/// rather than a struct.
814#[derive(Debug, Clone, Copy, PartialEq, Eq)]
815pub(crate) enum RevokeStatus {
816    /// `--dry-run`: nothing was sent, and no keystore was loaded.
817    DryRun,
818    /// Nothing was unseated: the node rejected the send before broadcast.
819    /// Distinct from `Reverted` (mined) and `MaybeBroadcast` (fate unknown).
820    Failed,
821    /// A transport-class send failure (timeout, reset, 5xx). The request may have
822    /// reached the node and unseated the operator, but no hash was captured, so
823    /// the outcome is unknown and re-running can double-submit (#1577).
824    MaybeBroadcast,
825    /// The transaction was broadcast but its receipt was unreadable, so the
826    /// operator may or may not still be seated. Keeps the hash — the only handle
827    /// on it.
828    InFlight(B256),
829    /// Mined, but the call reverted: it burned gas and unseated nothing. Keeps
830    /// its hash — distinct from `Failed`, which never broadcast (#1550).
831    Reverted(B256),
832    /// `removeOrigin` confirmed. The operator is unseated.
833    Revoked(B256),
834}
835
836impl RevokeStatus {
837    /// The transaction hash, for any state that has one.
838    const fn tx(self) -> Option<B256> {
839        match self {
840            Self::InFlight(hash) | Self::Reverted(hash) | Self::Revoked(hash) => Some(hash),
841            Self::DryRun | Self::Failed | Self::MaybeBroadcast => None,
842        }
843    }
844
845    /// Whether the transaction was broadcast, for the `submitted` field. `None`
846    /// for `MaybeBroadcast`, where it cannot be answered — a flat `false` there
847    /// is the #1577 lie.
848    const fn submitted(self) -> Option<bool> {
849        match self {
850            Self::MaybeBroadcast => None,
851            Self::InFlight(_) | Self::Reverted(_) | Self::Revoked(_) => Some(true),
852            Self::DryRun | Self::Failed => Some(false),
853        }
854    }
855
856    /// The `status=` token in the receipt.
857    const fn label(self) -> &'static str {
858        match self {
859            Self::DryRun => "dry_run",
860            Self::Failed => "failed",
861            Self::MaybeBroadcast => "maybe_broadcast",
862            Self::InFlight(_) => "unknown",
863            Self::Reverted(_) => "reverted",
864            Self::Revoked(_) => "revoked",
865        }
866    }
867}
868
869/// Receipt for `publish revoke`. One transaction, so the shape is simpler than
870/// [`AssignOutcome`] — but it needs the same honesty about a broadcast whose
871/// outcome could not be read.
872pub(crate) struct RevokeOutcome {
873    pub(crate) operator: Option<Address>,
874    pub(crate) origin_assignment: Address,
875    pub(crate) namespace_id: u64,
876    /// The operator being unseated.
877    pub(crate) revoked: Address,
878    pub(crate) status: RevokeStatus,
879}
880
881pub(crate) fn write_revoke_outcome(
882    w: &mut impl io::Write,
883    o: &RevokeOutcome,
884    json: bool,
885) -> io::Result<()> {
886    let tx = o.status.tx();
887    if json {
888        let value = serde_json::json!({
889            "submitted": o.status.submitted(),
890            "tx": tx.map(|h| format!("{h:#x}")),
891            "operator": o.operator.map(|a| format!("{a:#x}")),
892            "origin_assignment": format!("{:#x}", o.origin_assignment),
893            "namespace_id": o.namespace_id,
894            "revoked_operator": format!("{:#x}", o.revoked),
895            "status": o.status.label(),
896        });
897        return writeln!(w, "{value}");
898    }
899    if let Some(op) = o.operator {
900        writeln!(w, "operator={op:#x}")?;
901    }
902    writeln!(w, "origin_assignment={:#x}", o.origin_assignment)?;
903    writeln!(w, "namespace_id={}", o.namespace_id)?;
904    writeln!(w, "revoked_operator={:#x}", o.revoked)?;
905    match tx {
906        Some(h) => writeln!(w, "status={} tx={h:#x}", o.status.label()),
907        None => writeln!(
908            w,
909            "status={} submitted={}",
910            o.status.label(),
911            submitted_token(o.status.submitted())
912        ),
913    }
914}
915
916async fn revoke(args: &cli::RevokeArgs, global_config: Option<&Path>) -> anyhow::Result<()> {
917    let (resolved, oa_addr) = assignment_ctx(&args.chain, global_config)?;
918    let revoked = chain_ctx::parse_address(&args.operator, "operator")?;
919
920    let dry_run = args.chain.common.dry_run;
921    let mut operator = None;
922    let mut status = RevokeStatus::DryRun;
923
924    // Print before propagating, for the same reason `assign` does: a broadcast
925    // whose receipt could not be read leaves the hash set, and a `--json`
926    // consumer that only sees the error loses its one handle on the removal.
927    let mut chain_err = None;
928    if !dry_run {
929        let (signer, provider) = signer_and_provider(&resolved, &args.chain).await?;
930        operator = Some(signer.address());
931        let contract = OriginAssignment::new(oa_addr, &provider);
932        let mut send = SendOutcome::NotSent;
933        match decdn_incentive::tx::send_for_receipt(
934            contract.removeOrigin(U256::from(args.namespace), revoked),
935            "removeOrigin",
936            Some(
937                "the signer must own the namespace (or hold GOVERNANCE_ROLE) and the operator \
938                 must currently be seated as an authorized origin for it",
939            ),
940            &mut send,
941        )
942        .await
943        {
944            Ok(receipt) => status = RevokeStatus::Revoked(receipt.transaction_hash),
945            Err(err) => {
946                // `send` distinguishes a broadcast whose receipt was unreadable
947                // (`InFlight`), a transport failure that may have unseated
948                // (`MaybeBroadcast`), a confirmed revert (`Reverted`, unseated
949                // nothing), and a clean rejection (`Rejected` → `Failed`).
950                status = match send {
951                    SendOutcome::InFlight(hash) => RevokeStatus::InFlight(hash),
952                    SendOutcome::Reverted(hash) => RevokeStatus::Reverted(hash),
953                    SendOutcome::MaybeBroadcast => RevokeStatus::MaybeBroadcast,
954                    SendOutcome::Rejected | SendOutcome::Confirmed(_) | SendOutcome::NotSent => {
955                        RevokeStatus::Failed
956                    }
957                };
958                chain_err = Some(match revoke_failure_context(status) {
959                    Some(note) => err.context(note),
960                    None => err,
961                });
962            }
963        }
964    }
965
966    let outcome = RevokeOutcome {
967        operator,
968        origin_assignment: oa_addr,
969        namespace_id: args.namespace,
970        revoked,
971        status,
972    };
973    let mut out = io::stdout().lock();
974    let write_err = write_revoke_outcome(&mut out, &outcome, args.chain.common.json)
975        .and_then(|()| out.flush())
976        .err();
977    drop(out);
978    propagate(
979        chain_err,
980        write_err,
981        "failed to write revoke output",
982        &single_tx_receipt(None, outcome.status.tx()),
983    )
984}
985
986/// Extra guidance to attach to a `revoke` failure, or `None` when the underlying
987/// error already says everything true. The parallel of
988/// [`namespace_failure_context`].
989///
990/// Only the broadcast case needs it: a removal that landed makes the retry
991/// revert `NotAuthorizedOrigin`, which reads like a permissions problem rather
992/// than "this already worked". A rejected send unseated nothing and has no hash
993/// to cite, so `send_for_receipt`'s own error is already complete.
994const fn revoke_failure_context(status: RevokeStatus) -> Option<&'static str> {
995    match status {
996        RevokeStatus::InFlight(_) => Some(
997            "the transaction was broadcast — check the tx above before re-running, since a \
998             removal that landed makes the retry revert NotAuthorizedOrigin",
999        ),
1000        // A transport failure may have unseated too, but has no hash to cite.
1001        RevokeStatus::MaybeBroadcast => Some(
1002            "the request may have reached the node and broadcast the transaction — check this \
1003             signer's pending and mined transactions before re-running, since a removal that \
1004             landed makes the retry revert NotAuthorizedOrigin",
1005        ),
1006        // A rejected send or a confirmed revert unseated nothing.
1007        RevokeStatus::DryRun
1008        | RevokeStatus::Failed
1009        | RevokeStatus::Reverted(_)
1010        | RevokeStatus::Revoked(_) => None,
1011    }
1012}
1013
1014#[cfg(test)]
1015#[allow(clippy::unwrap_used, clippy::indexing_slicing)]
1016mod tests;