Skip to main content

decdn_common/
admin.rs

1//! Wire types for the loopback admin JSON-RPC surface (`appendix-local-admin-http.md`).
2//!
3//! Shared between the daemon (which implements `AdminRpcServer` over a
4//! local cache + peer table) and the user CLI (which speaks
5//! [`AdminRpcClient`] from `decdn node …`). jsonrpsee's `#[rpc]` macro
6//! consumes the source `AdminRpc` trait and emits `AdminRpcClient` in its
7//! place, plus `AdminRpcServer` under the `admin-server` feature, which only
8//! the daemon enables: the client SDK and the CLI link no JSON-RPC server.
9//! The original `AdminRpc` name is not a linkable rustdoc item, hence the
10//! bare backticks.
11//!
12//! DTO fields are deliberately wire-only: no engine handles, no
13//! peer-table types, no daemon runtime types. They use simple owned
14//! values (`String`, `u64`, primitive arrays). Pulling `decdn-common`
15//! into the CLI does not pull the daemon runtime.
16//!
17//! The helper [`parse_hash_arg`] returns [`struct@Hash`] from the
18//! `decdn-config-types` leaf crate (the same leaf the typed config
19//! fields `DecompressMode`, `RetryPolicy`, `OriginUrl`, `PinnedHashes`
20//! come from). That keeps `decdn-cache`/iroh-blobs out of the publisher
21//! CLI's dependency tree (#578); the daemon's `evict` handler converts
22//! the leaf hash to the blob-store hash at its boundary via
23//! `decdn_cache::to_store_hash`. The parser is used only by that
24//! handler today.
25
26use decdn_config_types::Hash;
27// The trait's `RpcResult<T>` returns are read by the macro, and only the
28// server half it emits names the type.
29#[cfg(feature = "admin-server")]
30use jsonrpsee::core::RpcResult;
31use jsonrpsee::proc_macros::rpc;
32use jsonrpsee::types::ErrorObjectOwned;
33use serde::{Deserialize, Serialize};
34
35/// Whether the node's local iroh key is the one bound to its operator address
36/// on-chain (#1034).
37///
38/// This is a correctness signal, not a liveness one. `SlashJudge` resolves an
39/// accused node through `CapacityBond.nodeIdOf`, so a node serving under a key
40/// no binding points at is **unslashable**: it earns normally while its bond is
41/// unreachable, which is a protocol fault rather than an outage — and one with
42/// no symptom the operator would otherwise notice. A key rotation that swapped
43/// `node.secret` without submitting `bindNodeId` lands exactly here, which is
44/// why `decdn node rotate-key` orders its steps to make the state unreachable
45/// and why the daemon reports it when it happens anyway.
46///
47/// Advisory by construction. The check is one RPC at bring-up, and a daemon
48/// that refused to start on it would be brickable by a transient RPC failure
49/// and unable to run the very rotation that repairs it.
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
51#[serde(rename_all = "snake_case")]
52pub enum BindingStatus {
53    /// The on-chain binding names this node's key. Nothing to do.
54    Bound,
55    /// The operator is bound to a DIFFERENT node id than the local key —
56    /// the un-slashable state. Either rotate on-chain to match the local key
57    /// (`decdn node rotate-key --key iroh --bind-existing`) or restore the
58    /// key the binding names. Sampled once at bring-up like every other
59    /// variant here, so a live repair does not clear this to `Bound` until
60    /// the daemon restarts — a `mismatch` seen right after running
61    /// `--bind-existing` is not a failed repair, just a stale sample.
62    Mismatch,
63    /// The operator address has no binding at all: never registered, or the
64    /// node id was reclaimed. `decdn node register` makes the initial binding.
65    Unbound,
66    /// Not determined — no `[blockchain]` configuration to check against, or
67    /// the read failed. Explicitly not a synonym for "fine".
68    Unknown,
69}
70
71/// Response body for `admin_v1_health`. Shared between the server
72/// (serializes) and `decdn node health` (deserializes via the generated
73/// client). Intentionally minimal: this method exists so an operator
74/// script can answer "is this admin port the node I think it is, and
75/// has it been up since I started watching?" with one RPC call.
76#[derive(Debug, Clone, Serialize, Deserialize)]
77pub struct HealthResponse {
78    /// Lowercase hex of this node's iroh `PublicKey`.
79    pub node_id: String,
80    /// Whole seconds since the runtime captured the process-start
81    /// `Instant` at the top of `decdn-node run` (before any `await`,
82    /// before the RPC preflight). Computed from a monotonic `Instant`
83    /// so wall-clock skew can't produce a negative or non-monotonic
84    /// value.
85    pub uptime_s: u64,
86    /// Currently in-flight QUIC handler tasks holding a dispatch permit
87    /// (read off the `decdn_dispatch_in_flight` gauge — see
88    /// `ConnectionLimiter` in `decdn-node`). Polled by `decdn node drain
89    /// --wait` (issue #604) to detect when all client streams have
90    /// completed during a graceful drain.
91    pub in_flight_streams: u64,
92    /// Whether `node_id` is the key bound to this operator on-chain (#1034).
93    /// Sampled once at bring-up — the binding only changes by an explicit
94    /// operator transaction, and re-reading it on every health poll would put
95    /// an RPC round trip behind the readiness probe.
96    pub binding: BindingStatus,
97    /// Lowercase hex of the node id the operator IS bound to, when that could
98    /// be read. Present for [`BindingStatus::Bound`] (where it equals
99    /// `node_id`) and, more usefully, for [`BindingStatus::Mismatch`], where it
100    /// names the key to restore. `None` for `Unbound` and `Unknown`.
101    pub bound_node_id: Option<String>,
102    /// Whether this node is in the on-chain active-staker set right now
103    /// (ADR 019 §Phase 4, criterion 1; #1030).
104    ///
105    /// Purely diagnostic. The daemon does **not** gate delivery on it — a node
106    /// policing its own registration constrains only operators who were never
107    /// the threat (see ADR 019 §How criterion 1 is enforced). What `false`
108    /// means is that the two EXTERNAL constraints are in force: the operator
109    /// accrues no governance weight, because its declared capacity caps
110    /// credited bytes at zero, and peers have no reason to route to a node they
111    /// cannot slash.
112    ///
113    /// Unlike [`Self::binding`], this is read LIVE on every poll — it is a
114    /// lookup in the registry projection the daemon already keeps current off
115    /// `CapacityBond` events, not a chain round trip — so an operator who has
116    /// just run `decdn setup` watches it flip to `true` without restarting the
117    /// daemon.
118    ///
119    /// `false` is the answer to "my node is up and healthy, why is it earning
120    /// nothing". Nothing on the wire says so; the traffic simply does not
121    /// arrive. The cause is one of never registered, registered under a key
122    /// this process no longer holds (see [`Self::binding`]), deregistered,
123    /// ejected, bond below `minBond`, or an unbonding request in flight.
124    pub registry_active: bool,
125}
126
127/// Request body for `admin_v1_evict` (issue #279).
128#[derive(Debug, Clone, Serialize, Deserialize)]
129pub struct EvictRequest {
130    /// Hex of the BLAKE3 hash to evict. Must be exactly 64 hex characters
131    /// (mixed case accepted, optional `0x`/`0X` prefix tolerated); rejected
132    /// at the server with `INVALID_PARAMS` otherwise.
133    pub hash: String,
134    /// If `true`, return only the pre-evict snapshot (size, last access,
135    /// pin status, already-evicted flag) without mutating cache state
136    /// (issue #379). Backs `decdn node evict --dry-run`. Required: a caller
137    /// states whether it wants the real evict.
138    pub dry_run: bool,
139}
140
141/// Response body for `admin_v1_evict`.
142///
143/// Carries both the pre-evict snapshot (always populated, so an operator's
144/// audit log captures size and pin status at the moment of evict) and a
145/// `dry_run` flag indicating whether the cache state was actually mutated.
146#[derive(Debug, Clone, Serialize, Deserialize, Default)]
147pub struct EvictResponse {
148    /// Whether the hash would have been served by the cache before this
149    /// call (i.e. `BlobStatus::Complete` *and* not already evicted).
150    /// `false` means the evict was a no-op safety measure (blob never
151    /// cached, or already in `evicted.log`). For a real evict the
152    /// effect is durable; for a dry-run no effect is committed and the
153    /// `was_present` snapshot describes the current state only.
154    pub was_present: bool,
155    /// `true` when this response describes a dry-run preview — the
156    /// cache state was *not* mutated and only [`Self::preview`] is
157    /// meaningful. `false` means the eviction was applied per the
158    /// existing `admin_v1_evict` behaviour.
159    pub dry_run: bool,
160    /// Pre-evict snapshot of the blob's local-cache state (#379).
161    /// Populated for both real and dry-run calls so an operator's
162    /// audit log captures size and pin status at the moment of
163    /// evict. The struct is nested (rather than flattened into
164    /// [`Self`]) so the `--dry-run` view doesn't push the bool count
165    /// past the `clippy::struct_excessive_bools` threshold and so a
166    /// future addition to the snapshot doesn't churn the top-level
167    /// response shape.
168    pub preview: EvictPreview,
169}
170
171/// Pre-evict snapshot returned inside [`EvictResponse::preview`].
172/// Mirrors the cache engine's `EvictionPreview` minus the
173/// engine-internal `served` field (folded into
174/// [`EvictResponse::was_present`]).
175#[derive(Debug, Clone, Serialize, Deserialize, Default)]
176pub struct EvictPreview {
177    /// Bytes the iroh-blobs store reports for this hash, read straight
178    /// from the underlying store regardless of evicted-log state.
179    /// `None` when the blob isn't in the store. A complete blob reports its
180    /// byte length. A partial blob (an interrupted or range pull) reports
181    /// the whole blob's validated size once its last chunk has arrived, and
182    /// `None` before — not the bytes it holds on disk.
183    pub size_bytes: Option<u64>,
184    /// Microseconds elapsed since the last recorded access to this hash: a
185    /// serve, a hit, or a fill. `None` when the hash has had no access since
186    /// the node started — including a blob on disk at start that nothing has
187    /// touched since — or when eviction or quarantine has cleared its entry.
188    pub last_accessed_us_ago: Option<u64>,
189    /// Whether the hash is in the operator-pinned set (#276).
190    /// Pinning protects against LRU eviction but **not** against an
191    /// explicit `admin_v1_evict`; surfaced here so dry-run callers
192    /// can confirm policy state before issuing the real takedown.
193    pub pinned: bool,
194    /// Whether the hash is already in `<cache_dir>/evicted.log`.
195    /// `true` means a real `admin_v1_evict` would short-circuit
196    /// (idempotent re-run, no log line appended).
197    pub already_evicted: bool,
198    /// Ordered list of origin backends the node would consult on a
199    /// post-eviction miss (#439, #284). Empty when the engine has no
200    /// origin configured (cache-only mode); a single-element vec is
201    /// the common single-origin case. Operators running DMCA takedowns or
202    /// LRU sweeps use this to estimate worst-case origin egress cost
203    /// — re-pulling from a `Filesystem` origin is a local read;
204    /// re-pulling through a chain of `Http`/`S3` mirrors multiplies
205    /// the metered-bandwidth bill on a deep fallback.
206    ///
207    /// `skip_serializing_if = "Vec::is_empty"` elides the field
208    /// entirely in cache-only mode, keeping the JSON output minimal;
209    /// `default` reads that elided field back as the empty list.
210    #[serde(default, skip_serializing_if = "Vec::is_empty")]
211    pub origin_kinds: Vec<decdn_config_types::OriginKind>,
212}
213
214/// Response body for `admin_v1_reload` (issue #373). Reports the
215/// post-reload values the SIGHUP arm logs to stdout, so operators using
216/// the RPC path get the same after-state confirmation without scraping
217/// `tracing` output. Only the *reloadable* fields appear here: changes
218/// to non-reloadable sections are logged by the reload path itself
219/// (one `info!` per changed-but-ignored field) and aren't echoed in
220/// the RPC response.
221#[derive(Debug, Clone, Serialize, Deserialize)]
222pub struct ReloadResponse {
223    /// `observability.log_level` after the reload, lowercase
224    /// (`"trace"` / `"debug"` / `"info"` / `"warn"` / `"error"`) — same
225    /// spelling the resolver and config file accept. `"unknown"` only
226    /// appears on the (unreachable post-success) path where the reload
227    /// committed a level but the snapshot mutex was poisoned by a
228    /// concurrent reader; emitted as a string rather than a literal so
229    /// operator scripts can parse one stable shape.
230    pub log_level: String,
231}
232
233/// Request body for `admin_v1_drain` (issue #244). Required, as is its
234/// one field: a caller states which shutdown ordering it wants.
235#[derive(Debug, Clone, Serialize, Deserialize)]
236pub struct DrainRequest {
237    /// When `true`, ask the runtime to keep the admin server alive
238    /// *through* `router.shutdown` instead of closing it early (issue
239    /// #604). This is the opt-in seam for `decdn node drain --wait`: the
240    /// CLI polls `admin_v1_health` for `in_flight_streams == 0` and the
241    /// admin port must stay open long enough for that loop to observe
242    /// completion. `false` keeps the SIGTERM ordering (admin closes
243    /// before `router.shutdown`); `true` only reorders that single drain.
244    pub wait_admin: bool,
245}
246
247/// Response body for `admin_v1_drain` (issue #244). `initiated: true`
248/// reports that the trigger fired; `wait_admin_honored` reports
249/// whether the server actually plans to keep admin alive through
250/// `router.shutdown` (issue #604). The trigger is first-writer-wins, so
251/// a `wait_admin: true` request that loses the race to an earlier drain
252/// without it gets `false` here. The `decdn node drain --wait` client
253/// then refuses to enter the polling loop instead of treating the
254/// imminent ECONNREFUSED as drain completion. A `true` ack covers
255/// admin-RPC races only: a SIGINT/SIGTERM that wins the runtime's
256/// shutdown select still closes admin early.
257#[derive(Debug, Clone, Default, Serialize, Deserialize)]
258pub struct DrainResponse {
259    /// `true` once the trigger has been fired and the runtime's
260    /// shutdown sequence is underway. "Initiated", not "completed":
261    /// when `wait_admin_honored` is `false`, the admin server may
262    /// close before the response itself is delivered (the SIGTERM
263    /// ordering).
264    pub initiated: bool,
265    /// `true` when the server received `wait_admin: true` *and* is
266    /// keeping admin alive through `router.shutdown` on this drain.
267    /// The `--wait` client treats `false` as "server cannot observe
268    /// completion safely" and refuses to poll.
269    pub wait_admin_honored: bool,
270}
271
272/// Per-bucket fill stat for `admin_v1_status` (issue #741). One entry
273/// per *non-empty* Kademlia k-bucket — empty buckets (the vast majority
274/// of the 256-bucket keyspace on a small network) are omitted so the
275/// snapshot stays compact. The bucket capacity is the same Kademlia `K`
276/// for every bucket and so lives once on [`RoutingHealth::bucket_capacity`]
277/// rather than being repeated here.
278#[derive(Debug, Clone, Serialize, Deserialize)]
279pub struct BucketStat {
280    /// Bucket index `0..=255` (XOR-distance shell from this node's id).
281    pub index: u16,
282    /// Live peers currently held in the bucket
283    /// (`1..=`[`RoutingHealth::bucket_capacity`]).
284    pub fill: u16,
285}
286
287/// Routing-table health section of `admin_v1_status` (issue #741).
288#[derive(Debug, Clone, Serialize, Deserialize)]
289pub struct RoutingHealth {
290    /// Total peers across all buckets. Denormalized convenience for
291    /// `--json` consumers and the grep-stable summary line; equals the sum
292    /// of every [`BucketStat::fill`].
293    pub total_peers: u64,
294    /// Number of non-empty buckets. Denormalized convenience equal to
295    /// `buckets.len()`; kept as a stable `key=value` token on the summary
296    /// line so operator scripts need not sum the table.
297    pub non_empty_buckets: u64,
298    /// Per-bucket fill, one entry per non-empty bucket, ascending by index.
299    pub buckets: Vec<BucketStat>,
300    /// Maximum entries any bucket can hold (Kademlia `K`, currently 20).
301    /// One value for the whole table — the renderer pairs it with each
302    /// [`BucketStat::fill`] to show a per-bucket fill ratio.
303    pub bucket_capacity: u16,
304    /// Bucket-refresh interval in whole seconds (ADR 022 §Routing Table,
305    /// default 1 hour). The refresh task refreshes *every* non-empty
306    /// bucket once per interval.
307    pub refresh_interval_s: u64,
308    /// Microseconds-since-epoch the most recent bucket-refresh pass
309    /// completed, or `None` if no pass has run yet (node up less than one
310    /// interval). The refresh pass touches all non-empty buckets at once,
311    /// so this is a single network-wide timestamp rather than a per-bucket
312    /// value.
313    pub last_refresh_us: Option<u64>,
314}
315
316/// DHT provider-record store utilization for `admin_v1_status` (issue #741).
317#[derive(Debug, Clone, Serialize, Deserialize)]
318pub struct RecordStoreHealth {
319    /// Provider records currently held.
320    pub records: u64,
321    /// Global record cap (`RecordStoreConfig::max_records_global`).
322    pub capacity: u64,
323}
324
325/// Republish-scheduler health for `admin_v1_status` (issue #741).
326#[derive(Debug, Clone, Serialize, Deserialize)]
327pub struct RepublishHealth {
328    /// Content hashes currently scheduled for periodic republish.
329    pub scheduled_records: u64,
330}
331
332/// Response body for `admin_v1_status` (issue #741): a single-shot
333/// snapshot of this node's DHT participation health, so an operator can
334/// diagnose cold-start / routing-table degradation without scraping
335/// Prometheus or reading logs.
336#[derive(Debug, Clone, Serialize, Deserialize)]
337pub struct StatusResponse {
338    /// Lowercase hex of this node's iroh `PublicKey` — same encoding as
339    /// [`HealthResponse::node_id`].
340    pub node_id: String,
341    /// Kademlia routing-table health.
342    pub routing: RoutingHealth,
343    /// Active stakers known to this node (the chain-backed `StakerSet`
344    /// cardinality — the set the DHT admits records and routes from).
345    pub known_stakers: u64,
346    /// DHT provider-record store utilization.
347    pub record_store: RecordStoreHealth,
348    /// Republish-scheduler depth.
349    pub republish: RepublishHealth,
350    /// Number of origins currently in the on-chain origin deny-set
351    /// (`ContentBlacklist` origin blacklist, ADR 011). Read from the live
352    /// denylist the client handler and blacklist watcher share; `0` when no
353    /// denylist is wired.
354    pub chain_denied_origins: u64,
355    /// The operator Ethereum address this node runs under — the wallet it is
356    /// funded, bonded, registered, and paid under. Rendered as an EIP-55
357    /// mixed-case checksummed hex string (`0x`-prefixed), the same encoding
358    /// [`LaneSnapshot::counterparty`] uses. Sampled once at bring-up from the
359    /// loaded eth keystore (the same value the node-id binding check derives),
360    /// not re-read per call. `None` when the operator can't be resolved — an
361    /// `AdminState` with no chain wiring (unit tests, cache-only nodes) —
362    /// matching how [`HealthResponse::bound_node_id`] degrades. The address is
363    /// public; no key material is ever exposed here.
364    pub operator_address: Option<String>,
365}
366
367/// JSON view of one lane this node provides against a `PaymentPool`,
368/// emitted by `admin_v1_lanes` (issue #749). A lane is keyed by
369/// `(pool_id, signer, provider)` — `decdn_incentive::LaneKey`. Defined
370/// separately from `decdn-incentive`'s internal `LaneState` so the
371/// replay-critical `last_*` accessors, the `U256` money types, and any
372/// future internal fields can't leak into the wire format: every field
373/// here is a plain owned wire value. Shared between the server
374/// (serializes) and `decdn node lanes` (deserializes via the generated
375/// client).
376///
377/// Money amounts are reported in **micro-USDC** (`u64`) — the same base
378/// unit `blockchain.redeem_threshold_micro_usdc` is configured in. The
379/// underlying `LaneState` carries them as `U256`; the server narrows with
380/// `u64::try_from(...).unwrap_or(u64::MAX)`, so a value that somehow
381/// exceeded `u64::MAX` micro-USDC (~1.8e13 USDC — unreachable for a real
382/// pool) saturates rather than wraps.
383#[derive(Debug, Clone, Serialize, Deserialize)]
384pub struct LaneSnapshot {
385    /// Lowercase hex of the on-chain `PaymentPool` id (`LaneKey::pool_id`),
386    /// `0x`-prefixed — same 32-byte encoding `alloy`'s `B256` `Display`
387    /// produces. Shared by every lane drawing from the same pool.
388    pub pool_id: String,
389    /// The pool owner's Ethereum address (`getPool.owner`) — the deposit owner
390    /// and refund destination, and the subject of the ADR-011 blacklist gates.
391    /// Rendered as an EIP-55 mixed-case checksummed hex string
392    /// (`0x`-prefixed) — `alloy`'s `Address` `Display`. Note this differs
393    /// from an iroh node id, which is plain lowercase hex of an Ed25519
394    /// public key (a different identity type, not an EVM address). It is
395    /// *not* necessarily the voucher signer — see [`Self::voucher_signer`].
396    ///
397    /// The node names the owner from its event-fed pool view, then from an
398    /// owner cache, then with one bounded `getPool` read. A lane the node
399    /// carries across a restart takes the read path on the first call. `None`
400    /// when that read fails or finds no such pool; the node logs the failure
401    /// and reads again on the next call. The node never reports the signer
402    /// in its place.
403    pub counterparty: Option<String>,
404    /// The capability signer authorizing vouchers on this lane
405    /// (`LaneKey::signer`): the EIP-712 recovery target for every voucher,
406    /// in the same EIP-55 rendering as [`Self::counterparty`]. Equal to the
407    /// pool owner only when the owner signs its own vouchers, as a node buyer
408    /// does. A `decdn get` on its own wallet's pool signs with a lane key
409    /// derived for its data dir, and a `decdn get --capability` signs with
410    /// the delegate key the capability names, so both differ from the owner.
411    pub voucher_signer: String,
412    /// Sequence number of the most-recently-accepted voucher. Not tracked
413    /// in the shared-pool model (a pool has no per-lane nonce), so this
414    /// always reports `0`.
415    pub last_nonce: u64,
416    /// Cumulative amount of the most-recently-accepted voucher, in
417    /// micro-USDC (`LaneState::last_amount`). This is the node's total
418    /// accrued claim on this lane — the figure that crosses the
419    /// redemption threshold. `0` before any voucher.
420    pub outstanding_micro_usdc: u64,
421    /// Not tracked in the shared-pool model (the pool's deposit is shared
422    /// across every lane it funds, so no single lane owns a deposit), so
423    /// this always reports `0`.
424    pub deposit_micro_usdc: u64,
425    /// Whole seconds since this process last accepted a voucher on this
426    /// lane, or `None` when no voucher has been observed *since the node
427    /// started*. The activity clock is in-memory: a lane hydrated from
428    /// `lanes.redb` at boot reports `None` until its next voucher, because
429    /// the persisted `LaneState` carries no last-voucher wall-clock.
430    /// Operators use this to spot stale lanes (high `outstanding` but no
431    /// recent vouchers).
432    pub seconds_since_last_voucher: Option<u64>,
433    /// `true` when the accrued claim (`outstanding_micro_usdc`) has
434    /// reached the node's configured redemption threshold
435    /// (`blockchain.redeem_threshold_micro_usdc`), i.e. the redeemer would
436    /// redeem this lane on its next tick. This is an **upper-bound**
437    /// signal: the admin surface does not read the on-chain redeemed
438    /// amount, so it compares the full accrued claim (not the
439    /// un-redeemed delta) against the threshold. A lane that already
440    /// redeemed up to its current claim may still report `true` until the
441    /// next voucher advances it — surfaced so operators can see which
442    /// lanes are *at or above* the redemption bar.
443    pub settlement_eligible: bool,
444}
445
446/// Response body for `admin_v1_lanes` (issue #749). Shared between the
447/// server (serializes), `decdn node lanes` (deserializes via the
448/// generated client), and the integration tests.
449#[derive(Debug, Clone, Serialize, Deserialize)]
450pub struct LanesResponse {
451    /// One entry per lane the node currently tracks. Ordering is most
452    /// recently active first (lanes with a known last-voucher time ahead
453    /// of those without), then by descending outstanding amount — the
454    /// on-call use case is "which lanes are closest to a settlement /
455    /// liquidity event?".
456    pub lanes: Vec<LaneSnapshot>,
457    /// The node's configured redemption threshold in micro-USDC
458    /// (`blockchain.redeem_threshold_micro_usdc`). Echoed once at the top
459    /// level — rather than repeated per lane — so the renderer can show
460    /// the bar each [`LaneSnapshot::settlement_eligible`] is measured
461    /// against without the operator cross-referencing the config.
462    pub redeem_threshold_micro_usdc: u64,
463}
464
465/// One lane the node pays on, as the buyer's own store records it
466/// (`admin_v1_pools`). The buyer-side mirror of [`LaneSnapshot`]: where that
467/// reports what a lane has accrued *against* this node, this reports what this
468/// node has already promised to pay a provider.
469#[derive(Debug, Clone, Serialize, Deserialize)]
470pub struct BuyerLaneSnapshot {
471    /// The capability signer whose key authorized the vouchers on this lane
472    /// (`LaneKey::signer`), EIP-55 checksummed hex. Equal to the pool owner for
473    /// the ordinary self-signing case, and a delegate key otherwise.
474    pub voucher_signer: String,
475    /// The provider being paid on this lane (`LaneKey::provider`), EIP-55
476    /// checksummed hex.
477    pub provider: String,
478    /// Cumulative amount of the last voucher this node signed on the lane, in
479    /// micro-USDC. This is the node's own spend watermark: re-signing a lower
480    /// total is what strands a lane, so it is the figure to compare against
481    /// a provider's complaint.
482    pub last_amount_micro_usdc: u64,
483    /// Bytes delivered as of that same voucher.
484    pub last_bytes_delivered: u64,
485}
486
487/// One `PaymentPool` deposit the node's buyer leg tracks (`admin_v1_pools`).
488///
489/// The amounts are the *stored* ones, not a chain read: this is what the daemon
490/// believes, which is the half an operator cannot otherwise see. A disagreement
491/// with `PaymentPool.getPool` is itself the diagnostic.
492#[derive(Debug, Clone, Serialize, Deserialize)]
493pub struct BuyerPoolSnapshot {
494    /// The on-chain pool id, `0x`-prefixed 32-byte hex.
495    ///
496    /// Unique only within one deployment (`chain_id` and `payment_pool`): the
497    /// contract derives it as `keccak256(owner, ownerPoolNonce)` and a fresh
498    /// deployment restarts that nonce, so two deployments' Nth pools share an
499    /// id. Read it alongside `chain_id` and `payment_pool`, never alone.
500    pub pool_id: String,
501    /// The EIP-712 chain id of the deployment the row was written against.
502    pub chain_id: u64,
503    /// The `PaymentPool` the row was written against, EIP-55 checksummed hex.
504    ///
505    /// With `chain_id`, the only fields that tell a live pool from a same-id
506    /// pool on a deployment the node is not configured against — which is what
507    /// makes them the fields to check when a tracked id and an on-chain
508    /// enumeration appear to agree.
509    pub payment_pool: String,
510    /// The deposit owner — this node's operator address, EIP-55 checksummed hex.
511    pub owner: String,
512    /// The ERC-20 the pool is denominated in (USDC per ADR 003), EIP-55
513    /// checksummed hex.
514    pub token: String,
515    /// The pool's escrowed deposit in micro-USDC, as the store recorded it.
516    ///
517    /// The stored value is a `U256`; the server narrows with
518    /// `u64::try_from(..).unwrap_or(u64::MAX)`, so a deposit that somehow
519    /// exceeded `u64::MAX` micro-USDC (~1.8e13 USDC — unreachable for a real
520    /// pool) saturates rather than wraps. The same holds for every
521    /// `U256`-derived field on [`BuyerLaneSnapshot`], including
522    /// `last_bytes_delivered`.
523    pub deposit_micro_usdc: u64,
524    /// Every lane funded from this pool, ordered by `(signer, provider)`. One
525    /// pool fans out to every provider the owner pays (ADR 003), so this is
526    /// where the pool's spend is actually visible.
527    pub lanes: Vec<BuyerLaneSnapshot>,
528}
529
530/// Snapshot of the node's buyer-side `PaymentPool` state (`admin_v1_pools`).
531///
532/// This is the only way to read `buyer.redb` on a running node: `redb` holds a
533/// process-exclusive lock on that file for the daemon's lifetime, so no CLI can
534/// open it — not even read-only — while the node is up.
535#[derive(Debug, Clone, Serialize, Deserialize)]
536pub struct BuyerPoolsResponse {
537    /// One entry per tracked pool, ordered by `pool_id` so the output is stable
538    /// across calls regardless of the store's internal key order.
539    pub pools: Vec<BuyerPoolSnapshot>,
540    /// `pool_id`s whose stored record would not decode, `0x`-prefixed hex.
541    /// Their deposits stay escrowed but untracked, so an empty [`Self::pools`]
542    /// beside a non-empty `skipped` does not mean "this node owns no pools".
543    pub skipped: Vec<String>,
544}
545
546/// One slash detected against this node's operator (`SlashJudge.Slashed`,
547/// #1032, G-NODE-05). Surfaced so an operator (or a keeper script) can notice a
548/// slash and file `decdn appeal slash` within the 30-day window without watching
549/// the chain directly.
550#[derive(Debug, Clone, Serialize, Deserialize)]
551pub struct SlashRecordDto {
552    /// The globally-monotonic on-chain `slashId` (`CapacityBond.slash`),
553    /// rendered as a decimal string — it is a `uint256` and can exceed `u64`.
554    /// This is the value passed to `decdn appeal slash <SLASH_ID>`.
555    pub slash_id: String,
556    /// Offense taxonomy index (ADR 014): `0` = `RateManipulation`,
557    /// `1` = Blacklist. Kept numeric to avoid drift if the enum grows.
558    pub offense_type: u8,
559    /// Bond amount slashed, in TOKEN base units, as a decimal string (`uint256`).
560    pub amount: String,
561    /// `keccak256` evidence digest from the `Slashed` event (ADR 014),
562    /// `0x`-prefixed 32-byte hex.
563    pub evidence_hash: String,
564    /// Block number the `Slashed` log was mined in, or `None` if the log was
565    /// still pending when observed (rare; live logs carry a block number).
566    pub block_number: Option<u64>,
567    /// **Nominal** appeal-window close (Unix seconds): the `Slashed` block
568    /// timestamp + 30 days, or `None` if the block read failed. This is a cheap
569    /// client-side hint, NOT read from the `CapacityBond` slash record, and it
570    /// ignores protocol-pause extensions — which only ever move the real
571    /// deadline *later* (`markAppealOpen` adds `pausedTotal`). Safe to file
572    /// before this; a keeper must not treat a just-past value as final.
573    pub appeal_window_close: Option<u64>,
574}
575
576/// Snapshot of every slash the watcher has detected against this node's
577/// operator (#1032). Empty list when none — a clean operator.
578#[derive(Debug, Clone, Serialize, Deserialize)]
579pub struct SlashesResponse {
580    /// One entry per distinct detected `slashId`, most-recent first.
581    pub slashes: Vec<SlashRecordDto>,
582}
583
584/// JSON-RPC error code: the request shape was wrong (bad hex, etc.).
585/// Matches the standard JSON-RPC 2.0 `Invalid params` code.
586pub const INVALID_PARAMS_CODE: i32 = -32_602;
587
588/// JSON-RPC error code: the cache layer reported an error during evict
589/// (e.g. the underlying iroh-blobs store I/O failed when persisting the
590/// evicted-hash log).
591pub const CACHE_ERROR_CODE: i32 = -32_002;
592
593/// JSON-RPC error code: the node was started without a config file
594/// path, so `admin_v1_reload` has nothing to re-read. Distinct from
595/// [`RELOAD_ERROR_CODE`] so an operator script can tell "this node
596/// can't reload, ever, until it's restarted with `--config <path>`"
597/// from "this node tried and failed".
598pub const CONFIG_PATH_UNSET_CODE: i32 = -32_003;
599
600/// JSON-RPC error code: `RuntimeReloadState::reload` returned an error
601/// (file unreadable, malformed TOML, rejected resolution, mutex poison,
602/// log-level setter failed). Mirrors the SIGHUP arm's "previous values
603/// retained" guarantee — by the time this surfaces, the running config
604/// is unchanged.
605pub const RELOAD_ERROR_CODE: i32 = -32_004;
606
607/// JSON-RPC error code: `admin_v1_status` was called on a node whose DHT
608/// subsystem is not wired (e.g. a future CLI-only or test invocation that
609/// brings up the admin surface without the DHT handler). A benign,
610/// expected configuration state — distinct from a generic failure so an
611/// operator gets "this node has no DHT to report on" rather than a
612/// confusing transport error, and distinct from [`DHT_POISONED_CODE`] so
613/// it isn't confused with an in-process fault.
614pub const DHT_UNAVAILABLE_CODE: i32 = -32_005;
615
616/// JSON-RPC error code: `admin_v1_status` found a DHT subsystem mutex
617/// (routing table or record store) poisoned — i.e. a thread panicked
618/// while holding it, so the DHT's in-memory state may be inconsistent.
619/// This is a severe in-process fault, kept distinct from the benign
620/// [`DHT_UNAVAILABLE_CODE`] ("no DHT wired") so an operator script can
621/// tell "this node never had a DHT" from "this node's DHT just broke".
622/// The server also logs the poisoning at `error` level.
623pub const DHT_POISONED_CODE: i32 = -32_006;
624
625/// JSON-RPC error code: `admin_v1_lanes` could not read the pool state
626/// store (issue #749) — e.g. the redb load failed or a poisoned
627/// in-memory mutex. A read-side fault distinct from the cache/DHT codes
628/// so an operator script can tell "lane snapshot is unavailable right
629/// now" from a generic transport failure. The server also logs the
630/// underlying store error.
631pub const POOL_STORE_ERROR_CODE: i32 = -32_007;
632
633/// JSON-RPC error code: `admin_v1_slashes` was called on a node whose slash
634/// watcher is not wired (e.g. no `slash_judge_address`, or a test/CLI-only
635/// invocation). Benign config state, distinct from a generic failure so an
636/// operator gets "no slash detection on this node" rather than an opaque error.
637pub const SLASH_DETECTION_UNAVAILABLE_CODE: i32 = -32_009;
638
639/// JSON-RPC error code: `admin_v1_pools` could not read the buyer pool store.
640/// Distinct from [`POOL_STORE_ERROR_CODE`], which covers the seller-side lane
641/// store: the two are different files, and a fault in one says nothing about
642/// the other.
643pub const BUYER_POOL_STORE_ERROR_CODE: i32 = -32_010;
644
645/// JSON-RPC error code: `admin_v1_pools` was called on an admin surface with
646/// no buyer pool store attached, so it can say nothing about what this node
647/// tracks.
648///
649/// Deliberately an error rather than an empty list — unlike `admin_v1_lanes`,
650/// which reports zero lanes as a legitimate state. "This node does not pay for
651/// pulls" and "this node owns no pools" demand opposite responses from an
652/// operator hunting a stranded deposit, and rendering the first as the second
653/// is the ambiguity this method exists to remove.
654///
655/// The production runtime attaches the buyer store unconditionally, so a
656/// running daemon does not answer this: it covers a hand-built `AdminState` —
657/// a test, or a future bring-up with no buy leg — the same posture as
658/// [`DHT_UNAVAILABLE_CODE`]. The alternative for that arm would be an empty
659/// list, which is the answer this code exists to avoid giving.
660pub const BUYER_POOL_UNAVAILABLE_CODE: i32 = -32_011;
661
662/// Admin RPC surface. Versioned via the namespace prefix
663/// (`admin_v1_...`): new methods may be added backwards-compatibly
664/// within `v1`, a breaking change cuts over to `admin_v2_...`.
665#[cfg_attr(feature = "admin-server", rpc(server, client, namespace = "admin_v1"))]
666#[cfg_attr(not(feature = "admin-server"), rpc(client, namespace = "admin_v1"))]
667pub trait AdminRpc {
668    /// Return this node's identity and process uptime.
669    #[method(name = "health")]
670    async fn health(&self) -> RpcResult<HealthResponse>;
671
672    /// Evict a single blob from the local cache (issue #279). The
673    /// eviction is logical: the iroh-blobs store still holds the bytes
674    /// (`Blobs::delete` is `pub(crate)` in iroh-blobs and reserved for
675    /// the GC task) until the next periodic GC sweep reclaims them
676    /// (#518). The takedown itself is persisted to
677    /// `<cache_dir>/evicted.log` so it survives a restart even when
678    /// the next sweep hasn't run yet.
679    ///
680    /// When `req.dry_run` is `true` (issue #379) the cache state is
681    /// *not* mutated: the response carries the pre-evict snapshot
682    /// (size, last-access elapsed time, pin status, already-evicted
683    /// flag) so operators running DMCA takedowns can confirm what the real
684    /// evict will touch
685    /// before committing. The same response shape is used for the
686    /// real-evict path with the snapshot reflecting the
687    /// pre-mutation state.
688    #[method(name = "evict")]
689    async fn evict(&self, req: EvictRequest) -> RpcResult<EvictResponse>;
690
691    /// Re-read the config file the node was started with and apply the
692    /// reloadable subset (issue #373) — the same path SIGHUP triggers,
693    /// exposed over the loopback admin surface for operators who want
694    /// scripted control without `kill -HUP`. Both paths share the same
695    /// `RuntimeReloadState::reload`, whose internal mutexes serialise
696    /// concurrent reloads, so a SIGHUP racing this RPC waits behind it
697    /// rather than corrupting state. Returns:
698    ///
699    /// - [`CONFIG_PATH_UNSET_CODE`] when the node was started without a
700    ///   config path (CLI-only flag invocation has nothing to re-read).
701    /// - [`RELOAD_ERROR_CODE`] when the reload itself fails — previous
702    ///   values are retained, matching the SIGHUP behaviour.
703    /// - On success: [`ReloadResponse`] carrying the post-reload
704    ///   `log_level`.
705    #[method(name = "reload")]
706    async fn reload(&self) -> RpcResult<ReloadResponse>;
707
708    /// Trigger graceful shutdown via the same path SIGTERM exercises (issue
709    /// #244, `appendix-local-admin-http.md`). Stops the iroh router's accept loop and awaits
710    /// in-flight `ProtocolHandler::shutdown` calls; the subsequent task
711    /// drain is bounded by the runtime's 15s `SHUTDOWN_DEADLINE` (the
712    /// router-shutdown step itself is unbounded — a stuck handler hangs
713    /// the runtime, only the post-router task join is timeout-gated).
714    ///
715    /// Fire-and-forget by default: the response returns as soon as the
716    /// trigger lands, not when shutdown completes. Equivalent to `kill
717    /// -TERM <pid>` for operators who'd rather not stat the PID.
718    ///
719    /// The [`DrainRequest`] parameter is required. `wait_admin: false`
720    /// gets the SIGTERM-equivalent shutdown order. `wait_admin: true`
721    /// (issue #604) asks the runtime to keep the admin server alive
722    /// through `router.shutdown` so a polling client can observe
723    /// `admin_v1_health.in_flight_streams` reach 0.
724    #[method(name = "drain")]
725    async fn drain(&self, req: DrainRequest) -> RpcResult<DrainResponse>;
726
727    /// Return a snapshot of this node's DHT participation health (issue
728    /// #741): routing-table bucket fill rates, the network-wide last
729    /// bucket-refresh timestamp, active-staker count, provider-record
730    /// store utilization, and republish-scheduler depth. Intended for
731    /// `decdn node status`, giving operators a single human-readable (or
732    /// `--json`) view to diagnose cold-start or routing-table
733    /// degradation without scraping Prometheus. Returns
734    /// [`DHT_UNAVAILABLE_CODE`] when the DHT subsystem is not wired on
735    /// this node.
736    #[method(name = "status")]
737    async fn status(&self) -> RpcResult<StatusResponse>;
738
739    /// Return a live snapshot of every lane this node provides against a
740    /// `PaymentPool` (issue #749): per lane the outstanding accrued claim,
741    /// time since the last voucher (in-memory, `None` after a restart
742    /// until the next voucher), and whether the accrued claim has reached
743    /// the redemption threshold. Backs `decdn node lanes`, giving
744    /// operators a single view to spot lanes approaching settlement,
745    /// stale lanes, or unusually high outstanding balances before they
746    /// become a liquidity risk — without scraping metrics or logs.
747    /// Returns [`POOL_STORE_ERROR_CODE`] if the pool state store cannot
748    /// be read.
749    #[method(name = "lanes")]
750    async fn lanes(&self) -> RpcResult<LanesResponse>;
751
752    /// Return every slash the node's watcher has detected against its own
753    /// operator (#1032, G-NODE-05): per slash the `slashId`, offense type,
754    /// amount, evidence digest, block, and the 30-day appeal-window close time.
755    /// Consumed directly by operators/keepers (no CLI subcommand wraps it) so
756    /// they notice a slash and file `decdn appeal slash` in time. Returns
757    /// [`SLASH_DETECTION_UNAVAILABLE_CODE`]
758    /// when the slash watcher is not wired on this node.
759    #[method(name = "slashes")]
760    async fn slashes(&self) -> RpcResult<SlashesResponse>;
761
762    /// Return the node's buyer-side `PaymentPool` state: every pool its buyer
763    /// leg tracks, the deposit it believes each holds, and the per-lane spend
764    /// watermark it has signed against each provider.
765    ///
766    /// This is the buyer-side counterpart of [`Self::lanes`], and the only read
767    /// path to `buyer.redb` on a running node — `redb` holds a
768    /// process-exclusive lock on that file for the daemon's lifetime, so a CLI
769    /// pointed at the daemon's data dir cannot open it and would otherwise
770    /// report an unrelated empty store as the node's state.
771    ///
772    /// Returns [`BUYER_POOL_UNAVAILABLE_CODE`] when no buyer leg is wired, and
773    /// [`BUYER_POOL_STORE_ERROR_CODE`] when the store cannot be read.
774    #[method(name = "pools")]
775    async fn pools(&self) -> RpcResult<BuyerPoolsResponse>;
776}
777
778/// Decode a 64-character hex BLAKE3 hash into a [`struct@Hash`].
779///
780/// Goes through `alloy::primitives::hex::decode` (case-insensitive,
781/// `0x`/`0X`-prefix-tolerant) rather than `Hash::from_str` so operators
782/// may paste a `0x`-prefixed hash and get a uniform `INVALID_PARAMS`
783/// error on malformed input. (The leaf [`struct@Hash`]'s own `FromStr`
784/// is panic-free since #578 — unlike the old iroh-blobs `Hash::from_str`
785/// which fell through to a base32 decoder that panicked on a
786/// wrong-size output buffer — but it does not accept a `0x` prefix, so
787/// the alloy path is kept for the operator ergonomics.)
788///
789/// # Errors
790/// Returns an [`ErrorObjectOwned`] with [`INVALID_PARAMS_CODE`] when the
791/// input is not valid hex or doesn't decode to exactly 32 bytes.
792pub fn parse_hash_arg(hex: &str) -> Result<Hash, ErrorObjectOwned> {
793    let trimmed = hex
794        .strip_prefix("0x")
795        .or_else(|| hex.strip_prefix("0X"))
796        .unwrap_or(hex);
797    let bytes = alloy::primitives::hex::decode(trimmed).map_err(|err| {
798        ErrorObjectOwned::owned(
799            INVALID_PARAMS_CODE,
800            format!("invalid hash {hex:?}: {err}"),
801            None::<()>,
802        )
803    })?;
804    let arr: [u8; 32] = bytes.as_slice().try_into().map_err(|_| {
805        ErrorObjectOwned::owned(
806            INVALID_PARAMS_CODE,
807            format!(
808                "invalid hash {hex:?}: expected 32 bytes (64 hex chars), got {}",
809                bytes.len()
810            ),
811            None::<()>,
812        )
813    })?;
814    Ok(Hash::from_bytes(arr))
815}
816
817#[cfg(test)]
818#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
819mod tests;