Skip to main content

decdn_node/
fee_shares_watcher.rs

1//! `FeeRouter.SharesUpdated` watcher (ADR 041 / ADR 016 § Tunable Economics).
2//!
3//! The node seeds its live operator fee-share cell ([`OperatorShares`]) from an
4//! authoritative `getShares()` read at startup (see `runtime::mod`). This
5//! watcher keeps that cell current: it follows `SharesUpdated` events off the
6//! same shared-head `eth_getLogs` poller every other chain watcher uses, and —
7//! as a safety net against a missed log — periodically re-reads `getShares()`
8//! authoritatively at `fee_shares_poll_interval`. Both paths `store` into the
9//! shared [`OperatorShares`], so a governance retune of the fee split reaches
10//! the running serve-economics margin calculation without a restart.
11//!
12//! The sink is read-only and holds no durable cursor, and it scans no history:
13//! the tail starts AT head. There is nothing for a lookback to recover — the
14//! startup `getShares()` read is authoritative and already reflects every event
15//! ever emitted, so re-scanning blocks below it can only re-derive a value the
16//! node already holds. The periodic re-read covers anything the tail drops.
17
18use std::sync::Arc;
19use std::time::{Duration, Instant};
20
21use alloy::primitives::{Address, U256};
22use alloy::providers::Provider;
23use alloy::rpc::types::eth::Log;
24use alloy::sol_types::SolEvent;
25use anyhow::Result;
26use decdn_common::redact::sanitize_err_chain;
27use decdn_incentive::payment_pool::FeeRouter;
28
29use crate::chain_events::multiplexed_poller::{Route, SinkSource};
30use crate::chain_events::resumable_watcher::{CursorStart, LogSink, clear_cadence_on_recovery};
31use crate::chain_events::timed;
32use crate::fee_shares::{OperatorShares, operator_bps_from_shares};
33
34/// Projection sink for `SharesUpdated`: decodes each event into the shared
35/// operator-fee-share cell, and re-reads `getShares()` authoritatively once
36/// per `poll_interval` at the end of a clean tick.
37struct FeeSharesSink<P: Provider + Clone> {
38    contract: FeeRouter::FeeRouterInstance<P>,
39    shares: OperatorShares,
40    /// Authoritative-re-read cadence (`fee_shares_poll_interval`).
41    poll_interval: Duration,
42    /// When the last authoritative re-read ran; `None` until the first.
43    last_poll: Option<Instant>,
44    /// Counts failed re-reads into `decdn_fee_shares_watcher_poll_failures_total`.
45    metrics: Arc<crate::metrics::Metrics>,
46}
47
48impl<P: Provider + Clone> FeeSharesSink<P> {
49    /// Narrow the on-chain `[operator, buyback, treasury]` split to the
50    /// operator's bps and store it. Fail-closed at the narrowing step
51    /// (`operator_bps_from_shares` rejects an out-of-range split), so a bad
52    /// split here simply never overwrites the current cell: it is logged and the
53    /// previous share is kept, because signing quotes against a value we cannot
54    /// validate is worse than signing against the last-known-good one.
55    fn store_shares(&self, raw_shares: [U256; 3], source: &str) {
56        match operator_bps_from_shares(raw_shares, source) {
57            Ok(bps) => {
58                let previous = self.shares.store(bps);
59                if previous == bps {
60                    tracing::debug!(bps, source, "operator fee share unchanged on chain");
61                } else {
62                    tracing::info!(
63                        bps,
64                        previous,
65                        source,
66                        "operator fee share updated from chain"
67                    );
68                }
69            }
70            Err(err) => {
71                tracing::error!(
72                    error = %err,
73                    source,
74                    "fee-shares watcher: on-chain FeeRouter split could not be narrowed to \
75                     operator bps; keeping the current share — governance must fix the split"
76                );
77            }
78        }
79    }
80}
81
82impl<P: Provider + Clone + 'static> LogSink for FeeSharesSink<P> {
83    async fn apply(&mut self, log: Log) -> Result<()> {
84        // Filter is scoped to the single SharesUpdated topic; match
85        // defensively so an unexpected log is skipped, not misdecoded.
86        if log.topic0() == Some(&FeeRouter::SharesUpdated::SIGNATURE_HASH) {
87            match FeeRouter::SharesUpdated::decode_log_data(&log.inner.data) {
88                Ok(ev) => {
89                    self.store_shares(ev.newShares, "event");
90                }
91                Err(err) => {
92                    // Undecodable log: log-and-skip (never Err — a deterministic
93                    // re-scan would hot-loop the cursor).
94                    tracing::warn!(error = %err, "fee-shares watcher: undecodable SharesUpdated log; skipping");
95                }
96            }
97        }
98        Ok(())
99    }
100
101    async fn on_tick_complete(&mut self) -> Result<()> {
102        let now = Instant::now();
103        let due = self
104            .last_poll
105            .is_none_or(|last| now.duration_since(last) >= self.poll_interval);
106        if !due {
107            return Ok(());
108        }
109        // Stamp BEFORE the call, not only on success: a persistently failing
110        // `getShares()` must retry on the slow cadence, not on every watcher
111        // tick, which would hammer the RPC.
112        self.last_poll = Some(now);
113        // Bounded by the per-call timeout: a provider that never answers would
114        // otherwise stall this tick, and with it every route on the poller.
115        match timed(
116            None,
117            "FeeRouter.getShares()",
118            self.contract.getShares().call(),
119        )
120        .await
121        {
122            Ok(shares) => {
123                self.store_shares(shares, "poll");
124            }
125            Err(err) => {
126                // Best-effort safety net: the event path is primary, so a failed
127                // re-read logs and keeps the current share rather than backing
128                // off the whole watcher (which would also stall event pickup).
129                // Returning Ok keeps the cursor advancing.
130                self.metrics.fee_shares_watcher_poll_failure();
131                tracing::warn!(
132                    error = %sanitize_err_chain(&err),
133                    "fee-shares watcher: authoritative getShares() poll failed; keeping current share"
134                );
135            }
136        }
137        Ok(())
138    }
139
140    /// Force the safety-net re-read on the tick the watcher recovers.
141    ///
142    /// [`Self::on_tick_complete`] stamps its clock before the call, so an outage
143    /// spanning a due poll defers the re-read a further `poll_interval` — hours,
144    /// at the default. The node meanwhile prices its share of every settlement
145    /// off a split the chain may have moved.
146    fn on_recovered(&mut self) {
147        clear_cadence_on_recovery(&mut self.last_poll, self.poll_interval);
148    }
149}
150
151/// Build the fee-shares [`Route`] for the shared multiplexed poller.
152/// `poll_interval` is the slower authoritative-re-read safety net
153/// (`fee_shares_poll_interval`) that rides the sink's `on_tick_complete`; the
154/// merged getLogs cadence is the poller's.
155pub fn route<P>(
156    provider: P,
157    fee_router_addr: Address,
158    shares: OperatorShares,
159    poll_interval: Duration,
160    metrics: &Arc<crate::metrics::Metrics>,
161) -> Route
162where
163    P: Provider + Clone + 'static,
164{
165    let contract = FeeRouter::new(fee_router_addr, provider);
166    let sink = FeeSharesSink {
167        contract,
168        shares,
169        poll_interval,
170        // Seeded to "just polled": the runtime performed the authoritative
171        // startup `getShares()` read moments ago, so leaving this `None`
172        // would fire a redundant re-read on the very first tick. The first
173        // safety-net poll is due one `poll_interval` from now.
174        last_poll: Some(Instant::now()),
175        metrics: Arc::clone(metrics),
176    };
177    Route {
178        addresses: vec![fee_router_addr],
179        topic0s: vec![FeeRouter::SharesUpdated::SIGNATURE_HASH],
180        // Start the tail at head — no historical scan at all. `window_blocks: 0`
181        // resolves to head exactly. The startup `getShares()` read is the
182        // authoritative baseline and already folds in every past event, so a
183        // lookback would re-derive a value the node holds; the periodic re-read
184        // is the backstop for anything the tail drops. No durable cursor needed.
185        start: CursorStart::HeadMinusWindow { window_blocks: 0 },
186        sink: SinkSource::Ready(Box::new(sink)),
187        label: "fee-shares",
188        on_established: Some(crate::metrics::metric_hook(
189            metrics,
190            crate::metrics::Metrics::fee_shares_watcher_cycle_established,
191        )),
192        on_backoff: Some(crate::metrics::metric_hook(
193            metrics,
194            crate::metrics::Metrics::fee_shares_watcher_backoff_started,
195        )),
196        // Liveness + panic signals. Load-bearing here more than for most
197        // watchers: this sink's poll-failure and undecodable-log paths both
198        // return `Ok` by design, so without these a route wedged in RPC backoff
199        // (or dead) looks identical to a healthy one while the node signs quotes
200        // against a stale operator fee share.
201        on_tick_success: Some(crate::metrics::metric_hook(
202            metrics,
203            crate::metrics::Metrics::fee_shares_watcher_tick,
204        )),
205        on_task_panic: Some(crate::metrics::metric_hook(
206            metrics,
207            crate::metrics::Metrics::fee_shares_watcher_task_panicked,
208        )),
209    }
210}
211
212#[cfg(test)]
213#[allow(clippy::unwrap_used, clippy::expect_used)]
214mod tests;