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;