Skip to main content

decdn_client/
credential.rs

1//! The delegate's voucher credential and its funding signal (ADR 039 § Client
2//! SDK funding signal, ADR 003 § Funding recovery).
3//!
4//! A delegated client pays from a pool it does not own, under a capability the
5//! pool owner signed for its voucher key. It cannot top the pool up. When the
6//! capability runs low, the application gets a new one from the owner and
7//! pushes it into the fetch's [`CredentialSlot`]. The fetch then retires every
8//! lane on the old key at a voucher boundary and opens new lanes under the new
9//! key, from the delivered frontier. Nothing is fetched or paid twice.
10//!
11//! The fetch computes the signal itself, with no chain read: the capability's
12//! headroom against every voucher signed under its key (earlier fetches'
13//! recorded spend at any provider, plus this fetch's lanes), the money the
14//! remaining work needs at the highest rate a node quoted, and the time to the
15//! capability's expiry ([`FundingEvent::RunningLow`]).
16
17use std::sync::Arc;
18use std::sync::atomic::{AtomicU64, Ordering};
19use std::time::Duration;
20
21use alloy::primitives::{Address, B256, U256};
22use alloy::signers::local::PrivateKeySigner;
23use decdn_incentive::SignedCapability;
24
25use crate::PoolContext;
26
27/// How long a delegated fetch, at the exhausted candidate set, waits for the
28/// application to swap in a new credential before it ends with
29/// [`FundingNeeded::NewCapability`]. One minute is time for an application to
30/// ask a sponsor gateway for a fresh capability; a fetch with no one to ask sets
31/// a zero wait ([`CredentialSlot::with_swap_wait`]).
32pub const CREDENTIAL_SWAP_WAIT: Duration = Duration::from_mins(1);
33
34/// The node's capability-expiry margin at the default redeem interval: inside
35/// it a node accepts no voucher for the capability (ADR 003 § Revocation).
36pub(crate) const NODE_EXPIRY_MARGIN_SECS: u64 = decdn_common::config::capability_expiry_margin_secs(
37    decdn_common::config::DEFAULT_REDEEM_INTERVAL_SECS,
38);
39
40/// One voucher credential: the key that signs vouchers and the capability the
41/// pool owner signed for it.
42#[derive(Clone, Debug)]
43pub struct Credential {
44    /// The voucher-signing key the capability names.
45    pub signer: Arc<PrivateKeySigner>,
46    /// The owner-signed capability (the `dcap1` grant) that lets `signer`
47    /// spend from the pool.
48    pub capability: SignedCapability,
49}
50
51/// A voucher key that is not the signer its capability names. Every voucher
52/// it signed would be refused, so a slot never holds it.
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub struct CredentialMismatch {
55    /// The voucher-signing key.
56    pub key: Address,
57    /// The signer the capability names.
58    pub capability_signer: Address,
59}
60
61impl std::fmt::Display for CredentialMismatch {
62    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63        write!(
64            f,
65            "voucher key {} is not the signer {} its capability names",
66            self.key, self.capability_signer
67        )
68    }
69}
70
71impl std::error::Error for CredentialMismatch {}
72
73impl Credential {
74    /// `signer` with `capability`, when the capability names `signer`.
75    fn checked(
76        signer: Arc<PrivateKeySigner>,
77        capability: SignedCapability,
78    ) -> Result<Self, CredentialMismatch> {
79        let (key, capability_signer) = (signer.address(), capability.capability.signer);
80        if key != capability_signer {
81            return Err(CredentialMismatch {
82                key,
83                capability_signer,
84            });
85        }
86        Ok(Self { signer, capability })
87    }
88}
89
90/// The current credential and how many swaps produced it.
91#[derive(Clone, Debug)]
92struct Current {
93    generation: u64,
94    credential: Credential,
95}
96
97#[derive(Debug)]
98struct SlotInner {
99    current: tokio::sync::watch::Sender<Current>,
100    events: tokio::sync::watch::Sender<Option<FundingEvent>>,
101    swap_wait: std::sync::Mutex<Duration>,
102    /// What earlier fetches signed under a key, from the application's local
103    /// lane records ([`CredentialSlot::record_spend`]).
104    recorded: std::sync::Mutex<Option<(Address, U256)>>,
105}
106
107/// A delegated fetch's voucher credential, which the application can swap at
108/// any time. Clones share one slot.
109///
110/// A source provider reads [`Self::current`] when it builds a lane, and puts
111/// its key and capability in the lane's [`PoolContext`]. After a
112/// [`Self::swap`], the fetch ends every live lane on the old key at its next
113/// voucher boundary, paid for what it received, and opens new lanes under the
114/// new credential. A registered signer's terms are write-once on chain, so a
115/// new capability names a new signer key.
116#[derive(Clone, Debug)]
117pub struct CredentialSlot {
118    inner: Arc<SlotInner>,
119}
120
121impl CredentialSlot {
122    /// A slot that holds `capability` for `signer`, with the
123    /// [`CREDENTIAL_SWAP_WAIT`] wait.
124    ///
125    /// # Errors
126    ///
127    /// [`CredentialMismatch`] when the capability names another signer.
128    pub fn new(
129        signer: Arc<PrivateKeySigner>,
130        capability: SignedCapability,
131    ) -> Result<Self, CredentialMismatch> {
132        let (current, _) = tokio::sync::watch::channel(Current {
133            generation: 0,
134            credential: Credential::checked(signer, capability)?,
135        });
136        let (events, _) = tokio::sync::watch::channel(None);
137        Ok(Self {
138            inner: Arc::new(SlotInner {
139                current,
140                events,
141                swap_wait: std::sync::Mutex::new(CREDENTIAL_SWAP_WAIT),
142                recorded: std::sync::Mutex::new(None),
143            }),
144        })
145    }
146
147    /// Wait `wait` for a swap at the exhausted candidate set. The slot and
148    /// every clone of it share the wait.
149    #[must_use]
150    pub fn with_swap_wait(self, wait: Duration) -> Self {
151        *self
152            .inner
153            .swap_wait
154            .lock()
155            .unwrap_or_else(std::sync::PoisonError::into_inner) = wait;
156        self
157    }
158
159    fn recorded_lock(&self) -> std::sync::MutexGuard<'_, Option<(Address, U256)>> {
160        self.inner
161            .recorded
162            .lock()
163            .unwrap_or_else(std::sync::PoisonError::into_inner)
164    }
165
166    /// Record that earlier fetches signed `spent` micro-USDC of vouchers under
167    /// `signer`, across every lane the application's local records hold for
168    /// it, at any provider. The fetch counts it toward the capability's spend
169    /// for the running-low signal and the [`FundingNeeded`] cause, on top of
170    /// what its own lanes sign past their recorded priors. One key's record
171    /// is held at a time; a key with no record counts from zero.
172    pub fn record_spend(&self, signer: Address, spent: U256) {
173        *self.recorded_lock() = Some((signer, spent));
174    }
175
176    /// What earlier fetches signed under `signer` ([`Self::record_spend`]).
177    pub(crate) fn recorded_spend(&self, signer: Address) -> U256 {
178        self.recorded_lock()
179            .filter(|(recorded, _)| *recorded == signer)
180            .map_or(U256::ZERO, |(_, spent)| spent)
181    }
182
183    /// Put `capability` for `signer` in the slot. Every fetch that holds the
184    /// slot retires its lanes on the old key and goes on under this one.
185    ///
186    /// # Errors
187    ///
188    /// [`CredentialMismatch`] when the capability names another signer. The
189    /// slot keeps its credential.
190    pub fn swap(
191        &self,
192        signer: Arc<PrivateKeySigner>,
193        capability: SignedCapability,
194    ) -> Result<(), CredentialMismatch> {
195        let credential = Credential::checked(signer, capability)?;
196        self.inner.current.send_modify(|current| {
197            current.generation = current.generation.saturating_add(1);
198            current.credential = credential;
199        });
200        Ok(())
201    }
202
203    /// The credential a new lane signs under.
204    #[must_use]
205    pub fn current(&self) -> Credential {
206        self.inner.current.borrow().credential.clone()
207    }
208
209    /// How many swaps the slot has taken.
210    #[must_use]
211    pub fn generation(&self) -> u64 {
212        self.inner.current.borrow().generation
213    }
214
215    /// The pool the current capability draws from.
216    #[must_use]
217    pub fn pool_id(&self) -> B256 {
218        self.inner
219            .current
220            .borrow()
221            .credential
222            .capability
223            .capability
224            .pool_id
225    }
226
227    /// How long a fetch at the exhausted candidate set waits for a swap.
228    #[must_use]
229    pub fn swap_wait(&self) -> Duration {
230        *self
231            .inner
232            .swap_wait
233            .lock()
234            .unwrap_or_else(std::sync::PoisonError::into_inner)
235    }
236
237    /// The fetch's funding signal: the latest [`FundingEvent`], or `None`
238    /// while the credential covers the work. The value changes when the
239    /// signal changes.
240    #[must_use]
241    pub fn funding_events(&self) -> tokio::sync::watch::Receiver<Option<FundingEvent>> {
242        self.inner.events.subscribe()
243    }
244
245    /// Publish `event` when it differs from the last one.
246    pub(crate) fn report(&self, event: Option<FundingEvent>) {
247        self.inner.events.send_if_modified(|last| {
248            let changed = *last != event;
249            if changed {
250                *last = event;
251            }
252            changed
253        });
254    }
255
256    /// Wait until the slot takes a swap past `generation`.
257    pub(crate) async fn swapped_since(&self, generation: u64) {
258        let mut rx = self.inner.current.subscribe();
259        // The sender lives as long as the slot, so the wait ends only on a swap.
260        let _ = rx.wait_for(|current| current.generation > generation).await;
261    }
262
263    /// Whether `ctx` signs under a key other than the current credential's.
264    pub(crate) fn is_stale(&self, ctx: &std::sync::Mutex<PoolContext>) -> bool {
265        let current = self.inner.current.borrow().credential.signer.address();
266        ctx.lock()
267            .map_or(true, |ctx| ctx.client_signer.address() != current)
268    }
269}
270
271/// A delegated fetch's funding signal ([`CredentialSlot::funding_events`]).
272#[derive(Clone, Copy, Debug, PartialEq, Eq)]
273#[non_exhaustive]
274pub enum FundingEvent {
275    /// The capability is about to stop paying: its headroom is short of the
276    /// remaining work plus one credit window, or its expiry is inside the
277    /// node's expiry margin. The application swaps in a new credential.
278    RunningLow {
279        /// The capability's spending cap less every voucher signed under its
280        /// key: earlier fetches' recorded spend and this fetch's, in
281        /// micro-USDC.
282        headroom: U256,
283        /// The remaining work's bytes at the highest rate a node quoted, in
284        /// micro-USDC.
285        projected_need: U256,
286        /// The capability's expiry, in Unix seconds.
287        expires_at: u64,
288    },
289}
290
291/// Why a delegated fetch needs a new capability.
292#[derive(Clone, Copy, Debug, PartialEq, Eq)]
293#[non_exhaustive]
294pub enum CapabilityCause {
295    /// The vouchers signed under it reached its spending cap.
296    CapSpent,
297    /// It expires inside the node's expiry margin.
298    Expired,
299    /// Nodes refuse it although its cap and expiry still cover the work.
300    Revoked,
301}
302
303impl std::fmt::Display for CapabilityCause {
304    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
305        f.write_str(match self {
306            Self::CapSpent => "has spent its cap",
307            Self::Expired => "has expired",
308            Self::Revoked => "was revoked",
309        })
310    }
311}
312
313/// The typed end of a delegated fetch that no node serves at its funding.
314/// It is fatal to the command ([`crate::classify`]).
315#[derive(Clone, Copy, Debug, PartialEq, Eq)]
316#[non_exhaustive]
317pub enum FundingNeeded {
318    /// The capability no longer pays, and no swap came within the slot's
319    /// wait. The pool owner issues a new capability for a new signer key.
320    NewCapability {
321        /// The pool the capability draws from.
322        pool: B256,
323        /// Why the capability no longer pays.
324        cause: CapabilityCause,
325    },
326    /// The capability still covers the work, but nodes refuse the funding
327    /// `Unfunded`. Only the pool owner can fix it: by a top-up, or, when the
328    /// signer's registered on-chain terms are spent (a node refuses those the
329    /// same way), by a capability for a new signer key.
330    PublisherPool {
331        /// The pool that cannot pay.
332        pool: B256,
333    },
334}
335
336impl std::fmt::Display for FundingNeeded {
337    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
338        match self {
339            Self::NewCapability { pool, cause } => write!(
340                f,
341                "funding needed: the capability for pool {pool} {cause}, and no new credential \
342                 came"
343            ),
344            Self::PublisherPool { pool } => write!(
345                f,
346                "funding needed: the capability covers the work, but the nodes refuse the \
347                 funding of pool {pool}; its owner must top it up"
348            ),
349        }
350    }
351}
352
353impl std::error::Error for FundingNeeded {}
354
355/// The highest rate and voucher interval any lane of one fetch was quoted.
356#[derive(Debug, Default)]
357#[doc(hidden)]
358pub struct QuoteMax {
359    rate_per_mb: AtomicU64,
360    interval_bytes: AtomicU64,
361}
362
363impl QuoteMax {
364    /// Fold one lane's quote in.
365    pub(crate) fn observe(&self, rate_per_mb: u64, interval_bytes: u64) {
366        self.rate_per_mb.fetch_max(rate_per_mb, Ordering::Relaxed);
367        self.interval_bytes
368            .fetch_max(interval_bytes, Ordering::Relaxed);
369    }
370
371    /// The highest rate quoted, in micro-USDC per MB.
372    pub(crate) fn rate_per_mb(&self) -> u64 {
373        self.rate_per_mb.load(Ordering::Relaxed)
374    }
375
376    /// The longest voucher interval quoted, in bytes.
377    pub(crate) fn interval_bytes(&self) -> u64 {
378        self.interval_bytes.load(Ordering::Relaxed)
379    }
380}
381
382/// The cost of `bytes` at `rate_per_mb`, rounded up.
383fn cost(bytes: u64, rate_per_mb: u64) -> U256 {
384    U256::from(bytes)
385        .saturating_mul(U256::from(rate_per_mb))
386        .div_ceil(U256::from(decdn_protocol::MB_BYTES))
387}
388
389/// The local view of one capability against the work left.
390#[derive(Clone, Copy, Debug, PartialEq, Eq)]
391pub(crate) struct CredentialView {
392    /// The capability's spending cap less everything signed under its key.
393    pub(crate) headroom: U256,
394    /// The remaining work at the highest quoted rate.
395    pub(crate) projected_need: U256,
396    /// One credit window (one voucher interval) at the highest quoted rate.
397    pub(crate) window: U256,
398    /// The capability's expiry, in Unix seconds.
399    pub(crate) expires_at: u64,
400    /// Whether `now` lies inside the node's expiry margin.
401    pub(crate) expiring: bool,
402}
403
404impl CredentialView {
405    /// The view of `credential` after `signed` micro-USDC of vouchers under
406    /// it, with `remaining_bytes` of work left at `quotes`, at Unix second
407    /// `now`.
408    pub(crate) fn of(
409        credential: &Credential,
410        signed: U256,
411        remaining_bytes: u64,
412        quotes: &QuoteMax,
413        now: u64,
414    ) -> Self {
415        let capability = &credential.capability.capability;
416        let rate = quotes.rate_per_mb();
417        Self {
418            headroom: U256::from(capability.spending_cap).saturating_sub(signed),
419            projected_need: cost(remaining_bytes, rate),
420            window: cost(quotes.interval_bytes(), rate),
421            expires_at: capability.expiry,
422            expiring: decdn_common::config::inside_capability_expiry_margin(
423                capability.expiry,
424                NODE_EXPIRY_MARGIN_SECS,
425                now,
426            ),
427        }
428    }
429
430    /// Whether the headroom falls short of the remaining work plus one
431    /// credit window.
432    pub(crate) fn short(&self) -> bool {
433        self.headroom < self.projected_need.saturating_add(self.window)
434    }
435
436    /// The running-low signal, or `None` while the capability covers the
437    /// work.
438    pub(crate) fn event(&self) -> Option<FundingEvent> {
439        (self.short() || self.expiring).then_some(FundingEvent::RunningLow {
440            headroom: self.headroom,
441            projected_need: self.projected_need,
442            expires_at: self.expires_at,
443        })
444    }
445
446    /// Why the capability no longer pays, from the local view: expiry first,
447    /// then a spent cap. `None` when both still cover the work.
448    pub(crate) fn cause(&self) -> Option<CapabilityCause> {
449        if self.expiring {
450            Some(CapabilityCause::Expired)
451        } else if self.short() {
452            Some(CapabilityCause::CapSpent)
453        } else {
454            None
455        }
456    }
457
458    /// Why a delegated fetch needs a new capability at its exhausted set, or
459    /// `None` when the capability still covers the work and only the pool
460    /// owner can help. The local [`Self::cause`] comes first. A cap with
461    /// nothing left is spent even when no work is priced yet. A capability
462    /// nodes refused (`capability_refused`) while it still covers the work
463    /// reads as revoked.
464    pub(crate) fn funding_cause(&self, capability_refused: bool) -> Option<CapabilityCause> {
465        self.cause()
466            .or(self.headroom.is_zero().then_some(CapabilityCause::CapSpent))
467            .or(capability_refused.then_some(CapabilityCause::Revoked))
468    }
469}
470
471/// What a lane signed past the prior its context was built with: its
472/// committed amount less the recorded amount it resumed from.
473pub(crate) fn signed_past_prior(
474    ctx: &std::sync::Mutex<PoolContext>,
475    ledger: &crate::PoolLedger,
476) -> U256 {
477    let prior = ctx.lock().map_or(U256::ZERO, |ctx| ctx.prior_amount);
478    ledger.committed().amount.saturating_sub(prior)
479}
480
481/// The current Unix second, or `0` when the clock reads before the epoch.
482pub(crate) fn unix_now() -> u64 {
483    std::time::SystemTime::now()
484        .duration_since(std::time::UNIX_EPOCH)
485        .map_or(0, |d| d.as_secs())
486}
487
488#[cfg(test)]
489#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] // tests
490mod tests;