Skip to main content

clear_signing/
engine.rs

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