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;