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;