Skip to main content

decdn_incentive/
pool_open_error.rs

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