o2_sdk/
onchain_revert.rs

1use serde_json::Value;
2
3// ---------------------------------------------------------------------------
4// ABI error enums — variant lists keyed by logId
5//
6// Source of truth: abi/mainnet/*.json  (loggedTypes + concreteTypes).
7// Validate with:  python scripts/validate_abi_enums.py
8// ---------------------------------------------------------------------------
9
10// (log_id from LogData receipt rb register, fully-qualified enum name, [(variant_name, description)])
11// Variant index = 0-based discriminant found in LogData.data first 8 bytes.
12type ErrorEnum = (u64, &'static str, &'static [(&'static str, &'static str)]);
13const ABI_ERROR_ENUMS: &[ErrorEnum] = &[
14    (
15        537125673719950211,
16        "upgradability::errors::SetProxyOwnerError",
17        &[("CannotUninitialize", "Cannot uninitialize proxy owner")],
18    ),
19    (
20        821289540733930261,
21        "contract_schema::trade_account::CallerError",
22        &[("InvalidCaller", "Caller is not authorized for this operation")],
23    ),
24    (
25        1043998670105365804,
26        "contract_schema::order_book::OrderCancelError",
27        &[
28            ("NotOrderOwner", "You can only cancel your own orders"),
29            ("TraderNotBlacklisted", "Trader is not blacklisted"),
30            ("NoBlacklist", "No blacklist configured for this market"),
31        ],
32    ),
33    (
34        2735857006735158246,
35        "contract_schema::trade_account::SessionError",
36        &[
37            ("SessionInThePast", "Session expiry is in the past. Create a new session."),
38            ("NoApprovedContractIdsProvided", "Session must include at least one approved contract"),
39        ],
40    ),
41    (
42        4755763688038835574,
43        "contract_schema::order_book::FeeError",
44        &[("NoFeesAvailable", "No fees to collect")],
45    ),
46    (
47        4997665884103701952,
48        "pausable::errors::PauseError",
49        &[
50            ("Paused", "Market is paused"),
51            ("NotPaused", "Market is not paused"),
52        ],
53    ),
54    (
55        5347491661573165298,
56        "contract_schema::whitelist::WhitelistError",
57        &[
58            ("TraderAlreadyWhitelisted", "Account is already whitelisted"),
59            ("TraderNotWhitelisted", "Account is not whitelisted"),
60        ],
61    ),
62    (
63        8930260739195532515,
64        "contract_schema::order_book::OrderBookInitializationError",
65        &[
66            ("InvalidAsset", "Invalid asset configuration (admin)"),
67            ("InvalidDecimals", "Invalid decimals configuration (admin)"),
68            ("InvalidPriceWindow", "Invalid price window (admin)"),
69            ("InvalidPricePrecision", "Invalid price precision (admin)"),
70            ("OwnerNotSet", "Owner not set (admin)"),
71            ("InvalidMinOrder", "Invalid minimum order (admin)"),
72        ],
73    ),
74    (
75        9305944841695250538,
76        "contract_schema::register::TradeAccountRegistryError",
77        &[
78            ("OwnerAlreadyHasTradeAccount", "This wallet already has a trade account"),
79            ("TradeAccountNotRegistered", "Trade account not found. Call setup_account() first."),
80            ("TradeAccountAlreadyHasReferer", "Referral code already set for this account"),
81        ],
82    ),
83    (
84        11035215306127844569,
85        "contract_schema::trade_account::SignerError",
86        &[
87            ("InvalidSigner", "Signature doesn't match the session signer"),
88            ("ProxyOwnerIsContract", "Contract IDs cannot be used as proxy owners"),
89        ],
90    ),
91    (
92        12033795032676640771,
93        "contract_schema::order_book::OrderCreationError",
94        &[
95            ("InvalidOrderArgs", "Order arguments are invalid"),
96            ("InvalidInputAmount", "Input amount doesn\u{2019}t match price \u{00d7} quantity. Check your balance."),
97            ("InvalidAsset", "Wrong asset for this market"),
98            ("PriceExceedsRange", "Price is outside the allowed range for this market"),
99            ("PricePrecision", "Price doesn\u{2019}t align with the market\u{2019}s tick size. Use Market.scale_price()."),
100            ("InvalidHeapPrices", "Internal order book state error. Retry the order."),
101            ("FractionalPrice", "price \u{00d7} quantity must be divisible by 10^base_decimals. Use Market.adjust_quantity()."),
102            ("OrderNotFilled", "FillOrKill order could not be fully filled. Try a smaller quantity or use Spot."),
103            ("OrderPartiallyFilled", "PostOnly order would cross the spread. Use a lower buy price or higher sell price."),
104            ("TraderNotWhiteListed", "Account not whitelisted. Call whitelist_account() first."),
105            ("TraderBlackListed", "Account is blacklisted and cannot trade on this market"),
106            ("InvalidMarketOrder", "Market orders are not supported on this order book"),
107            ("InvalidMarketOrderArgs", "Invalid arguments for bounded market order"),
108        ],
109    ),
110    (
111        12825652816513834595,
112        "ownership::errors::InitializationError",
113        &[("CannotReinitialized", "Contract already initialized")],
114    ),
115    (
116        13517258236389385817,
117        "contract_schema::blacklist::BlacklistError",
118        &[
119            ("TraderAlreadyBlacklisted", "Account is already blacklisted"),
120            ("TraderNotBlacklisted", "Account is not blacklisted"),
121        ],
122    ),
123    (
124        14509209538366790003,
125        "std::crypto::signature_error::SignatureError",
126        &[
127            ("UnrecoverablePublicKey", "Could not recover public key from signature"),
128            ("InvalidPublicKey", "Public key is invalid"),
129            ("InvalidSignature", "Signature verification failed"),
130            ("InvalidOperation", "Invalid cryptographic operation"),
131        ],
132    ),
133    (
134        14888260448086063780,
135        "contract_schema::trade_account::WithdrawError",
136        &[
137            ("AmountIsZero", "Withdrawal amount must be greater than zero"),
138            ("NotEnoughBalance", "Insufficient balance for withdrawal"),
139        ],
140    ),
141    (
142        17376141311665587813,
143        "src5::AccessError",
144        &[("NotOwner", "Caller is not the contract owner")],
145    ),
146    (
147        17909535172322737929,
148        "contract_schema::trade_account::NonceError",
149        &[("InvalidNonce", "Nonce is stale or out of sequence. Refresh the nonce and retry.")],
150    ),
151];
152
153// Fuel VM signal constants (from sway-lib-std/src/error_signals.sw).
154// These are the REVERT receipt ra values — they identify the *type* of failure,
155// NOT the specific error variant.
156const SIGNAL_CONSTANTS: &[(u64, &str)] = &[
157    (0xFFFF_FFFF_FFFF_0000, "FAILED_REQUIRE"),
158    (0xFFFF_FFFF_FFFF_0001, "FAILED_TRANSFER_TO_ADDRESS"),
159    (0xFFFF_FFFF_FFFF_0003, "FAILED_ASSERT_EQ"),
160    (0xFFFF_FFFF_FFFF_0004, "FAILED_ASSERT"),
161    (0xFFFF_FFFF_FFFF_0005, "FAILED_ASSERT_NE"),
162    (0xFFFF_FFFF_FFFF_0006, "REVERT_WITH_LOG"),
163];
164
165/// Format a decoded error into the standard output string.
166///
167/// Output: `EnumShortName::Variant — description`
168///
169/// The short name is the last segment of the fully-qualified enum name
170/// (e.g. `contract_schema::order_book::OrderCreationError` -> `OrderCreationError`).
171fn format_error(enum_name: &str, variant: &str, description: &str) -> String {
172    let short_name = enum_name.rsplit("::").next().unwrap_or(enum_name);
173    format!("{short_name}::{variant} \u{2014} {description}")
174}
175
176/// Look up a variant name in ABI_ERROR_ENUMS and return the formatted error string.
177/// Returns the first match (most specific).
178fn variant_to_qualified(variant: &str) -> Option<String> {
179    for &(_, enum_name, variants) in ABI_ERROR_ENUMS {
180        for &(v, desc) in variants {
181            if v == variant {
182                return Some(format_error(enum_name, v, desc));
183            }
184        }
185    }
186    None
187}
188
189// ---------------------------------------------------------------------------
190// Extraction helpers
191// ---------------------------------------------------------------------------
192
193/// Extract the last decoded error name from a `LogResult { results: [...] }` block.
194///
195/// The backend formats failed transaction logs as:
196///     LogResult { results: [Ok("Event1"), Ok("Event2"), Ok("ErrorName")] }
197///
198/// The last `Ok("...")` entry that matches a known error variant is the error.
199fn extract_log_result_error(text: &str) -> Option<String> {
200    let mut result: Option<&str> = None;
201
202    // Match both Ok("...") and Ok(\"...\") forms
203    let mut offset = 0;
204    while offset < text.len() {
205        // Try Ok(\"...\") first (escaped quotes from JSON)
206        if let Some(pos) = text[offset..].find("Ok(\\\"") {
207            let start = offset + pos + 5; // skip Ok(\"
208            if let Some(end_rel) = text[start..].find("\\\"") {
209                let name = &text[start..start + end_rel];
210                // Check if it's a known variant (no spaces — variant names are single words)
211                if !name.is_empty() && !name.contains(' ') && variant_to_qualified(name).is_some() {
212                    result = Some(name);
213                }
214                offset = start + end_rel + 2;
215                continue;
216            }
217        }
218
219        // Try Ok("...") (unescaped quotes)
220        if let Some(pos) = text[offset..].find("Ok(\"") {
221            let start = offset + pos + 4; // skip Ok("
222            if let Some(end_rel) = text[start..].find("\")") {
223                let name = &text[start..start + end_rel];
224                if !name.is_empty() && !name.contains(' ') && variant_to_qualified(name).is_some() {
225                    result = Some(name);
226                }
227                offset = start + end_rel + 2;
228                continue;
229            }
230        }
231
232        // Neither pattern found from this offset — done
233        break;
234    }
235
236    result.and_then(variant_to_qualified)
237}
238
239/// Parse the LogData receipt before a Revert receipt for logId + discriminant.
240///
241/// In the embedded receipt text, the LogData immediately before the Revert has:
242///     LogData { ..., rb: <logId>, ..., data: Some(Bytes(<hex>)) }
243///
244/// `rb` identifies the enum type (via ABI loggedTypes).
245/// First 8 bytes of `data` (16 hex chars) is the 0-based variant discriminant.
246fn extract_logdata_error(text: &str) -> Option<String> {
247    // Find the last "Revert {" then find the LogData before it
248    let revert_idx = text.rfind("Revert {")?;
249    let logdata_idx = text[..revert_idx].rfind("LogData {")?;
250    let logdata_block = &text[logdata_idx..revert_idx];
251
252    // Extract rb: <digits>
253    let rb_idx = logdata_block.find("rb:")?;
254    let after_rb = &logdata_block[rb_idx + 3..];
255    let after_rb = after_rb.trim_start();
256    let digits: String = after_rb
257        .chars()
258        .take_while(|c| c.is_ascii_digit())
259        .collect();
260    if digits.is_empty() {
261        return None;
262    }
263    let log_id: u64 = digits.parse().ok()?;
264
265    // Find matching enum
266    let (_, enum_name, variants) = ABI_ERROR_ENUMS.iter().find(|(id, _, _)| *id == log_id)?;
267
268    // Extract data: Some(Bytes(<hex>))
269    let bytes_marker = "Bytes(";
270    let data_idx = logdata_block.find(bytes_marker)?;
271    let hex_start = data_idx + bytes_marker.len();
272    let hex_end = logdata_block[hex_start..].find(')')? + hex_start;
273    let hex_str = &logdata_block[hex_start..hex_end];
274
275    // First 8 bytes = 16 hex chars = u64 big-endian discriminant
276    if hex_str.len() < 16 {
277        return None;
278    }
279    let discriminant = u64::from_str_radix(&hex_str[..16], 16).ok()? as usize;
280
281    if discriminant < variants.len() {
282        let (variant_name, desc) = variants[discriminant];
283        Some(format_error(enum_name, variant_name, desc))
284    } else {
285        Some(format!("{enum_name}::unknown(discriminant={discriminant})"))
286    }
287}
288
289/// Extract a Fuel VM panic reason from `PanicInstruction { reason: ... }`.
290fn extract_panic_reason(text: &str) -> Option<String> {
291    let marker = "PanicInstruction {";
292    let start = text.find(marker)?;
293    let after = &text[start + marker.len()..];
294    let reason_pos = after.find("reason:")?;
295    let name_start = &after[reason_pos + "reason:".len()..];
296    let name: String = name_start
297        .trim_start()
298        .chars()
299        .take_while(|c| c.is_alphanumeric() || *c == '_')
300        .collect();
301    if name.is_empty() {
302        None
303    } else {
304        Some(name)
305    }
306}
307
308/// Extract all revert codes from `Revert(DIGITS)` and `Revert { ra: DIGITS }`.
309fn extract_revert_codes(text: &str) -> Vec<u64> {
310    let mut codes = Vec::new();
311
312    // Match Revert(DIGITS)
313    let mut offset = 0;
314    while let Some(start_rel) = text[offset..].find("Revert(") {
315        let start = offset + start_rel + "Revert(".len();
316        let digits: String = text[start..]
317            .chars()
318            .take_while(|c| c.is_ascii_digit())
319            .collect();
320        if !digits.is_empty()
321            && text[start + digits.len()..]
322                .chars()
323                .next()
324                .is_some_and(|c| c == ')')
325        {
326            if let Ok(v) = digits.parse::<u64>() {
327                codes.push(v);
328            }
329        }
330        offset = start;
331    }
332
333    // Match Revert { ... ra: DIGITS ... }
334    offset = 0;
335    while let Some(start_rel) = text[offset..].find("Revert {") {
336        let block_start = offset + start_rel;
337        let brace_end = text[block_start..].find('}');
338        if let Some(ra_rel) = text[block_start..].find("ra:") {
339            let brace_end_abs = brace_end.map(|e| block_start + e);
340            let ra_abs = block_start + ra_rel;
341            if brace_end_abs.is_none() || ra_abs < brace_end_abs.unwrap() {
342                let after_ra = &text[ra_abs + 3..];
343                let after_ra = after_ra.trim_start();
344                let digits: String = after_ra
345                    .chars()
346                    .take_while(|c| c.is_ascii_digit())
347                    .collect();
348                if let Ok(v) = digits.parse::<u64>() {
349                    codes.push(v);
350                }
351            }
352        }
353        offset = block_start + "Revert {".len();
354    }
355
356    codes
357}
358
359/// Identify Fuel VM signal constants from revert codes in text.
360fn recognize_signal(text: &str) -> Option<&'static str> {
361    for code in extract_revert_codes(text) {
362        for &(signal_val, signal_name) in SIGNAL_CONSTANTS {
363            if code == signal_val {
364                return Some(signal_name);
365            }
366        }
367    }
368    None
369}
370
371// ---------------------------------------------------------------------------
372// Public API
373// ---------------------------------------------------------------------------
374
375/// Return a human-readable error name decoded from the backend's error response.
376///
377/// Tries multiple strategies in priority order:
378///
379/// 1. Extract the error variant from the backend's decoded `LogResult`
380/// 2. Parse the `LogData` receipt (logId + discriminant) from embedded receipts
381/// 3. Recognize Fuel VM signal constants
382/// 4. Extract `PanicInstruction` reason
383/// 5. Extract `and error:` summary
384/// 6. Truncate raw reason as last resort
385pub(crate) fn augment_revert_reason(
386    message: &str,
387    reason: &str,
388    receipts: Option<&Value>,
389) -> String {
390    let receipts_text = match receipts {
391        Some(v) => serde_json::to_string(v).unwrap_or_else(|_| v.to_string()),
392        None => String::new(),
393    };
394
395    let context = format!("{message}\n{reason}\n{receipts_text}");
396
397    // 1. Extract from backend-decoded LogResult (most reliable)
398    if let Some(decoded) = extract_log_result_error(&context) {
399        return decoded;
400    }
401
402    // 2. Parse LogData receipt before Revert (fallback)
403    if let Some(decoded) = extract_logdata_error(&context) {
404        return decoded;
405    }
406
407    // 3. Recognize signal constant (tells what KIND of failure, not which variant)
408    let signal = recognize_signal(&context);
409
410    // 4. Check for PanicInstruction
411    if let Some(panic) = extract_panic_reason(&context) {
412        return panic;
413    }
414
415    // 5. Extract "and error:" summary
416    if let Some(err_idx) = context.find("and error:") {
417        let after = context[err_idx + "and error:".len()..].trim_start();
418        let summary = if let Some(receipts_idx) = after.find(", receipts:") {
419            after[..receipts_idx].trim()
420        } else {
421            &after[..after.len().min(200)]
422        };
423        if !summary.is_empty() {
424            return summary.to_string();
425        }
426    }
427
428    // 6. If we recognized a signal, return it as context
429    if let Some(signal_name) = signal {
430        return format!("{signal_name} (specific error unknown \u{2014} check .receipts)");
431    }
432
433    // 7. Truncate raw reason
434    if reason.len() > 200 {
435        return format!(
436            "{}... (truncated, full receipts on .receipts)",
437            &reason[..200]
438        );
439    }
440    reason.to_string()
441}
442
443#[cfg(test)]
444mod tests {
445    use super::augment_revert_reason;
446
447    // Realistic reason string from a real backend error response.
448    const REALISTIC_REASON: &str = concat!(
449        "Failed to process SessionCallPayload { actions: [MarketActions { actions: ",
450        "[SettleBalance, CreateOrder { side: Buy }] }] } with error: ",
451        "Transaction abc123 failed with logs: LogResult { results: ",
452        "[Ok(\"IncrementNonceEvent { nonce: 2752 }\"), ",
453        "Ok(\"SessionContractCallEvent { nonce: 2751 }\"), ",
454        "Ok(\"SessionContractCallEvent { nonce: 2751 }\"), ",
455        "Ok(\"OrderCreatedEvent { quantity: 1000000, price: 2129980000000 }\"), ",
456        "Ok(\"OrderMatchedEvent { quantity: 1000000, price: 2129320000000 }\"), ",
457        "Ok(\"FeesCollectedEvent { base_fees: 100, quote_fees: 0 }\"), ",
458        "Ok(\"OrderPartiallyFilled\")] } ",
459        "and error: transaction reverted: Revert(18446744073709486086), ",
460        "receipts: [Call { id: 0000, to: f155, amount: 0 }, ",
461        "LogData { id: f155, ra: 0, rb: 2261086600904378517, ptr: 67108286, len: 8, ",
462        "digest: abc, data: Some(Bytes(0000000000000000)) }, ",
463        "LogData { id: 2a78, ra: 0, rb: 12033795032676640771, ptr: 67100980, len: 8, ",
464        "digest: 4c0e, data: Some(Bytes(0000000000000008)) }, ",
465        "Revert { id: 2a78, ra: 18446744073709486086 }, ",
466        "ScriptResult { result: Revert }]"
467    );
468
469    // -----------------------------------------------------------------------
470    // Strategy 1: LogResult extraction
471    // -----------------------------------------------------------------------
472
473    #[test]
474    fn test_extracts_error_from_log_result() {
475        let decoded =
476            augment_revert_reason("Failed to process transaction", REALISTIC_REASON, None);
477        assert_eq!(
478            decoded,
479            "OrderCreationError::OrderPartiallyFilled \u{2014} PostOnly order would cross the spread. Use a lower buy price or higher sell price."
480        );
481    }
482
483    #[test]
484    fn test_log_result_with_escaped_quotes() {
485        let reason = concat!(
486            "LogResult { results: [Ok(\\\"IncrementNonceEvent\\\"), ",
487            "Ok(\\\"TraderNotWhiteListed\\\")] }"
488        );
489        let decoded = augment_revert_reason("msg", reason, None);
490        assert_eq!(
491            decoded,
492            "OrderCreationError::TraderNotWhiteListed \u{2014} Account not whitelisted. Call whitelist_account() first."
493        );
494    }
495
496    #[test]
497    fn test_log_result_ignores_non_error_entries() {
498        let reason = concat!(
499            "LogResult { results: [Ok(\"IncrementNonceEvent\"), ",
500            "Ok(\"OrderCreatedEvent\"), Ok(\"NotEnoughBalance\")] }"
501        );
502        let decoded = augment_revert_reason("msg", reason, None);
503        assert_eq!(
504            decoded,
505            "WithdrawError::NotEnoughBalance \u{2014} Insufficient balance for withdrawal"
506        );
507    }
508
509    // -----------------------------------------------------------------------
510    // Strategy 2: LogData receipt parsing
511    // -----------------------------------------------------------------------
512
513    #[test]
514    fn test_extracts_error_from_logdata_receipt() {
515        let reason = concat!(
516            "receipts: [LogData { id: abc, ra: 0, rb: 12033795032676640771, ",
517            "ptr: 100, len: 8, digest: def, data: Some(Bytes(0000000000000008)) }, ",
518            "Revert { id: abc, ra: 18446744073709486086 }]"
519        );
520        let decoded = augment_revert_reason("msg", reason, None);
521        assert_eq!(
522            decoded,
523            "OrderCreationError::OrderPartiallyFilled \u{2014} PostOnly order would cross the spread. Use a lower buy price or higher sell price."
524        );
525    }
526
527    #[test]
528    fn test_logdata_discriminant_zero() {
529        let reason = concat!(
530            "LogData { id: x, ra: 0, rb: 12033795032676640771, ",
531            "ptr: 0, len: 8, digest: y, data: Some(Bytes(0000000000000000)) }, ",
532            "Revert { id: x, ra: 18446744073709486086 }"
533        );
534        let decoded = augment_revert_reason("msg", reason, None);
535        assert_eq!(
536            decoded,
537            "OrderCreationError::InvalidOrderArgs \u{2014} Order arguments are invalid"
538        );
539    }
540
541    #[test]
542    fn test_logdata_withdraw_error() {
543        let reason = concat!(
544            "LogData { id: x, ra: 0, rb: 14888260448086063780, ",
545            "ptr: 0, len: 8, digest: y, data: Some(Bytes(0000000000000001)) }, ",
546            "Revert { id: x, ra: 18446744073709486000 }"
547        );
548        let decoded = augment_revert_reason("msg", reason, None);
549        assert_eq!(
550            decoded,
551            "WithdrawError::NotEnoughBalance \u{2014} Insufficient balance for withdrawal"
552        );
553    }
554
555    #[test]
556    fn test_logdata_unknown_log_id_falls_through() {
557        let reason = concat!(
558            "LogData { id: x, ra: 0, rb: 9999999999999999999, ",
559            "ptr: 0, len: 8, digest: y, data: Some(Bytes(0000000000000000)) }, ",
560            "Revert { id: x, ra: 18446744073709486086 }"
561        );
562        let decoded = augment_revert_reason("msg", reason, None);
563        assert!(
564            decoded.contains("REVERT_WITH_LOG"),
565            "expected REVERT_WITH_LOG, got: {decoded}"
566        );
567    }
568
569    // -----------------------------------------------------------------------
570    // Strategy 3: Signal constant recognition
571    // -----------------------------------------------------------------------
572
573    #[test]
574    fn test_recognizes_failed_require_signal() {
575        let reason = "Revert(18446744073709486080)"; // 0xffffffffffff0000
576        let decoded = augment_revert_reason("msg", reason, None);
577        assert!(
578            decoded.contains("FAILED_REQUIRE"),
579            "expected FAILED_REQUIRE, got: {decoded}"
580        );
581    }
582
583    #[test]
584    fn test_recognizes_revert_with_log_signal() {
585        let reason = "Revert(18446744073709486086)"; // 0xffffffffffff0006
586        let decoded = augment_revert_reason("msg", reason, None);
587        assert!(
588            decoded.contains("REVERT_WITH_LOG"),
589            "expected REVERT_WITH_LOG, got: {decoded}"
590        );
591    }
592
593    #[test]
594    fn test_non_signal_revert_code_falls_through() {
595        let decoded = augment_revert_reason("msg", "Revert(42)", None);
596        assert_eq!(decoded, "Revert(42)");
597    }
598
599    // -----------------------------------------------------------------------
600    // Strategy 4: PanicInstruction
601    // -----------------------------------------------------------------------
602
603    #[test]
604    fn test_extracts_panic_reason() {
605        let reason = concat!(
606            "receipts: [Panic { id: abc, reason: PanicInstruction ",
607            "{ reason: NotEnoughBalance, instruction: CALL {} }, pc: 123 }]"
608        );
609        let decoded = augment_revert_reason("msg", reason, None);
610        assert_eq!(decoded, "NotEnoughBalance");
611    }
612
613    // -----------------------------------------------------------------------
614    // Strategy 5: "and error:" fallback
615    // -----------------------------------------------------------------------
616
617    #[test]
618    fn test_extracts_and_error_summary() {
619        let reason = "lots of noise and error: transaction reverted: SomeError, receipts: [...]";
620        let decoded = augment_revert_reason("msg", reason, None);
621        assert_eq!(decoded, "transaction reverted: SomeError");
622    }
623
624    // -----------------------------------------------------------------------
625    // Edge cases
626    // -----------------------------------------------------------------------
627
628    #[test]
629    fn test_leaves_reason_unchanged_when_no_patterns() {
630        let decoded = augment_revert_reason("plain error", "some reason", None);
631        assert_eq!(decoded, "some reason");
632    }
633
634    #[test]
635    fn test_truncates_long_reason() {
636        let reason = "x".repeat(500);
637        let decoded = augment_revert_reason("error", &reason, None);
638        assert!(decoded.len() < 300);
639        assert!(decoded.contains("truncated"));
640    }
641
642    #[test]
643    fn test_receipts_json_searched() {
644        let receipts =
645            serde_json::from_str::<serde_json::Value>(r#"[{"note": "Ok(\"InvalidNonce\")"}]"#)
646                .unwrap();
647        let decoded = augment_revert_reason("msg", "", Some(&receipts));
648        assert_eq!(
649            decoded,
650            "NonceError::InvalidNonce \u{2014} Nonce is stale or out of sequence. Refresh the nonce and retry."
651        );
652    }
653
654    #[test]
655    fn test_priority_log_result_over_logdata() {
656        let decoded =
657            augment_revert_reason("Failed to process transaction", REALISTIC_REASON, None);
658        assert!(
659            decoded.contains("OrderPartiallyFilled"),
660            "expected OrderPartiallyFilled, got: {decoded}"
661        );
662    }
663}