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;