newton-chainio 0.5.2

newton prover chainio
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
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
//! Contract error registry for errors not exposed in generated bindings.
//!
//! This module provides error definitions for Solidity errors that are either:
//! - From library contracts (e.g., TaskLib) not directly included in generated bindings
//! - From external dependencies (e.g., EigenLayer contracts)
//!
//! For errors defined in Newton's own contracts, prefer using the generated bindings
//! which provide compile-time verified selectors via `SolError::SELECTOR`.

/// Error info for contract errors not in generated bindings.
#[derive(Debug, Clone, Copy)]
pub struct ExternalError {
    /// 4-byte function selector (keccak256 of signature truncated to 4 bytes)
    pub selector: [u8; 4],
    /// Error name (e.g., "TaskResponseTooLate")
    pub name: &'static str,
    /// Human-readable description of what the error means
    pub description: &'static str,
}

impl ExternalError {
    /// Returns the selector as a hex string (without 0x prefix)
    pub fn selector_hex(&self) -> String {
        hex::encode(self.selector)
    }
}

/// TaskLib errors (library used by TaskManager, not directly in bindings)
pub mod task_lib {
    use super::ExternalError;

    /// TaskResponseTooLate(uint32 blockNumber, uint32 taskCreatedBlock, uint32 taskResponseWindowBlock)
    /// Selector: 0xef58cb64
    pub const TASK_RESPONSE_TOO_LATE: ExternalError = ExternalError {
        selector: [0xef, 0x58, 0xcb, 0x64],
        name: "TaskResponseTooLate",
        description: "Response submitted after taskCreatedBlock + responseWindowBlock",
    };

    /// TaskCreatedBlockTooOld(uint256 providedBlock, uint256 currentBlock, uint256 bufferWindow)
    /// Selector: 0xeb790a32
    /// Reverts when taskCreatedBlock is older than (currentBlock - taskCreationBufferWindow)
    pub const TASK_CREATED_BLOCK_TOO_OLD: ExternalError = ExternalError {
        selector: [0xeb, 0x79, 0x0a, 0x32],
        name: "TaskCreatedBlockTooOld",
        description: "taskCreatedBlock is older than currentBlock - bufferWindow (30 blocks on L1, 180 on L2)",
    };

    /// TaskResponseWindowPassed()
    /// Selector: 0x8ab0a89e
    /// Reverts when current block exceeds taskCreatedBlock + taskResponseWindowBlock
    pub const TASK_RESPONSE_WINDOW_PASSED: ExternalError = ExternalError {
        selector: [0x8a, 0xb0, 0xa8, 0x9e],
        name: "TaskResponseWindowPassed",
        description: "Task response window has passed (current block > taskCreatedBlock + responseWindow)",
    };
}

/// PolicyValidationLib errors (library used for policy validation)
pub mod policy_validation {
    use super::ExternalError;

    /// InvalidSourceOrDestination() - Intent.from or intent.to is zero address
    /// Selector: 0x01a23562
    pub const INVALID_SOURCE_OR_DESTINATION: ExternalError = ExternalError {
        selector: [0x01, 0xa2, 0x35, 0x62],
        name: "InvalidSourceOrDestination",
        description: "Intent.from or intent.to is zero address",
    };

    /// PolicyIdMismatch() - Policy ID mismatch
    /// Selector: 0xac0b52a7
    pub const POLICY_ID_MISMATCH: ExternalError = ExternalError {
        selector: [0xac, 0x0b, 0x52, 0xa7],
        name: "PolicyIdMismatch",
        description: "Policy ID mismatch",
    };

    /// PolicyAddressMismatch() - Policy address mismatch
    /// Selector: 0xd648c18f
    pub const POLICY_ADDRESS_MISMATCH: ExternalError = ExternalError {
        selector: [0xd6, 0x48, 0xc1, 0x8f],
        name: "PolicyAddressMismatch",
        description: "Policy address mismatch",
    };

    /// PolicyDataLengthMismatch() - Policy data length mismatch
    /// Selector: 0x3317d504
    pub const POLICY_DATA_LENGTH_MISMATCH: ExternalError = ExternalError {
        selector: [0x33, 0x17, 0xd5, 0x04],
        name: "PolicyDataLengthMismatch",
        description: "Policy data length mismatch",
    };

    /// PolicyDataAddressMismatch() - Policy data address mismatch
    /// Selector: 0xce35713f
    pub const POLICY_DATA_ADDRESS_MISMATCH: ExternalError = ExternalError {
        selector: [0xce, 0x35, 0x71, 0x3f],
        name: "PolicyDataAddressMismatch",
        description: "Policy data address mismatch",
    };

    /// PolicyDataExpired() - Policy data expired
    /// Selector: 0x6d58815b
    pub const POLICY_DATA_EXPIRED: ExternalError = ExternalError {
        selector: [0x6d, 0x58, 0x81, 0x5b],
        name: "PolicyDataExpired",
        description: "Policy data expired",
    };
}

/// EigenLayer AllocationManager errors
pub mod allocation_manager {
    use super::ExternalError;

    /// SameMagnitude() - Allocation magnitude is same as current
    /// Selector: 0x8c0c2f26
    pub const SAME_MAGNITUDE: ExternalError = ExternalError {
        selector: [0x8c, 0x0c, 0x2f, 0x26],
        name: "SameMagnitude",
        description: "Allocation magnitude is same as current value",
    };

    /// InsufficientMagnitude() - Insufficient magnitude for operation
    /// Selector: 0x4e23d035
    pub const INSUFFICIENT_MAGNITUDE: ExternalError = ExternalError {
        selector: [0x4e, 0x23, 0xd0, 0x35],
        name: "InsufficientMagnitude",
        description: "Insufficient magnitude for allocation",
    };

    /// InvalidOperatorSet() - Invalid operator set
    /// Selector: 0x7e0e1e8e
    pub const INVALID_OPERATOR_SET: ExternalError = ExternalError {
        selector: [0x7e, 0x0e, 0x1e, 0x8e],
        name: "InvalidOperatorSet",
        description: "Invalid operator set",
    };
}

/// EigenLayer DelegationManager errors
pub mod delegation_manager {
    use super::ExternalError;

    /// OperatorNotRegistered() - Operator is not registered
    /// Selector: 0x25ec6c1f
    pub const OPERATOR_NOT_REGISTERED: ExternalError = ExternalError {
        selector: [0x25, 0xec, 0x6c, 0x1f],
        name: "OperatorNotRegistered",
        description: "Operator is not registered with EigenLayer",
    };

    /// AlreadyRegistered() - Operator is already registered
    /// Selector: 0x0c74a0e1
    pub const ALREADY_REGISTERED: ExternalError = ExternalError {
        selector: [0x0c, 0x74, 0xa0, 0xe1],
        name: "AlreadyRegistered",
        description: "Operator is already registered",
    };
}

// ---------------------------------------------------------------------------
// Newton contract error string matching
// ---------------------------------------------------------------------------
// These helpers match errors that arrive at the gateway layer as formatted
// strings (e.g., `format!("{}", ChainIoError::...)`) where typed enum
// matching is not possible. Each helper checks both the human-readable error
// name AND the hex selector to handle both chainio-formatted errors and raw
// RPC revert data.

/// Newton contract error selectors (from generated alloy bindings).
/// These mirror the `SolError::SELECTOR` constants in writer.rs but are
/// expressed as string patterns for gateway-layer string matching.
pub mod newton_errors {
    /// TaskAlreadyResponded error name
    pub const TASK_ALREADY_RESPONDED_NAME: &str = "TaskAlreadyResponded";
    /// TaskAlreadyResponded 4-byte selector (0x68905dff)
    pub const TASK_ALREADY_RESPONDED_SELECTOR: &str = "68905dff";

    /// TaskAlreadyExists error name
    pub const TASK_ALREADY_EXISTS_NAME: &str = "TaskAlreadyExists";
    /// TaskAlreadyExists 4-byte selector (0x2e98c533)
    pub const TASK_ALREADY_EXISTS_SELECTOR: &str = "2e98c533";

    /// BatchPartialFailure error name
    pub const BATCH_PARTIAL_FAILURE_NAME: &str = "BatchPartialFailure";
    /// BatchPartialFailure 4-byte selector (0x19ed9977)
    pub const BATCH_PARTIAL_FAILURE_SELECTOR: &str = "19ed9977";
}

/// Check if an error string represents a `TaskAlreadyExists` revert.
pub fn is_task_already_exists(error_str: &str) -> bool {
    error_str.contains(newton_errors::TASK_ALREADY_EXISTS_NAME)
        || error_str.contains(newton_errors::TASK_ALREADY_EXISTS_SELECTOR)
}

/// Check if an error string represents a `TaskAlreadyResponded` revert.
pub fn is_task_already_responded(error_str: &str) -> bool {
    error_str.contains(newton_errors::TASK_ALREADY_RESPONDED_NAME)
        || error_str.contains(newton_errors::TASK_ALREADY_RESPONDED_SELECTOR)
}

/// Check if an error string represents a `BatchPartialFailure` revert.
pub fn is_batch_partial_failure(error_str: &str) -> bool {
    error_str.contains(newton_errors::BATCH_PARTIAL_FAILURE_NAME)
        || error_str.contains(newton_errors::BATCH_PARTIAL_FAILURE_SELECTOR)
}

/// Classify whether an RPC/transport error is transient (worth retrying with
/// backoff) versus non-transient (contract revert, validation failure, etc.).
///
/// This centralizes the pattern matching previously duplicated across
/// `batch_submitter.rs`, `tx_worker.rs`, and `sync.rs`.
///
/// Detection strategy: check for contract revert indicators first (non-transient),
/// then check for known transient patterns. This ordering prevents false positives
/// where a revert error message happens to contain a transient keyword (e.g.,
/// `"Internal server error: ... execution reverted"` matching `"server error"`).
pub fn is_transient_rpc_error(error_str: &str) -> bool {
    let lower = error_str.to_lowercase();

    // Contract reverts are never transient — the same calldata against the same
    // on-chain state will always produce the same revert. Alloy formats these in
    // multiple ways depending on the error path:
    //   - "execution reverted" — standard EVM revert from eth_call or eth_sendTransaction
    //   - "revert" with hex selector — decoded revert from alloy ContractError
    //   - "error code: 3" — EIP-1474 JSON-RPC execution error code
    //   - "ContractError" — alloy's typed contract error variant
    let revert_patterns = ["execution reverted", "error code: 3,", "contracterror", "revert 0x"];
    if revert_patterns.iter().any(|p| lower.contains(p)) {
        return false;
    }

    let transient_patterns = [
        "nonce",
        "timeout",
        "connection",
        "network",
        "server error",
        "internal error",
        "rate limit",
        "429",
        "503",
    ];
    transient_patterns.iter().any(|p| lower.contains(p))
}

// ---------------------------------------------------------------------------
// Selector-based batch item classification (no string matching)
// ---------------------------------------------------------------------------

/// Selectors as byte arrays for exhaustive batch item classification.
pub mod selectors {
    /// TaskAlreadyExists(bytes32) — 0x2e98c533
    pub const TASK_ALREADY_EXISTS: [u8; 4] = [0x2e, 0x98, 0xc5, 0x33];
    /// TaskAlreadyResponded(bytes32) — 0x68905dff
    pub const TASK_ALREADY_RESPONDED: [u8; 4] = [0x68, 0x90, 0x5d, 0xff];
    /// BatchPartialFailure(FailedItem[]) — 0x19ed9977
    pub const BATCH_PARTIAL_FAILURE: [u8; 4] = [0x19, 0xed, 0x99, 0x77];
    /// ItemLikelyOutOfGas(uint256,bytes32,uint256) — 0xb52cd890.
    /// Emitted by BatchTaskManager when a per-item `try` catches an empty-reason
    /// revert (presumed inner OOG) and substitutes this selector + decoded params.
    pub const ITEM_LIKELY_OUT_OF_GAS: [u8; 4] = [0xb5, 0x2c, 0xd8, 0x90];
    /// InsufficientGasForItem(uint256,bytes32,uint256) — 0x06dfb451.
    /// Emitted by BatchTaskManager when the pre-check `gasleft()` is below the
    /// minimum safe forwarding threshold; the item is recorded without execution.
    pub const INSUFFICIENT_GAS_FOR_ITEM: [u8; 4] = [0x06, 0xdf, 0xb4, 0x51];
    /// Panic(uint256) — 0x4e487b71. Solidity reserves this for VM-level
    /// faults; the trailing uint256 carries the panic code (see `panic_name`).
    pub const PANIC: [u8; 4] = [0x4e, 0x48, 0x7b, 0x71];
}

/// Map a Solidity panic code (the uint256 payload of Panic(uint256)) to a
/// human-readable name. Codes are defined in the Solidity language reference.
pub fn panic_name(code: u8) -> &'static str {
    match code {
        0x00 => "GenericCompilerPanic",
        0x01 => "AssertFalse",
        0x11 => "ArithmeticOverflow",
        0x12 => "DivisionByZero",
        0x21 => "EnumOutOfRange",
        0x22 => "StorageBytesEncodingError",
        0x31 => "PopOnEmptyArray",
        0x32 => "ArrayOutOfBounds",
        0x41 => "OutOfMemory",
        0x51 => "InvalidInternalFunction",
        _ => "UnknownPanicCode",
    }
}

/// Decode the panic code from `Panic(uint256)` revert data.
/// Returns `None` if data is not exactly `0x4e487b71 || bytes32(code)`.
pub fn decode_panic_code(data: &[u8]) -> Option<u8> {
    if data.len() != 36 || data[0..4] != selectors::PANIC {
        return None;
    }
    // ABI-encoded uint256: high 31 bytes are zero for valid panic codes.
    Some(data[35])
}

/// Read the `param_index`-th uint256 parameter from ABI-encoded revert data
/// `[selector(4) || param0(32) || param1(32) || ...]`, returning the low 64 bits
/// as `u64`. Returns `None` if `data` is too short to contain the parameter.
/// Used for decoding gas values from `ItemLikelyOutOfGas` / `InsufficientGasForItem`.
pub fn decode_uint_param(data: &[u8], param_index: usize) -> Option<u64> {
    let start = 4 + param_index * 32;
    let end = start + 32;
    if data.len() < end {
        return None;
    }
    let word = &data[start..end];
    // Gas values always fit in u64; take the low 8 big-endian bytes.
    let mut buf = [0u8; 8];
    buf.copy_from_slice(&word[24..32]);
    Some(u64::from_be_bytes(buf))
}

/// Classify a per-item revert reason from `BatchPartialFailure` by its 4-byte selector.
///
/// This is the definitive classification point — no string matching, no guessing.
/// Every selector is either recognized and mapped to a specific `BatchItemError` variant,
/// or classified as `Unknown` with the raw data preserved for logging.
pub fn classify_batch_item_revert(data: &[u8]) -> crate::error::BatchItemError {
    use crate::error::BatchItemError;

    // Legacy 0-byte revert: older BatchTaskManager versions (no explicit gas
    // forwarding, no selector substitution) catch OOG as empty bytes. Treat as
    // a likely OOG since nothing else produces empty reason data in our path.
    if data.len() < 4 {
        return BatchItemError::LikelyOutOfGas { gas_forwarded: None };
    }
    let selector: [u8; 4] = [data[0], data[1], data[2], data[3]];
    match selector {
        selectors::TASK_ALREADY_EXISTS => BatchItemError::TaskAlreadyExists,
        selectors::TASK_ALREADY_RESPONDED => BatchItemError::TaskAlreadyResponded,
        selectors::ITEM_LIKELY_OUT_OF_GAS => BatchItemError::LikelyOutOfGas {
            gas_forwarded: decode_uint_param(data, 2),
        },
        selectors::INSUFFICIENT_GAS_FOR_ITEM => BatchItemError::InsufficientGasForItem {
            gas_left: decode_uint_param(data, 2),
        },
        selectors::PANIC => {
            let name = match decode_panic_code(data) {
                Some(code) => format!("Panic({}): {}", code, panic_name(code)),
                None => "Panic(uint256): malformed".to_string(),
            };
            BatchItemError::ContractRevert {
                selector: hex::encode(selector),
                name,
            }
        }
        _ => {
            // Try to decode via the external error registry and known Newton selectors
            let name = if let Some(ext) = lookup_external_error(&selector) {
                ext.name.to_string()
            } else {
                format!("0x{}", hex::encode(selector))
            };
            BatchItemError::ContractRevert {
                selector: hex::encode(selector),
                name,
            }
        }
    }
}

/// Classify a top-level contract revert (not inside BatchPartialFailure) into a `ChainIoError`.
/// Extracts the 4-byte selector and returns the appropriate typed variant.
pub fn classify_top_level_revert(data: &[u8]) -> crate::error::ChainIoError {
    use crate::error::ChainIoError;

    if data.len() < 4 {
        return ChainIoError::ContractRevert {
            selector: String::new(),
            name: "empty revert".to_string(),
            raw_data: data.to_vec(),
        };
    }
    let selector: [u8; 4] = [data[0], data[1], data[2], data[3]];
    match selector {
        selectors::TASK_ALREADY_EXISTS => {
            // Try to extract task_id from revert data (bytes32 after selector)
            let task_id = if data.len() >= 36 {
                alloy::primitives::B256::from_slice(&data[4..36])
            } else {
                alloy::primitives::B256::ZERO
            };
            ChainIoError::TaskAlreadyExists { task_id }
        }
        selectors::TASK_ALREADY_RESPONDED => {
            let task_id = if data.len() >= 36 {
                alloy::primitives::B256::from_slice(&data[4..36])
            } else {
                alloy::primitives::B256::ZERO
            };
            ChainIoError::TaskAlreadyResponded { task_id }
        }
        selectors::PANIC => {
            let name = match decode_panic_code(data) {
                Some(code) => format!("Panic({}): {}", code, panic_name(code)),
                None => "Panic(uint256): malformed".to_string(),
            };
            ChainIoError::ContractRevert {
                selector: hex::encode(selector),
                name,
                raw_data: data.to_vec(),
            }
        }
        _ => {
            let name = if let Some(ext) = lookup_external_error(&selector) {
                ext.name.to_string()
            } else {
                format!("0x{}", hex::encode(selector))
            };
            ChainIoError::ContractRevert {
                selector: hex::encode(selector),
                name,
                raw_data: data.to_vec(),
            }
        }
    }
}

/// All external errors for lookup
static EXTERNAL_ERRORS: &[&ExternalError] = &[
    // TaskLib
    &task_lib::TASK_RESPONSE_TOO_LATE,
    &task_lib::TASK_CREATED_BLOCK_TOO_OLD,
    &task_lib::TASK_RESPONSE_WINDOW_PASSED,
    // PolicyValidationLib
    &policy_validation::INVALID_SOURCE_OR_DESTINATION,
    &policy_validation::POLICY_ID_MISMATCH,
    &policy_validation::POLICY_ADDRESS_MISMATCH,
    &policy_validation::POLICY_DATA_LENGTH_MISMATCH,
    &policy_validation::POLICY_DATA_ADDRESS_MISMATCH,
    &policy_validation::POLICY_DATA_EXPIRED,
    // EigenLayer AllocationManager
    &allocation_manager::SAME_MAGNITUDE,
    &allocation_manager::INSUFFICIENT_MAGNITUDE,
    &allocation_manager::INVALID_OPERATOR_SET,
    // EigenLayer DelegationManager
    &delegation_manager::OPERATOR_NOT_REGISTERED,
    &delegation_manager::ALREADY_REGISTERED,
];

/// Lookup an external error by its 4-byte selector.
/// Returns None if the selector doesn't match any known external error.
pub fn lookup_external_error(selector: &[u8; 4]) -> Option<&'static ExternalError> {
    EXTERNAL_ERRORS.iter().find(|e| &e.selector == selector).copied()
}

/// Check if an error string contains a specific error selector.
/// Searches for both "0x" prefixed and non-prefixed hex representations.
pub fn error_matches_selector(error_str: &str, error: &ExternalError) -> bool {
    let hex_selector = error.selector_hex();
    let prefixed = format!("0x{}", hex_selector);
    error_str.contains(&prefixed) || error_str.contains(&hex_selector)
}

/// Extract a 4-byte selector from an error string if present.
/// Looks for patterns like "0x8c0c2f26" in the error message.
pub fn extract_selector_from_error(error_str: &str) -> Option<[u8; 4]> {
    // Look for 0x followed by 8 hex chars
    if let Some(pos) = error_str.find("0x") {
        let hex_part = &error_str[pos + 2..];
        if hex_part.len() >= 8 {
            let hex_bytes = &hex_part[..8];
            if let Ok(bytes) = hex::decode(hex_bytes) {
                if bytes.len() == 4 {
                    return Some([bytes[0], bytes[1], bytes[2], bytes[3]]);
                }
            }
        }
    }
    None
}

/// Decode an error from its string representation.
/// Returns a human-readable message if the error is recognized.
pub fn decode_error_from_string(error_str: &str) -> Option<String> {
    if let Some(selector) = extract_selector_from_error(error_str) {
        if let Some(ext_error) = lookup_external_error(&selector) {
            return Some(format!("{} - {}", ext_error.name, ext_error.description));
        }
    }
    None
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_lookup_task_response_too_late() {
        let selector = [0xef, 0x58, 0xcb, 0x64];
        let error = lookup_external_error(&selector);
        assert!(error.is_some());
        assert_eq!(error.unwrap().name, "TaskResponseTooLate");
    }

    #[test]
    fn test_lookup_task_created_block_too_old() {
        let selector = [0xeb, 0x79, 0x0a, 0x32];
        let error = lookup_external_error(&selector);
        assert!(error.is_some());
        assert_eq!(error.unwrap().name, "TaskCreatedBlockTooOld");
    }

    #[test]
    fn test_lookup_task_response_window_passed() {
        let selector = [0x8a, 0xb0, 0xa8, 0x9e];
        let error = lookup_external_error(&selector);
        assert!(error.is_some());
        assert_eq!(error.unwrap().name, "TaskResponseWindowPassed");
    }

    #[test]
    fn test_lookup_unknown_selector() {
        let selector = [0x00, 0x00, 0x00, 0x00];
        let error = lookup_external_error(&selector);
        assert!(error.is_none());
    }

    #[test]
    fn test_selector_hex() {
        assert_eq!(task_lib::TASK_RESPONSE_TOO_LATE.selector_hex(), "ef58cb64");
        assert_eq!(task_lib::TASK_CREATED_BLOCK_TOO_OLD.selector_hex(), "eb790a32");
    }

    // --- Newton error string matching ---

    #[test]
    fn test_is_task_already_exists_by_name() {
        assert!(is_task_already_exists(
            "TaskAlreadyExists(0x1234) - task with this ID already exists"
        ));
    }

    #[test]
    fn test_is_task_already_exists_by_selector() {
        assert!(is_task_already_exists("revert 0x2e98c533"));
    }

    #[test]
    fn test_is_task_already_exists_negative() {
        assert!(!is_task_already_exists("TaskMismatch"));
    }

    #[test]
    fn test_is_task_already_responded_by_name() {
        assert!(is_task_already_responded(
            "TaskAlreadyResponded(0xabcd) - task already has a response"
        ));
    }

    #[test]
    fn test_is_task_already_responded_by_selector() {
        assert!(is_task_already_responded("revert 0x68905dff"));
    }

    #[test]
    fn test_is_batch_partial_failure() {
        assert!(is_batch_partial_failure("BatchPartialFailure: 3 items failed"));
        assert!(is_batch_partial_failure("revert 0x19ed9977"));
        assert!(!is_batch_partial_failure("nonce too low"));
    }

    #[test]
    fn test_is_transient_rpc_error_transient() {
        assert!(is_transient_rpc_error("nonce too low"));
        assert!(is_transient_rpc_error("connection refused"));
        assert!(is_transient_rpc_error("HTTP 429 Too Many Requests"));
        assert!(is_transient_rpc_error("HTTP 503 Service Unavailable"));
        assert!(is_transient_rpc_error("request timeout after 30s"));
        assert!(is_transient_rpc_error("rate limit exceeded"));
    }

    // --- Batch item revert classification ---

    #[test]
    fn test_classify_task_already_exists() {
        let mut data = vec![0x2e, 0x98, 0xc5, 0x33];
        data.extend_from_slice(&[0u8; 32]);
        let result = classify_batch_item_revert(&data);
        assert_eq!(result, crate::error::BatchItemError::TaskAlreadyExists);
        assert!(result.is_idempotent());
    }

    #[test]
    fn test_classify_task_already_responded() {
        let mut data = vec![0x68, 0x90, 0x5d, 0xff];
        data.extend_from_slice(&[0u8; 32]);
        let result = classify_batch_item_revert(&data);
        assert_eq!(result, crate::error::BatchItemError::TaskAlreadyResponded);
        assert!(result.is_idempotent());
    }

    #[test]
    fn test_classify_unknown_selector() {
        let data = vec![0xde, 0xad, 0xbe, 0xef];
        let result = classify_batch_item_revert(&data);
        match &result {
            crate::error::BatchItemError::ContractRevert { selector, .. } => {
                assert_eq!(selector, "deadbeef");
            }
            _ => panic!("expected ContractRevert"),
        }
        assert!(!result.is_idempotent());
    }

    #[test]
    fn test_classify_short_data() {
        // Data under 4 bytes (bare `revert()` / inner OOG from legacy contracts)
        // is classified as retryable LikelyOutOfGas rather than unknown poison,
        // so healthy items in the same batch keep their retry opportunity.
        let result = classify_batch_item_revert(&[0x01, 0x02]);
        assert!(matches!(
            result,
            crate::error::BatchItemError::LikelyOutOfGas { gas_forwarded: None }
        ));
        assert!(result.is_retryable());
    }

    #[test]
    fn test_classify_panic_array_oob() {
        let mut data = vec![0x4e, 0x48, 0x7b, 0x71];
        data.extend_from_slice(&[0u8; 31]);
        data.push(0x32);
        let result = classify_batch_item_revert(&data);
        match &result {
            crate::error::BatchItemError::ContractRevert { selector, name } => {
                assert_eq!(selector, "4e487b71");
                assert!(name.contains("ArrayOutOfBounds"), "got: {name}");
                assert!(name.contains("50"), "panic code 0x32 = 50: {name}");
            }
            _ => panic!("expected ContractRevert with decoded panic"),
        }
        assert!(!result.is_idempotent());
    }

    #[test]
    fn test_decode_panic_code_codes() {
        let mut data = vec![0x4e, 0x48, 0x7b, 0x71];
        data.extend_from_slice(&[0u8; 31]);
        data.push(0x11);
        assert_eq!(decode_panic_code(&data), Some(0x11));
        assert_eq!(panic_name(0x11), "ArithmeticOverflow");
        assert_eq!(panic_name(0x12), "DivisionByZero");
        assert_eq!(panic_name(0x32), "ArrayOutOfBounds");
    }

    #[test]
    fn test_decode_panic_code_rejects_non_panic_selector() {
        let mut data = vec![0xde, 0xad, 0xbe, 0xef];
        data.extend_from_slice(&[0u8; 32]);
        assert_eq!(decode_panic_code(&data), None);
    }

    #[test]
    fn test_classify_known_external_error() {
        // TaskResponseTooLate: 0xef58cb64
        let data = vec![0xef, 0x58, 0xcb, 0x64];
        let result = classify_batch_item_revert(&data);
        match result {
            crate::error::BatchItemError::ContractRevert { name, .. } => {
                assert_eq!(name, "TaskResponseTooLate");
            }
            _ => panic!("expected ContractRevert with known name"),
        }
    }

    #[test]
    fn test_classify_top_level_task_already_exists() {
        let mut data = vec![0x2e, 0x98, 0xc5, 0x33];
        data.extend_from_slice(&[0xAB; 32]); // task_id
        let result = classify_top_level_revert(&data);
        match result {
            crate::error::ChainIoError::TaskAlreadyExists { task_id } => {
                assert_eq!(task_id[0], 0xAB);
            }
            _ => panic!("expected TaskAlreadyExists"),
        }
    }

    #[test]
    fn test_is_transient_rpc_error_non_transient() {
        assert!(!is_transient_rpc_error("TaskAlreadyExists(0x1234)"));
        assert!(!is_transient_rpc_error("revert 0x2e98c533"));
        // Contract reverts containing transient keywords must NOT be classified as transient
        assert!(!is_transient_rpc_error(
            "Internal server error: Failed to batch create tasks: \
             Alloy Contract Error TransportError(ErrorResp(execution reverted))"
        ));
        assert!(!is_transient_rpc_error("execution reverted: TaskCreatedBlockTooOld"));
        // EIP-1474 error code 3 = execution error
        assert!(!is_transient_rpc_error(
            "ErrorResp { code: 3, message: \"execution reverted\", data: Some(\"0xeb790a32\") }"
        ));
        assert!(!is_transient_rpc_error("Alloy Contract Error ContractError(...)"));
    }
}