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;