Skip to main content

decdn_node/
fee_shares.rs

1//! Live operator fee-share (basis points), read from `FeeRouter.getShares()[0]`.
2//! A single `Arc<AtomicU16>` shared by all clones, seeded at startup and
3//! refreshed by the multiplexed log poller (see `fee_shares_watcher.rs`).
4//! `(1 - f)` numerator for the serve-economics margin.
5
6use std::sync::{
7    Arc,
8    atomic::{AtomicU16, Ordering},
9};
10
11use alloy::primitives::{Address, U256};
12use alloy::providers::Provider;
13use anyhow::Context;
14use decdn_common::redact::sanitize_err_chain;
15use decdn_incentive::payment_pool::{FeeRouter, PaymentPool};
16
17use crate::chain_events::boot_retry::BootRetry;
18use crate::chain_events::timed;
19
20const BPS_DENOMINATOR: u64 = 10_000;
21
22/// Cloneable live operator fee share. All clones share one atomic cell.
23#[derive(Clone, Debug)]
24pub struct OperatorShares {
25    bps: Arc<AtomicU16>,
26}
27
28impl OperatorShares {
29    /// Seed the cell, typically from the startup `getShares()` read.
30    #[must_use]
31    pub fn new(bps: u16) -> Self {
32        Self {
33            bps: Arc::new(AtomicU16::new(bps)),
34        }
35    }
36
37    /// Current operator fee share, in basis points.
38    #[must_use]
39    pub fn bps(&self) -> u16 {
40        self.bps.load(Ordering::Relaxed)
41    }
42
43    /// Publish a new operator share and return the one it replaces — called by
44    /// the fee-shares watcher on a `SharesUpdated` event and by the periodic
45    /// authoritative re-read.
46    pub fn store(&self, bps: u16) -> u16 {
47        self.bps.swap(bps, Ordering::Relaxed)
48    }
49}
50
51/// Narrow the on-chain `[operator, buyback, treasury]` shares to operator bps.
52///
53/// Fail-closed: an out-of-range or unnarrowable operator share is a hard
54/// error, not a clamp, so a malformed read never silently understates the
55/// skim.
56pub fn operator_bps_from_shares(shares: [U256; 3], addr: &str) -> anyhow::Result<u16> {
57    let operator = shares.first().copied().unwrap_or(U256::ZERO);
58    let raw = u64::try_from(operator).map_err(|_| {
59        anyhow::anyhow!("FeeRouter operator share {operator} at {addr} exceeds u64")
60    })?;
61    anyhow::ensure!(
62        raw <= BPS_DENOMINATOR,
63        "FeeRouter operator share {raw} at {addr} exceeds {BPS_DENOMINATOR} bps"
64    );
65    // raw <= BPS_DENOMINATOR (10_000), so this narrowing is always lossless.
66    u16::try_from(raw)
67        .map_err(|_| anyhow::anyhow!("FeeRouter operator share {raw} at {addr} exceeds u16"))
68}
69
70/// The startup fee-share state: the router the fee-shares watcher follows, and
71/// the operator share that seeds [`OperatorShares`].
72#[derive(Debug, PartialEq, Eq)]
73pub(crate) struct FeeShareSeed {
74    /// `PaymentPool.feeRouter()`, or `None` when it cannot be read. With no
75    /// router address there is nothing to watch, so the runtime registers the
76    /// fee-shares route only when this is `Some`. When `None`, `operator_bps` is
77    /// `floor_bps`.
78    pub(crate) router: Option<Address>,
79    /// The operator share, or `floor_bps` when it cannot be read or narrowed.
80    pub(crate) operator_bps: u16,
81}
82
83/// Read the fee router and its operator share at startup.
84///
85/// Neither read is fatal to boot: the serve-economics margin is an economic
86/// optimization, not a safety invariant, so a failure seeds `floor_bps` and the
87/// node still comes up. Both reads still retry transient errors on `boot`'s
88/// shared deadline, bounded by the per-call RPC timeout. `feeRouter` is
89/// immutable, so a missed `feeRouter()` read leaves the fee-shares watcher
90/// unregistered for the life of the process; a retry at boot is the only
91/// recovery. A deterministic error falls back at once.
92///
93/// A failed `getShares()` read still returns the router: the watcher's periodic
94/// authoritative re-read recovers the share from there.
95pub(crate) async fn seed_from_chain<P: Provider + Clone>(
96    provider: P,
97    payment_pool_addr: Address,
98    floor_bps: u16,
99    boot: &BootRetry,
100) -> FeeShareSeed {
101    let pool = PaymentPool::new(payment_pool_addr, provider.clone());
102    let router = match boot
103        .run("PaymentPool.feeRouter()", || async {
104            timed(None, "PaymentPool.feeRouter()", pool.feeRouter().call())
105                .await
106                .with_context(|| format!("PaymentPool.feeRouter() at {payment_pool_addr}"))
107        })
108        .await
109    {
110        Ok(router) => router,
111        Err(err) => {
112            tracing::warn!(
113                error = %sanitize_err_chain(&err),
114                fallback_bps = floor_bps,
115                "PaymentPool.feeRouter() startup read failed; operator fee share seeded to the \
116                 FeeRouter OPERATOR_BPS_FLOOR and the fee-shares watcher is not registered"
117            );
118            return FeeShareSeed {
119                router: None,
120                operator_bps: floor_bps,
121            };
122        }
123    };
124
125    let operator_bps = read_operator_bps(&FeeRouter::new(router, provider), floor_bps, boot).await;
126    FeeShareSeed {
127        router: Some(router),
128        operator_bps,
129    }
130}
131
132/// The `getShares()` leg of [`seed_from_chain`]: the operator share, or
133/// `floor_bps` when the read fails or the split cannot be narrowed.
134async fn read_operator_bps<P: Provider + Clone>(
135    fee_router: &FeeRouter::FeeRouterInstance<P>,
136    floor_bps: u16,
137    boot: &BootRetry,
138) -> u16 {
139    let router = *fee_router.address();
140    match boot
141        .run("FeeRouter.getShares()", || async {
142            timed(None, "FeeRouter.getShares()", fee_router.getShares().call())
143                .await
144                .with_context(|| format!("FeeRouter.getShares() at {router}"))
145        })
146        .await
147    {
148        Ok(shares) => match operator_bps_from_shares(shares, &router.to_string()) {
149            Ok(bps) => bps,
150            Err(err) => {
151                tracing::warn!(
152                    error = %sanitize_err_chain(&err),
153                    fallback_bps = floor_bps,
154                    "FeeRouter.getShares() startup read could not be narrowed to operator bps; \
155                     falling back to OPERATOR_BPS_FLOOR"
156                );
157                floor_bps
158            }
159        },
160        Err(err) => {
161            tracing::warn!(
162                error = %sanitize_err_chain(&err),
163                fallback_bps = floor_bps,
164                "FeeRouter.getShares() startup read failed; falling back to OPERATOR_BPS_FLOOR"
165            );
166            floor_bps
167        }
168    }
169}
170
171#[cfg(test)]
172#[allow(clippy::unwrap_used)]
173mod tests;