1use serde_json::Value;
2
3type 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
153const 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
165fn 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
176fn 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
189fn extract_log_result_error(text: &str) -> Option<String> {
200 let mut result: Option<&str> = None;
201
202 let mut offset = 0;
204 while offset < text.len() {
205 if let Some(pos) = text[offset..].find("Ok(\\\"") {
207 let start = offset + pos + 5; if let Some(end_rel) = text[start..].find("\\\"") {
209 let name = &text[start..start + end_rel];
210 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 if let Some(pos) = text[offset..].find("Ok(\"") {
221 let start = offset + pos + 4; 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 break;
234 }
235
236 result.and_then(variant_to_qualified)
237}
238
239fn extract_logdata_error(text: &str) -> Option<String> {
247 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 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 let (_, enum_name, variants) = ABI_ERROR_ENUMS.iter().find(|(id, _, _)| *id == log_id)?;
267
268 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 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
289fn 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
308fn extract_revert_codes(text: &str) -> Vec<u64> {
310 let mut codes = Vec::new();
311
312 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 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
359fn 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
371pub(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 if let Some(decoded) = extract_log_result_error(&context) {
399 return decoded;
400 }
401
402 if let Some(decoded) = extract_logdata_error(&context) {
404 return decoded;
405 }
406
407 let signal = recognize_signal(&context);
409
410 if let Some(panic) = extract_panic_reason(&context) {
412 return panic;
413 }
414
415 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 if let Some(signal_name) = signal {
430 return format!("{signal_name} (specific error unknown \u{2014} check .receipts)");
431 }
432
433 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 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 #[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 #[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 #[test]
574 fn test_recognizes_failed_require_signal() {
575 let reason = "Revert(18446744073709486080)"; 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)"; 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 #[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 #[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 #[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}