decdn-incentive 0.0.2

Payment channels, bonding, and voucher management for deCDN
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
//! `PaymentPool` contract ABI bindings (ADR 003 § `PaymentPool`).
//!
//! Generated by the `alloy::sol!` macro from the Solidity surface in
//! `contracts/src/PaymentPool.sol`. One funded USDC pool backs payments from
//! many independent capped signers to many nodes: an owner opens a single
//! pool and pays every node from it, never opening a pool per node or per
//! client. Signers are authorized off-chain by an owner-signed capability,
//! capped by a spending limit and an expiry, and registered lazily on first
//! redemption into a two-dimensional sharded register keyed by
//! `(signer, provider)`.
//!
//! Surface consumed by both the seller-side settlement path (a node redeems
//! vouchers presented against its own `provider` address) and the buyer-side
//! cache-miss path (the node opens and tops up its own pool as a paying
//! client on the upstream leg):
//!
//! - reads: `usdc()` and `feeRouter()` (immutables/governable addresses),
//!   `getPool(poolId)` (owner, status, deposit, dispute deadline,
//!   totalRedeemed), `getAuthorization(poolId, signer)` (spending cap,
//!   expiry, spent), `getAuthorizations(poolIds, signers)` (the batch
//!   companion to `getAuthorization`, one read per pair in input order),
//!   `getWatermark(poolId, signer, provider)` (the
//!   cumulative-paid lane a redeemer advances),
//!   `getWatermarks(poolIds, signers, providers)` (the batch companion to
//!   `getWatermark`, one read per triple in input order),
//!   `ownerPoolNonce(owner)` (next
//!   nonce used to derive a pool id), `getPools(owner, offset, limit)` (page
//!   of an owner's pool ids), and `getRateBounds()` (the governable per-MB
//!   delivery-rate floor);
//! - owner writes: `openPool` (escrow a deposit and derive a fresh
//!   `poolId`), `topUp` (add funds to an open pool), `closePool` (start the
//!   grace-window close — `redeem` stays callable until the dispute
//!   deadline), and `reclaim` (callable by anyone once the window elapses;
//!   refunds the owner's residual and closes the pool);
//! - redeemer (node) writes: `redeem` (pay a single `(signer, provider)`
//!   lane against a monotone cumulative voucher, optionally registering the
//!   signer's capability in the same call) and `redeemMany` (register a
//!   batch of capabilities, then redeem a batch of vouchers, in one
//!   transaction — the node's redeem sweep collapses a tick's redemptions
//!   into one tx);
//! - events: `PoolOpened`, `PoolToppedUp` (→ raise the tracked deposit so
//!   post-top-up vouchers are not wrongly rejected), `PoolRedeemed` (a node
//!   filters on its own `provider` topic as the single write path for the
//!   paid side), `PoolCloseInitiated` (the owner started the grace-window
//!   close), `PoolReclaimed` (the residual was refunded and the pool is now
//!   closed), and `RateBoundsUpdated` (governance changed the redemption-time
//!   delivery-rate floor).
//!
//! The capability/voucher EIP-712 domain (name `"PaymentPool"`, version
//! `"1"`) lives in [`crate::voucher`]; the `capability` argument to `redeem`
//! is the ABI-encoded `(spendingCap, expiry, ownerSig)` tuple carrying the
//! owner's EIP-712 `Capability` signature, and `voucherSig` is the signer's
//! EIP-712 `Voucher` signature over `{poolId, signer, provider, cumulative,
//! bytesDelivered}`.

// The `sol!`-generated bindings include macro-emitted code that uses
// patterns workspace clippy denies (raw indexing into ABI fixed-size byte
// arrays, `unwrap` on infallible conversions). These allows scope the
// relaxation to this module only — same posture as `capacity_bond`.
#[allow(
    clippy::expect_used,
    clippy::indexing_slicing,
    clippy::panic,
    clippy::unwrap_used,
    clippy::pub_underscore_fields,
    clippy::missing_docs_in_private_items,
    clippy::too_many_arguments,
    missing_debug_implementations,
    missing_docs,
    non_snake_case,
    non_camel_case_types
)]
mod sol_types {
    alloy::sol! {
        #[sol(rpc)]
        contract PaymentPool {
            // -----------------------------------------------------------------
            // Types — field order is part of the ABI; do not rearrange.
            // -----------------------------------------------------------------

            /// Pool lifecycle status. Mirrors the on-chain enum at
            /// `contracts/src/PaymentPool.sol`.
            enum Status {
                Open,
                Closing,
                Closed
            }

            /// Per-pool on-chain state. Mirrors the on-chain `Pool` struct
            /// verbatim — including field order, which is part of the
            /// `getPool` ABI tuple.
            struct Pool {
                address owner;
                Status status;
                uint64 disputeDeadline;
                uint64 deposit;
                uint64 totalRedeemed;
            }

            /// Per-`(pool, signer)` authorization, set once on first
            /// redemption for that signer from the owner-signed capability.
            /// Field order is part of the `getAuthorization` ABI tuple.
            struct Authorization {
                uint64 cap;
                uint64 expiry;
                uint64 spent;
            }

            /// Per-`(pool, signer, provider)` redemption lane. Field order
            /// is part of the `getWatermark` ABI tuple.
            struct Lane {
                uint64 amount;
                uint64 bytesDelivered;
            }

            /// One signer registration inside a pool's batch: the owner's
            /// EIP-712 `Capability` signature and the limits it authorizes.
            /// The pool is the enclosing `PoolBatch`. Field order is part of
            /// the ABI tuple; do not rearrange.
            struct CapabilityReg {
                address signer;
                uint64 spendingCap;
                uint64 expiry;
                bytes ownerSig;
            }

            /// One lane's voucher inside a pool's batch. Names neither its
            /// pool (the enclosing `PoolBatch` does) nor its payee: the
            /// contract redeems for `msg.sender` and rebuilds the EIP-712
            /// hash with it, so a voucher signed for another node fails as
            /// `InvalidVoucherSignature`. The signature is the EIP-2098
            /// compact pair `(r, vs)`, which makes this struct *static*: an
            /// array of it carries no per-element offset, no length word and
            /// no padding. A voucher signer must therefore be an EOA — the
            /// contract recovers with `ecrecover`, not ERC-1271. Field order
            /// is part of the ABI tuple; do not rearrange.
            ///
            /// The three chain words bring a lane to 8 words / 256 calldata
            /// bytes (ADR 003 §Voucher signatures are compact). `chainMeter`
            /// is the packing that keeps it at 8 rather than 9:
            ///
            /// ```text
            ///  byte  0                     22 23            30 31
            ///       +------------------------+----------------+--+
            ///       |  reserved — MUST be 0  |   chunkPrice   |ci|
            ///       +------------------------+----------------+--+
            /// ```
            ///
            /// The packing is invisible to signers: the contract decodes
            /// `chunkPrice`, widens it back to `uint256`, and rebuilds the
            /// same EIP-712 digest, while `chainIndex` is a redemption
            /// parameter and is not signed at all. On a cooperative
            /// redemption all three words are nearly all zero bytes — a
            /// closing voucher carries a zero root, a zero preimage, and a
            /// zero index — so they compress to almost nothing on an L2.
            struct LaneVoucher {
                address signer;
                uint64 cumulative;
                uint64 bytesDelivered;
                bytes32 r;
                bytes32 vs;
                bytes32 chainRoot;
                bytes32 preimage;
                uint256 chainMeter;
            }

            /// Everything a node redeems against one pool. Naming the pool
            /// once per group rather than once per entry is both the
            /// calldata saving and what lets the pool's status gate and its
            /// `totalRedeemed` write happen once for the whole group. Field
            /// order is part of the ABI tuple; do not rearrange.
            struct PoolBatch {
                bytes32 poolId;
                CapabilityReg[] capabilities;
                LaneVoucher[] vouchers;
            }

            // -----------------------------------------------------------------
            // View functions
            // -----------------------------------------------------------------

            /// Immutable settlement token (USDC).
            function usdc() external view returns (address);

            /// Governance-re-pointable settlement router target.
            function feeRouter() external view returns (address);

            /// Full on-chain pool record.
            function getPool(bytes32 poolId) external view returns (Pool memory);

            /// A signer's spending cap, expiry, and spent-so-far against a
            /// pool. Zero cap and zero expiry means the signer is not yet
            /// registered.
            function getAuthorization(bytes32 poolId, address signer) external view returns (Authorization memory);

            /// Batch companion to `getAuthorization`: one
            /// `authorized[poolId][signer]` read per `(poolIds[i],
            /// signers[i])` pair, in input order. Reverts on a length
            /// mismatch. The redeemer reads every lane whose registration it
            /// does not already know in one call instead of one `eth_call`
            /// per lane.
            function getAuthorizations(bytes32[] calldata poolIds, address[] calldata signers)
                external
                view
                returns (Authorization[] memory);

            /// A `(signer, provider)` lane's cumulative-paid amount and
            /// cumulative paid-proportional bytes delivered.
            ///
            /// Read by the seller's pre-redeem reconciliation, and by the buyer
            /// to reseed a lane whose local progress it has lost: a voucher at
            /// or below the watermark is a transient-empty redemption that pays
            /// nothing, so a buyer resuming from zero would stream bytes nobody
            /// can cash. It reflects *redeemed* vouchers only — a provider still
            /// holding an unredeemed voucher is ahead of it, by at most its own
            /// redemption threshold.
            function getWatermark(bytes32 poolId, address signer, address provider)
                external
                view
                returns (Lane memory);

            /// Batch companion to `getWatermark`: one
            /// `watermark[poolId][signer][provider]` read per `(poolIds[i],
            /// signers[i], providers[i])` triple, in input order. Reverts on a
            /// length mismatch. The redeemer reconciles a bounded batch of
            /// planned lanes against the chain in one call instead of one
            /// `eth_call` per lane; a larger plan set takes one call per
            /// batch.
            function getWatermarks(bytes32[] calldata poolIds, address[] calldata signers, address[] calldata providers)
                external
                view
                returns (Lane[] memory);

            /// Governable per-megabyte delivery-rate floor (ADR 003). A soft
            /// floor: `redeem` settles a sub-floor voucher in full and clamps
            /// only the byte count it credits toward governance vote weight. It
            /// is not a quote gate — nodes never read it to bound their own
            /// rate — so this getter is governance-observability only.
            function getRateBounds() external view returns (uint256 floor);

            /// Per-owner monotonic pool counter (public mapping getter). The
            /// *next* nonce `openPool` will assign to `owner`; the resulting
            /// `poolId` is `keccak256(owner, nonce)`.
            function ownerPoolNonce(address owner) external view returns (uint256);

            /// A page of the pool ids `owner` has opened, oldest first.
            /// `ownerPoolNonce(owner)` is the count.
            function getPools(address owner, uint256 offset, uint256 limit)
                external
                view
                returns (bytes32[] memory);


            // -----------------------------------------------------------------
            // Write functions (owner path)
            // -----------------------------------------------------------------

            /// Open a USDC pool; transfers `deposit` in and derives
            /// `poolId = keccak256(owner, ownerPoolNonce[owner])`. Names no
            /// provider and no signer — a pool is bound to no payee at open.
            function openPool(uint64 deposit) external returns (bytes32 poolId);

            /// Owner-only: add `additionalDeposit` USDC to an open pool.
            function topUp(bytes32 poolId, uint64 additionalDeposit) external;

            /// Owner-only: start the grace-window close on `poolId`. Moves
            /// no funds — redemption stays callable until `disputeDeadline`.
            function closePool(bytes32 poolId) external;

            /// Callable by anyone once the grace window has elapsed:
            /// transfers `deposit - totalRedeemed` to the owner and closes
            /// the pool.
            function reclaim(bytes32 poolId) external;

            // -----------------------------------------------------------------
            // Write functions (redeemer / node path)
            // -----------------------------------------------------------------

            /// The sole redemption entry point: per pool, register every
            /// capability, then redeem every voucher, all in one
            /// transaction. A node redeems every lane it holds at once, and
            /// a single lane is a one-pool batch of one. Each voucher pays
            /// the increment over its lane watermark, bounded by the
            /// signer's remaining cap and the pool's remaining deposit; a
            /// transient-empty voucher (already-registered signer, expired
            /// capability, stale cumulative, or a drained lane/cap) is
            /// skipped rather than reverting. Grouping by pool is what makes
            /// the pool's status gate and its `totalRedeemed` write happen
            /// once per group instead of once per lane. Every voucher is
            /// redeemed for `msg.sender`, so the whole call's payout settles
            /// through `FeeRouter.routeSettlement` in one go.
            function redeemMany(PoolBatch[] calldata batches) external returns (uint256 totalPaid);

            // -----------------------------------------------------------------
            // Events
            // -----------------------------------------------------------------

            /// An owner opened a pool.
            event PoolOpened(bytes32 indexed poolId, address indexed owner, uint256 deposit);

            /// An owner topped up an open pool. The watcher updates the
            /// tracked deposit to `newDeposit` so a redeemer's `paid <=
            /// remaining deposit` bound accepts post-top-up redemptions
            /// instead of rejecting them against a stale deposit.
            event PoolToppedUp(bytes32 indexed poolId, uint256 additionalDeposit, uint256 newDeposit);

            /// One lane's outcome inside a `PoolRedeemed`.
            /// `newPaidCumulative` is the lane's cumulative paid amount
            /// after the advance — the value a node writes to its paid
            /// watermark, cumulative rather than a delta so the stream is
            /// idempotent and survives a gap. `bytesPaid` is the
            /// paid-proportional served byte count, carried for per-lane
            /// accounting. Field order is part of the ABI tuple; do not
            /// rearrange.
            struct LaneSettled {
                address signer;
                uint64 newPaidCumulative;
                uint64 bytesPaid;
            }

            /// A node cashed vouchers against one pool: one event per pool
            /// group, with an entry per lane that actually paid. Emitting
            /// once for the group rather than once per lane is what keeps
            /// the log base and its topics off the per-lane cost. A node
            /// follows this event, filtered on its own `provider`, as the
            /// single write path for the paid side.
            event PoolRedeemed(bytes32 indexed poolId, address indexed provider, LaneSettled[] lanes);

            /// The owner started the grace-window close on `poolId`.
            /// `redeem` stays callable until `disputeDeadline`.
            event PoolCloseInitiated(bytes32 indexed poolId, address indexed owner, uint256 disputeDeadline);

            /// `reclaim` refunded `ownerRefund` (`deposit - totalRedeemed`)
            /// to `owner` and the pool is now `Closed`.
            event PoolReclaimed(bytes32 indexed poolId, address indexed owner, uint256 ownerRefund);

            /// Governance changed the per-MB delivery-rate floor. The floor is
            /// redemption-time contract state (the soft vote-weight credit
            /// clamp); this event announces the change to on-chain observers. No
            /// `indexed` params — matches `setRateBounds`.
            event RateBoundsUpdated(uint256 newDeliveryFloor);
        }
    }

    alloy::sol! {
        #[sol(rpc)]
        contract FeeRouter {
            /// Current fee split, as `[operator, buyback, treasury]` basis
            /// points summing to `BPS_DENOMINATOR`. Mirrors
            /// `contracts/src/FeeRouter.sol`.
            function getShares() external view returns (uint256[3]);

            /// Governance retuned the fee split. The fee-shares watcher
            /// decodes the new split and stores the operator bucket into the
            /// node's live cell so a governance retune reaches running nodes
            /// without a restart.
            event SharesUpdated(uint256[3] newShares);
        }
    }
}

pub use sol_types::{FeeRouter, PaymentPool};

/// Narrow a USDC amount, or a byte count, to the `uint64` the `PaymentPool`
/// carries in its packed fields.
///
/// Off-chain the amounts travel as `U256`, because that is the width the ERC-20
/// surface and the voucher store speak. On-chain every USDC counter and both
/// lane watermarks are `uint64`: at USDC's six decimals that ceiling is about
/// $18.4 trillion, and 18.4 exabytes for a byte count, so this rejects only a
/// value that could never have been a real lane. Call it at the contract
/// boundary rather than widening the callers.
///
/// # Errors
///
/// Errors if `value` does not fit `u64`, naming `what` so the caller's context
/// says which field overflowed.
pub fn to_pool_u64(value: alloy::primitives::U256, what: &str) -> anyhow::Result<u64> {
    u64::try_from(value)
        .map_err(|_| anyhow::anyhow!("{what} {value} exceeds the PaymentPool's uint64 field"))
}

/// One page of [`enumerate_owned_pools`]. `getPools` clamps `limit` to the
/// remaining count, so a short page ends the walk with no separate
/// `ownerPoolNonce` read.
const OWNED_POOLS_PAGE: usize = 256;

/// Every pool id `owner` has opened, read from chain oldest-first by paging
/// `getPools`.
///
/// Chain-authoritative, which is the point: the local buyer store drops a row
/// at close and is lost outright if the data dir is reset, so it cannot answer
/// "do I already own a pool". Asking the chain instead is what stops a node
/// or client from escrowing a second deposit against a pool it has simply forgotten
/// (#2072), and it enumerates pools in every lifecycle state, which is what
/// reaching a historical `Closing` pool awaiting reclaim needs.
///
/// # Errors
///
/// Errors if any page read fails, naming the offset that failed.
pub async fn enumerate_owned_pools<P>(
    contract: &PaymentPool::PaymentPoolInstance<P>,
    owner: alloy::primitives::Address,
) -> anyhow::Result<Vec<crate::lane::PoolId>>
where
    P: alloy::providers::Provider + Clone,
{
    use alloy::primitives::U256;

    let mut ids = Vec::new();
    let mut offset: u64 = 0;
    loop {
        let page = contract
            .getPools(owner, U256::from(offset), U256::from(OWNED_POOLS_PAGE))
            .call()
            .await
            .map_err(|e| {
                anyhow::Error::new(e).context(format!("getPools(offset={offset}) failed"))
            })?;
        let n = page.len();
        ids.extend(page);
        if n < OWNED_POOLS_PAGE {
            break;
        }
        offset = offset.saturating_add(u64::try_from(n)?);
    }
    Ok(ids)
}

/// The newest pool `owner` holds on this contract that is `Open` and still has
/// deposit left to spend, or `None` if it holds no such pool.
///
/// This is the chain's answer to "do I already own a pool I can pay from",
/// which the local buyer store cannot give once it has lost its row — to a
/// reset data dir, a new machine, or a store-format change. Opening a pool on
/// that question's behalf when this would have answered `Some` escrows a second
/// deposit beside a live one, and reverts when the wallet's remaining USDC
/// cannot cover it.
///
/// Newest first, because a later pool is the one an earlier session would have
/// been paying from. That assumes the wallet has one buyer: a wallet shared by
/// two buyers — a node's operator key used by a client as well — has no single
/// "earlier session", and the caller must not adopt at all. A fully-redeemed pool is still `Open` on chain, and
/// adopting one would leave nothing to spend, so solvency is part of the test.
///
/// # Errors
///
/// Errors if the enumeration or any pool read fails. A read that faulted says
/// nothing about whether that pool is live, and treating it as absent is
/// exactly the unobserved second deposit this exists to prevent — so the
/// caller learns it could not tell, rather than being told there is no pool.
pub async fn newest_solvent_owned_pool<P>(
    contract: &PaymentPool::PaymentPoolInstance<P>,
    owner: alloy::primitives::Address,
) -> anyhow::Result<Option<(crate::lane::PoolId, PaymentPool::Pool)>>
where
    P: alloy::providers::Provider + Clone,
{
    use anyhow::Context as _;
    for pool_id in enumerate_owned_pools(contract, owner)
        .await?
        .into_iter()
        .rev()
    {
        let pool = contract
            .getPool(pool_id)
            .call()
            .await
            .with_context(|| format!("getPool({pool_id}) failed"))?;
        if matches!(pool.status, PaymentPool::Status::Open) && pool.deposit > pool.totalRedeemed {
            return Ok(Some((pool_id, pool)));
        }
    }
    Ok(None)
}

/// A voucher signer's on-chain authorization in one pool, as a node's admit path
/// and a capability client read it from `getAuthorization` (ADR 003 §Capability delegation).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SignerAuthorization {
    /// The signer holds no registration (the all-zero `Authorization`). Its first
    /// redemption registers whichever owner-signed capability lands first.
    Unregistered,
    /// The signer is registered. `cap` and `expiry` are write-once on-chain, so
    /// they never change for this `(pool, signer)`; `spent` only grows.
    Registered {
        /// The registered spending cap, in micro-USDC.
        cap: u64,
        /// The registered expiry, in Unix seconds. The chain treats a voucher at
        /// or past it as unredeemable.
        expiry: u64,
        /// What the signer has redeemed across every provider, in micro-USDC.
        spent: u64,
    },
}

impl SignerAuthorization {
    /// Classify a raw `getAuthorization` result. The contract marks a signer
    /// registered when `cap != 0 || expiry != 0`, so a registration with a zero
    /// `cap` but a non-zero `expiry` is registered with no headroom.
    #[must_use]
    pub const fn from_onchain(auth: &PaymentPool::Authorization) -> Self {
        if auth.cap == 0 && auth.expiry == 0 {
            Self::Unregistered
        } else {
            Self::Registered {
                cap: auth.cap,
                expiry: auth.expiry,
                spent: auth.spent,
            }
        }
    }

    /// Whether the signer can still pay at least `floor_micro` at Unix time `now`.
    /// An unregistered signer always can: it admits on its presented capability.
    /// A registered signer needs a live registration and `cap − spent` headroom
    /// of at least `floor_micro`.
    #[must_use]
    pub fn covers(self, floor_micro: alloy::primitives::U256, now: u64) -> bool {
        match self {
            Self::Unregistered => true,
            Self::Registered { cap, expiry, spent } => {
                now < expiry
                    && alloy::primitives::U256::from(cap.saturating_sub(spent)) >= floor_micro
            }
        }
    }
}

#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::indexing_slicing)]
mod tests;