1use std::collections::HashMap;
5use std::future::Future;
6use std::pin::Pin;
7
8use num_bigint::BigUint;
9use serde::{Deserialize, Serialize};
10use tiny_keccak::{Hasher, Keccak};
11
12use crate::engine::{
13 ensure_single_nested_param_source, normalized_nested_calldata, parse_nested_address_param,
14 parse_nested_amount_literal, parse_nested_selector_param, uint_bytes_from_biguint,
15 DisplayEntry, DisplayItem, DisplayModel, GroupIteration,
16};
17use crate::error::Error;
18use crate::outcome::{render_warning, FormatDiagnostic, RenderDiagnosticKind, RenderState};
19use crate::path::{apply_collection_access, CollectionSelection};
20use crate::provider::DataProvider;
21use crate::render_shared::{
22 chain_name, coerce_unsigned_decimal_string_from_typed_value, format_blockheight_timestamp,
23 format_duration_seconds, format_timestamp, format_token_amount_output, format_unit_biguint,
24 is_excluded_path, lookup_map_entry, native_token_meta, parse_unsigned_biguint_from_typed_value,
25 resolve_interpolation_field_spec, resolve_metadata_constant_str,
26};
27use crate::resolver::ResolvedDescriptor;
28use crate::types::descriptor::Descriptor;
29use crate::types::display::{
30 DisplayField, DisplayFormat, FieldFormat, FieldGroup, FormatParams, Iteration, VisibleLiteral,
31 VisibleRule,
32};
33
34const MAX_CALLDATA_DEPTH: u8 = 3;
36
37type RenderDiagnostics = Vec<FormatDiagnostic>;
38
39#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct TypedData {
42 pub types: HashMap<String, Vec<TypedDataField>>,
43
44 #[serde(rename = "primaryType")]
45 pub primary_type: String,
46
47 pub domain: TypedDataDomain,
48
49 #[serde(skip_serializing_if = "Option::is_none")]
50 pub container: Option<TypedDataContainer>,
51
52 pub message: serde_json::Value,
53}
54
55#[derive(Debug, Clone, Default, Serialize, Deserialize)]
56pub struct TypedDataContainer {
57 #[serde(skip_serializing_if = "Option::is_none")]
58 pub from: Option<String>,
59}
60
61#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct TypedDataField {
63 pub name: String,
64
65 #[serde(rename = "type")]
66 pub field_type: String,
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct TypedDataDomain {
71 #[serde(skip_serializing_if = "Option::is_none")]
72 pub name: Option<String>,
73
74 #[serde(skip_serializing_if = "Option::is_none")]
75 pub version: Option<String>,
76
77 #[serde(rename = "chainId")]
78 #[serde(skip_serializing_if = "Option::is_none")]
79 #[serde(default, deserialize_with = "deserialize_chain_id")]
80 pub chain_id: Option<u64>,
81
82 #[serde(rename = "verifyingContract")]
83 #[serde(skip_serializing_if = "Option::is_none")]
84 pub verifying_contract: Option<String>,
85
86 #[serde(skip_serializing_if = "Option::is_none")]
87 pub salt: Option<String>,
88
89 #[serde(flatten, default)]
90 pub extra: HashMap<String, serde_json::Value>,
91}
92
93#[derive(Clone, Copy)]
94struct TypedContainerContext<'a> {
95 chain_id: Option<u64>,
96 verifying_contract: Option<&'a str>,
97 from: Option<&'a str>,
98}
99
100impl<'a> TypedContainerContext<'a> {
101 fn from_typed_data(data: &'a TypedData) -> Self {
102 Self {
103 chain_id: data.domain.chain_id,
104 verifying_contract: data.domain.verifying_contract.as_deref(),
105 from: data
106 .container
107 .as_ref()
108 .and_then(|container| container.from.as_deref()),
109 }
110 }
111}
112
113fn deserialize_chain_id<'de, D>(deserializer: D) -> Result<Option<u64>, D::Error>
115where
116 D: serde::Deserializer<'de>,
117{
118 use serde::de;
119
120 struct ChainIdVisitor;
121
122 impl<'de> de::Visitor<'de> for ChainIdVisitor {
123 type Value = Option<u64>;
124
125 fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
126 formatter.write_str("a number or hex string for chainId")
127 }
128
129 fn visit_none<E: de::Error>(self) -> Result<Self::Value, E> {
130 Ok(None)
131 }
132
133 fn visit_unit<E: de::Error>(self) -> Result<Self::Value, E> {
134 Ok(None)
135 }
136
137 fn visit_u64<E: de::Error>(self, v: u64) -> Result<Self::Value, E> {
138 Ok(Some(v))
139 }
140
141 fn visit_i64<E: de::Error>(self, v: i64) -> Result<Self::Value, E> {
142 Ok(Some(v as u64))
143 }
144
145 fn visit_str<E: de::Error>(self, v: &str) -> Result<Self::Value, E> {
146 let trimmed = v.trim();
147 if let Some(hex) = trimmed
148 .strip_prefix("0x")
149 .or_else(|| trimmed.strip_prefix("0X"))
150 {
151 u64::from_str_radix(hex, 16)
152 .map(Some)
153 .map_err(de::Error::custom)
154 } else {
155 trimmed.parse::<u64>().map(Some).map_err(de::Error::custom)
156 }
157 }
158 }
159
160 deserializer.deserialize_any(ChainIdVisitor)
161}
162
163pub async fn format_typed_data(
167 descriptor: &Descriptor,
168 data: &TypedData,
169 data_provider: &dyn DataProvider,
170 descriptors: &[ResolvedDescriptor],
171) -> Result<DisplayModel, Error> {
172 let format = find_typed_format(descriptor, data)?;
173 let mut state = RenderState::default();
174 format_typed_data_with_format(
175 descriptor,
176 data,
177 format,
178 data_provider,
179 descriptors,
180 &mut state,
181 )
182 .await
183}
184
185pub(crate) async fn format_typed_data_with_format(
186 descriptor: &Descriptor,
187 data: &TypedData,
188 format: &DisplayFormat,
189 data_provider: &dyn DataProvider,
190 descriptors: &[ResolvedDescriptor],
191 state: &mut RenderState,
192) -> Result<DisplayModel, Error> {
193 let container = TypedContainerContext::from_typed_data(data);
194 let mut warnings = RenderDiagnostics::new();
195 let mut nested_fallback = false;
196 let expanded_fields =
197 crate::engine::expand_display_fields(descriptor, &format.fields, &mut warnings);
198 let renderable_fields = filter_excluded_fields(&expanded_fields, &format.excluded);
199 let entries = render_typed_fields(
200 descriptor,
201 &data.message,
202 &renderable_fields,
203 container,
204 data_provider,
205 &mut warnings,
206 descriptors,
207 0,
208 &mut nested_fallback,
209 )
210 .await?;
211
212 let model = DisplayModel {
213 intent: format
214 .intent
215 .as_ref()
216 .map(crate::types::display::intent_as_string)
217 .unwrap_or_else(|| data.primary_type.clone()),
218 interpolated_intent: match format.interpolated_intent.as_ref() {
219 Some(template) => match interpolate_typed_intent(
220 template,
221 descriptor,
222 &data.message,
223 container,
224 &expanded_fields,
225 &format.excluded,
226 data_provider,
227 )
228 .await
229 {
230 Ok(rendered) => Some(rendered),
231 Err(err) => {
232 warnings.push(render_warning(
233 RenderDiagnosticKind::InterpolatedIntentSkipped,
234 format!("interpolated intent skipped: {err}"),
235 ));
236 None
237 }
238 },
239 None => None,
240 },
241 entries,
242 owner: descriptor.metadata.owner.clone(),
243 contract_name: descriptor.metadata.contract_name.clone(),
244 };
245
246 crate::engine::record_diagnostics(state, &warnings);
247 if nested_fallback {
248 state.mark_nested_fallback();
249 }
250
251 Ok(model)
252}
253
254pub(crate) fn validate_descriptor_domain_binding(
255 descriptor: &Descriptor,
256 data: &TypedData,
257) -> Result<(), Error> {
258 crate::eip712_domain::validate_descriptor_eip712_context(descriptor, data)
259}
260
261fn filter_excluded_fields(fields: &[DisplayField], excluded: &[String]) -> Vec<DisplayField> {
262 fields
263 .iter()
264 .filter_map(|field| match field {
265 DisplayField::Simple {
266 path: Some(path), ..
267 } if is_excluded_path(excluded, path) => None,
268 DisplayField::Group { field_group } => Some(DisplayField::Group {
269 field_group: FieldGroup {
270 path: field_group.path.clone(),
271 label: field_group.label.clone(),
272 iteration: field_group.iteration.clone(),
273 fields: filter_excluded_fields(&field_group.fields, excluded),
274 },
275 }),
276 DisplayField::Scope {
277 path,
278 label,
279 iteration,
280 fields,
281 } => Some(DisplayField::Scope {
282 path: path.clone(),
283 label: label.clone(),
284 iteration: iteration.clone(),
285 fields: filter_excluded_fields(fields, excluded),
286 }),
287 _ => Some(field.clone()),
288 })
289 .collect()
290}
291
292#[allow(clippy::too_many_arguments)]
296fn render_typed_fields<'a>(
297 descriptor: &'a Descriptor,
298 message: &'a serde_json::Value,
299 fields: &[DisplayField],
300 container: TypedContainerContext<'a>,
301 data_provider: &'a dyn DataProvider,
302 warnings: &'a mut RenderDiagnostics,
303 descriptors: &'a [ResolvedDescriptor],
304 depth: u8,
305 nested_fallback: &'a mut bool,
306) -> Pin<Box<dyn Future<Output = Result<Vec<DisplayEntry>, Error>> + Send + 'a>> {
307 let fields = fields.to_vec();
308 Box::pin(async move {
309 let mut entries = Vec::new();
310
311 for field in &fields {
312 match field {
313 DisplayField::Group { field_group } => {
314 entries.extend(
315 render_typed_field_group_entries(
316 descriptor,
317 message,
318 field_group,
319 container,
320 data_provider,
321 warnings,
322 descriptors,
323 depth,
324 nested_fallback,
325 )
326 .await?,
327 );
328 }
329 DisplayField::Simple {
330 path,
331 label,
332 value: literal_value,
333 format,
334 params,
335 separator: _,
336 visible,
337 } => {
338 if let Some(lit) = literal_value {
340 if !check_typed_visibility(visible, &None, label, "")? {
341 continue;
342 }
343 let resolved = resolve_metadata_constant_str(descriptor, lit);
344 entries.push(DisplayEntry::Item(DisplayItem {
345 label: label.clone(),
346 value: resolved,
347 }));
348 continue;
349 }
350
351 let path_str = path.as_deref().unwrap_or("");
352
353 if let Some((base, rest)) = crate::engine::split_array_iter_path(path_str) {
355 if let Some(serde_json::Value::Array(items)) =
356 resolve_typed_path_in_context(message, base, container)?
357 {
358 for item in &items {
359 let val = if rest.is_empty() {
360 Some(item.clone())
361 } else {
362 resolve_typed_path_in_context(item, rest, container)?
363 };
364 if !check_typed_visibility(visible, &val, label, path_str)? {
365 continue;
366 }
367 let formatted = format_typed_value(
368 descriptor,
369 &val,
370 format.as_ref(),
371 params.as_ref(),
372 container,
373 message,
374 None,
375 data_provider,
376 warnings,
377 )
378 .await?;
379 entries.push(DisplayEntry::Item(DisplayItem {
380 label: label.clone(),
381 value: formatted,
382 }));
383 }
384 continue;
385 }
386 }
387
388 let value = resolve_typed_path_in_context(message, path_str, container)?;
389
390 if !check_typed_visibility(visible, &value, label, path_str)? {
392 continue;
393 }
394
395 if matches!(format.as_ref(), Some(FieldFormat::Calldata)) {
397 let entry = render_typed_calldata_field(
398 descriptor,
399 message,
400 &value,
401 params.as_ref(),
402 label,
403 container,
404 data_provider,
405 descriptors,
406 depth,
407 warnings,
408 nested_fallback,
409 )
410 .await?;
411 entries.push(entry);
412 continue;
413 }
414
415 let formatted = format_typed_value(
416 descriptor,
417 &value,
418 format.as_ref(),
419 params.as_ref(),
420 container,
421 message,
422 None,
423 data_provider,
424 warnings,
425 )
426 .await?;
427
428 entries.push(DisplayEntry::Item(DisplayItem {
429 label: label.clone(),
430 value: formatted,
431 }));
432 }
433 DisplayField::Reference { .. } | DisplayField::Scope { .. } => {
434 warnings.push(render_warning(
435 RenderDiagnosticKind::GenericRenderWarning,
436 "unexpanded display field reached typed renderer; skipping",
437 ));
438 }
439 }
440 }
441
442 Ok(entries)
443 })
444}
445
446enum TypedGroupRenderKind {
447 Scalar(Vec<DisplayItem>),
448 Bundles(Vec<Vec<DisplayItem>>),
449}
450
451#[allow(clippy::too_many_arguments)]
452fn render_typed_group_field_kind<'a>(
453 descriptor: &'a Descriptor,
454 message: &'a serde_json::Value,
455 field: &'a DisplayField,
456 container: TypedContainerContext<'a>,
457 data_provider: &'a dyn DataProvider,
458 warnings: &'a mut RenderDiagnostics,
459 descriptors: &'a [ResolvedDescriptor],
460 depth: u8,
461 nested_fallback: &'a mut bool,
462) -> Pin<Box<dyn Future<Output = Result<TypedGroupRenderKind, Error>> + Send + 'a>> {
463 Box::pin(async move {
464 match field {
465 DisplayField::Group { field_group } => {
466 render_typed_group_kind(
467 descriptor,
468 message,
469 field_group,
470 container,
471 data_provider,
472 warnings,
473 descriptors,
474 depth,
475 nested_fallback,
476 )
477 .await
478 }
479 DisplayField::Simple {
480 path,
481 label,
482 value: literal_value,
483 format,
484 params,
485 separator: _,
486 visible,
487 } => {
488 if let Some(lit) = literal_value {
489 if !check_typed_visibility(visible, &None, label, "")? {
490 return Ok(TypedGroupRenderKind::Scalar(Vec::new()));
491 }
492 return Ok(TypedGroupRenderKind::Scalar(vec![DisplayItem {
493 label: label.clone(),
494 value: resolve_metadata_constant_str(descriptor, lit),
495 }]));
496 }
497
498 let path_str = path.as_deref().unwrap_or("");
499 if let Some((base, rest)) = crate::engine::split_array_iter_path(path_str) {
500 if let Some(serde_json::Value::Array(items)) =
501 resolve_typed_path_in_context(message, base, container)?
502 {
503 let mut bundles = Vec::new();
504 for item in &items {
505 let val = if rest.is_empty() {
506 Some(item.clone())
507 } else {
508 resolve_typed_path_in_context(item, rest, container)?
509 };
510 if !check_typed_visibility(visible, &val, label, path_str)? {
511 continue;
512 }
513 let item_params = item_scoped_typed_params(base, params.as_ref());
514 let rendered = if matches!(format.as_ref(), Some(FieldFormat::Calldata))
515 {
516 crate::engine::flatten_display_entry(
517 render_typed_calldata_field(
518 descriptor,
519 message,
520 &val,
521 item_params.as_ref(),
522 label,
523 container,
524 data_provider,
525 descriptors,
526 depth,
527 warnings,
528 nested_fallback,
529 )
530 .await?,
531 )
532 } else {
533 vec![DisplayItem {
534 label: label.clone(),
535 value: format_typed_value(
536 descriptor,
537 &val,
538 format.as_ref(),
539 item_params.as_ref(),
540 container,
541 message,
542 Some(item),
543 data_provider,
544 warnings,
545 )
546 .await?,
547 }]
548 };
549 bundles.push(rendered);
550 }
551 return Ok(TypedGroupRenderKind::Bundles(bundles));
552 }
553 }
554
555 let value = resolve_typed_path_in_context(message, path_str, container)?;
556 if !check_typed_visibility(visible, &value, label, path_str)? {
557 return Ok(TypedGroupRenderKind::Scalar(Vec::new()));
558 }
559
560 if matches!(format.as_ref(), Some(FieldFormat::Calldata)) {
561 return Ok(TypedGroupRenderKind::Scalar(
562 crate::engine::flatten_display_entry(
563 render_typed_calldata_field(
564 descriptor,
565 message,
566 &value,
567 params.as_ref(),
568 label,
569 container,
570 data_provider,
571 descriptors,
572 depth,
573 warnings,
574 nested_fallback,
575 )
576 .await?,
577 ),
578 ));
579 }
580
581 Ok(TypedGroupRenderKind::Scalar(vec![DisplayItem {
582 label: label.clone(),
583 value: format_typed_value(
584 descriptor,
585 &value,
586 format.as_ref(),
587 params.as_ref(),
588 container,
589 message,
590 None,
591 data_provider,
592 warnings,
593 )
594 .await?,
595 }]))
596 }
597 DisplayField::Reference { .. } | DisplayField::Scope { .. } => {
598 Ok(TypedGroupRenderKind::Scalar(Vec::new()))
599 }
600 }
601 })
602}
603
604fn item_scoped_typed_params(base: &str, params: Option<&FormatParams>) -> Option<FormatParams> {
605 let mut params = params.cloned()?;
606
607 if let Some(token_path) = params.token_path.as_ref() {
608 let prefix = format!("{base}.[].");
609 if let Some(item_relative) = token_path.strip_prefix(&prefix) {
610 params.token_path = Some(item_relative.to_string());
611 }
612 }
613
614 Some(params)
615}
616
617fn resolve_typed_param_path_in_context(
618 message: &serde_json::Value,
619 path: &str,
620 container: TypedContainerContext<'_>,
621 item_scope: Option<&serde_json::Value>,
622) -> Result<Option<serde_json::Value>, Error> {
623 if path.starts_with("#.") || path.starts_with("@.") {
624 return resolve_typed_path_in_context(message, path, container);
625 }
626
627 if let Some(item_scope) = item_scope {
628 return Ok(resolve_typed_message_path(item_scope, path));
629 }
630
631 resolve_typed_path_in_context(message, path, container)
632}
633
634#[allow(clippy::too_many_arguments)]
635fn render_typed_group_kind<'a>(
636 descriptor: &'a Descriptor,
637 message: &'a serde_json::Value,
638 group: &'a FieldGroup,
639 container: TypedContainerContext<'a>,
640 data_provider: &'a dyn DataProvider,
641 warnings: &'a mut RenderDiagnostics,
642 descriptors: &'a [ResolvedDescriptor],
643 depth: u8,
644 nested_fallback: &'a mut bool,
645) -> Pin<Box<dyn Future<Output = Result<TypedGroupRenderKind, Error>> + Send + 'a>> {
646 Box::pin(async move {
647 let mut child_kinds = Vec::new();
648 for field in &group.fields {
649 child_kinds.push(
650 render_typed_group_field_kind(
651 descriptor,
652 message,
653 field,
654 container,
655 data_provider,
656 warnings,
657 descriptors,
658 depth,
659 nested_fallback,
660 )
661 .await?,
662 );
663 }
664
665 match group.iteration {
666 Iteration::Sequential => {
667 let items = child_kinds
668 .into_iter()
669 .flat_map(|kind| match kind {
670 TypedGroupRenderKind::Scalar(items) => items,
671 TypedGroupRenderKind::Bundles(bundles) => {
672 bundles.into_iter().flatten().collect()
673 }
674 })
675 .collect();
676 Ok(TypedGroupRenderKind::Scalar(items))
677 }
678 Iteration::Bundled => {
679 let mut bundle_sets = Vec::new();
680 for kind in child_kinds {
681 match kind {
682 TypedGroupRenderKind::Bundles(bundles) => bundle_sets.push(bundles),
683 TypedGroupRenderKind::Scalar(_) => {
684 return Err(Error::Render(
685 "bundled groups cannot mix array-expanded and scalar fields"
686 .to_string(),
687 ));
688 }
689 }
690 }
691
692 if bundle_sets.is_empty() {
693 return Ok(TypedGroupRenderKind::Bundles(Vec::new()));
694 }
695
696 let expected_len = bundle_sets[0].len();
697 if bundle_sets
698 .iter()
699 .any(|bundles| bundles.len() != expected_len)
700 {
701 return Err(Error::Render(
702 "bundled groups require all array-expanded fields to have the same length"
703 .to_string(),
704 ));
705 }
706
707 let mut bundled = vec![Vec::new(); expected_len];
708 for bundles in bundle_sets {
709 for (index, items) in bundles.into_iter().enumerate() {
710 bundled[index].extend(items);
711 }
712 }
713 Ok(TypedGroupRenderKind::Bundles(bundled))
714 }
715 }
716 })
717}
718
719#[allow(clippy::too_many_arguments)]
720async fn render_typed_field_group_entries<'a>(
721 descriptor: &'a Descriptor,
722 message: &'a serde_json::Value,
723 group: &FieldGroup,
724 container: TypedContainerContext<'a>,
725 data_provider: &'a dyn DataProvider,
726 warnings: &'a mut RenderDiagnostics,
727 descriptors: &'a [ResolvedDescriptor],
728 depth: u8,
729 nested_fallback: &'a mut bool,
730) -> Result<Vec<DisplayEntry>, Error> {
731 let rendered = render_typed_group_kind(
732 descriptor,
733 message,
734 group,
735 container,
736 data_provider,
737 warnings,
738 descriptors,
739 depth,
740 nested_fallback,
741 )
742 .await?;
743 match rendered {
744 TypedGroupRenderKind::Scalar(items) => {
745 if items.is_empty() {
746 return Ok(Vec::new());
747 }
748 if let Some(label) = group.label.as_ref() {
749 Ok(vec![DisplayEntry::Group {
750 label: label.clone(),
751 iteration: GroupIteration::Sequential,
752 items,
753 }])
754 } else {
755 Ok(items.into_iter().map(DisplayEntry::Item).collect())
756 }
757 }
758 TypedGroupRenderKind::Bundles(bundles) => {
759 let items: Vec<DisplayItem> = bundles.into_iter().flatten().collect();
760 if items.is_empty() {
761 return Ok(Vec::new());
762 }
763 Ok(vec![DisplayEntry::Group {
764 label: group.label.clone().unwrap_or_default(),
765 iteration: GroupIteration::Bundled,
766 items,
767 }])
768 }
769 }
770}
771
772#[allow(clippy::too_many_arguments)]
776async fn render_typed_calldata_field(
777 _descriptor: &Descriptor,
778 message: &serde_json::Value,
779 val: &Option<serde_json::Value>,
780 params: Option<&FormatParams>,
781 label: &str,
782 container: TypedContainerContext<'_>,
783 data_provider: &dyn DataProvider,
784 descriptors: &[ResolvedDescriptor],
785 depth: u8,
786 warnings: &mut RenderDiagnostics,
787 nested_fallback: &mut bool,
788) -> Result<DisplayEntry, Error> {
789 let inner_calldata = match val {
791 Some(serde_json::Value::String(s)) => {
792 let hex_str = s
793 .strip_prefix("0x")
794 .or_else(|| s.strip_prefix("0X"))
795 .unwrap_or(s);
796 match hex::decode(hex_str) {
797 Ok(bytes) => bytes,
798 Err(_) => {
799 warnings.push(render_warning(
800 RenderDiagnosticKind::NestedCalldataDegraded,
801 "could not decode calldata hex",
802 ));
803 *nested_fallback = true;
804 return Ok(DisplayEntry::Nested {
805 label: label.to_string(),
806 intent: "Unknown".to_string(),
807 entries: vec![DisplayEntry::Item(DisplayItem {
808 label: "Raw data".to_string(),
809 value: s.clone(),
810 })],
811 });
812 }
813 }
814 }
815 _ => {
816 let raw = val
817 .as_ref()
818 .map(json_value_to_string)
819 .unwrap_or_else(|| "<unresolved>".to_string());
820 warnings.push(render_warning(
821 RenderDiagnosticKind::NestedCalldataInvalidType,
822 "calldata field is not a hex string",
823 ));
824 *nested_fallback = true;
825 return Ok(DisplayEntry::Nested {
826 label: label.to_string(),
827 intent: "Unknown".to_string(),
828 entries: vec![DisplayEntry::Item(DisplayItem {
829 label: "Raw data".to_string(),
830 value: raw,
831 })],
832 });
833 }
834 };
835
836 if depth >= MAX_CALLDATA_DEPTH {
838 warnings.push(render_warning(
839 RenderDiagnosticKind::NestedCalldataDegraded,
840 format!(
841 "nested calldata depth limit ({}) reached",
842 MAX_CALLDATA_DEPTH
843 ),
844 ));
845 *nested_fallback = true;
846 return Ok(DisplayEntry::Nested {
847 label: label.to_string(),
848 intent: "Unknown".to_string(),
849 entries: vec![DisplayEntry::Item(DisplayItem {
850 label: "Raw data".to_string(),
851 value: format!("0x{}", hex::encode(&inner_calldata)),
852 })],
853 });
854 }
855
856 let callee = match resolve_typed_nested_callee(message, params, container)? {
857 Some(addr) => addr,
858 None => {
859 warnings.push(render_warning(
860 RenderDiagnosticKind::NestedCalldataDegraded,
861 "nested calldata callee could not be resolved",
862 ));
863 *nested_fallback = true;
864 return Ok(crate::engine::build_raw_nested(label, &inner_calldata));
865 }
866 };
867
868 let amount_bytes = resolve_typed_nested_amount(message, params, container)?;
869 let spender_addr = resolve_typed_nested_spender(message, params, container)?;
870 let chain_id = resolve_typed_nested_chain_id(message, params, container)?.ok_or_else(|| {
871 Error::Descriptor(
872 "EIP-712 container value @.chainId is required for nested calldata".to_string(),
873 )
874 })?;
875 let selector_override = resolve_typed_nested_selector(message, params, container)?;
876 let normalized_calldata = normalized_nested_calldata(&inner_calldata, selector_override);
877
878 if normalized_calldata.len() < 4 {
879 warnings.push(render_warning(
880 RenderDiagnosticKind::NestedCalldataDegraded,
881 "inner calldata too short",
882 ));
883 *nested_fallback = true;
884 return Ok(DisplayEntry::Nested {
885 label: label.to_string(),
886 intent: "Unknown".to_string(),
887 entries: vec![DisplayEntry::Item(DisplayItem {
888 label: "Raw data".to_string(),
889 value: format!("0x{}", hex::encode(&inner_calldata)),
890 })],
891 });
892 }
893
894 let inner_descriptor = descriptors.iter().find(|rd| {
895 rd.descriptor.context.deployments().iter().any(|dep| {
896 dep.chain_id == chain_id && dep.address.to_lowercase() == callee.to_lowercase()
897 })
898 });
899
900 let inner_descriptor = match inner_descriptor {
901 Some(rd) => &rd.descriptor,
902 None => {
903 warnings.push(render_warning(
904 RenderDiagnosticKind::NestedDescriptorNotFound,
905 "No matching descriptor for inner call",
906 ));
907 *nested_fallback = true;
908 return Ok(crate::engine::build_raw_nested(label, &inner_calldata));
909 }
910 };
911
912 let (sig, _) = match crate::find_matching_signature(inner_descriptor, &normalized_calldata[..4])
914 {
915 Ok(result) => result,
916 Err(_) => {
917 warnings.push(render_warning(
918 RenderDiagnosticKind::NestedDescriptorNotFound,
919 "No matching descriptor for inner call",
920 ));
921 *nested_fallback = true;
922 return Ok(crate::engine::build_raw_nested(label, &inner_calldata));
923 }
924 };
925
926 let mut decoded = match crate::decoder::decode_calldata(&sig, &normalized_calldata) {
927 Ok(d) => d,
928 Err(_) => {
929 warnings.push(render_warning(
930 RenderDiagnosticKind::NestedCalldataDegraded,
931 "inner calldata could not be decoded",
932 ));
933 *nested_fallback = true;
934 return Ok(crate::engine::build_raw_nested(label, &inner_calldata));
935 }
936 };
937
938 crate::inject_container_values(
939 &mut decoded,
940 chain_id,
941 &callee,
942 amount_bytes.as_deref(),
943 spender_addr.as_deref(),
944 );
945
946 let mut inner_state = RenderState::default();
948 let result = crate::engine::format_calldata(
949 inner_descriptor,
950 chain_id,
951 &callee,
952 &decoded,
953 amount_bytes.as_deref(),
954 data_provider,
955 descriptors,
956 &mut inner_state,
957 )
958 .await?;
959
960 if inner_state.fallback_reason().is_some() {
961 *nested_fallback = true;
962 }
963 warnings.extend(inner_state.diagnostics().iter().cloned());
964
965 Ok(DisplayEntry::Nested {
966 label: label.to_string(),
967 intent: result.intent,
968 entries: result.entries,
969 })
970}
971
972pub(crate) fn build_typed_raw_fallback(data: &TypedData) -> DisplayModel {
974 let mut entries = Vec::new();
975
976 if let Some(type_fields) = data.types.get(&data.primary_type) {
978 for field in type_fields {
979 let value = data
980 .message
981 .get(&field.name)
982 .map(json_value_to_string)
983 .unwrap_or_else(|| "<missing>".to_string());
984 entries.push(DisplayEntry::Item(DisplayItem {
985 label: field.name.clone(),
986 value,
987 }));
988 }
989 } else if let Some(obj) = data.message.as_object() {
990 for (key, val) in obj {
992 entries.push(DisplayEntry::Item(DisplayItem {
993 label: key.clone(),
994 value: json_value_to_string(val),
995 }));
996 }
997 }
998
999 DisplayModel {
1000 intent: data.primary_type.clone(),
1001 interpolated_intent: None,
1002 entries,
1003 owner: None,
1004 contract_name: None,
1005 }
1006}
1007
1008pub(crate) fn find_typed_format<'a>(
1009 descriptor: &'a Descriptor,
1010 data: &TypedData,
1011) -> Result<&'a crate::types::display::DisplayFormat, Error> {
1012 match find_typed_format_optional(descriptor, data)? {
1013 Some(format) => Ok(format),
1014 None => Err(Error::Descriptor(format!(
1015 "no EIP-712 display format found for primaryType '{}' (expected encodeType '{}')",
1016 data.primary_type,
1017 encode_type_for_primary_type(data)?
1018 ))),
1019 }
1020}
1021
1022pub(crate) fn find_typed_format_optional<'a>(
1023 descriptor: &'a Descriptor,
1024 data: &TypedData,
1025) -> Result<Option<&'a crate::types::display::DisplayFormat>, Error> {
1026 let encode_type = encode_type_for_primary_type(data)?;
1027 let expected_hash = keccak256(encode_type.as_bytes());
1028 let mut matches = Vec::new();
1029
1030 for (key, format) in &descriptor.display.formats {
1031 if keccak256(key.as_bytes()) == expected_hash {
1032 matches.push((key, format));
1033 }
1034 }
1035
1036 match matches.len() {
1037 1 => Ok(Some(matches[0].1)),
1038 0 => Ok(None),
1039 _ => {
1040 let keys: Vec<&str> = matches.iter().map(|(key, _)| key.as_str()).collect();
1041 Err(Error::Descriptor(format!(
1042 "multiple EIP-712 display formats match primaryType '{}': {}",
1043 data.primary_type,
1044 keys.join(", ")
1045 )))
1046 }
1047 }
1048}
1049
1050pub(crate) fn encode_type_for_primary_type(data: &TypedData) -> Result<String, Error> {
1051 encode_type_for_type(&data.types, &data.primary_type)
1052}
1053
1054pub(crate) fn encode_type_hash_hex_for_primary_type(data: &TypedData) -> Result<String, Error> {
1055 let encode_type = encode_type_for_primary_type(data)?;
1056 Ok(format_key_hash_hex(&encode_type))
1057}
1058
1059pub(crate) fn format_key_hash_hex(key: &str) -> String {
1060 format!("0x{}", hex::encode(keccak256(key.as_bytes())))
1061}
1062
1063pub(crate) fn encode_type_for_type(
1064 types: &HashMap<String, Vec<TypedDataField>>,
1065 type_name: &str,
1066) -> Result<String, Error> {
1067 let primary_fields = types.get(type_name).ok_or_else(|| {
1068 Error::Descriptor(format!("missing EIP-712 type definition '{}'", type_name))
1069 })?;
1070
1071 let mut dependencies = std::collections::BTreeSet::new();
1072 collect_type_dependencies(type_name, types, &mut dependencies)?;
1073 dependencies.remove(type_name);
1074
1075 let mut encoded = encode_type_segment(type_name, primary_fields);
1076 for dependency in dependencies {
1077 let fields = types.get(&dependency).ok_or_else(|| {
1078 Error::Descriptor(format!(
1079 "missing EIP-712 type definition for dependency '{}'",
1080 dependency
1081 ))
1082 })?;
1083 encoded.push_str(&encode_type_segment(&dependency, fields));
1084 }
1085
1086 Ok(encoded)
1087}
1088
1089fn collect_type_dependencies(
1090 type_name: &str,
1091 types: &HashMap<String, Vec<TypedDataField>>,
1092 dependencies: &mut std::collections::BTreeSet<String>,
1093) -> Result<(), Error> {
1094 let fields = types.get(type_name).ok_or_else(|| {
1095 Error::Descriptor(format!("missing EIP-712 type definition '{}'", type_name))
1096 })?;
1097
1098 for field in fields {
1099 let base_type = base_type_name(&field.field_type);
1100 if types.contains_key(base_type) && dependencies.insert(base_type.to_string()) {
1101 collect_type_dependencies(base_type, types, dependencies)?;
1102 }
1103 }
1104
1105 Ok(())
1106}
1107
1108fn encode_type_segment(type_name: &str, fields: &[TypedDataField]) -> String {
1109 let fields = fields
1110 .iter()
1111 .map(|field| format!("{} {}", field.field_type, field.name))
1112 .collect::<Vec<_>>()
1113 .join(",");
1114 format!("{type_name}({fields})")
1115}
1116
1117fn base_type_name(field_type: &str) -> &str {
1118 let mut base_type = field_type;
1119 while let Some(stripped) = base_type.strip_suffix(']') {
1120 if let Some(array_start) = stripped.rfind('[') {
1121 base_type = &stripped[..array_start];
1122 } else {
1123 break;
1124 }
1125 }
1126 base_type
1127}
1128
1129pub(crate) fn keccak256(bytes: &[u8]) -> [u8; 32] {
1130 let mut hasher = Keccak::v256();
1131 hasher.update(bytes);
1132 let mut output = [0u8; 32];
1133 hasher.finalize(&mut output);
1134 output
1135}
1136
1137fn resolve_typed_message_path(
1141 message: &serde_json::Value,
1142 path: &str,
1143) -> Option<serde_json::Value> {
1144 let mut current = message.clone();
1145
1146 for segment in path.split('.') {
1147 if let Some(bracket) = segment.find('[') {
1149 let key = &segment[..bracket];
1150 let access = &segment[bracket..];
1151
1152 if !key.is_empty() {
1153 current = current.get(key)?.clone();
1154 }
1155
1156 current = apply_typed_access(¤t, access)?;
1157 } else {
1158 current = current.get(segment)?.clone();
1159 }
1160 }
1161
1162 Some(current)
1163}
1164
1165#[allow(dead_code)]
1166pub(crate) fn resolve_typed_path(
1167 message: &serde_json::Value,
1168 path: &str,
1169 chain_id: u64,
1170 verifying_contract: Option<&str>,
1171) -> Option<serde_json::Value> {
1172 if let Some(message_path) = path.strip_prefix("#.") {
1173 return resolve_typed_message_path(message, message_path);
1174 }
1175
1176 match path {
1177 "@.to" => verifying_contract.map(|addr| serde_json::Value::String(addr.to_string())),
1178 "@.chainId" => Some(serde_json::Value::from(chain_id)),
1179 "@.value" => Some(serde_json::Value::from(0u64)),
1180 "@.from" => None,
1181 _ if path.starts_with("@.") => None,
1182 _ => resolve_typed_message_path(message, path),
1183 }
1184}
1185
1186fn resolve_typed_path_in_context(
1187 message: &serde_json::Value,
1188 path: &str,
1189 container: TypedContainerContext<'_>,
1190) -> Result<Option<serde_json::Value>, Error> {
1191 if let Some(message_path) = path.strip_prefix("#.") {
1192 return Ok(resolve_typed_message_path(message, message_path));
1193 }
1194
1195 match path {
1196 "@.to" => container
1197 .verifying_contract
1198 .map(|addr| serde_json::Value::String(addr.to_string()))
1199 .map(Some)
1200 .ok_or_else(|| {
1201 Error::Descriptor(
1202 "EIP-712 container value @.to is required but unavailable".to_string(),
1203 )
1204 }),
1205 "@.chainId" => container
1206 .chain_id
1207 .map(serde_json::Value::from)
1208 .map(Some)
1209 .ok_or_else(|| {
1210 Error::Descriptor(
1211 "EIP-712 container value @.chainId is required but unavailable".to_string(),
1212 )
1213 }),
1214 "@.value" => Ok(Some(serde_json::Value::from(0u64))),
1215 "@.from" => container
1216 .from
1217 .map(|addr| serde_json::Value::String(addr.to_string()))
1218 .map(Some)
1219 .ok_or_else(|| {
1220 Error::Descriptor(
1221 "EIP-712 container value @.from is required but unavailable".to_string(),
1222 )
1223 }),
1224 _ if path.starts_with("@.") => Err(Error::Descriptor(format!(
1225 "unsupported EIP-712 container path '{}'",
1226 path
1227 ))),
1228 _ => Ok(resolve_typed_message_path(message, path)),
1229 }
1230}
1231
1232fn apply_typed_access(current: &serde_json::Value, segment: &str) -> Option<serde_json::Value> {
1233 match current {
1234 serde_json::Value::Array(items) => match apply_collection_access(items, segment)? {
1235 CollectionSelection::Item(item) => Some(item),
1236 CollectionSelection::Slice(slice) => Some(serde_json::Value::Array(slice)),
1237 },
1238 serde_json::Value::String(s) => {
1239 let hex_str = s.strip_prefix("0x").or_else(|| s.strip_prefix("0X"))?;
1240 let bytes = hex::decode(hex_str).ok()?;
1241 match apply_collection_access(&bytes, segment)? {
1242 CollectionSelection::Item(byte) => {
1243 Some(serde_json::Value::String(format!("0x{:02x}", byte)))
1244 }
1245 CollectionSelection::Slice(slice) => Some(serde_json::Value::String(format!(
1246 "0x{}",
1247 hex::encode(slice)
1248 ))),
1249 }
1250 }
1251 _ => None,
1252 }
1253}
1254
1255fn typed_visibility_context(label: &str, path: &str) -> String {
1256 if path.is_empty() {
1257 format!("field '{}'", label)
1258 } else {
1259 format!("field '{}' (path '{}')", label, path)
1260 }
1261}
1262
1263fn check_typed_visibility(
1264 rule: &VisibleRule,
1265 value: &Option<serde_json::Value>,
1266 label: &str,
1267 path: &str,
1268) -> Result<bool, Error> {
1269 match rule {
1270 VisibleRule::Always => Ok(true),
1271 VisibleRule::Bool(b) => Ok(*b),
1272 VisibleRule::Named(literal) => Ok(matches!(
1273 literal,
1274 VisibleLiteral::Always | VisibleLiteral::Optional
1275 )),
1276 VisibleRule::Condition(cond) => {
1277 let Some(val) = value else {
1278 if cond.must_match.is_some() {
1279 return Err(Error::Render(format!(
1280 "{} uses visible.mustMatch but the value could not be resolved",
1281 typed_visibility_context(label, path)
1282 )));
1283 }
1284 return Ok(true);
1285 };
1286
1287 if cond.hides_for_if_not_in(val) {
1288 return Ok(false);
1289 }
1290 if cond.must_match.is_some() {
1291 if cond.matches_must_match(val) {
1292 return Ok(false);
1293 }
1294 return Err(Error::Render(format!(
1295 "{} failed visible.mustMatch",
1296 typed_visibility_context(label, path)
1297 )));
1298 }
1299 Ok(true)
1300 }
1301 }
1302}
1303
1304pub(crate) fn coerce_typed_numeric_string(val: &serde_json::Value) -> Option<String> {
1305 coerce_unsigned_decimal_string_from_typed_value(val)
1306}
1307
1308pub(crate) fn parse_typed_biguint_value(
1309 val: &serde_json::Value,
1310 format_name: &str,
1311) -> Result<BigUint, Error> {
1312 parse_unsigned_biguint_from_typed_value(val, format_name)
1313}
1314
1315fn parse_typed_u64_value(val: &serde_json::Value, format_name: &str) -> Result<u64, Error> {
1316 let value = parse_typed_biguint_value(val, format_name)?;
1317 u64::try_from(&value)
1318 .map_err(|_| Error::Render(format!("{format_name} field does not fit into u64")))
1319}
1320
1321fn parse_typed_i64_value(val: &serde_json::Value, format_name: &str) -> Result<i64, Error> {
1322 match val {
1323 serde_json::Value::Number(n) => {
1324 if let Some(value) = n.as_i64() {
1325 Ok(value)
1326 } else if let Some(value) = n.as_u64() {
1327 i64::try_from(value).map_err(|_| {
1328 Error::Render(format!("{format_name} field does not fit into i64"))
1329 })
1330 } else {
1331 Err(Error::Render(format!(
1332 "{format_name} field must be an integer"
1333 )))
1334 }
1335 }
1336 serde_json::Value::String(s) => {
1337 let trimmed = s.trim();
1338 if let Ok(value) = trimmed.parse::<i64>() {
1339 Ok(value)
1340 } else if let Some(hex_str) = trimmed
1341 .strip_prefix("0x")
1342 .or_else(|| trimmed.strip_prefix("0X"))
1343 {
1344 let bytes = hex::decode(hex_str).map_err(|_| {
1345 Error::Render(format!("{format_name} field must be an integer"))
1346 })?;
1347 let value = BigUint::from_bytes_be(&bytes);
1348 i64::try_from(&value).map_err(|_| {
1349 Error::Render(format!("{format_name} field does not fit into i64"))
1350 })
1351 } else {
1352 let value = trimmed.parse::<BigUint>().map_err(|_| {
1353 Error::Render(format!("{format_name} field must be an integer"))
1354 })?;
1355 i64::try_from(&value).map_err(|_| {
1356 Error::Render(format!("{format_name} field does not fit into i64"))
1357 })
1358 }
1359 }
1360 _ => Err(Error::Render(format!(
1361 "{format_name} field must be an integer"
1362 ))),
1363 }
1364}
1365
1366pub(crate) fn coerce_typed_address_string(val: &serde_json::Value) -> Option<String> {
1367 match val {
1368 serde_json::Value::String(s) => {
1369 let hex_str = s.strip_prefix("0x").or_else(|| s.strip_prefix("0X"))?;
1370 let bytes = hex::decode(hex_str).ok()?;
1371 let addr = crate::engine::address_bytes_from_raw_bytes(&bytes)?;
1372 if bytes.len() == 20 && hex_str.len() == 40 {
1373 Some(s.clone())
1374 } else {
1375 Some(format!("0x{}", hex::encode(addr)))
1376 }
1377 }
1378 serde_json::Value::Number(n) => n.as_u64().map(|v| format!("0x{:040x}", v)),
1379 _ => None,
1380 }
1381}
1382
1383pub(crate) fn selector_from_typed_value(val: &serde_json::Value) -> Option<[u8; 4]> {
1384 match val {
1385 serde_json::Value::String(s) => {
1386 let hex_str = s.strip_prefix("0x").or_else(|| s.strip_prefix("0X"))?;
1387 let bytes = hex::decode(hex_str).ok()?;
1388 if bytes.len() < 4 {
1389 return None;
1390 }
1391 let mut selector = [0u8; 4];
1392 selector.copy_from_slice(&bytes[..4]);
1393 Some(selector)
1394 }
1395 serde_json::Value::Number(_) => {
1396 let numeric = coerce_typed_numeric_string(val)?;
1397 let biguint = numeric.parse::<BigUint>().ok()?;
1398 let bytes = biguint.to_bytes_be();
1399 if bytes.len() > 32 || bytes.is_empty() {
1400 return None;
1401 }
1402 let mut padded = vec![0u8; 32usize.saturating_sub(bytes.len())];
1403 padded.extend_from_slice(&bytes);
1404 let mut selector = [0u8; 4];
1405 selector.copy_from_slice(&padded[padded.len() - 4..]);
1406 Some(selector)
1407 }
1408 _ => None,
1409 }
1410}
1411
1412fn chain_id_from_typed_value(val: &serde_json::Value) -> Option<u64> {
1413 let numeric = coerce_typed_numeric_string(val)?;
1414 numeric.parse::<u64>().ok()
1415}
1416
1417fn uint_bytes_from_typed_value(val: &serde_json::Value) -> Option<Vec<u8>> {
1418 let numeric = coerce_typed_numeric_string(val)?;
1419 let biguint = numeric.parse::<BigUint>().ok()?;
1420 uint_bytes_from_biguint(&biguint, "amount").ok()
1421}
1422
1423fn resolve_typed_map(
1424 descriptor: &Descriptor,
1425 message: &serde_json::Value,
1426 container: TypedContainerContext<'_>,
1427 map_ref: &str,
1428 val: &serde_json::Value,
1429) -> Result<Option<String>, Error> {
1430 let Some(map_def) = descriptor.metadata.maps.get(map_ref) else {
1431 return Ok(None);
1432 };
1433
1434 let key = if let Some(ref key_path) = map_def.key_path {
1435 let Some(key_val) = resolve_typed_path_in_context(message, key_path, container)? else {
1436 return Ok(None);
1437 };
1438 json_value_to_string(&key_val)
1439 } else {
1440 json_value_to_string(val)
1441 };
1442
1443 Ok(lookup_map_entry(descriptor, map_ref, &key))
1444}
1445
1446fn resolve_typed_nested_callee(
1447 message: &serde_json::Value,
1448 params: Option<&FormatParams>,
1449 container: TypedContainerContext<'_>,
1450) -> Result<Option<String>, Error> {
1451 let Some(params) = params else {
1452 return Ok(None);
1453 };
1454 ensure_single_nested_param_source(
1455 params.callee.is_some(),
1456 params.callee_path.is_some(),
1457 "callee",
1458 )?;
1459 if let Some(callee) = params.callee.as_deref() {
1460 return parse_nested_address_param(callee, "callee").map(Some);
1461 }
1462 Ok(match params.callee_path.as_ref() {
1463 Some(path) => resolve_typed_path_in_context(message, path, container)?
1464 .as_ref()
1465 .and_then(coerce_typed_address_string),
1466 None => None,
1467 })
1468}
1469
1470fn resolve_typed_nested_spender(
1471 message: &serde_json::Value,
1472 params: Option<&FormatParams>,
1473 container: TypedContainerContext<'_>,
1474) -> Result<Option<String>, Error> {
1475 let Some(params) = params else {
1476 return Ok(None);
1477 };
1478 ensure_single_nested_param_source(
1479 params.spender.is_some(),
1480 params.spender_path.is_some(),
1481 "spender",
1482 )?;
1483 if let Some(spender) = params.spender.as_deref() {
1484 return parse_nested_address_param(spender, "spender").map(Some);
1485 }
1486 Ok(match params.spender_path.as_ref() {
1487 Some(path) => resolve_typed_path_in_context(message, path, container)?
1488 .as_ref()
1489 .and_then(coerce_typed_address_string),
1490 None => None,
1491 })
1492}
1493
1494fn resolve_typed_nested_amount(
1495 message: &serde_json::Value,
1496 params: Option<&FormatParams>,
1497 container: TypedContainerContext<'_>,
1498) -> Result<Option<Vec<u8>>, Error> {
1499 let Some(params) = params else {
1500 return Ok(None);
1501 };
1502 ensure_single_nested_param_source(
1503 params.amount.is_some(),
1504 params.amount_path.is_some(),
1505 "amount",
1506 )?;
1507 if let Some(amount) = params.amount.as_ref() {
1508 return parse_nested_amount_literal(amount, "amount").map(Some);
1509 }
1510 Ok(match params.amount_path.as_ref() {
1511 Some(path) => resolve_typed_path_in_context(message, path, container)?
1512 .as_ref()
1513 .and_then(uint_bytes_from_typed_value),
1514 None => None,
1515 })
1516}
1517
1518fn resolve_typed_nested_chain_id(
1519 message: &serde_json::Value,
1520 params: Option<&FormatParams>,
1521 container: TypedContainerContext<'_>,
1522) -> Result<Option<u64>, Error> {
1523 let Some(params) = params else {
1524 return Ok(container.chain_id);
1525 };
1526 ensure_single_nested_param_source(
1527 params.chain_id.is_some(),
1528 params.chain_id_path.is_some(),
1529 "chainId",
1530 )?;
1531 if let Some(chain_id) = params.chain_id {
1532 return Ok(Some(chain_id));
1533 }
1534 Ok(match params.chain_id_path.as_ref() {
1535 Some(path) => resolve_typed_path_in_context(message, path, container)?
1536 .as_ref()
1537 .and_then(chain_id_from_typed_value),
1538 None => container.chain_id,
1539 })
1540}
1541
1542fn resolve_typed_nested_selector(
1543 message: &serde_json::Value,
1544 params: Option<&FormatParams>,
1545 container: TypedContainerContext<'_>,
1546) -> Result<Option<[u8; 4]>, Error> {
1547 let Some(params) = params else {
1548 return Ok(None);
1549 };
1550 ensure_single_nested_param_source(
1551 params.selector.is_some(),
1552 params.selector_path.is_some(),
1553 "selector",
1554 )?;
1555 if let Some(selector) = params.selector.as_deref() {
1556 return parse_nested_selector_param(selector, "selector").map(Some);
1557 }
1558 Ok(match params.selector_path.as_ref() {
1559 Some(path) => resolve_typed_path_in_context(message, path, container)?
1560 .as_ref()
1561 .and_then(selector_from_typed_value),
1562 None => None,
1563 })
1564}
1565
1566#[allow(clippy::too_many_arguments)]
1567async fn format_typed_value(
1568 descriptor: &Descriptor,
1569 value: &Option<serde_json::Value>,
1570 format: Option<&FieldFormat>,
1571 params: Option<&FormatParams>,
1572 container: TypedContainerContext<'_>,
1573 message: &serde_json::Value,
1574 item_scope: Option<&serde_json::Value>,
1575 data_provider: &dyn DataProvider,
1576 warnings: &mut RenderDiagnostics,
1577) -> Result<String, Error> {
1578 let Some(val) = value else {
1579 return Ok("<unresolved>".to_string());
1580 };
1581
1582 if let Some(params) = params {
1584 if let Some(ref enc) = params.encryption {
1585 if let Some(ref fallback) = enc.fallback_label {
1586 return Ok(fallback.clone());
1587 }
1588 }
1589 }
1590
1591 if let Some(params) = params {
1593 if let Some(ref map_ref) = params.map_reference {
1594 if let Some(mapped) = resolve_typed_map(descriptor, message, container, map_ref, val)? {
1595 return Ok(mapped);
1596 }
1597 }
1598 }
1599
1600 let Some(fmt) = format else {
1601 return Ok(json_value_to_string(val));
1602 };
1603
1604 match fmt {
1605 FieldFormat::Address => {
1606 Ok(coerce_typed_address_string(val).unwrap_or_else(|| json_value_to_string(val)))
1607 }
1608 FieldFormat::AddressName | FieldFormat::InteroperableAddressName => {
1609 let addr =
1610 coerce_typed_address_string(val).unwrap_or_else(|| json_value_to_string(val));
1611
1612 if let Some(params) = params {
1614 if let Some(ref sender) = params.sender_address {
1615 let sender_addrs = match sender {
1616 crate::types::display::SenderAddress::Single(s) => vec![s.as_str()],
1617 crate::types::display::SenderAddress::Multiple(v) => {
1618 v.iter().map(|s| s.as_str()).collect()
1619 }
1620 };
1621 for sender_ref in &sender_addrs {
1622 let resolved =
1623 if sender_ref.starts_with("@.") || sender_ref.starts_with('#') {
1624 resolve_typed_path_in_context(message, sender_ref, container)?
1625 .and_then(|v| match v {
1626 serde_json::Value::String(s) => Some(s),
1627 _ => None,
1628 })
1629 } else {
1630 Some(sender_ref.to_string())
1631 };
1632 if let Some(resolved_addr) = resolved {
1633 if resolved_addr.to_lowercase() == addr.to_lowercase() {
1634 return Ok("Sender".to_string());
1635 }
1636 }
1637 }
1638 }
1639 }
1640
1641 let sources = params.and_then(|p| p.sources.as_ref());
1643 let local_allowed = sources
1644 .map(|s| s.iter().any(|src| src == "local"))
1645 .unwrap_or(true);
1646 let ens_allowed = sources
1647 .map(|s| s.iter().any(|src| src == "ens"))
1648 .unwrap_or(true);
1649 let chain_id = container.chain_id.ok_or_else(|| {
1650 Error::Descriptor(
1651 "EIP-712 container value @.chainId is required for addressName".to_string(),
1652 )
1653 })?;
1654
1655 if local_allowed {
1656 if let Some(name) = data_provider
1657 .resolve_local_name(&addr, chain_id, params.and_then(|p| p.types.as_deref()))
1658 .await
1659 {
1660 return Ok(name);
1661 }
1662 }
1663 if ens_allowed {
1664 if let Some(name) = data_provider
1665 .resolve_ens_name(&addr, chain_id, params.and_then(|p| p.types.as_deref()))
1666 .await
1667 {
1668 return Ok(name);
1669 }
1670 }
1671 Ok(addr)
1672 }
1673 FieldFormat::TokenAmount => {
1674 let amount = parse_typed_biguint_value(val, "tokenAmount")?;
1675
1676 let lookup_chain = resolve_typed_chain_id(params, container, message)?;
1677
1678 let token_meta = if let Some(params) = params {
1679 if let Some(ref token_path) = params.token_path {
1680 let token_addr = resolve_typed_param_path_in_context(
1681 message, token_path, container, item_scope,
1682 )?;
1683 let addr_str = token_addr.as_ref().and_then(coerce_typed_address_string);
1684 if let Some(addr) = addr_str {
1685 if let Some(ref native) = params.native_currency_address {
1686 if native.matches(&addr, &descriptor.metadata.constants) {
1687 Some(native_token_meta(lookup_chain))
1688 } else {
1689 data_provider.resolve_token(lookup_chain, &addr).await
1690 }
1691 } else {
1692 data_provider.resolve_token(lookup_chain, &addr).await
1693 }
1694 } else {
1695 None
1696 }
1697 } else if let Some(ref token_ref) = params.token {
1698 let addr = resolve_metadata_constant_str(descriptor, token_ref);
1699 if let Some(ref native) = params.native_currency_address {
1700 if native.matches(&addr, &descriptor.metadata.constants) {
1701 Some(native_token_meta(lookup_chain))
1702 } else {
1703 data_provider.resolve_token(lookup_chain, &addr).await
1704 }
1705 } else {
1706 data_provider.resolve_token(lookup_chain, &addr).await
1707 }
1708 } else {
1709 None
1710 }
1711 } else {
1712 None
1713 };
1714
1715 Ok(format_token_amount_output(
1716 descriptor,
1717 &amount,
1718 params,
1719 token_meta.as_ref(),
1720 ))
1721 }
1722 FieldFormat::Date => {
1723 if params.and_then(|p| p.encoding.as_deref()) == Some("blockheight") {
1724 let chain_id = container.chain_id.ok_or_else(|| {
1725 Error::Descriptor(
1726 "EIP-712 container value @.chainId is required for blockheight dates"
1727 .to_string(),
1728 )
1729 })?;
1730 let block_number = parse_typed_u64_value(val, "date")?;
1731 format_blockheight_timestamp(data_provider, chain_id, block_number).await
1732 } else {
1733 let ts = parse_typed_i64_value(val, "date")?;
1734 format_timestamp(ts)
1735 }
1736 }
1737 FieldFormat::Enum => {
1738 let raw = coerce_typed_numeric_string(val).unwrap_or_else(|| json_value_to_string(val));
1739 if let Some(params) = params {
1740 if let Some(ref enum_path) = params.enum_path {
1741 if let Some(enum_def) = descriptor.metadata.enums.get(enum_path) {
1742 if let Some(label) = enum_def.get(&raw) {
1743 return Ok(label.clone());
1744 }
1745 }
1746 }
1747 if let Some(ref ref_path) = params.ref_path {
1749 if let Some(enum_name) = ref_path.strip_prefix("$.metadata.enums.") {
1750 if let Some(enum_def) = descriptor.metadata.enums.get(enum_name) {
1751 if let Some(label) = enum_def.get(&raw) {
1752 return Ok(label.clone());
1753 }
1754 }
1755 }
1756 }
1757 }
1758 Ok(raw)
1759 }
1760 FieldFormat::Number => Ok(coerce_unsigned_decimal_string_from_typed_value(val)
1761 .unwrap_or_else(|| json_value_to_string(val))),
1762 FieldFormat::TokenTicker => {
1763 let lookup_chain = resolve_typed_chain_id(params, container, message)?;
1764 let addr =
1765 coerce_typed_address_string(val).unwrap_or_else(|| json_value_to_string(val));
1766 if let Some(meta) = data_provider.resolve_token(lookup_chain, &addr).await {
1767 Ok(meta.symbol)
1768 } else {
1769 warnings.push(render_warning(
1770 RenderDiagnosticKind::TokenTickerNotFound,
1771 "token ticker not found",
1772 ));
1773 Ok(addr)
1774 }
1775 }
1776 FieldFormat::ChainId => {
1777 let cid = parse_typed_u64_value(val, "chainId")?;
1778 Ok(chain_name(cid))
1779 }
1780 FieldFormat::Raw => Ok(json_value_to_string(val)),
1781 FieldFormat::Amount => Ok(coerce_unsigned_decimal_string_from_typed_value(val)
1782 .unwrap_or_else(|| json_value_to_string(val))),
1783 FieldFormat::Duration => {
1784 let secs = parse_typed_u64_value(val, "duration")?;
1785 Ok(format_duration_seconds(secs))
1786 }
1787 FieldFormat::Unit => {
1788 let amount = parse_unsigned_biguint_from_typed_value(val, "unit")?;
1789 Ok(format_unit_biguint(&amount, params))
1790 }
1791 FieldFormat::NftName => {
1792 let token_id = json_value_to_string(val);
1793 let collection_addr = match params {
1794 Some(p) => {
1795 if let Some(ref cpath) = p.collection_path {
1796 match resolve_typed_path_in_context(message, cpath, container)? {
1797 Some(serde_json::Value::String(addr)) => Some(addr),
1798 _ => p.collection.clone(),
1799 }
1800 } else {
1801 p.collection.clone()
1802 }
1803 }
1804 None => None,
1805 };
1806 if let Some(ref addr) = collection_addr {
1807 let chain_id = container.chain_id.ok_or_else(|| {
1808 Error::Descriptor(
1809 "EIP-712 container value @.chainId is required for nftName".to_string(),
1810 )
1811 })?;
1812 if let Some(name) = data_provider
1813 .resolve_nft_collection_name(addr, chain_id)
1814 .await
1815 {
1816 return Ok(format!("{} #{}", name, token_id));
1817 }
1818 }
1819 Ok(format!("#{}", token_id))
1820 }
1821 FieldFormat::Calldata => {
1822 warnings.push(render_warning(
1824 RenderDiagnosticKind::GenericRenderWarning,
1825 "calldata format should be handled separately",
1826 ));
1827 Ok(json_value_to_string(val))
1828 }
1829 }
1830}
1831
1832fn resolve_typed_chain_id(
1833 params: Option<&FormatParams>,
1834 container: TypedContainerContext<'_>,
1835 message: &serde_json::Value,
1836) -> Result<u64, Error> {
1837 if let Some(params) = params {
1838 if let Some(cid) = params.chain_id {
1839 return Ok(cid);
1840 }
1841 if let Some(ref path) = params.chain_id_path {
1842 if let Some(val) = resolve_typed_path_in_context(message, path, container)? {
1843 return parse_typed_u64_value(&val, "chainId");
1844 }
1845 }
1846 }
1847 container.chain_id.ok_or_else(|| {
1848 Error::Descriptor(
1849 "EIP-712 container value @.chainId is required but unavailable".to_string(),
1850 )
1851 })
1852}
1853
1854fn json_value_to_string(val: &serde_json::Value) -> String {
1855 match val {
1856 serde_json::Value::String(s) => s.clone(),
1857 serde_json::Value::Number(n) => n.to_string(),
1858 serde_json::Value::Bool(b) => b.to_string(),
1859 serde_json::Value::Null => "null".to_string(),
1860 other => other.to_string(),
1861 }
1862}
1863
1864async fn interpolate_typed_intent(
1865 template: &str,
1866 descriptor: &Descriptor,
1867 message: &serde_json::Value,
1868 container: TypedContainerContext<'_>,
1869 fields: &[DisplayField],
1870 excluded: &[String],
1871 data_provider: &dyn DataProvider,
1872) -> Result<String, Error> {
1873 const OPEN_SENTINEL: &str = "\x00OPEN_BRACE\x00";
1875 const CLOSE_SENTINEL: &str = "\x00CLOSE_BRACE\x00";
1876 let mut result = template
1877 .replace("{{", OPEN_SENTINEL)
1878 .replace("}}", CLOSE_SENTINEL);
1879
1880 while let Some(start) = result.find("${") {
1882 let end = match result[start..].find('}') {
1883 Some(e) => start + e,
1884 None => break,
1885 };
1886 let path = &result[start + 2..end];
1887 let replacement = resolve_and_format_typed_interpolation(
1888 descriptor,
1889 message,
1890 container,
1891 fields,
1892 path,
1893 excluded,
1894 data_provider,
1895 )
1896 .await?;
1897 result.replace_range(start..=end, &replacement);
1898 }
1899
1900 let mut pos = 0;
1902 while pos < result.len() {
1903 if let Some(rel_start) = result[pos..].find('{') {
1904 let start = pos + rel_start;
1905 if start > 0 && result.as_bytes()[start - 1] == b'$' {
1906 pos = start + 1;
1907 continue;
1908 }
1909 let end = match result[start..].find('}') {
1910 Some(e) => start + e,
1911 None => break,
1912 };
1913 let path = result[start + 1..end].to_string();
1914 let replacement = resolve_and_format_typed_interpolation(
1915 descriptor,
1916 message,
1917 container,
1918 fields,
1919 &path,
1920 excluded,
1921 data_provider,
1922 )
1923 .await?;
1924 result.replace_range(start..=end, &replacement);
1925 pos = start + replacement.len();
1926 } else {
1927 break;
1928 }
1929 }
1930
1931 let result = result
1933 .replace(OPEN_SENTINEL, "{")
1934 .replace(CLOSE_SENTINEL, "}");
1935
1936 Ok(result)
1937}
1938
1939async fn resolve_and_format_typed_interpolation(
1940 descriptor: &Descriptor,
1941 message: &serde_json::Value,
1942 container: TypedContainerContext<'_>,
1943 fields: &[DisplayField],
1944 path: &str,
1945 excluded: &[String],
1946 data_provider: &dyn DataProvider,
1947) -> Result<String, Error> {
1948 let field = resolve_interpolation_field_spec(fields, excluded, path)?;
1949
1950 let value =
1951 resolve_typed_path_in_context(message, field.path, container)?.ok_or_else(|| {
1952 Error::Descriptor(format!(
1953 "interpolatedIntent path '{}' could not be resolved from typed data",
1954 path
1955 ))
1956 })?;
1957
1958 let mut warnings = Vec::new();
1959 format_typed_value(
1960 descriptor,
1961 &Some(value),
1962 field.format,
1963 field.params,
1964 container,
1965 message,
1966 None,
1967 data_provider,
1968 &mut warnings,
1969 )
1970 .await
1971}
1972
1973#[cfg(test)]
1974mod tests {
1975 use super::*;
1976 use crate::token::{StaticTokenSource, TokenMeta};
1977
1978 #[test]
1979 fn test_resolve_typed_path() {
1980 let message = serde_json::json!({
1981 "recipient": "0xabc",
1982 "details": {
1983 "amount": "1000",
1984 "token": "0xdef"
1985 }
1986 });
1987
1988 assert_eq!(
1989 resolve_typed_message_path(&message, "recipient"),
1990 Some(serde_json::json!("0xabc"))
1991 );
1992 assert_eq!(
1993 resolve_typed_message_path(&message, "details.amount"),
1994 Some(serde_json::json!("1000"))
1995 );
1996 assert_eq!(resolve_typed_message_path(&message, "nonexistent"), None);
1997 }
1998
1999 #[test]
2000 fn test_resolve_typed_path_hex_slices() {
2001 let message = serde_json::json!({
2002 "hookData": "0x636374702d666f72776172640000000000000000000000000000000000000018f0a063a21be62b709937ca2a808594b662fe41e600000000",
2003 "packed": "0x000000000000000000000000b21d281dedb17ae5b501f6aa8256fe38c4e45757"
2004 });
2005
2006 assert_eq!(
2007 resolve_typed_message_path(&message, "hookData.[32:52]"),
2008 Some(serde_json::json!(
2009 "0xf0a063a21be62b709937ca2a808594b662fe41e6"
2010 ))
2011 );
2012 assert_eq!(
2013 resolve_typed_message_path(&message, "hookData.[52:53]"),
2014 Some(serde_json::json!("0x00"))
2015 );
2016 assert_eq!(
2017 resolve_typed_message_path(&message, "packed.[-20:]"),
2018 Some(serde_json::json!(
2019 "0xb21d281dedb17ae5b501f6aa8256fe38c4e45757"
2020 ))
2021 );
2022 }
2023
2024 #[test]
2025 fn test_resolve_typed_path_container_values() {
2026 let message = serde_json::json!({
2027 "to": "0xa95d9c1f655341597c94393fddc30cf3c08e4fce"
2028 });
2029
2030 assert_eq!(
2031 resolve_typed_path(
2032 &message,
2033 "to",
2034 42161,
2035 Some("0xaf88d065e77c8cc2239327c5edb3a432268e5831"),
2036 ),
2037 Some(serde_json::json!(
2038 "0xa95d9c1f655341597c94393fddc30cf3c08e4fce"
2039 ))
2040 );
2041 assert_eq!(
2042 resolve_typed_path(
2043 &message,
2044 "@.to",
2045 42161,
2046 Some("0xaf88d065e77c8cc2239327c5edb3a432268e5831"),
2047 ),
2048 Some(serde_json::json!(
2049 "0xaf88d065e77c8cc2239327c5edb3a432268e5831"
2050 ))
2051 );
2052 assert_eq!(
2053 resolve_typed_path(
2054 &message,
2055 "@.chainId",
2056 42161,
2057 Some("0xaf88d065e77c8cc2239327c5edb3a432268e5831"),
2058 ),
2059 Some(serde_json::json!(42161))
2060 );
2061 assert_eq!(
2062 resolve_typed_path(
2063 &message,
2064 "@.value",
2065 42161,
2066 Some("0xaf88d065e77c8cc2239327c5edb3a432268e5831"),
2067 ),
2068 Some(serde_json::json!(0))
2069 );
2070 assert_eq!(
2071 resolve_typed_path(
2072 &message,
2073 "@.from",
2074 42161,
2075 Some("0xaf88d065e77c8cc2239327c5edb3a432268e5831"),
2076 ),
2077 None
2078 );
2079 assert_eq!(
2080 resolve_typed_path(
2081 &message,
2082 "@.verifyingContract",
2083 42161,
2084 Some("0xaf88d065e77c8cc2239327c5edb3a432268e5831"),
2085 ),
2086 None
2087 );
2088 }
2089
2090 #[test]
2091 fn test_json_value_to_string() {
2092 assert_eq!(json_value_to_string(&serde_json::json!("hello")), "hello");
2093 assert_eq!(json_value_to_string(&serde_json::json!(42)), "42");
2094 assert_eq!(json_value_to_string(&serde_json::json!(true)), "true");
2095 }
2096
2097 #[tokio::test]
2098 async fn test_typed_byte_slice_formatters() {
2099 let descriptor_json = r#"{
2100 "context": {
2101 "eip712": {
2102 "deployments": [{"chainId": 1, "address": "0xabc"}]
2103 }
2104 },
2105 "metadata": {
2106 "owner": "test",
2107 "enums": { "dex": { "0": "Perp" } },
2108 "constants": {},
2109 "maps": {}
2110 },
2111 "display": {
2112 "definitions": {},
2113 "formats": {
2114 "SliceTest(bytes hookData,bytes32 tokenWord,uint256 amount)": {
2115 "intent": "Slice test",
2116 "fields": [
2117 {"path": "hookData.[32:52]", "label": "Recipient", "format": "addressName"},
2118 {"path": "hookData.[52:53]", "label": "Dex", "format": "enum", "params": {"$ref": "$.metadata.enums.dex"}},
2119 {"path": "amount", "label": "Amount", "format": "tokenAmount", "params": {"tokenPath": "tokenWord.[-20:]"}}
2120 ]
2121 }
2122 }
2123 }
2124 }"#;
2125
2126 let typed_data: TypedData = serde_json::from_value(serde_json::json!({
2127 "types": {
2128 "EIP712Domain": [],
2129 "SliceTest": [
2130 {"name": "hookData", "type": "bytes"},
2131 {"name": "tokenWord", "type": "bytes32"},
2132 {"name": "amount", "type": "uint256"}
2133 ]
2134 },
2135 "primaryType": "SliceTest",
2136 "domain": {"chainId": 1, "verifyingContract": "0xabc"},
2137 "message": {
2138 "hookData": "0x636374702d666f72776172640000000000000000000000000000000000000018f0a063a21be62b709937ca2a808594b662fe41e600000000",
2139 "tokenWord": "0x000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48",
2140 "amount": "1500000"
2141 }
2142 }))
2143 .unwrap();
2144
2145 let descriptor = Descriptor::from_json(descriptor_json).unwrap();
2146 let mut tokens = StaticTokenSource::new();
2147 tokens.insert(
2148 1,
2149 "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48",
2150 TokenMeta {
2151 symbol: "USDC".to_string(),
2152 decimals: 6,
2153 name: "USD Coin".to_string(),
2154 },
2155 );
2156
2157 let result = format_typed_data(&descriptor, &typed_data, &tokens, &[])
2158 .await
2159 .unwrap();
2160
2161 match &result.entries[0] {
2162 DisplayEntry::Item(item) => {
2163 assert_eq!(item.label, "Recipient");
2164 assert_eq!(item.value, "0xf0a063a21be62b709937ca2a808594b662fe41e6");
2165 }
2166 _ => panic!("expected Item"),
2167 }
2168 match &result.entries[1] {
2169 DisplayEntry::Item(item) => assert_eq!(item.value, "Perp"),
2170 _ => panic!("expected Item"),
2171 }
2172 match &result.entries[2] {
2173 DisplayEntry::Item(item) => assert_eq!(item.value, "1.5 USDC"),
2174 _ => panic!("expected Item"),
2175 }
2176 }
2177
2178 #[tokio::test]
2179 async fn test_typed_byte_slice_numeric_formatters_match_calldata_semantics() {
2180 let descriptor_json = r#"{
2181 "context": {
2182 "eip712": {
2183 "deployments": [{"chainId": 1, "address": "0xabc"}]
2184 }
2185 },
2186 "metadata": {
2187 "owner": "test",
2188 "enums": {},
2189 "constants": {},
2190 "maps": {}
2191 },
2192 "display": {
2193 "definitions": {},
2194 "formats": {
2195 "SliceTest(bytes32 tokenWord,bytes32 amountWord)": {
2196 "intent": "Slice test",
2197 "fields": [
2198 {"path": "amountWord.[-2:]", "label": "Token Amount", "format": "tokenAmount", "params": {"tokenPath": "tokenWord.[-20:]"}},
2199 {"path": "amountWord.[-2:]", "label": "Number", "format": "number"},
2200 {"path": "amountWord.[-2:]", "label": "Amount", "format": "amount"},
2201 {"path": "amountWord.[-2:]", "label": "Unit", "format": "unit", "params": {"base": "bps"}}
2202 ]
2203 }
2204 }
2205 }
2206 }"#;
2207
2208 let typed_data: TypedData = serde_json::from_value(serde_json::json!({
2209 "types": {
2210 "EIP712Domain": [],
2211 "SliceTest": [
2212 {"name": "tokenWord", "type": "bytes32"},
2213 {"name": "amountWord", "type": "bytes32"}
2214 ]
2215 },
2216 "primaryType": "SliceTest",
2217 "domain": {"chainId": 1, "verifyingContract": "0xabc"},
2218 "message": {
2219 "tokenWord": "0x000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48",
2220 "amountWord": "0x00000000000000000000000000000000000000000000000000000000000001f4"
2221 }
2222 }))
2223 .unwrap();
2224
2225 let descriptor = Descriptor::from_json(descriptor_json).unwrap();
2226 let mut tokens = StaticTokenSource::new();
2227 tokens.insert(
2228 1,
2229 "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48",
2230 TokenMeta {
2231 symbol: "USDC".to_string(),
2232 decimals: 6,
2233 name: "USD Coin".to_string(),
2234 },
2235 );
2236
2237 let result = format_typed_data(&descriptor, &typed_data, &tokens, &[])
2238 .await
2239 .unwrap();
2240
2241 match &result.entries[0] {
2242 DisplayEntry::Item(item) => assert_eq!(item.value, "0.0005 USDC"),
2243 _ => panic!("expected Item"),
2244 }
2245 match &result.entries[1] {
2246 DisplayEntry::Item(item) => assert_eq!(item.value, "500"),
2247 _ => panic!("expected Item"),
2248 }
2249 match &result.entries[2] {
2250 DisplayEntry::Item(item) => assert_eq!(item.value, "500"),
2251 _ => panic!("expected Item"),
2252 }
2253 match &result.entries[3] {
2254 DisplayEntry::Item(item) => assert_eq!(item.value, "500bps"),
2255 _ => panic!("expected Item"),
2256 }
2257 }
2258
2259 #[tokio::test]
2260 async fn test_receive_with_authorization_token_amount_uses_container_to() {
2261 let descriptor_json = r#"{
2262 "$schema": "../../specs/erc7730-v1.schema.json",
2263 "context": {
2264 "eip712": {
2265 "domain": { "name": "USD Coin", "version": "2" },
2266 "deployments": [
2267 { "chainId": 42161, "address": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831" }
2268 ],
2269 "schemas": [
2270 {
2271 "primaryType": "ReceiveWithAuthorization",
2272 "types": {
2273 "EIP712Domain": [
2274 { "name": "name", "type": "string" },
2275 { "name": "version", "type": "string" },
2276 { "name": "chainId", "type": "uint256" },
2277 { "name": "verifyingContract", "type": "address" }
2278 ],
2279 "ReceiveWithAuthorization": [
2280 { "name": "from", "type": "address" },
2281 { "name": "to", "type": "address" },
2282 { "name": "value", "type": "uint256" },
2283 { "name": "validAfter", "type": "uint256" },
2284 { "name": "validBefore", "type": "uint256" },
2285 { "name": "nonce", "type": "bytes32" }
2286 ]
2287 }
2288 }
2289 ]
2290 }
2291 },
2292 "metadata": {
2293 "owner": "Circle",
2294 "info": { "legalName": "Circle Internet Financial", "url": "https://www.circle.com/" },
2295 "enums": {},
2296 "constants": {},
2297 "maps": {}
2298 },
2299 "display": {
2300 "formats": {
2301 "ReceiveWithAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 nonce)": {
2302 "intent": "Authorize USDC transfer",
2303 "interpolatedIntent": "Authorize on {@.chainId}",
2304 "fields": [
2305 { "path": "from", "label": "From", "format": "addressName", "params": { "types": ["wallet"], "sources": ["local", "ens"] } },
2306 { "path": "to", "label": "To", "format": "addressName", "params": { "types": ["eoa", "contract"], "sources": ["local", "ens"] } },
2307 { "path": "value", "label": "Amount", "format": "tokenAmount", "params": { "tokenPath": "@.to" } },
2308 { "path": "@.chainId", "label": "Chain ID", "visible": "never" },
2309 { "path": "validAfter", "label": "Valid after", "format": "date", "params": { "encoding": "timestamp" } },
2310 { "path": "validBefore", "label": "Valid before", "format": "date", "params": { "encoding": "timestamp" } }
2311 ],
2312 "required": ["from", "to", "value"],
2313 "excluded": ["nonce"]
2314 }
2315 }
2316 }
2317 }"#;
2318
2319 let typed_data: TypedData = serde_json::from_value(serde_json::json!({
2320 "domain": {
2321 "name": "USD Coin",
2322 "version": "2",
2323 "chainId": 42161,
2324 "verifyingContract": "0xaf88d065e77c8cc2239327c5edb3a432268e5831"
2325 },
2326 "message": {
2327 "from": "0xbf01daf454dce008d3e2bfd47d5e186f71477253",
2328 "to": "0xa95d9c1f655341597c94393fddc30cf3c08e4fce",
2329 "value": "6050000",
2330 "validAfter": 1774534342,
2331 "validBefore": 1774538002,
2332 "nonce": "0x9048fcc0671336730dda26a2a19a8ccdb2a6b7da00eeae556dd7f10c8a8d3a16"
2333 },
2334 "primaryType": "ReceiveWithAuthorization",
2335 "types": {
2336 "EIP712Domain": [
2337 { "name": "name", "type": "string" },
2338 { "name": "version", "type": "string" },
2339 { "name": "chainId", "type": "uint256" },
2340 { "name": "verifyingContract", "type": "address" }
2341 ],
2342 "ReceiveWithAuthorization": [
2343 { "name": "from", "type": "address" },
2344 { "name": "to", "type": "address" },
2345 { "name": "value", "type": "uint256" },
2346 { "name": "validAfter", "type": "uint256" },
2347 { "name": "validBefore", "type": "uint256" },
2348 { "name": "nonce", "type": "bytes32" }
2349 ]
2350 }
2351 }))
2352 .unwrap();
2353
2354 let descriptor = Descriptor::from_json(descriptor_json).unwrap();
2355 let mut tokens = StaticTokenSource::new();
2356 tokens.insert(
2357 42161,
2358 "0xaf88d065e77c8cc2239327c5edb3a432268e5831",
2359 TokenMeta {
2360 symbol: "USDC".to_string(),
2361 decimals: 6,
2362 name: "USD Coin".to_string(),
2363 },
2364 );
2365
2366 let result = format_typed_data(&descriptor, &typed_data, &tokens, &[])
2367 .await
2368 .unwrap();
2369
2370 assert_eq!(result.intent, "Authorize USDC transfer");
2371 assert_eq!(
2372 result.interpolated_intent.as_deref(),
2373 Some("Authorize on 42161")
2374 );
2375 match &result.entries[2] {
2376 DisplayEntry::Item(item) => assert_eq!(item.value, "6.05 USDC"),
2377 _ => panic!("expected Item"),
2378 }
2379 }
2380
2381 #[tokio::test]
2382 async fn test_receive_with_authorization_token_amount_graceful_degrades_without_metadata() {
2383 let typed_data: TypedData = serde_json::from_value(serde_json::json!({
2384 "domain": {
2385 "chainId": 42161,
2386 "verifyingContract": "0xaf88d065e77c8cc2239327c5edb3a432268e5831"
2387 },
2388 "message": {
2389 "value": "6050000"
2390 },
2391 "primaryType": "ReceiveWithAuthorization",
2392 "types": {
2393 "EIP712Domain": [],
2394 "ReceiveWithAuthorization": [
2395 { "name": "value", "type": "uint256" }
2396 ]
2397 }
2398 }))
2399 .unwrap();
2400
2401 let provider = crate::provider::EmptyDataProvider;
2402 let result = crate::format_typed_data(&[], &typed_data, &provider)
2403 .await
2404 .unwrap();
2405
2406 match &result.entries[0] {
2407 DisplayEntry::Item(item) => assert_eq!(item.value, "6050000"),
2408 _ => panic!("expected Item"),
2409 }
2410 }
2411
2412 #[tokio::test]
2413 async fn test_permit_graceful_fallback() {
2414 let typed_data_json = r#"{
2416 "types": {
2417 "EIP712Domain": [
2418 {"name":"name","type":"string"},
2419 {"name":"version","type":"string"},
2420 {"name":"chainId","type":"uint256"},
2421 {"name":"verifyingContract","type":"address"}
2422 ],
2423 "Permit": [
2424 {"name":"owner","type":"address"},
2425 {"name":"spender","type":"address"},
2426 {"name":"value","type":"uint256"},
2427 {"name":"nonce","type":"uint256"},
2428 {"name":"deadline","type":"uint256"}
2429 ]
2430 },
2431 "primaryType": "Permit",
2432 "domain": {
2433 "name": "USD Coin",
2434 "version": "2",
2435 "chainId": 1,
2436 "verifyingContract": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48"
2437 },
2438 "message": {
2439 "owner": "0xbf01daf454dce008d3e2bfd47d5e186f71477253",
2440 "spender": "0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2",
2441 "value": "100000",
2442 "nonce": 0,
2443 "deadline": "1773156895"
2444 }
2445 }"#;
2446
2447 let typed_data: TypedData = serde_json::from_str(typed_data_json).unwrap();
2448
2449 let provider = crate::provider::EmptyDataProvider;
2450
2451 let result = crate::format_typed_data(&[], &typed_data, &provider)
2452 .await
2453 .unwrap();
2454
2455 assert_eq!(result.intent, "Permit");
2456 assert_eq!(
2457 result.fallback_reason(),
2458 Some(&crate::FallbackReason::DescriptorNotFound)
2459 );
2460 assert!(
2461 result.diagnostics().iter().any(|diagnostic| diagnostic
2462 .message
2463 .contains("no typed-data descriptor matched")),
2464 "expected descriptor-not-found diagnostic"
2465 );
2466
2467 assert_eq!(result.entries.len(), 5);
2469
2470 if let DisplayEntry::Item(ref item) = result.entries[0] {
2471 assert_eq!(item.label, "owner");
2472 assert_eq!(item.value, "0xbf01daf454dce008d3e2bfd47d5e186f71477253");
2473 } else {
2474 panic!("expected Item");
2475 }
2476
2477 if let DisplayEntry::Item(ref item) = result.entries[1] {
2478 assert_eq!(item.label, "spender");
2479 assert_eq!(item.value, "0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2");
2480 } else {
2481 panic!("expected Item");
2482 }
2483
2484 if let DisplayEntry::Item(ref item) = result.entries[2] {
2485 assert_eq!(item.label, "value");
2486 assert_eq!(item.value, "100000");
2487 } else {
2488 panic!("expected Item");
2489 }
2490
2491 if let DisplayEntry::Item(ref item) = result.entries[4] {
2492 assert_eq!(item.label, "deadline");
2493 assert_eq!(item.value, "1773156895");
2494 } else {
2495 panic!("expected Item");
2496 }
2497 }
2498}