Skip to main content

clear_signing/
eip712.rs

1//! EIP-712 typed data formatting — parses structured typed data and produces
2//! a [`DisplayModel`](crate::engine::DisplayModel) using the same descriptor format as calldata.
3
4use 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
34/// Maximum recursion depth for nested calldata in EIP-712 context.
35const MAX_CALLDATA_DEPTH: u8 = 3;
36
37type RenderDiagnostics = Vec<FormatDiagnostic>;
38
39/// EIP-712 typed data as received for signing.
40#[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
113/// Deserialize chainId that may be a number or a hex string (e.g. "0xa" for 10).
114fn 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
163/// Format EIP-712 typed data into a display model.
164///
165/// `descriptors` provides pre-resolved inner descriptors for nested calldata support.
166pub 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/// Render typed data fields recursively.
293///
294/// Uses `Pin<Box<dyn Future>>` to support recursive calls.
295#[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 literal value is provided (no path), resolve constant refs and use it
339                    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                    // Check for .[] array iteration — expand into one entry per element
354                    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                    // Check visibility
391                    if !check_typed_visibility(visible, &value, label, path_str)? {
392                        continue;
393                    }
394
395                    // Intercept calldata format
396                    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/// Render a nested calldata field within EIP-712 typed data.
773///
774/// The `#.` path prefix resolves from message fields (EIP-712 specific).
775#[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    // Extract hex bytes from JSON value
790    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    // Check depth limit
837    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    // Find matching signature + decode
913    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    // Use engine's format pipeline for the inner call
947    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
972/// Build a raw fallback DisplayModel for EIP-712 typed data when no format matches.
973pub(crate) fn build_typed_raw_fallback(data: &TypedData) -> DisplayModel {
974    let mut entries = Vec::new();
975
976    // Use the primary type's field definitions to order entries if available
977    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        // Fallback: iterate message keys
991        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
1137/// Resolve a path in EIP-712 message JSON (e.g., "recipient" or "details.amount").
1138///
1139/// Supports `[index]` and `[start:end]` slice notation.
1140fn 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        // Handle array index: "items[0]" or "items[0:3]"
1148        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(&current, 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    // Check encryption fallback
1583    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    // Map reference
1592    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            // Check senderAddress
1613            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            // Determine allowed sources
1642            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                // $ref path (v2): "$.metadata.enums.interestRateMode"
1748                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            // Should not reach here — calldata is intercepted in render_typed_fields
1823            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    // Pre-process: replace {{ and }} with sentinels
1874    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    // First pass: replace ${path} patterns (v1)
1881    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    // Second pass: replace {name} patterns (v2)
1901    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    // Post-process: restore escaped braces
1932    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        // Real USDC Permit typed data from wallet — no descriptor format for "Permit"
2415        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        // Should have all 5 fields from the Permit type, in order
2468        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}