openzeppelin_monitor/services/notification/
payload_builder.rs

1//! Webhook payload builder implementation.
2//!
3//! This module provides functionality to build webhook payloads for different notification services (Telegram, Slack, Discord, etc.).
4
5use regex::Regex;
6use serde_json::json;
7use std::collections::HashMap;
8
9use super::template_formatter;
10
11/// Trait for building webhook payloads.
12pub trait WebhookPayloadBuilder: Send + Sync {
13	/// Builds a webhook payload by formatting the template and applying channel-specific rules.
14	///
15	/// # Arguments
16	///
17	/// * `title` - The raw title of the message.
18	/// * `body_template` - The message body template with variables like `${...}`.
19	/// * `variables` - The map of variables to substitute into the template.
20	///
21	/// # Returns
22	///
23	/// A `serde_json::Value` representing the payload.
24	fn build_payload(
25		&self,
26		title: &str,
27		body_template: &str,
28		variables: &HashMap<String, String>,
29	) -> serde_json::Value;
30}
31
32/// Formats a message by substituting variables in the template.
33pub fn format_template(template: &str, variables: &HashMap<String, String>) -> String {
34	template_formatter::format_template(template, variables)
35}
36
37/// A payload builder for Slack.
38pub struct SlackPayloadBuilder;
39
40impl WebhookPayloadBuilder for SlackPayloadBuilder {
41	fn build_payload(
42		&self,
43		title: &str,
44		body_template: &str,
45		variables: &HashMap<String, String>,
46	) -> serde_json::Value {
47		let formatted_title = format_template(title, variables);
48		let formatted_message = format_template(body_template, variables);
49		let full_message = format!("*{}*\n\n{}", formatted_title, formatted_message);
50		json!({
51			"blocks": [
52				{
53					"type": "section",
54					"text": {
55						"type": "mrkdwn",
56						"text": full_message
57					}
58				}
59			]
60		})
61	}
62}
63
64/// A payload builder for Discord.
65pub struct DiscordPayloadBuilder;
66
67impl WebhookPayloadBuilder for DiscordPayloadBuilder {
68	fn build_payload(
69		&self,
70		title: &str,
71		body_template: &str,
72		variables: &HashMap<String, String>,
73	) -> serde_json::Value {
74		let formatted_title = format_template(title, variables);
75		let formatted_message = format_template(body_template, variables);
76		let full_message = format!("*{}*\n\n{}", formatted_title, formatted_message);
77		json!({
78			"content": full_message
79		})
80	}
81}
82
83/// A payload builder for Telegram.
84pub struct TelegramPayloadBuilder {
85	pub chat_id: String,
86	pub disable_web_preview: bool,
87}
88
89impl TelegramPayloadBuilder {
90	/// Escape a full MarkdownV2 message, preserving entities and
91	/// escaping *all* special chars inside link URLs too.
92	fn escape_markdown_v2(text: &str) -> String {
93		const SPECIAL: &[char] = &[
94			'_', '*', '[', ']', '(', ')', '~', '`', '>', '#', '+', '-', '=', '|', '{', '}', '.',
95			'!', '\\',
96		];
97
98		let re =
99			Regex::new(r"(?s)```.*?```|`[^`]*`|\*[^*]*\*|_[^_]*_|~[^~]*~|\[([^\]]+)\]\(([^)]+)\)")
100				.unwrap();
101
102		let mut out = String::with_capacity(text.len());
103		let mut last = 0;
104
105		for caps in re.captures_iter(text) {
106			let mat = caps.get(0).unwrap();
107
108			for c in text[last..mat.start()].chars() {
109				if SPECIAL.contains(&c) {
110					out.push('\\');
111				}
112				out.push(c);
113			}
114
115			if let (Some(lbl), Some(url)) = (caps.get(1), caps.get(2)) {
116				let mut esc_label = String::with_capacity(lbl.as_str().len() * 2);
117				for c in lbl.as_str().chars() {
118					if SPECIAL.contains(&c) {
119						esc_label.push('\\');
120					}
121					esc_label.push(c);
122				}
123				let mut esc_url = String::with_capacity(url.as_str().len() * 2);
124				for c in url.as_str().chars() {
125					if SPECIAL.contains(&c) {
126						esc_url.push('\\');
127					}
128					esc_url.push(c);
129				}
130				out.push('[');
131				out.push_str(&esc_label);
132				out.push(']');
133				out.push('(');
134				out.push_str(&esc_url);
135				out.push(')');
136			} else {
137				out.push_str(mat.as_str());
138			}
139
140			last = mat.end();
141		}
142
143		for c in text[last..].chars() {
144			if SPECIAL.contains(&c) {
145				out.push('\\');
146			}
147			out.push(c);
148		}
149
150		out
151	}
152}
153
154impl WebhookPayloadBuilder for TelegramPayloadBuilder {
155	fn build_payload(
156		&self,
157		title: &str,
158		body_template: &str,
159		variables: &HashMap<String, String>,
160	) -> serde_json::Value {
161		// First, substitute variables.
162		let formatted_title = format_template(title, variables);
163		let formatted_message = format_template(body_template, variables);
164
165		// Then, escape both the title and the formatted message for Telegram MarkdownV2.
166		let escaped_title = Self::escape_markdown_v2(&formatted_title);
167		let escaped_message = Self::escape_markdown_v2(&formatted_message);
168
169		let full_message = format!("*{}* \n\n{}", escaped_title, escaped_message);
170		json!({
171			"chat_id": self.chat_id,
172			"text": full_message,
173			"parse_mode": "MarkdownV2",
174			"disable_web_page_preview": self.disable_web_preview
175		})
176	}
177}
178
179/// A payload builder for generic webhooks.
180pub struct GenericWebhookPayloadBuilder;
181
182impl WebhookPayloadBuilder for GenericWebhookPayloadBuilder {
183	fn build_payload(
184		&self,
185		title: &str,
186		body_template: &str,
187		variables: &HashMap<String, String>,
188	) -> serde_json::Value {
189		let formatted_title = format_template(title, variables);
190		let formatted_message = format_template(body_template, variables);
191		json!({
192			"title": formatted_title,
193			"body": formatted_message
194		})
195	}
196}
197
198#[cfg(test)]
199mod tests {
200	use super::*;
201	use serde_json::json;
202
203	#[test]
204	fn test_slack_payload_builder() {
205		let title = "Test ${title_value}";
206		let message = "Test ${message_value}";
207		let variables = HashMap::from([
208			("title_value".to_string(), "Title".to_string()),
209			("message_value".to_string(), "Message".to_string()),
210		]);
211		let payload = SlackPayloadBuilder.build_payload(title, message, &variables);
212		assert_eq!(
213			payload,
214			json!({
215				"blocks": [
216					{
217						"type": "section",
218						"text": {
219							"type": "mrkdwn",
220							"text": "*Test Title*\n\nTest Message"
221						}
222					}
223				]
224			})
225		);
226	}
227
228	#[test]
229	fn test_discord_payload_builder() {
230		let title = "Test ${title_value}";
231		let message = "Test ${message_value}";
232		let variables = HashMap::from([
233			("title_value".to_string(), "Title".to_string()),
234			("message_value".to_string(), "Message".to_string()),
235		]);
236		let payload = DiscordPayloadBuilder.build_payload(title, message, &variables);
237		assert_eq!(
238			payload,
239			json!({
240				"content": "*Test Title*\n\nTest Message"
241			})
242		);
243	}
244
245	#[test]
246	fn test_telegram_payload_builder() {
247		let builder = TelegramPayloadBuilder {
248			chat_id: "12345".to_string(),
249			disable_web_preview: true,
250		};
251		let title = "Test ${title_value}";
252		let message = "Test ${message_value}";
253		let variables = HashMap::from([
254			("title_value".to_string(), "Title".to_string()),
255			("message_value".to_string(), "Message".to_string()),
256		]);
257		let payload = builder.build_payload(title, message, &variables);
258		assert_eq!(
259			payload,
260			json!({
261				"chat_id": "12345",
262				"text": "*Test Title* \n\nTest Message",
263				"parse_mode": "MarkdownV2",
264				"disable_web_page_preview": true
265			})
266		);
267	}
268
269	#[test]
270	fn test_generic_webhook_payload_builder() {
271		let title = "Test ${title_value}";
272		let message = "Test ${message_value}";
273		let variables = HashMap::from([
274			("title_value".to_string(), "Title".to_string()),
275			("message_value".to_string(), "Message".to_string()),
276		]);
277		let payload = GenericWebhookPayloadBuilder.build_payload(title, message, &variables);
278		assert_eq!(
279			payload,
280			json!({
281				"title": "Test Title",
282				"body": "Test Message"
283			})
284		);
285	}
286
287	#[test]
288	fn test_escape_markdown_v2() {
289		// Test for real life examples
290		assert_eq!(
291			TelegramPayloadBuilder::escape_markdown_v2(
292				"*Transaction Alert*\n*Network:* Base Sepolia\n*From:* 0x00001\n*To:* 0x00002\n*Transaction:* [View on Blockscout](https://base-sepolia.blockscout.com/tx/0x00003)"
293			),
294			"*Transaction Alert*\n*Network:* Base Sepolia\n*From:* 0x00001\n*To:* 0x00002\n*Transaction:* [View on Blockscout](https://base\\-sepolia\\.blockscout\\.com/tx/0x00003)"
295		);
296
297		// Test basic special character escaping
298		assert_eq!(
299			TelegramPayloadBuilder::escape_markdown_v2("Hello *world*!"),
300			"Hello *world*\\!"
301		);
302
303		// Test multiple special characters
304		assert_eq!(
305			TelegramPayloadBuilder::escape_markdown_v2("(test) [test] {test} <test>"),
306			"\\(test\\) \\[test\\] \\{test\\} <test\\>"
307		);
308
309		// Test markdown code blocks (should be preserved)
310		assert_eq!(
311			TelegramPayloadBuilder::escape_markdown_v2("```code block```"),
312			"```code block```"
313		);
314
315		// Test inline code (should be preserved)
316		assert_eq!(
317			TelegramPayloadBuilder::escape_markdown_v2("`inline code`"),
318			"`inline code`"
319		);
320
321		// Test bold text (should be preserved)
322		assert_eq!(
323			TelegramPayloadBuilder::escape_markdown_v2("*bold text*"),
324			"*bold text*"
325		);
326
327		// Test italic text (should be preserved)
328		assert_eq!(
329			TelegramPayloadBuilder::escape_markdown_v2("_italic text_"),
330			"_italic text_"
331		);
332
333		// Test strikethrough (should be preserved)
334		assert_eq!(
335			TelegramPayloadBuilder::escape_markdown_v2("~strikethrough~"),
336			"~strikethrough~"
337		);
338
339		// Test links with special characters
340		assert_eq!(
341			TelegramPayloadBuilder::escape_markdown_v2("[link](https://example.com/test.html)"),
342			"[link](https://example\\.com/test\\.html)"
343		);
344
345		// Test complex link with special characters in both label and URL
346		assert_eq!(
347			TelegramPayloadBuilder::escape_markdown_v2(
348				"[test!*_]{link}](https://test.com/path[1])"
349			),
350			"\\[test\\!\\*\\_\\]\\{link\\}\\]\\(https://test\\.com/path\\[1\\]\\)"
351		);
352
353		// Test mixed content
354		assert_eq!(
355			TelegramPayloadBuilder::escape_markdown_v2(
356				"Hello *bold* and [link](http://test.com) and `code`"
357			),
358			"Hello *bold* and [link](http://test\\.com) and `code`"
359		);
360
361		// Test escaping backslashes
362		assert_eq!(
363			TelegramPayloadBuilder::escape_markdown_v2("test\\test"),
364			"test\\\\test"
365		);
366
367		// Test all special characters
368		assert_eq!(
369			TelegramPayloadBuilder::escape_markdown_v2("_*[]()~`>#+-=|{}.!\\"),
370			"\\_\\*\\[\\]\\(\\)\\~\\`\\>\\#\\+\\-\\=\\|\\{\\}\\.\\!\\\\",
371		);
372
373		// Test nested markdown (outer should be preserved, inner escaped)
374		assert_eq!(
375			TelegramPayloadBuilder::escape_markdown_v2("*bold with [link](http://test.com)*"),
376			"*bold with [link](http://test.com)*"
377		);
378
379		// Test empty string
380		assert_eq!(TelegramPayloadBuilder::escape_markdown_v2(""), "");
381
382		// Test string with only special characters
383		assert_eq!(
384			TelegramPayloadBuilder::escape_markdown_v2("***"),
385			"**\\*" // First * is preserved as markdown, others escaped
386		);
387	}
388
389	#[test]
390	fn test_events_match_reasons_single_event() {
391		let variables = HashMap::from([
392			(
393				"events.0.signature".to_string(),
394				"Transfer(address,address,uint256)".to_string(),
395			),
396			("events.0.args.from".to_string(), "0x1234".to_string()),
397			("events.0.args.to".to_string(), "0x5678".to_string()),
398		]);
399
400		let result = template_formatter::build_match_reasons(&variables, "events");
401		assert!(result.is_some());
402		let expected = "\n\n*Matched Events:*\n\n*Reason 1*\n\n*Signature:* `Transfer(address,address,uint256)`\n\n*Params:*\n\nfrom: `0x1234`\nto: `0x5678`";
403		assert_eq!(result.unwrap(), expected);
404	}
405
406	#[test]
407	fn test_events_match_reasons_multiple_events() {
408		let variables = HashMap::from([
409			(
410				"events.0.signature".to_string(),
411				"Transfer(address,address,uint256)".to_string(),
412			),
413			("events.0.args.from".to_string(), "0x1234".to_string()),
414			("events.0.args.to".to_string(), "0x5678".to_string()),
415			(
416				"events.1.signature".to_string(),
417				"Approval(address,address,uint256)".to_string(),
418			),
419			(
420				"events.1.args.owner".to_string(),
421				"0x742d35Cc6634C0532925a3b8D4C9db96C4b4d8b6".to_string(),
422			),
423			(
424				"events.1.args.spender".to_string(),
425				"0x742d35Cc6634C0532925a3b8D4C9db96C4b4d8b6".to_string(),
426			),
427			("events.1.args.value".to_string(), "1000000000".to_string()),
428			(
429				"events.2.signature".to_string(),
430				"ValueChanged(uint256)".to_string(),
431			),
432			("events.2.args.value".to_string(), "1000000000".to_string()),
433		]);
434
435		let result = template_formatter::build_match_reasons(&variables, "events");
436		assert!(result.is_some());
437		let expected = "\n\n*Matched Events:*\n\n*Reason 1*\n\n*Signature:* `Transfer(address,address,uint256)`\n\n*Params:*\n\nfrom: `0x1234`\nto: `0x5678`\n\n*Reason 2*\n\n*Signature:* `Approval(address,address,uint256)`\n\n*Params:*\n\nowner: `0x742d35Cc6634C0532925a3b8D4C9db96C4b4d8b6`\nspender: `0x742d35Cc6634C0532925a3b8D4C9db96C4b4d8b6`\nvalue: `1000000000`\n\n*Reason 3*\n\n*Signature:* `ValueChanged(uint256)`\n\n*Params:*\n\nvalue: `1000000000`";
438		assert_eq!(result.unwrap(), expected);
439	}
440
441	#[test]
442	fn test_events_match_reasons_no_events() {
443		let variables = HashMap::from([
444			("transaction.hash".to_string(), "0x1234".to_string()),
445			("monitor.name".to_string(), "Test Monitor".to_string()),
446		]);
447
448		let result = template_formatter::build_match_reasons(&variables, "events");
449		assert!(result.is_none());
450	}
451
452	#[test]
453	fn test_events_match_reasons_out_of_order() {
454		let variables = HashMap::from([
455			(
456				"events.2.signature".to_string(),
457				"ValueChanged(uint256)".to_string(),
458			),
459			("events.2.args.value".to_string(), "1000000000".to_string()),
460			(
461				"events.0.signature".to_string(),
462				"Transfer(address,address,uint256)".to_string(),
463			),
464			("events.0.args.from".to_string(), "0x1234".to_string()),
465			("events.0.args.to".to_string(), "0x5678".to_string()),
466			(
467				"events.1.signature".to_string(),
468				"Approval(address,address,uint256)".to_string(),
469			),
470			(
471				"events.1.args.owner".to_string(),
472				"0x742d35Cc6634C0532925a3b8D4C9db96C4b4d8b6".to_string(),
473			),
474			(
475				"events.1.args.spender".to_string(),
476				"0x742d35Cc6634C0532925a3b8D4C9db96C4b4d8b6".to_string(),
477			),
478			("events.1.args.value".to_string(), "1000000000".to_string()),
479		]);
480
481		let result = template_formatter::build_match_reasons(&variables, "events");
482		assert!(result.is_some());
483		let expected = "\n\n*Matched Events:*\n\n*Reason 1*\n\n*Signature:* `Transfer(address,address,uint256)`\n\n*Params:*\n\nfrom: `0x1234`\nto: `0x5678`\n\n*Reason 2*\n\n*Signature:* `Approval(address,address,uint256)`\n\n*Params:*\n\nowner: `0x742d35Cc6634C0532925a3b8D4C9db96C4b4d8b6`\nspender: `0x742d35Cc6634C0532925a3b8D4C9db96C4b4d8b6`\nvalue: `1000000000`\n\n*Reason 3*\n\n*Signature:* `ValueChanged(uint256)`\n\n*Params:*\n\nvalue: `1000000000`";
484		assert_eq!(result.unwrap(), expected);
485	}
486
487	#[test]
488	fn test_format_template_with_all_events() {
489		let template = "Transaction detected: ${transaction.hash}\n\n${events}";
490		let variables = HashMap::from([
491			(
492				"transaction.hash".to_string(),
493				"0x1234567890abcdef".to_string(),
494			),
495			(
496				"events.0.signature".to_string(),
497				"Transfer(address,address,uint256)".to_string(),
498			),
499			("events.0.args.from".to_string(), "0x1234".to_string()),
500			("events.0.args.to".to_string(), "0x5678".to_string()),
501			(
502				"events.1.signature".to_string(),
503				"Approval(address,address,uint256)".to_string(),
504			),
505			(
506				"events.1.args.owner".to_string(),
507				"0x742d35Cc6634C0532925a3b8D4C9db96C4b4d8b6".to_string(),
508			),
509			(
510				"events.1.args.spender".to_string(),
511				"0x742d35Cc6634C0532925a3b8D4C9db96C4b4d8b6".to_string(),
512			),
513			("events.1.args.value".to_string(), "1000000000".to_string()),
514		]);
515
516		let result = format_template(template, &variables);
517		// Since the template contains ${events}, it should get the match reasons section
518		let expected = "Transaction detected: 0x1234567890abcdef\n\n\n\n*Matched Events:*\n\n*Reason 1*\n\n*Signature:* `Transfer(address,address,uint256)`\n\n*Params:*\n\nfrom: `0x1234`\nto: `0x5678`\n\n*Reason 2*\n\n*Signature:* `Approval(address,address,uint256)`\n\n*Params:*\n\nowner: `0x742d35Cc6634C0532925a3b8D4C9db96C4b4d8b6`\nspender: `0x742d35Cc6634C0532925a3b8D4C9db96C4b4d8b6`\nvalue: `1000000000`";
519		assert_eq!(result, expected);
520	}
521
522	#[test]
523	fn test_functions_match_reasons_single_function() {
524		let variables = HashMap::from([
525			(
526				"functions.0.signature".to_string(),
527				"transfer(address,uint256)".to_string(),
528			),
529			("functions.0.args.to".to_string(), "0x1234".to_string()),
530			("functions.0.args.amount".to_string(), "1000000".to_string()),
531		]);
532
533		let result = template_formatter::build_match_reasons(&variables, "functions");
534		assert!(result.is_some());
535		let expected = "\n\n*Matched Functions:*\n\n*Reason 1*\n\n*Signature:* `transfer(address,uint256)`\n\n*Params:*\n\namount: `1000000`\nto: `0x1234`";
536		assert_eq!(result.unwrap(), expected);
537	}
538
539	#[test]
540	fn test_functions_match_reasons_multiple_functions() {
541		let variables = HashMap::from([
542			(
543				"functions.0.signature".to_string(),
544				"transfer(address,uint256)".to_string(),
545			),
546			("functions.0.args.to".to_string(), "0x1234".to_string()),
547			("functions.0.args.amount".to_string(), "1000000".to_string()),
548			(
549				"functions.1.signature".to_string(),
550				"approve(address,uint256)".to_string(),
551			),
552			(
553				"functions.1.args.spender".to_string(),
554				"0x742d35Cc6634C0532925a3b8D4C9db96C4b4d8b6".to_string(),
555			),
556			("functions.1.args.amount".to_string(), "500000".to_string()),
557			(
558				"functions.2.signature".to_string(),
559				"mint(uint256)".to_string(),
560			),
561			("functions.2.args.amount".to_string(), "1000000".to_string()),
562		]);
563
564		let result = template_formatter::build_match_reasons(&variables, "functions");
565		assert!(result.is_some());
566		let expected = "\n\n*Matched Functions:*\n\n*Reason 1*\n\n*Signature:* `transfer(address,uint256)`\n\n*Params:*\n\namount: `1000000`\nto: `0x1234`\n\n*Reason 2*\n\n*Signature:* `approve(address,uint256)`\n\n*Params:*\n\namount: `500000`\nspender: `0x742d35Cc6634C0532925a3b8D4C9db96C4b4d8b6`\n\n*Reason 3*\n\n*Signature:* `mint(uint256)`\n\n*Params:*\n\namount: `1000000`";
567		assert_eq!(result.unwrap(), expected);
568	}
569
570	#[test]
571	fn test_functions_match_reasons_no_functions() {
572		let variables = HashMap::from([
573			("transaction.hash".to_string(), "0x1234".to_string()),
574			("monitor.name".to_string(), "Test Monitor".to_string()),
575		]);
576
577		let result = template_formatter::build_match_reasons(&variables, "functions");
578		assert!(result.is_none());
579	}
580
581	#[test]
582	fn test_functions_match_reasons_out_of_order() {
583		let variables = HashMap::from([
584			(
585				"functions.2.signature".to_string(),
586				"mint(uint256)".to_string(),
587			),
588			("functions.2.args.amount".to_string(), "1000000".to_string()),
589			(
590				"functions.0.signature".to_string(),
591				"transfer(address,uint256)".to_string(),
592			),
593			("functions.0.args.to".to_string(), "0x1234".to_string()),
594			("functions.0.args.amount".to_string(), "1000000".to_string()),
595			(
596				"functions.1.signature".to_string(),
597				"approve(address,uint256)".to_string(),
598			),
599			(
600				"functions.1.args.spender".to_string(),
601				"0x742d35Cc6634C0532925a3b8D4C9db96C4b4d8b6".to_string(),
602			),
603			("functions.1.args.amount".to_string(), "500000".to_string()),
604		]);
605
606		let result = template_formatter::build_match_reasons(&variables, "functions");
607		assert!(result.is_some());
608		let expected = "\n\n*Matched Functions:*\n\n*Reason 1*\n\n*Signature:* `transfer(address,uint256)`\n\n*Params:*\n\namount: `1000000`\nto: `0x1234`\n\n*Reason 2*\n\n*Signature:* `approve(address,uint256)`\n\n*Params:*\n\namount: `500000`\nspender: `0x742d35Cc6634C0532925a3b8D4C9db96C4b4d8b6`\n\n*Reason 3*\n\n*Signature:* `mint(uint256)`\n\n*Params:*\n\namount: `1000000`";
609		assert_eq!(result.unwrap(), expected);
610	}
611
612	#[test]
613	fn test_format_template_with_all_functions() {
614		let template = "Transaction detected: ${transaction.hash}\n\n${functions}";
615		let variables = HashMap::from([
616			(
617				"transaction.hash".to_string(),
618				"0x1234567890abcdef".to_string(),
619			),
620			(
621				"functions.0.signature".to_string(),
622				"transfer(address,uint256)".to_string(),
623			),
624			("functions.0.args.to".to_string(), "0x1234".to_string()),
625			("functions.0.args.amount".to_string(), "1000000".to_string()),
626			(
627				"functions.1.signature".to_string(),
628				"approve(address,uint256)".to_string(),
629			),
630			(
631				"functions.1.args.spender".to_string(),
632				"0x742d35Cc6634C0532925a3b8D4C9db96C4b4d8b6".to_string(),
633			),
634			("functions.1.args.amount".to_string(), "500000".to_string()),
635		]);
636
637		let result = template_formatter::format_template(template, &variables);
638		// Since the template contains ${functions}, it should get the match reasons section
639		let expected = "Transaction detected: 0x1234567890abcdef\n\n\n\n*Matched Functions:*\n\n*Reason 1*\n\n*Signature:* `transfer(address,uint256)`\n\n*Params:*\n\namount: `1000000`\nto: `0x1234`\n\n*Reason 2*\n\n*Signature:* `approve(address,uint256)`\n\n*Params:*\n\namount: `500000`\nspender: `0x742d35Cc6634C0532925a3b8D4C9db96C4b4d8b6`";
640		assert_eq!(result, expected);
641	}
642
643	#[test]
644	fn test_format_template_with_functions_and_events() {
645		let template = "Transaction detected: ${transaction.hash}\n\n${functions}\n\n${events}";
646		let variables = HashMap::from([
647			(
648				"transaction.hash".to_string(),
649				"0x1234567890abcdef".to_string(),
650			),
651			(
652				"events.0.signature".to_string(),
653				"Transfer(address,address,uint256)".to_string(),
654			),
655			("events.0.args.from".to_string(), "0x1234".to_string()),
656			("events.0.args.to".to_string(), "0x5678".to_string()),
657			("events.0.args.value".to_string(), "1000000".to_string()),
658			(
659				"events.1.signature".to_string(),
660				"Approval(address,address,uint256)".to_string(),
661			),
662			(
663				"events.1.args.owner".to_string(),
664				"0x742d35Cc6634C0532925a3b8D4C9db96C4b4d8b6".to_string(),
665			),
666			(
667				"events.1.args.spender".to_string(),
668				"0x742d35Cc6634C0532925a3b8D4C9db96C4b4d8b6".to_string(),
669			),
670			("events.1.args.value".to_string(), "500000".to_string()),
671			(
672				"functions.0.signature".to_string(),
673				"transfer(address,uint256)".to_string(),
674			),
675			("functions.0.args.to".to_string(), "0x9abc".to_string()),
676			("functions.0.args.amount".to_string(), "750000".to_string()),
677			(
678				"functions.1.signature".to_string(),
679				"mint(uint256)".to_string(),
680			),
681			("functions.1.args.amount".to_string(), "250000".to_string()),
682		]);
683
684		let result = template_formatter::format_template(template, &variables);
685		// The template contains both ${events} and ${functions}, so both sections should be included
686		// Functions are processed before events, so functions section appears first
687		let expected = "Transaction detected: 0x1234567890abcdef\n\n\n\n*Matched Functions:*\n\n*Reason 1*\n\n*Signature:* `transfer(address,uint256)`\n\n*Params:*\n\namount: `750000`\nto: `0x9abc`\n\n*Reason 2*\n\n*Signature:* `mint(uint256)`\n\n*Params:*\n\namount: `250000`\n\n\n\n*Matched Events:*\n\n*Reason 1*\n\n*Signature:* `Transfer(address,address,uint256)`\n\n*Params:*\n\nfrom: `0x1234`\nto: `0x5678`\nvalue: `1000000`\n\n*Reason 2*\n\n*Signature:* `Approval(address,address,uint256)`\n\n*Params:*\n\nowner: `0x742d35Cc6634C0532925a3b8D4C9db96C4b4d8b6`\nspender: `0x742d35Cc6634C0532925a3b8D4C9db96C4b4d8b6`\nvalue: `500000`";
688		assert_eq!(result, expected);
689	}
690
691	#[test]
692	fn test_format_template_with_no_events_removes_variable() {
693		let template = "Transaction detected: ${transaction.hash}\n\n${events}";
694		let variables = HashMap::from([
695			(
696				"transaction.hash".to_string(),
697				"0x1234567890abcdef".to_string(),
698			),
699			// No events variables present
700		]);
701
702		let result = template_formatter::format_template(template, &variables);
703		// Since there are no events, ${events} should be replaced with empty string
704		let expected = "Transaction detected: 0x1234567890abcdef\n\n";
705		assert_eq!(result, expected);
706	}
707
708	#[test]
709	fn test_format_template_with_no_functions_removes_variable() {
710		let template = "Transaction detected: ${transaction.hash}\n\n${functions}";
711		let variables = HashMap::from([
712			(
713				"transaction.hash".to_string(),
714				"0x1234567890abcdef".to_string(),
715			),
716			// No functions variables present
717		]);
718
719		let result = template_formatter::format_template(template, &variables);
720		// Since there are no functions, ${functions} should be replaced with empty string
721		let expected = "Transaction detected: 0x1234567890abcdef\n\n";
722		assert_eq!(result, expected);
723	}
724
725	#[test]
726	fn test_build_match_reasons_no_index_part() {
727		let variables = HashMap::from([
728			// Key that starts with prefix but doesn't end with .signature
729			("events.0.args.from".to_string(), "0x1234".to_string()),
730			// Key that doesn't start with prefix
731			("transaction.hash".to_string(), "0x1234".to_string()),
732			// Key that starts with prefix but has wrong format
733			("events.signature".to_string(), "Transfer".to_string()),
734		]);
735
736		let result = template_formatter::build_match_reasons(&variables, "events");
737		// Should return None since no valid signature keys were found
738		assert!(result.is_none());
739	}
740
741	#[test]
742	fn test_build_match_reasons_no_signature() {
743		let variables = HashMap::from([
744			// Has the signature key structure but no actual signature value
745			("events.0.signature".to_string(), "".to_string()),
746			// Has args but no signature
747			("events.0.args.from".to_string(), "0x1234".to_string()),
748			("events.0.args.to".to_string(), "0x5678".to_string()),
749		]);
750
751		let result = template_formatter::build_match_reasons(&variables, "events");
752		let result_str = result.unwrap();
753
754		assert!(result_str.contains("\n\n*Matched Events:*\n"));
755	}
756
757	#[test]
758	fn test_build_match_reasons_mixed_valid_and_invalid() {
759		let variables = HashMap::from([
760			// Valid signature
761			(
762				"events.0.signature".to_string(),
763				"Transfer(address,address,uint256)".to_string(),
764			),
765			("events.0.args.from".to_string(), "0x1234".to_string()),
766			// Invalid signature (empty)
767			("events.1.signature".to_string(), "".to_string()),
768			("events.1.args.to".to_string(), "0x5678".to_string()),
769			// Valid signature
770			(
771				"events.2.signature".to_string(),
772				"Approval(address,address,uint256)".to_string(),
773			),
774			("events.2.args.owner".to_string(), "0x9abc".to_string()),
775		]);
776
777		let result = template_formatter::build_match_reasons(&variables, "events");
778		// Should only include events 0 and 2, skipping event 1 due to empty signature
779		assert!(result.is_some());
780		let result_str = result.unwrap();
781		assert!(result_str.contains("Transfer(address,address,uint256)"));
782		assert!(!result_str.contains("events.1")); // Should not contain event 1
783		assert!(result_str.contains("Approval(address,address,uint256)"));
784	}
785
786	#[test]
787	fn test_build_match_reasons_invalid_index_format() {
788		let variables = HashMap::from([
789			// Invalid index format - not a number
790			("events.abc.signature".to_string(), "Transfer".to_string()),
791			// Invalid index format - negative number
792			("events.-1.signature".to_string(), "Transfer".to_string()),
793			// Valid index format
794			(
795				"events.0.signature".to_string(),
796				"Transfer(address,address,uint256)".to_string(),
797			),
798			("events.0.args.from".to_string(), "0x1234".to_string()),
799		]);
800
801		let result = template_formatter::build_match_reasons(&variables, "events");
802		// Should only include the valid event 0, skipping invalid formats
803		assert!(result.is_some());
804		let result_str = result.unwrap();
805		assert!(result_str.contains("Transfer(address,address,uint256)"));
806		assert!(!result_str.contains("abc")); // Should not contain invalid index
807		assert!(!result_str.contains("-1")); // Should not contain negative index
808	}
809}