1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
use alloy::{
contract::Error as ContractError,
primitives::{Address, Bytes, B256},
providers::PendingTransactionError as AlloyError,
signers::local::LocalSignerError,
transports::{RpcError, TransportErrorKind},
};
use eigensdk::client_avsregistry::error::AvsRegistryError;
use newton_core::{config::error::ConfigError, keys::error::KeyError};
use thiserror::Error;
/// Per-item failure classification within a batch, decoded from the 4-byte revert selector.
/// Used by the batch submitter to decide per-item handling without string matching.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BatchItemError {
/// Task already exists on-chain — idempotent, treat as success
TaskAlreadyExists,
/// Task already responded — idempotent, treat as success
TaskAlreadyResponded,
/// Per-item inner call ran out of gas. Either signaled explicitly by the
/// contract's `ItemLikelyOutOfGas` selector (newer contracts) or inferred
/// from a 0-byte revert `reason` (older contracts whose `try/catch` swallowed
/// OOG without a selector fingerprint). Transient — safe to retry.
LikelyOutOfGas {
/// Gas cap the contract forwarded to the inner call, decoded from the
/// explicit error. `None` for legacy empty-reason reverts.
gas_forwarded: Option<u64>,
},
/// Batch contract's per-item pre-check rejected the item because `gasleft()`
/// was below the minimum safe forwarding threshold. Means the item never
/// ran — retry in a fresh batch where it gets a full gas budget.
InsufficientGasForItem {
/// `gasleft()` at the time of the pre-check, from the explicit error.
gas_left: Option<u64>,
},
/// Contract reverted with a known non-recoverable error
ContractRevert {
/// 4-byte selector as hex string
selector: String,
/// Human-readable error name
name: String,
},
/// Revert data too short or unrecognized selector
Unknown {
/// Raw revert bytes
raw: Vec<u8>,
},
}
impl BatchItemError {
/// Returns true if this is an idempotent success (task already processed).
pub fn is_idempotent(&self) -> bool {
matches!(self, Self::TaskAlreadyExists | Self::TaskAlreadyResponded)
}
/// Returns true if this revert is transient and the item should be re-queued.
/// OOG and insufficient-gas pre-check rejections are non-deterministic failures
/// that depend on the batch context; a fresh batch gives the item a new shot.
pub fn is_retryable(&self) -> bool {
matches!(self, Self::LikelyOutOfGas { .. } | Self::InsufficientGasForItem { .. })
}
}
/// Error returned by chainio
#[derive(Debug, Error)]
pub enum ChainIoError {
/// Create new task call failed
#[error("Create new task call failed: {reason}")]
CreateNewTaskCallFail {
/// The reason for the create new task call failure
reason: String,
},
/// Submit identity data call failed
#[error("Submit identity data call failed: {reason}")]
SubmitIdentityDataCallFail {
/// The reason for the submit identity data call failure
reason: String,
},
/// `commitStateRoot` call failed.
#[error("Commit state root call failed: {reason}")]
CommitStateRootCallFail {
/// The reason for the commit state root call failure
reason: String,
},
/// Bls response conversion error
#[error("Bls response conversion error: {reason}")]
BlsResponseConversionError {
/// The reason for the bls response conversion error
reason: String,
},
/// Alloy contract error
#[error("Alloy provider error: {0:?}")]
AlloyProviderError(#[from] AlloyError),
/// No logs generated in Create new task function
#[error("No logs generated in Create new task function")]
CreateNewTaskNoEventFound,
/// Config error
#[error("Config error {0:?}")]
ConfigParseError(#[from] ConfigError),
/// Avs registry error in eigensdk-rs
#[error("AvsRegistry error in eigensdk-rs {0:?}")]
SdkAvsRegistryChainError(#[from] AvsRegistryError),
/// Alloy Rpc Error
#[error("Alloy Rpc Error {0:?}")]
RpcError(#[from] RpcError<TransportErrorKind>),
/// Alloy Signer Error
#[error("Alloy Signer Error {0:?}")]
SignerError(#[from] eyre::Error),
/// Key error
#[error("Key error {0:?}")]
KeyError(#[from] KeyError),
/// Alloy Contract Error
#[error("Alloy Contract Error {0:?}")]
ContractError(#[from] ContractError),
/// Alloy Contract Error with transaction context
#[error("Alloy Contract Error {source:?} (tx from={from}, to={to}, value={value}, data={data})")]
ContractErrorWithTx {
/// Transaction sender
from: Address,
/// Transaction target
to: Address,
/// Transaction value (hex, 0x-prefixed)
value: String,
/// Transaction calldata (hex, 0x-prefixed)
data: String,
/// Underlying alloy contract error
#[source]
source: ContractError,
},
/// Send aggregated response error
#[error("Send aggregated response error")]
SendAggregatedResponseError,
/// Policy deployed event not found
#[error("Policy deployed event not found")]
PolicyDeployedEventNotFound,
/// Policy data deployed event not found
#[error("Policy data deployed event not found")]
PolicyDataDeployedEventNotFound,
/// Identity data submitted event not found
#[error("Identity data submitted event not found")]
IdentityDataSubmittedEventNotFound,
/// Batch transaction partially failed — some items reverted.
/// Contains per-item failure details parsed from `BatchPartialFailure` revert data.
#[error("Batch partial failure: {count} items failed", count = failures.len())]
BatchPartialFailure {
/// Failed items with index, task ID, and ABI-encoded revert reason
failures: Vec<crate::avs::writer::BatchFailedItem>,
},
/// Task already exists on-chain (idempotent — not a real error).
#[error("Task {task_id} already exists on-chain")]
TaskAlreadyExists {
/// The task ID that already exists
task_id: B256,
},
/// Task already responded to on-chain (idempotent — not a real error).
#[error("Task {task_id} already responded")]
TaskAlreadyResponded {
/// The task ID that was already responded to
task_id: B256,
},
/// Contract reverted with a known error selector.
#[error("Contract reverted: {name} (0x{selector})")]
ContractRevert {
/// 4-byte selector as hex string
selector: String,
/// Human-readable error name
name: String,
/// Full revert data for downstream decoding
raw_data: Vec<u8>,
},
/// Transaction was mined but reverted on-chain
#[error("Transaction {0} reverted on-chain")]
TransactionReverted(B256),
/// Transaction submission timed out
#[error("Transaction submission timed out after {timeout_secs} seconds")]
TransactionTimeout {
/// Timeout duration in seconds
timeout_secs: u64,
},
/// Transaction submission retries exhausted
#[error("Transaction submission failed after {attempts} attempts: {last_error}")]
RetriesExhausted {
/// Number of attempts made
attempts: usize,
/// Last error message
last_error: String,
},
/// A broadcast failed at the `send` step *after* its nonce was reserved and
/// set explicitly — so the transaction may already have reached the mempool.
/// Unlike a pre-send (`fill`/gas-estimate) failure, the reserved nonce cannot
/// be safely released: a propagated tx could still mine, and reusing the
/// nonce would collide with it. The reserved nonce is carried so the caller
/// drives a same-nonce cancel (confirm-or-cancel) to resolve the slot on-chain
/// rather than orphaning it. Only produced when an owned nonce allocator is
/// active.
#[error("broadcast send failed at nonce {nonce}: {reason}")]
BroadcastSendFailed {
/// The reserved nonce whose slot must be resolved on-chain.
nonce: u64,
/// `max_fee_per_gas` the failed tx was signed with. Carried so the
/// same-nonce cancel that resolves the slot can be seeded at (or above)
/// the original price — a cancel priced below a propagated original is
/// rejected "replacement underpriced" and burns escalation cycles.
max_fee_per_gas: u128,
/// `max_priority_fee_per_gas` the failed tx was signed with (see above).
max_priority_fee_per_gas: u128,
/// Underlying send error, formatted.
reason: String,
},
}
impl ChainIoError {
/// Returns true if this error is transient and the operation may succeed on retry.
/// Transient errors are network/RPC issues and receipt timeouts, not contract
/// reverts or config errors.
pub fn is_transient(&self) -> bool {
matches!(
self,
ChainIoError::RpcError(_) | ChainIoError::AlloyProviderError(_) | ChainIoError::TransactionTimeout { .. }
)
}
/// Returns true if this error represents an idempotent success (task already on-chain).
pub fn is_idempotent(&self) -> bool {
matches!(
self,
ChainIoError::TaskAlreadyExists { .. } | ChainIoError::TaskAlreadyResponded { .. }
)
}
/// Returns true if the node rejected the broadcast because the signer can't
/// afford `gas_limit × max_fee_per_gas (+ value)` — geth/erigon surface this
/// as `"insufficient funds for gas * price + value"` (JSON-RPC code -32000).
///
/// Critical for the cancel/replacement retry loops: an underpriced rejection
/// means "bid higher", but an insufficient-funds rejection means the bid is
/// ALREADY too high for the balance — escalating the fee makes the next
/// attempt even less affordable. Callers must hold the fee flat (and wait for
/// funding) on this class, never climb. Matched on the message because alloy
/// collapses these into an opaque `TransportError(ErrorResp(..))`.
pub fn is_insufficient_funds(&self) -> bool {
let s = self.to_string().to_lowercase();
s.contains("insufficient funds")
}
/// Returns true if the node rejected a broadcast because the nonce is below
/// the account's current nonce — geth/erigon phrase this `"nonce too low"`.
/// This is a definitive signal that the slot is ALREADY resolved on-chain:
/// some transaction at this nonce (the original, a gas-bump replacement, or
/// an earlier cancel) has mined, so the slot can never be replaced again.
///
/// The bump/cancel pursuit loops use this to STOP — re-broadcasting at a
/// consumed nonce can only ever fail, and escalating its fee is pointless.
/// The payload is re-queued (re-submission is idempotent on-chain) rather
/// than chasing a ghost. Note: this is distinct from "replacement
/// underpriced" (slot still open, bid higher) and "already known" (our tx is
/// already pending — keep waiting, don't treat as resolved).
pub fn is_nonce_consumed(&self) -> bool {
let s = self.to_string().to_lowercase();
s.contains("nonce too low")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn is_insufficient_funds_matches_geth_message() {
// Verbatim message from the runaway-bump incident: a 21k-gas cancel
// priced at ~786k gwei (16.5 ETH) against a 0.13 ETH balance. alloy
// collapses this into a Debug-formatted ContractError whose string
// contains the node's message; matching on the substring is what lets
// the cancel loop stop escalating an already-unaffordable tx.
let err = ChainIoError::CreateNewTaskCallFail {
reason: "Alloy Contract Error TransportError(ErrorResp(ErrorPayload { code: -32000, \
message: \"insufficient funds for gas * price + value: balance 130453576665547003, \
tx cost 16524416509720140000, overshot 16393962933054592997\", data: None }))"
.to_string(),
};
assert!(err.is_insufficient_funds());
}
#[test]
fn is_insufficient_funds_case_insensitive() {
let err = ChainIoError::CreateNewTaskCallFail {
reason: "INSUFFICIENT FUNDS for gas".to_string(),
};
assert!(err.is_insufficient_funds());
}
#[test]
fn is_insufficient_funds_ignores_underpriced() {
// An underpriced rejection is the OPPOSITE class — the caller SHOULD
// climb the fee, so this must not be misclassified as unaffordable.
let err = ChainIoError::CreateNewTaskCallFail {
reason: "replacement transaction underpriced".to_string(),
};
assert!(!err.is_insufficient_funds());
}
#[test]
fn is_nonce_consumed_matches_nonce_too_low() {
let err = ChainIoError::CreateNewTaskCallFail {
reason: "nonce too low: next nonce 42, tx nonce 41".to_string(),
};
assert!(err.is_nonce_consumed());
}
#[test]
fn is_nonce_consumed_ignores_unrelated_and_sibling_classes() {
// "replacement underpriced" (slot still open, bid higher) and
// "insufficient funds" (can't pay) must NOT be read as a consumed slot —
// those keep their own handling.
for reason in [
"replacement transaction underpriced",
"insufficient funds for gas * price + value",
"connection refused",
] {
let err = ChainIoError::CreateNewTaskCallFail {
reason: reason.to_string(),
};
assert!(!err.is_nonce_consumed(), "must not flag nonce-consumed: {reason}");
}
}
}