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;