Skip to main content

decdn_incentive/
payment_pool.rs

1//! `PaymentPool` contract ABI bindings (ADR 003 § `PaymentPool`).
2//!
3//! Generated by the `alloy::sol!` macro from the Solidity surface in
4//! `contracts/src/PaymentPool.sol`. One funded USDC pool backs payments from
5//! many independent capped signers to many nodes: an owner opens a single
6//! pool and pays every node from it, never opening a pool per node or per
7//! client. Signers are authorized off-chain by an owner-signed capability,
8//! capped by a spending limit and an expiry, and registered lazily on first
9//! redemption into a two-dimensional sharded register keyed by
10//! `(signer, provider)`.
11//!
12//! Surface consumed by both the seller-side settlement path (a node redeems
13//! vouchers presented against its own `provider` address) and the buyer-side
14//! cache-miss path (the node opens and tops up its own pool as a paying
15//! client on the upstream leg):
16//!
17//! - reads: `usdc()` and `feeRouter()` (immutables/governable addresses),
18//!   `getPool(poolId)` (owner, status, deposit, dispute deadline,
19//!   totalRedeemed), `getAuthorization(poolId, signer)` (spending cap,
20//!   expiry, spent), `getAuthorizations(poolIds, signers)` (the batch
21//!   companion to `getAuthorization`, one read per pair in input order),
22//!   `getWatermark(poolId, signer, provider)` (the
23//!   cumulative-paid lane a redeemer advances),
24//!   `getWatermarks(poolIds, signers, providers)` (the batch companion to
25//!   `getWatermark`, one read per triple in input order),
26//!   `ownerPoolNonce(owner)` (next
27//!   nonce used to derive a pool id), `getPools(owner, offset, limit)` (page
28//!   of an owner's pool ids), and `getRateBounds()` (the governable per-MB
29//!   delivery-rate floor);
30//! - owner writes: `openPool` (escrow a deposit and derive a fresh
31//!   `poolId`), `topUp` (add funds to an open pool), `closePool` (start the
32//!   grace-window close — `redeem` stays callable until the dispute
33//!   deadline), and `reclaim` (callable by anyone once the window elapses;
34//!   refunds the owner's residual and closes the pool);
35//! - redeemer (node) writes: `redeem` (pay a single `(signer, provider)`
36//!   lane against a monotone cumulative voucher, optionally registering the
37//!   signer's capability in the same call) and `redeemMany` (register a
38//!   batch of capabilities, then redeem a batch of vouchers, in one
39//!   transaction — the node's redeem sweep collapses a tick's redemptions
40//!   into one tx);
41//! - events: `PoolOpened`, `PoolToppedUp` (→ raise the tracked deposit so
42//!   post-top-up vouchers are not wrongly rejected), `PoolRedeemed` (a node
43//!   filters on its own `provider` topic as the single write path for the
44//!   paid side), `PoolCloseInitiated` (the owner started the grace-window
45//!   close), `PoolReclaimed` (the residual was refunded and the pool is now
46//!   closed), and `RateBoundsUpdated` (governance changed the redemption-time
47//!   delivery-rate floor).
48//!
49//! The capability/voucher EIP-712 domain (name `"PaymentPool"`, version
50//! `"1"`) lives in [`crate::voucher`]; the `capability` argument to `redeem`
51//! is the ABI-encoded `(spendingCap, expiry, ownerSig)` tuple carrying the
52//! owner's EIP-712 `Capability` signature, and `voucherSig` is the signer's
53//! EIP-712 `Voucher` signature over `{poolId, signer, provider, cumulative,
54//! bytesDelivered}`.
55
56// The `sol!`-generated bindings include macro-emitted code that uses
57// patterns workspace clippy denies (raw indexing into ABI fixed-size byte
58// arrays, `unwrap` on infallible conversions). These allows scope the
59// relaxation to this module only — same posture as `capacity_bond`.
60#[allow(
61    clippy::expect_used,
62    clippy::indexing_slicing,
63    clippy::panic,
64    clippy::unwrap_used,
65    clippy::pub_underscore_fields,
66    clippy::missing_docs_in_private_items,
67    clippy::too_many_arguments,
68    missing_debug_implementations,
69    missing_docs,
70    non_snake_case,
71    non_camel_case_types
72)]
73mod sol_types {
74    alloy::sol! {
75        #[sol(rpc)]
76        contract PaymentPool {
77            // -----------------------------------------------------------------
78            // Types — field order is part of the ABI; do not rearrange.
79            // -----------------------------------------------------------------
80
81            /// Pool lifecycle status. Mirrors the on-chain enum at
82            /// `contracts/src/PaymentPool.sol`.
83            enum Status {
84                Open,
85                Closing,
86                Closed
87            }
88
89            /// Per-pool on-chain state. Mirrors the on-chain `Pool` struct
90            /// verbatim — including field order, which is part of the
91            /// `getPool` ABI tuple.
92            struct Pool {
93                address owner;
94                Status status;
95                uint64 disputeDeadline;
96                uint64 deposit;
97                uint64 totalRedeemed;
98            }
99
100            /// Per-`(pool, signer)` authorization, set once on first
101            /// redemption for that signer from the owner-signed capability.
102            /// Field order is part of the `getAuthorization` ABI tuple.
103            struct Authorization {
104                uint64 cap;
105                uint64 expiry;
106                uint64 spent;
107            }
108
109            /// Per-`(pool, signer, provider)` redemption lane. Field order
110            /// is part of the `getWatermark` ABI tuple.
111            struct Lane {
112                uint64 amount;
113                uint64 bytesDelivered;
114            }
115
116            /// One signer registration inside a pool's batch: the owner's
117            /// EIP-712 `Capability` signature and the limits it authorizes.
118            /// The pool is the enclosing `PoolBatch`. Field order is part of
119            /// the ABI tuple; do not rearrange.
120            struct CapabilityReg {
121                address signer;
122                uint64 spendingCap;
123                uint64 expiry;
124                bytes ownerSig;
125            }
126
127            /// One lane's voucher inside a pool's batch. Names neither its
128            /// pool (the enclosing `PoolBatch` does) nor its payee: the
129            /// contract redeems for `msg.sender` and rebuilds the EIP-712
130            /// hash with it, so a voucher signed for another node fails as
131            /// `InvalidVoucherSignature`. The signature is the EIP-2098
132            /// compact pair `(r, vs)`, which makes this struct *static*: an
133            /// array of it carries no per-element offset, no length word and
134            /// no padding. A voucher signer must therefore be an EOA — the
135            /// contract recovers with `ecrecover`, not ERC-1271. Field order
136            /// is part of the ABI tuple; do not rearrange.
137            ///
138            /// The three chain words bring a lane to 8 words / 256 calldata
139            /// bytes (ADR 003 §Voucher signatures are compact). `chainMeter`
140            /// is the packing that keeps it at 8 rather than 9:
141            ///
142            /// ```text
143            ///  byte  0                     22 23            30 31
144            ///       +------------------------+----------------+--+
145            ///       |  reserved — MUST be 0  |   chunkPrice   |ci|
146            ///       +------------------------+----------------+--+
147            /// ```
148            ///
149            /// The packing is invisible to signers: the contract decodes
150            /// `chunkPrice`, widens it back to `uint256`, and rebuilds the
151            /// same EIP-712 digest, while `chainIndex` is a redemption
152            /// parameter and is not signed at all. On a cooperative
153            /// redemption all three words are nearly all zero bytes — a
154            /// closing voucher carries a zero root, a zero preimage, and a
155            /// zero index — so they compress to almost nothing on an L2.
156            struct LaneVoucher {
157                address signer;
158                uint64 cumulative;
159                uint64 bytesDelivered;
160                bytes32 r;
161                bytes32 vs;
162                bytes32 chainRoot;
163                bytes32 preimage;
164                uint256 chainMeter;
165            }
166
167            /// Everything a node redeems against one pool. Naming the pool
168            /// once per group rather than once per entry is both the
169            /// calldata saving and what lets the pool's status gate and its
170            /// `totalRedeemed` write happen once for the whole group. Field
171            /// order is part of the ABI tuple; do not rearrange.
172            struct PoolBatch {
173                bytes32 poolId;
174                CapabilityReg[] capabilities;
175                LaneVoucher[] vouchers;
176            }
177
178            // -----------------------------------------------------------------
179            // View functions
180            // -----------------------------------------------------------------
181
182            /// Immutable settlement token (USDC).
183            function usdc() external view returns (address);
184
185            /// Governance-re-pointable settlement router target.
186            function feeRouter() external view returns (address);
187
188            /// Full on-chain pool record.
189            function getPool(bytes32 poolId) external view returns (Pool memory);
190
191            /// A signer's spending cap, expiry, and spent-so-far against a
192            /// pool. Zero cap and zero expiry means the signer is not yet
193            /// registered.
194            function getAuthorization(bytes32 poolId, address signer) external view returns (Authorization memory);
195
196            /// Batch companion to `getAuthorization`: one
197            /// `authorized[poolId][signer]` read per `(poolIds[i],
198            /// signers[i])` pair, in input order. Reverts on a length
199            /// mismatch. The redeemer reads every lane whose registration it
200            /// does not already know in one call instead of one `eth_call`
201            /// per lane.
202            function getAuthorizations(bytes32[] calldata poolIds, address[] calldata signers)
203                external
204                view
205                returns (Authorization[] memory);
206
207            /// A `(signer, provider)` lane's cumulative-paid amount and
208            /// cumulative paid-proportional bytes delivered.
209            ///
210            /// Read by the seller's pre-redeem reconciliation, and by the buyer
211            /// to reseed a lane whose local progress it has lost: a voucher at
212            /// or below the watermark is a transient-empty redemption that pays
213            /// nothing, so a buyer resuming from zero would stream bytes nobody
214            /// can cash. It reflects *redeemed* vouchers only — a provider still
215            /// holding an unredeemed voucher is ahead of it, by at most its own
216            /// redemption threshold.
217            function getWatermark(bytes32 poolId, address signer, address provider)
218                external
219                view
220                returns (Lane memory);
221
222            /// Batch companion to `getWatermark`: one
223            /// `watermark[poolId][signer][provider]` read per `(poolIds[i],
224            /// signers[i], providers[i])` triple, in input order. Reverts on a
225            /// length mismatch. The redeemer reconciles a bounded batch of
226            /// planned lanes against the chain in one call instead of one
227            /// `eth_call` per lane; a larger plan set takes one call per
228            /// batch.
229            function getWatermarks(bytes32[] calldata poolIds, address[] calldata signers, address[] calldata providers)
230                external
231                view
232                returns (Lane[] memory);
233
234            /// Governable per-megabyte delivery-rate floor (ADR 003). A soft
235            /// floor: `redeem` settles a sub-floor voucher in full and clamps
236            /// only the byte count it credits toward governance vote weight. It
237            /// is not a quote gate — nodes never read it to bound their own
238            /// rate — so this getter is governance-observability only.
239            function getRateBounds() external view returns (uint256 floor);
240
241            /// Per-owner monotonic pool counter (public mapping getter). The
242            /// *next* nonce `openPool` will assign to `owner`; the resulting
243            /// `poolId` is `keccak256(owner, nonce)`.
244            function ownerPoolNonce(address owner) external view returns (uint256);
245
246            /// A page of the pool ids `owner` has opened, oldest first.
247            /// `ownerPoolNonce(owner)` is the count.
248            function getPools(address owner, uint256 offset, uint256 limit)
249                external
250                view
251                returns (bytes32[] memory);
252
253
254            // -----------------------------------------------------------------
255            // Write functions (owner path)
256            // -----------------------------------------------------------------
257
258            /// Open a USDC pool; transfers `deposit` in and derives
259            /// `poolId = keccak256(owner, ownerPoolNonce[owner])`. Names no
260            /// provider and no signer — a pool is bound to no payee at open.
261            function openPool(uint64 deposit) external returns (bytes32 poolId);
262
263            /// Owner-only: add `additionalDeposit` USDC to an open pool.
264            function topUp(bytes32 poolId, uint64 additionalDeposit) external;
265
266            /// Owner-only: start the grace-window close on `poolId`. Moves
267            /// no funds — redemption stays callable until `disputeDeadline`.
268            function closePool(bytes32 poolId) external;
269
270            /// Callable by anyone once the grace window has elapsed:
271            /// transfers `deposit - totalRedeemed` to the owner and closes
272            /// the pool.
273            function reclaim(bytes32 poolId) external;
274
275            // -----------------------------------------------------------------
276            // Write functions (redeemer / node path)
277            // -----------------------------------------------------------------
278
279            /// The sole redemption entry point: per pool, register every
280            /// capability, then redeem every voucher, all in one
281            /// transaction. A node redeems every lane it holds at once, and
282            /// a single lane is a one-pool batch of one. Each voucher pays
283            /// the increment over its lane watermark, bounded by the
284            /// signer's remaining cap and the pool's remaining deposit; a
285            /// transient-empty voucher (already-registered signer, expired
286            /// capability, stale cumulative, or a drained lane/cap) is
287            /// skipped rather than reverting. Grouping by pool is what makes
288            /// the pool's status gate and its `totalRedeemed` write happen
289            /// once per group instead of once per lane. Every voucher is
290            /// redeemed for `msg.sender`, so the whole call's payout settles
291            /// through `FeeRouter.routeSettlement` in one go.
292            function redeemMany(PoolBatch[] calldata batches) external returns (uint256 totalPaid);
293
294            // -----------------------------------------------------------------
295            // Events
296            // -----------------------------------------------------------------
297
298            /// An owner opened a pool.
299            event PoolOpened(bytes32 indexed poolId, address indexed owner, uint256 deposit);
300
301            /// An owner topped up an open pool. The watcher updates the
302            /// tracked deposit to `newDeposit` so a redeemer's `paid <=
303            /// remaining deposit` bound accepts post-top-up redemptions
304            /// instead of rejecting them against a stale deposit.
305            event PoolToppedUp(bytes32 indexed poolId, uint256 additionalDeposit, uint256 newDeposit);
306
307            /// One lane's outcome inside a `PoolRedeemed`.
308            /// `newPaidCumulative` is the lane's cumulative paid amount
309            /// after the advance — the value a node writes to its paid
310            /// watermark, cumulative rather than a delta so the stream is
311            /// idempotent and survives a gap. `bytesPaid` is the
312            /// paid-proportional served byte count, carried for per-lane
313            /// accounting. Field order is part of the ABI tuple; do not
314            /// rearrange.
315            struct LaneSettled {
316                address signer;
317                uint64 newPaidCumulative;
318                uint64 bytesPaid;
319            }
320
321            /// A node cashed vouchers against one pool: one event per pool
322            /// group, with an entry per lane that actually paid. Emitting
323            /// once for the group rather than once per lane is what keeps
324            /// the log base and its topics off the per-lane cost. A node
325            /// follows this event, filtered on its own `provider`, as the
326            /// single write path for the paid side.
327            event PoolRedeemed(bytes32 indexed poolId, address indexed provider, LaneSettled[] lanes);
328
329            /// The owner started the grace-window close on `poolId`.
330            /// `redeem` stays callable until `disputeDeadline`.
331            event PoolCloseInitiated(bytes32 indexed poolId, address indexed owner, uint256 disputeDeadline);
332
333            /// `reclaim` refunded `ownerRefund` (`deposit - totalRedeemed`)
334            /// to `owner` and the pool is now `Closed`.
335            event PoolReclaimed(bytes32 indexed poolId, address indexed owner, uint256 ownerRefund);
336
337            /// Governance changed the per-MB delivery-rate floor. The floor is
338            /// redemption-time contract state (the soft vote-weight credit
339            /// clamp); this event announces the change to on-chain observers. No
340            /// `indexed` params — matches `setRateBounds`.
341            event RateBoundsUpdated(uint256 newDeliveryFloor);
342        }
343    }
344
345    alloy::sol! {
346        #[sol(rpc)]
347        contract FeeRouter {
348            /// Current fee split, as `[operator, buyback, treasury]` basis
349            /// points summing to `BPS_DENOMINATOR`. Mirrors
350            /// `contracts/src/FeeRouter.sol`.
351            function getShares() external view returns (uint256[3]);
352
353            /// Governance retuned the fee split. The fee-shares watcher
354            /// decodes the new split and stores the operator bucket into the
355            /// node's live cell so a governance retune reaches running nodes
356            /// without a restart.
357            event SharesUpdated(uint256[3] newShares);
358        }
359    }
360}
361
362pub use sol_types::{FeeRouter, PaymentPool};
363
364/// Narrow a USDC amount, or a byte count, to the `uint64` the `PaymentPool`
365/// carries in its packed fields.
366///
367/// Off-chain the amounts travel as `U256`, because that is the width the ERC-20
368/// surface and the voucher store speak. On-chain every USDC counter and both
369/// lane watermarks are `uint64`: at USDC's six decimals that ceiling is about
370/// $18.4 trillion, and 18.4 exabytes for a byte count, so this rejects only a
371/// value that could never have been a real lane. Call it at the contract
372/// boundary rather than widening the callers.
373///
374/// # Errors
375///
376/// Errors if `value` does not fit `u64`, naming `what` so the caller's context
377/// says which field overflowed.
378pub fn to_pool_u64(value: alloy::primitives::U256, what: &str) -> anyhow::Result<u64> {
379    u64::try_from(value)
380        .map_err(|_| anyhow::anyhow!("{what} {value} exceeds the PaymentPool's uint64 field"))
381}
382
383/// One page of [`enumerate_owned_pools`]. `getPools` clamps `limit` to the
384/// remaining count, so a short page ends the walk with no separate
385/// `ownerPoolNonce` read.
386const OWNED_POOLS_PAGE: usize = 256;
387
388/// Every pool id `owner` has opened, read from chain oldest-first by paging
389/// `getPools`.
390///
391/// Chain-authoritative, which is the point: the local buyer store drops a row
392/// at close and is lost outright if the data dir is reset, so it cannot answer
393/// "do I already own a pool". Asking the chain instead is what stops a node
394/// or client from escrowing a second deposit against a pool it has simply forgotten
395/// (#2072), and it enumerates pools in every lifecycle state, which is what
396/// reaching a historical `Closing` pool awaiting reclaim needs.
397///
398/// # Errors
399///
400/// Errors if any page read fails, naming the offset that failed.
401pub async fn enumerate_owned_pools<P>(
402    contract: &PaymentPool::PaymentPoolInstance<P>,
403    owner: alloy::primitives::Address,
404) -> anyhow::Result<Vec<crate::lane::PoolId>>
405where
406    P: alloy::providers::Provider + Clone,
407{
408    use alloy::primitives::U256;
409
410    let mut ids = Vec::new();
411    let mut offset: u64 = 0;
412    loop {
413        let page = contract
414            .getPools(owner, U256::from(offset), U256::from(OWNED_POOLS_PAGE))
415            .call()
416            .await
417            .map_err(|e| {
418                anyhow::Error::new(e).context(format!("getPools(offset={offset}) failed"))
419            })?;
420        let n = page.len();
421        ids.extend(page);
422        if n < OWNED_POOLS_PAGE {
423            break;
424        }
425        offset = offset.saturating_add(u64::try_from(n)?);
426    }
427    Ok(ids)
428}
429
430/// The newest pool `owner` holds on this contract that is `Open` and still has
431/// deposit left to spend, or `None` if it holds no such pool.
432///
433/// This is the chain's answer to "do I already own a pool I can pay from",
434/// which the local buyer store cannot give once it has lost its row — to a
435/// reset data dir, a new machine, or a store-format change. Opening a pool on
436/// that question's behalf when this would have answered `Some` escrows a second
437/// deposit beside a live one, and reverts when the wallet's remaining USDC
438/// cannot cover it.
439///
440/// Newest first, because a later pool is the one an earlier session would have
441/// been paying from. That assumes the wallet has one buyer: a wallet shared by
442/// two buyers — a node's operator key used by a client as well — has no single
443/// "earlier session", and the caller must not adopt at all. A fully-redeemed pool is still `Open` on chain, and
444/// adopting one would leave nothing to spend, so solvency is part of the test.
445///
446/// # Errors
447///
448/// Errors if the enumeration or any pool read fails. A read that faulted says
449/// nothing about whether that pool is live, and treating it as absent is
450/// exactly the unobserved second deposit this exists to prevent — so the
451/// caller learns it could not tell, rather than being told there is no pool.
452pub async fn newest_solvent_owned_pool<P>(
453    contract: &PaymentPool::PaymentPoolInstance<P>,
454    owner: alloy::primitives::Address,
455) -> anyhow::Result<Option<(crate::lane::PoolId, PaymentPool::Pool)>>
456where
457    P: alloy::providers::Provider + Clone,
458{
459    use anyhow::Context as _;
460    for pool_id in enumerate_owned_pools(contract, owner)
461        .await?
462        .into_iter()
463        .rev()
464    {
465        let pool = contract
466            .getPool(pool_id)
467            .call()
468            .await
469            .with_context(|| format!("getPool({pool_id}) failed"))?;
470        if matches!(pool.status, PaymentPool::Status::Open) && pool.deposit > pool.totalRedeemed {
471            return Ok(Some((pool_id, pool)));
472        }
473    }
474    Ok(None)
475}
476
477/// A voucher signer's on-chain authorization in one pool, as a node's admit path
478/// and a capability client read it from `getAuthorization` (ADR 003 §Capability delegation).
479#[derive(Clone, Copy, Debug, PartialEq, Eq)]
480pub enum SignerAuthorization {
481    /// The signer holds no registration (the all-zero `Authorization`). Its first
482    /// redemption registers whichever owner-signed capability lands first.
483    Unregistered,
484    /// The signer is registered. `cap` and `expiry` are write-once on-chain, so
485    /// they never change for this `(pool, signer)`; `spent` only grows.
486    Registered {
487        /// The registered spending cap, in micro-USDC.
488        cap: u64,
489        /// The registered expiry, in Unix seconds. The chain treats a voucher at
490        /// or past it as unredeemable.
491        expiry: u64,
492        /// What the signer has redeemed across every provider, in micro-USDC.
493        spent: u64,
494    },
495}
496
497impl SignerAuthorization {
498    /// Classify a raw `getAuthorization` result. The contract marks a signer
499    /// registered when `cap != 0 || expiry != 0`, so a registration with a zero
500    /// `cap` but a non-zero `expiry` is registered with no headroom.
501    #[must_use]
502    pub const fn from_onchain(auth: &PaymentPool::Authorization) -> Self {
503        if auth.cap == 0 && auth.expiry == 0 {
504            Self::Unregistered
505        } else {
506            Self::Registered {
507                cap: auth.cap,
508                expiry: auth.expiry,
509                spent: auth.spent,
510            }
511        }
512    }
513
514    /// Whether the signer can still pay at least `floor_micro` at Unix time `now`.
515    /// An unregistered signer always can: it admits on its presented capability.
516    /// A registered signer needs a live registration and `cap − spent` headroom
517    /// of at least `floor_micro`.
518    #[must_use]
519    pub fn covers(self, floor_micro: alloy::primitives::U256, now: u64) -> bool {
520        match self {
521            Self::Unregistered => true,
522            Self::Registered { cap, expiry, spent } => {
523                now < expiry
524                    && alloy::primitives::U256::from(cap.saturating_sub(spent)) >= floor_micro
525            }
526        }
527    }
528}
529
530#[cfg(test)]
531#[allow(clippy::unwrap_used, clippy::indexing_slicing)]
532mod tests;