Skip to main content

decdn_incentive/
pool_open_error.rs

1//! `PaymentPool` revert decoding: the failure-class taxonomy for the
2//! buyer-side `openPool` path (#966) and the pool-lifecycle reverts.
3//!
4//! The buyer `open_pool` kernel (`decdn-client`) can fail three ways
5//! that an operator must triage differently:
6//!
7//! - **`InsufficientDeposit`** — a *misconfiguration*: the node's USDC balance
8//!   or standing allowance cannot cover the deposit; or the deposit is zero —
9//!   either as requested, or as the balance delta actually received under a
10//!   fee-on-transfer token; or the credited deposit is below the
11//!   governance-set `minDeposit` Sybil floor. (A zero deposit cannot be
12//!   requested: both the daemon
13//!   and the CLI validate `buyer_working_deposit_micro_usdc` / the
14//!   `--working-deposit-micro-usdc` flag `> 0` at config load, so the reachable
15//!   zero here is the received-delta one.) The fix is operator-side (fund or
16//!   resize the deposit), not infrastructure.
17//! - **`ContractRevert`** — any *other* deterministic on-chain revert (a
18//!   paused contract, a future revert reason). The deposit was not escrowed;
19//!   the cause is on-chain state, not this node's wallet or RPC.
20//! - **`RpcError`** — a *transient* transport/RPC fault (connectivity, a
21//!   timed out receipt wait, a transaction-nonce blip — the sender's tx
22//!   sequence number, unrelated to a voucher). Retrying typically clears it;
23//!   the fix is infrastructure-side.
24//!
25//! Splitting these lets the node bump the matching
26//! `decdn_pool_open_failures_{insufficient_deposit,contract_revert,rpc_error}_total`
27//! sibling counter (a plain counter field carries no label dimension, so each class is its own
28//! counter rather than one labeled `{reason=…}` series) and emit the same
29//! `reason` token as a structured-log field, so a dashboard distinguishes "the
30//! operator under-funded the gas/USDC wallet" from "the RPC endpoint is flaky"
31//! — the whole point of #966. The classification is driven by the alloy
32//! error's *revert data*: a deterministic revert carries ABI-encoded error data
33//! (caught at gas estimation, so it surfaces on `send()` before a receipt),
34//! whereas a transport fault carries none. A present revert selector is decoded
35//! against the known insufficient-deposit error signatures to split
36//! `InsufficientDeposit` from a generic `ContractRevert`. A token that reverts
37//! in the older `Error(string)` style carries no custom-error selector, so its
38//! message is matched for the same shortfall — an under-funded wallet reads as
39//! `InsufficientDeposit` whichever revert style the deployed USDC uses.
40//!
41//! The module also decodes `topUp`'s `PoolNotOpen` ([`is_pool_not_open`]) and
42//! the `closePool` / `reclaim` reverts ([`PoolLifecycleRevert`]).
43
44use alloy::primitives::Bytes;
45use alloy::sol;
46use alloy::sol_types::{Revert, SolError};
47
48sol! {
49    /// `PaymentPool.openPool` reverts this when the requested deposit — or
50    /// the balance delta actually received, under a fee-on-transfer token —
51    /// is zero. Declared here only for its 4-byte selector, so the
52    /// classifier needs no live contract binding.
53    ///
54    /// This selector is NOT unique to `openPool`: `PaymentPool.topUp`
55    /// reverts it too, and `FeeRouter`, `CapacityBond` and `BuybackBurner`
56    /// each declare the identical argument-less signature, so all five
57    /// share one 4-byte selector. Safe here only because
58    /// [`PoolOpenFailureReason::classify_revert_data`] is called on the
59    /// open path alone; widening its use would need this checked.
60    error ZeroAmount();
61
62    /// `PaymentPool.openPool` reverts this when the credited deposit — the
63    /// received balance delta — is below the governance-set `minDeposit`
64    /// Sybil floor. The fix is operator-side: raise
65    /// `buyer_working_deposit_micro_usdc` to at least the network minimum.
66    error BelowMinDeposit(uint256 received, uint256 minDeposit);
67
68    /// `OpenZeppelin` v5 `ERC20`: the spender's balance is below the transfer
69    /// amount. `openPool`'s `safeTransferFrom` bubbles this up verbatim when
70    /// the node's USDC balance cannot cover the deposit.
71    error ERC20InsufficientBalance(address sender, uint256 balance, uint256 needed);
72
73    /// `OpenZeppelin` v5 `ERC20`: the standing allowance is below the transfer
74    /// amount. Surfaces when the one-time `approve` never ran (or was set too
75    /// low) for the `PaymentPool` spender.
76    error ERC20InsufficientAllowance(address spender, uint256 allowance, uint256 needed);
77
78    /// `PaymentPool.topUp` and `closePool` revert this when the pool is
79    /// `Closing` or `Closed`. Declared here only for its 4-byte selector.
80    error PoolNotOpen();
81
82    /// `PaymentPool.topUp` and `closePool` revert this when the caller is not
83    /// the pool owner. Declared here only for its 4-byte selector.
84    error NotPoolOwner();
85
86    /// `PaymentPool.reclaim` reverts this when the pool is not `Closing`.
87    /// Declared here only for its 4-byte selector.
88    error PoolNotClosing();
89
90    /// `PaymentPool.reclaim` reverts this before the pool's dispute deadline.
91    /// Declared here only for its 4-byte selector.
92    error GraceWindowActive();
93}
94
95/// Which class of failure aborted a buyer `openPool` attempt (#966). Carried
96/// through the `anyhow` error chain as typed context so the metrics layer can
97/// bump the matching `decdn_pool_open_failures_{reason}_total` sibling
98/// counter (one counter per class — a plain counter field carries no label dimension) and
99/// attach a structured `reason` log field, while the human-readable message is
100/// preserved for logs.
101///
102/// The sibling-counter shape is the settled convention (#1475), not a stopgap:
103/// these three classes have unrelated operator remedies (fund the wallet / fix
104/// the contract call / chase the RPC) *and* no meaningful aggregate — "how many
105/// pool opens failed" is not a question with one answer or one response. `decdn_probe_hold_unavailable_total` is the one
106/// labeled reason split, and it is labeled precisely because its values *do*
107/// share one aggregate and one budget axis.
108#[derive(Clone, Copy, Debug, PartialEq, Eq)]
109pub enum PoolOpenFailureReason {
110    /// The node's USDC balance/allowance cannot cover the deposit; or the
111    /// deposit is zero — as requested, or as the received balance delta under
112    /// a fee-on-transfer token; or the credited deposit is below the
113    /// network's `minDeposit`. An operator misconfiguration either way.
114    /// Metric label `insufficient_deposit`.
115    InsufficientDeposit,
116    /// Any other deterministic on-chain revert (the deposit was not escrowed).
117    /// Metric label `contract_revert`.
118    ContractRevert,
119    /// A transient transport/RPC fault (no revert data) — submit or receipt
120    /// wait failed at the network layer. Metric label `rpc_error`.
121    RpcError,
122}
123
124impl PoolOpenFailureReason {
125    /// The metric/label and structured-log `reason` token for this class —
126    /// the `{reason}` slug in the `decdn_pool_open_failures_{reason}_total`
127    /// sibling counter name and the value of the structured-log `reason` field.
128    #[must_use]
129    pub const fn as_label(self) -> &'static str {
130        match self {
131            Self::InsufficientDeposit => "insufficient_deposit",
132            Self::ContractRevert => "contract_revert",
133            Self::RpcError => "rpc_error",
134        }
135    }
136
137    /// Classify the revert data (if any) attached to an `openPool` *submit*
138    /// (`send()`) error. `None` revert data is a transport/RPC fault; present
139    /// revert data is decoded against the insufficient-deposit error selectors
140    /// to split `InsufficientDeposit` from a generic `ContractRevert`.
141    ///
142    /// Pass the result of alloy's `Error::as_revert_data()` (an inherent method
143    /// on `alloy_contract::Error`); keeping the signature on raw bytes makes the
144    /// policy unit-testable without constructing a live RPC error.
145    #[must_use]
146    pub fn classify_revert_data(revert_data: Option<&Bytes>) -> Self {
147        let Some(data) = revert_data else {
148            // No revert payload → the call never reached deterministic execution
149            // (connectivity, tx-nonce, a timed-out estimate): a transport fault.
150            return Self::RpcError;
151        };
152        if is_insufficient_deposit_selector(data) {
153            Self::InsufficientDeposit
154        } else {
155            Self::ContractRevert
156        }
157    }
158}
159
160impl std::fmt::Display for PoolOpenFailureReason {
161    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
162        write!(f, "pool-open failure reason: {}", self.as_label())
163    }
164}
165
166/// Whether ABI-encoded `revert_data` begins with one of the known
167/// insufficient-deposit error selectors (the leading 4 bytes). A too-short
168/// payload (no full selector) is treated as not-matching, so it falls through to
169/// the generic `ContractRevert` class rather than panicking on a slice.
170///
171/// A token that predates custom errors reverts `Error(string)` instead, so the
172/// string payload is matched as well — see [`is_erc20_shortfall_string`].
173fn is_insufficient_deposit_selector(revert_data: &[u8]) -> bool {
174    let Some(selector) = revert_data.get(..4) else {
175        return false;
176    };
177    selector == ZeroAmount::SELECTOR
178        || selector == BelowMinDeposit::SELECTOR
179        || selector == ERC20InsufficientBalance::SELECTOR
180        || selector == ERC20InsufficientAllowance::SELECTOR
181        || is_erc20_shortfall_string(revert_data)
182}
183
184/// Whether `revert_data` is a solidity `Error(string)` revert whose message is
185/// an ERC-20 balance or allowance shortfall.
186///
187/// `PaymentPool.openPool` bubbles whatever its `safeTransferFrom` reverts, and
188/// the USDC deployments the network settles against are not all
189/// `OpenZeppelin` v5: a token built on the string-revert `require` style emits
190/// `Error("ERC20: transfer amount exceeds balance")` rather than the
191/// `ERC20InsufficientBalance` custom error. Both mean the same thing to an
192/// operator — fund the wallet — so both classify as
193/// [`PoolOpenFailureReason::InsufficientDeposit`]. Matching on the message
194/// text is what the payload affords: a string revert carries no structured
195/// discriminant, and the alternative is reporting an under-funded wallet as an
196/// opaque `ContractRevert` that names no remedy.
197fn is_erc20_shortfall_string(revert_data: &[u8]) -> bool {
198    let Some(reason) = Revert::abi_decode(revert_data).ok().map(|r| r.reason) else {
199        return false;
200    };
201    let reason = reason.to_ascii_lowercase();
202    reason.contains("exceeds balance")
203        || reason.contains("insufficient balance")
204        || reason.contains("exceeds allowance")
205        || reason.contains("insufficient allowance")
206}
207
208/// Whether an ERC-20 revert is specifically an *allowance* shortfall
209/// (`ERC20InsufficientAllowance`) — the one class a just-in-time `approve`
210/// can fix. An insufficient-balance revert, a zero-amount revert, or any
211/// other selector returns `false`: an `approve` cannot recover those, so the
212/// caller must treat them as terminal. A string-revert token spells the same
213/// shortfall as `Error("ERC20: insufficient allowance")`, which counts too.
214/// Pass alloy's `Error::as_revert_data()`.
215#[must_use]
216pub fn is_erc20_allowance_shortfall(revert_data: Option<&Bytes>) -> bool {
217    let Some(data) = revert_data else {
218        return false;
219    };
220    if data
221        .get(..4)
222        .is_some_and(|selector| selector == ERC20InsufficientAllowance::SELECTOR)
223    {
224        return true;
225    }
226    Revert::abi_decode(data).is_ok_and(|r| {
227        let reason = r.reason.to_ascii_lowercase();
228        reason.contains("exceeds allowance") || reason.contains("insufficient allowance")
229    })
230}
231
232/// A `PaymentPool` pool-lifecycle revert that `closePool` or `reclaim` can
233/// raise. Decoding the selector names the cause, so a caller does not have to
234/// guess it from the call that failed.
235#[derive(Clone, Copy, Debug, PartialEq, Eq)]
236pub enum PoolLifecycleRevert {
237    /// The pool is `Closing` or `Closed` (`closePool`).
238    PoolNotOpen,
239    /// The caller does not own the pool (`closePool`).
240    NotPoolOwner,
241    /// The pool is not `Closing` (`reclaim`).
242    PoolNotClosing,
243    /// The chain time is before the pool's dispute deadline (`reclaim`).
244    GraceWindowActive,
245}
246
247impl PoolLifecycleRevert {
248    /// Decode the leading 4-byte selector of a `PaymentPool` revert. Returns
249    /// `None` for no data, a payload shorter than a selector, or any other
250    /// selector. Pass alloy's `Error::as_revert_data()`.
251    #[must_use]
252    pub fn decode(revert_data: Option<&Bytes>) -> Option<Self> {
253        let selector = revert_data?.get(..4)?;
254        [
255            (PoolNotOpen::SELECTOR, Self::PoolNotOpen),
256            (NotPoolOwner::SELECTOR, Self::NotPoolOwner),
257            (PoolNotClosing::SELECTOR, Self::PoolNotClosing),
258            (GraceWindowActive::SELECTOR, Self::GraceWindowActive),
259        ]
260        .into_iter()
261        .find_map(|(known, revert)| (selector == known).then_some(revert))
262    }
263
264    /// The Solidity error signature, e.g. `PoolNotOpen()`.
265    #[must_use]
266    pub const fn signature(self) -> &'static str {
267        match self {
268            Self::PoolNotOpen => PoolNotOpen::SIGNATURE,
269            Self::NotPoolOwner => NotPoolOwner::SIGNATURE,
270            Self::PoolNotClosing => PoolNotClosing::SIGNATURE,
271            Self::GraceWindowActive => GraceWindowActive::SIGNATURE,
272        }
273    }
274}
275
276/// Whether a `topUp` revert is `PoolNotOpen`: the pool is `Closing` or
277/// `Closed` and accepts no funds. Pass alloy's `Error::as_revert_data()`.
278#[must_use]
279pub fn is_pool_not_open(revert_data: Option<&Bytes>) -> bool {
280    PoolLifecycleRevert::decode(revert_data) == Some(PoolLifecycleRevert::PoolNotOpen)
281}
282
283#[cfg(test)]
284mod tests;