Skip to main content

clear_signing/
lib.rs

1//! ERC-7730 v2 clear signing library — decodes and formats contract calldata
2//! and EIP-712 typed data for human-readable display using JSON descriptors.
3//!
4//! Entry points: [`format_calldata()`], [`format_typed_data()`].
5
6#[cfg(feature = "uniffi")]
7uniffi::setup_scaffolding!();
8
9pub mod decoder;
10pub mod eip712;
11mod eip712_domain;
12pub mod engine;
13pub mod error;
14pub mod merge;
15pub mod outcome;
16mod path;
17pub mod provider;
18mod render_shared;
19pub mod resolver;
20pub mod token;
21pub mod types;
22#[cfg(feature = "uniffi")]
23pub mod uniffi_compat;
24
25use error::Error;
26use outcome::RenderState;
27
28// Re-exports for convenience
29pub use engine::{DisplayEntry, DisplayItem, DisplayModel};
30pub use error::FormatFailure;
31pub use merge::merge_descriptors;
32pub use outcome::{
33    DescriptorResolutionOutcome, DiagnosticSeverity, FallbackReason, FormatDiagnostic,
34    FormatOutcome, ResolvedDescriptorResolution,
35};
36pub use provider::{DataProvider, EmptyDataProvider};
37#[cfg(feature = "github-registry")]
38pub use resolver::resolve_descriptors_for_typed_data;
39pub use resolver::{
40    resolve_descriptors_for_tx, DescriptorSource, ResolvedDescriptor, TypedDescriptorLookup,
41};
42pub use token::{CompositeDataProvider, TokenMeta, WellKnownTokenSource};
43pub use types::descriptor::Descriptor;
44
45/// Transaction context for calldata formatting.
46pub struct TransactionContext<'a> {
47    pub chain_id: u64,
48    pub to: &'a str,
49    pub calldata: &'a [u8],
50    pub value: Option<&'a [u8]>,
51    pub from: Option<&'a str>,
52    /// For proxy contracts: the implementation address to match descriptors against.
53    /// When set, descriptor matching uses this instead of `to`.
54    /// Container value `@.to` still uses `to` (the proxy address the user interacts with).
55    pub implementation_address: Option<&'a str>,
56}
57
58/// Format contract calldata for clear signing display.
59///
60/// This is the main entry point for calldata clear signing.
61/// Takes a slice of pre-resolved descriptors. The outer descriptor is found by
62/// matching `chain_id + tx.to`. Remaining descriptors are available for nested calldata.
63/// Single-element slice = simple case, multi-element = nesting.
64pub async fn format_calldata(
65    descriptors: &[ResolvedDescriptor],
66    tx: &TransactionContext<'_>,
67    data_provider: &dyn DataProvider,
68) -> Result<FormatOutcome, FormatFailure> {
69    if tx.calldata.len() < 4 {
70        return Err(FormatFailure::InvalidInput {
71            message: error::DecodeError::CalldataTooShort {
72                expected: 4,
73                actual: tx.calldata.len(),
74            }
75            .to_string(),
76            retryable: false,
77        });
78    }
79
80    // Find the outer descriptor matching chain_id + address.
81    // For proxy contracts, match against implementation_address instead of to.
82    let match_address = tx.implementation_address.unwrap_or(tx.to);
83    let outer_idx = descriptors.iter().position(|rd| {
84        rd.descriptor.context.deployments().iter().any(|dep| {
85            dep.chain_id == tx.chain_id
86                && dep.address.to_lowercase() == match_address.to_lowercase()
87        })
88    });
89
90    let outer_idx = match outer_idx {
91        Some(idx) => idx,
92        None => {
93            if descriptors.is_empty() {
94                return Ok(fallback_outcome(
95                    build_raw_fallback(tx.calldata),
96                    FallbackReason::DescriptorNotFound,
97                    "descriptor_not_found",
98                    format!(
99                        "no descriptor matched chain_id={} address={}",
100                        tx.chain_id, match_address
101                    ),
102                ));
103            }
104            return Err(FormatFailure::InvalidDescriptor {
105                message: format!(
106                    "no outer descriptor matches chain_id={} address={}",
107                    tx.chain_id, match_address
108                ),
109                retryable: false,
110            });
111        }
112    };
113
114    let outer_descriptor = &descriptors[outer_idx].descriptor;
115    let actual_selector = &tx.calldata[..4];
116
117    // Find matching format key and parse its signature
118    let (sig, _format_key) = match find_matching_signature(outer_descriptor, actual_selector) {
119        Ok(result) => result,
120        Err(_) => {
121            // Graceful fallback: return raw preview for unknown selectors
122            return Ok(fallback_outcome(
123                build_raw_fallback(tx.calldata),
124                FallbackReason::FormatNotFound,
125                "format_not_found",
126                format!(
127                    "no descriptor format matched selector 0x{}",
128                    hex::encode(actual_selector)
129                ),
130            ));
131        }
132    };
133
134    // Decode calldata using the parsed signature
135    let mut decoded =
136        decoder::decode_calldata(&sig, tx.calldata).map_err(|err| FormatFailure::InvalidInput {
137            message: err.to_string(),
138            retryable: false,
139        })?;
140
141    // Inject container values as synthetic arguments
142    inject_container_values(&mut decoded, tx.chain_id, tx.to, tx.value, tx.from);
143
144    // Render the display model
145    let mut state = RenderState::default();
146    let model = engine::format_calldata(
147        outer_descriptor,
148        tx.chain_id,
149        tx.to,
150        &decoded,
151        tx.value,
152        data_provider,
153        descriptors,
154        &mut state,
155    )
156    .await
157    .map_err(FormatFailure::from)?;
158
159    Ok(state.outcome(model, None))
160}
161
162/// Inject EIP-7730 container values (@.value, @.to, @.chainId, @.from) as synthetic arguments.
163pub(crate) fn inject_container_values(
164    decoded: &mut decoder::DecodedArguments,
165    chain_id: u64,
166    to: &str,
167    value: Option<&[u8]>,
168    from: Option<&str>,
169) {
170    // @.value — transaction ETH value
171    if let Some(val_bytes) = value {
172        let mut padded = vec![0u8; 32usize.saturating_sub(val_bytes.len())];
173        padded.extend_from_slice(val_bytes);
174        decoded.args.push(decoder::DecodedArgument {
175            index: decoded.args.len(),
176            name: Some("value".into()),
177            param_type: decoder::ParamType::Uint(256),
178            value: decoder::ArgumentValue::Uint(padded),
179        });
180    }
181
182    // @.to — target contract address
183    if let Some(addr) = parse_address_bytes(to) {
184        decoded.args.push(decoder::DecodedArgument {
185            index: decoded.args.len(),
186            name: Some("to".into()),
187            param_type: decoder::ParamType::Address,
188            value: decoder::ArgumentValue::Address(addr),
189        });
190    }
191
192    // @.chainId
193    let chain_bytes = {
194        let mut buf = [0u8; 32];
195        buf[24..32].copy_from_slice(&chain_id.to_be_bytes());
196        buf.to_vec()
197    };
198    decoded.args.push(decoder::DecodedArgument {
199        index: decoded.args.len(),
200        name: Some("chainId".into()),
201        param_type: decoder::ParamType::Uint(256),
202        value: decoder::ArgumentValue::Uint(chain_bytes),
203    });
204
205    // @.from — sender address (if provided)
206    if let Some(from_addr) = from {
207        if let Some(addr) = parse_address_bytes(from_addr) {
208            decoded.args.push(decoder::DecodedArgument {
209                index: decoded.args.len(),
210                name: Some("from".into()),
211                param_type: decoder::ParamType::Address,
212                value: decoder::ArgumentValue::Address(addr),
213            });
214        }
215    }
216}
217
218pub(crate) fn parse_address_bytes(addr: &str) -> Option<[u8; 20]> {
219    let hex_str = addr
220        .strip_prefix("0x")
221        .or_else(|| addr.strip_prefix("0X"))
222        .unwrap_or(addr);
223    let bytes = hex::decode(hex_str).ok()?;
224    if bytes.len() != 20 {
225        return None;
226    }
227    let mut result = [0u8; 20];
228    result.copy_from_slice(&bytes);
229    Some(result)
230}
231
232/// Build a raw fallback DisplayModel for unknown selectors (graceful degradation).
233pub(crate) fn build_raw_fallback(calldata: &[u8]) -> DisplayModel {
234    let selector = if calldata.len() >= 4 {
235        format!("0x{}", hex::encode(&calldata[..4]))
236    } else {
237        format!("0x{}", hex::encode(calldata))
238    };
239
240    let mut entries = Vec::new();
241    let data = if calldata.len() > 4 {
242        &calldata[4..]
243    } else {
244        &[]
245    };
246
247    // Split into 32-byte words
248    for (i, chunk) in data.chunks(32).enumerate() {
249        entries.push(DisplayEntry::Item(DisplayItem {
250            label: format!("Param {}", i),
251            value: format!("0x{}", hex::encode(chunk)),
252        }));
253    }
254
255    DisplayModel {
256        intent: format!("Unknown function {}", selector),
257        interpolated_intent: None,
258        entries,
259        owner: None,
260        contract_name: None,
261    }
262}
263
264/// Format EIP-712 typed data for clear signing display.
265///
266/// Takes a slice of pre-resolved descriptors. The outer descriptor is found by
267/// matching `chain_id + verifying_contract`. Remaining descriptors are available
268/// for nested calldata. Single-element slice = simple case, multi-element = nesting.
269pub async fn format_typed_data(
270    descriptors: &[ResolvedDescriptor],
271    data: &eip712::TypedData,
272    data_provider: &dyn DataProvider,
273) -> Result<FormatOutcome, FormatFailure> {
274    if descriptors.is_empty() {
275        return Ok(fallback_outcome(
276            eip712::build_typed_raw_fallback(data),
277            FallbackReason::DescriptorNotFound,
278            "descriptor_not_found",
279            "no typed-data descriptor matched the verifying contract".to_string(),
280        ));
281    }
282
283    let chain_id = match data.domain.chain_id {
284        Some(chain_id) => chain_id,
285        None => {
286            return Ok(fallback_outcome(
287                eip712::build_typed_raw_fallback(data),
288                FallbackReason::InsufficientContext,
289                "insufficient_context",
290                "EIP-712 domain.chainId is required for descriptor-based clear signing".to_string(),
291            ));
292        }
293    };
294    let verifying_contract = match data.domain.verifying_contract.as_deref() {
295        Some(verifying_contract) => verifying_contract,
296        None => {
297            return Ok(fallback_outcome(
298                eip712::build_typed_raw_fallback(data),
299                FallbackReason::InsufficientContext,
300                "insufficient_context",
301                "EIP-712 domain.verifyingContract is required for descriptor-based clear signing"
302                    .to_string(),
303            ));
304        }
305    };
306
307    let selection =
308        resolver::select_typed_outer_descriptor(descriptors, data).map_err(FormatFailure::from)?;
309
310    let selected = match selection {
311        resolver::TypedOuterSelection::Selected(selected) => selected,
312        resolver::TypedOuterSelection::NoMatch(no_match) => {
313            if no_match.domain_errors.is_empty() && no_match.format_misses.len() == 1 {
314                return Ok(fallback_outcome(
315                    eip712::build_typed_raw_fallback(data),
316                    FallbackReason::FormatNotFound,
317                    "format_not_found",
318                    format!(
319                        "no descriptor format matched primaryType={} encodeType for verifying_contract={}",
320                        data.primary_type, verifying_contract
321                    ),
322                ));
323            }
324            let mut message = format!(
325                "no EIP-712 descriptor found for chain_id={} verifying_contract={} after domain and encodeType validation",
326                chain_id, verifying_contract
327            );
328            if !no_match.domain_errors.is_empty() {
329                message.push_str(": ");
330                message.push_str(&no_match.domain_errors.join("; "));
331            } else if !no_match.format_misses.is_empty() {
332                return Ok(fallback_outcome(
333                    eip712::build_typed_raw_fallback(data),
334                    FallbackReason::FormatNotFound,
335                    "format_not_found",
336                    "no descriptor matched the typed-data encodeType".to_string(),
337                ));
338            }
339            return Err(FormatFailure::InvalidDescriptor {
340                message,
341                retryable: false,
342            });
343        }
344    };
345
346    let mut state = RenderState::default();
347    let model = eip712::format_typed_data_with_format(
348        &selected.outer.descriptor,
349        data,
350        selected.format,
351        data_provider,
352        descriptors,
353        &mut state,
354    )
355    .await
356    .map_err(FormatFailure::from)?;
357
358    Ok(state.outcome(model, None))
359}
360
361fn fallback_outcome(
362    model: DisplayModel,
363    reason: FallbackReason,
364    code: &str,
365    message: String,
366) -> FormatOutcome {
367    let mut state = RenderState::default();
368    state.warn(code, message);
369    state.outcome(model, Some(reason))
370}
371
372/// Find a format key whose signature matches the calldata selector.
373pub(crate) fn find_matching_signature(
374    descriptor: &Descriptor,
375    actual_selector: &[u8],
376) -> Result<(decoder::FunctionSignature, String), Error> {
377    for key in descriptor.display.formats.keys() {
378        if key.contains('(') {
379            match decoder::parse_signature(key) {
380                Ok(sig) => {
381                    if sig.selector[..] == actual_selector[..4] {
382                        return Ok((sig, key.clone()));
383                    }
384                }
385                Err(_) => continue,
386            }
387        }
388    }
389
390    Err(Error::Render(format!(
391        "no matching format key for selector 0x{}",
392        hex::encode(&actual_selector[..4])
393    )))
394}
395
396#[cfg(test)]
397mod tests {
398    use super::*;
399    use crate::provider::EmptyDataProvider;
400    use crate::token::StaticTokenSource;
401
402    fn wrap_rd(descriptor: Descriptor, chain_id: u64, address: &str) -> Vec<ResolvedDescriptor> {
403        vec![ResolvedDescriptor {
404            descriptor,
405            chain_id,
406            address: address.to_lowercase(),
407        }]
408    }
409
410    fn test_descriptor_json() -> &'static str {
411        r#"{
412            "context": {
413                "contract": {
414                    "deployments": [
415                        { "chainId": 1, "address": "0xdac17f958d2ee523a2206206994597c13d831ec7" }
416                    ]
417                }
418            },
419            "metadata": {
420                "owner": "test",
421                "contractName": "Tether USD",
422                "enums": {},
423                "constants": {},
424                "addressBook": {},
425                "maps": {}
426            },
427            "display": {
428                "definitions": {},
429                "formats": {
430                    "transfer(address,uint256)": {
431                        "intent": "Transfer tokens",
432                        "fields": [
433                            {
434                                "path": "@.0",
435                                "label": "To",
436                                "format": "address"
437                            },
438                            {
439                                "path": "@.1",
440                                "label": "Amount",
441                                "format": "number"
442                            }
443                        ]
444                    }
445                }
446            }
447        }"#
448    }
449
450    #[tokio::test]
451    async fn test_full_calldata_pipeline() {
452        let descriptor = Descriptor::from_json(test_descriptor_json()).unwrap();
453        let sig = decoder::parse_signature("transfer(address,uint256)").unwrap();
454
455        // Build calldata: transfer(0x0000...0001, 1000)
456        let mut calldata = Vec::new();
457        calldata.extend_from_slice(&sig.selector);
458        let mut addr_word = [0u8; 32];
459        addr_word[31] = 1;
460        calldata.extend_from_slice(&addr_word);
461        let mut amount_word = [0u8; 32];
462        amount_word[30] = 0x03;
463        amount_word[31] = 0xe8;
464        calldata.extend_from_slice(&amount_word);
465
466        let provider = EmptyDataProvider;
467        let addr = "0xdac17f958d2ee523a2206206994597c13d831ec7";
468        let descriptors = wrap_rd(descriptor, 1, addr);
469        let tx = TransactionContext {
470            chain_id: 1,
471            to: addr,
472            calldata: &calldata,
473            value: None,
474            from: None,
475            implementation_address: None,
476        };
477        let result = format_calldata(&descriptors, &tx, &provider).await.unwrap();
478
479        assert_eq!(result.intent, "Transfer tokens");
480        assert_eq!(result.entries.len(), 2);
481
482        if let DisplayEntry::Item(ref item) = result.entries[0] {
483            assert_eq!(item.label, "To");
484            assert_eq!(item.value, "0x0000000000000000000000000000000000000001");
485        } else {
486            panic!("expected Item");
487        }
488
489        if let DisplayEntry::Item(ref item) = result.entries[1] {
490            assert_eq!(item.label, "Amount");
491            assert_eq!(item.value, "1000");
492        } else {
493            panic!("expected Item");
494        }
495    }
496
497    #[tokio::test]
498    async fn test_full_pipeline_with_token_amount() {
499        let json = r#"{
500            "context": {
501                "contract": {
502                    "deployments": [
503                        { "chainId": 1, "address": "0xdac17f958d2ee523a2206206994597c13d831ec7" }
504                    ]
505                }
506            },
507            "metadata": {
508                "owner": "test",
509                "contractName": "Tether USD",
510                "enums": {},
511                "constants": {},
512                "addressBook": {},
513                "maps": {}
514            },
515            "display": {
516                "definitions": {},
517                "formats": {
518                    "transfer(address,uint256)": {
519                        "intent": "Transfer tokens",
520                        "interpolatedIntent": "Send ${@.1} to ${@.0}",
521                        "fields": [
522                            {
523                                "path": "@.0",
524                                "label": "To",
525                                "format": "addressName"
526                            },
527                            {
528                                "path": "@.1",
529                                "label": "Amount",
530                                "format": "tokenAmount",
531                                "params": {
532                                    "tokenPath": "@.0"
533                                }
534                            }
535                        ]
536                    }
537                }
538            }
539        }"#;
540
541        let descriptor = Descriptor::from_json(json).unwrap();
542        let sig = decoder::parse_signature("transfer(address,uint256)").unwrap();
543
544        let mut calldata = Vec::new();
545        calldata.extend_from_slice(&sig.selector);
546        // token address
547        let token_addr =
548            hex::decode("000000000000000000000000dac17f958d2ee523a2206206994597c13d831ec7")
549                .unwrap();
550        calldata.extend_from_slice(&token_addr);
551        // amount: 1_000_000 (1 USDT with 6 decimals)
552        let mut amount_word = [0u8; 32];
553        amount_word[29] = 0x0f;
554        amount_word[30] = 0x42;
555        amount_word[31] = 0x40;
556        calldata.extend_from_slice(&amount_word);
557
558        let mut tokens = StaticTokenSource::new();
559        tokens.insert(
560            1,
561            "0xdac17f958d2ee523a2206206994597c13d831ec7",
562            TokenMeta {
563                symbol: "USDT".to_string(),
564                decimals: 6,
565                name: "Tether USD".to_string(),
566            },
567        );
568
569        let addr = "0xdac17f958d2ee523a2206206994597c13d831ec7";
570        let descriptors = wrap_rd(descriptor, 1, addr);
571        let tx = TransactionContext {
572            chain_id: 1,
573            to: addr,
574            calldata: &calldata,
575            value: None,
576            from: None,
577            implementation_address: None,
578        };
579        let result = format_calldata(&descriptors, &tx, &tokens).await.unwrap();
580
581        assert_eq!(result.intent, "Transfer tokens");
582
583        // The "To" field should show the address (addressName resolves via data provider)
584        if let DisplayEntry::Item(ref item) = result.entries[0] {
585            assert_eq!(item.label, "To");
586        }
587
588        // The amount should be formatted with token decimals
589        if let DisplayEntry::Item(ref item) = result.entries[1] {
590            assert_eq!(item.label, "Amount");
591            assert_eq!(item.value, "1 USDT");
592        }
593    }
594
595    #[tokio::test]
596    async fn test_visibility_rules() {
597        let json = r#"{
598            "context": {
599                "contract": {
600                    "deployments": [
601                        { "chainId": 1, "address": "0xabc" }
602                    ]
603                }
604            },
605            "metadata": {
606                "owner": "test",
607                "enums": {},
608                "constants": {},
609                "addressBook": {},
610                "maps": {}
611            },
612            "display": {
613                "definitions": {},
614                "formats": {
615                    "foo(uint256,uint256)": {
616                        "intent": "Test visibility",
617                        "fields": [
618                            {
619                                "path": "@.0",
620                                "label": "Always visible",
621                                "format": "number"
622                            },
623                            {
624                                "path": "@.1",
625                                "label": "Hidden",
626                                "format": "number",
627                                "visible": false
628                            }
629                        ]
630                    }
631                }
632            }
633        }"#;
634
635        let descriptor = Descriptor::from_json(json).unwrap();
636        let sig = decoder::parse_signature("foo(uint256,uint256)").unwrap();
637
638        let mut calldata = Vec::new();
639        calldata.extend_from_slice(&sig.selector);
640        calldata.extend_from_slice(&[0u8; 32]); // arg 0
641        calldata.extend_from_slice(&[0u8; 32]); // arg 1
642
643        let descriptors = wrap_rd(descriptor, 1, "0xabc");
644        let tx = TransactionContext {
645            chain_id: 1,
646            to: "0xabc",
647            calldata: &calldata,
648            value: None,
649            from: None,
650            implementation_address: None,
651        };
652        let result = format_calldata(&descriptors, &tx, &EmptyDataProvider)
653            .await
654            .unwrap();
655
656        // Only 1 field should be visible (the second has visible: false)
657        assert_eq!(result.entries.len(), 1);
658        if let DisplayEntry::Item(ref item) = result.entries[0] {
659            assert_eq!(item.label, "Always visible");
660        }
661    }
662
663    #[tokio::test]
664    async fn test_field_group() {
665        let json = r#"{
666            "context": {
667                "contract": {
668                    "deployments": [
669                        { "chainId": 1, "address": "0xabc" }
670                    ]
671                }
672            },
673            "metadata": {
674                "owner": "test",
675                "enums": {},
676                "constants": {},
677                "addressBook": {},
678                "maps": {}
679            },
680            "display": {
681                "definitions": {},
682                "formats": {
683                    "foo(address,uint256)": {
684                        "intent": "Test groups",
685                        "fields": [
686                            {
687                                "fieldGroup": {
688                                    "label": "Transfer Details",
689                                    "fields": [
690                                        {
691                                            "path": "@.0",
692                                            "label": "Recipient",
693                                            "format": "address"
694                                        },
695                                        {
696                                            "path": "@.1",
697                                            "label": "Amount",
698                                            "format": "number"
699                                        }
700                                    ]
701                                }
702                            }
703                        ]
704                    }
705                }
706            }
707        }"#;
708
709        let descriptor = Descriptor::from_json(json).unwrap();
710        let sig = decoder::parse_signature("foo(address,uint256)").unwrap();
711
712        let mut calldata = Vec::new();
713        calldata.extend_from_slice(&sig.selector);
714        let mut addr = [0u8; 32];
715        addr[31] = 0x42;
716        calldata.extend_from_slice(&addr);
717        let mut amount = [0u8; 32];
718        amount[31] = 100;
719        calldata.extend_from_slice(&amount);
720
721        let descriptors = wrap_rd(descriptor, 1, "0xabc");
722        let tx = TransactionContext {
723            chain_id: 1,
724            to: "0xabc",
725            calldata: &calldata,
726            value: None,
727            from: None,
728            implementation_address: None,
729        };
730        let result = format_calldata(&descriptors, &tx, &EmptyDataProvider)
731            .await
732            .unwrap();
733
734        assert_eq!(result.entries.len(), 1);
735        if let DisplayEntry::Group { label, items, .. } = &result.entries[0] {
736            assert_eq!(label, "Transfer Details");
737            assert_eq!(items.len(), 2);
738            assert_eq!(items[0].label, "Recipient");
739            assert_eq!(items[1].label, "Amount");
740            assert_eq!(items[1].value, "100");
741        } else {
742            panic!("expected Group");
743        }
744    }
745
746    #[tokio::test]
747    async fn test_maps_lookup() {
748        let json = r#"{
749            "context": {
750                "contract": {
751                    "deployments": [
752                        { "chainId": 1, "address": "0xabc" }
753                    ]
754                }
755            },
756            "metadata": {
757                "owner": "test",
758                "enums": {},
759                "constants": {},
760                "addressBook": {},
761                "maps": {
762                    "orderTypes": {
763                        "entries": {
764                            "0": "Market",
765                            "1": "Limit",
766                            "2": "Stop"
767                        }
768                    }
769                }
770            },
771            "display": {
772                "definitions": {},
773                "formats": {
774                    "placeOrder(uint256)": {
775                        "intent": "Place order",
776                        "fields": [
777                            {
778                                "path": "@.0",
779                                "label": "Order Type",
780                                "params": {
781                                    "mapReference": "orderTypes"
782                                }
783                            }
784                        ]
785                    }
786                }
787            }
788        }"#;
789
790        let descriptor = Descriptor::from_json(json).unwrap();
791        let sig = decoder::parse_signature("placeOrder(uint256)").unwrap();
792
793        let mut calldata = Vec::new();
794        calldata.extend_from_slice(&sig.selector);
795        let mut word = [0u8; 32];
796        word[31] = 1; // value = 1 → "Limit"
797        calldata.extend_from_slice(&word);
798
799        let descriptors = wrap_rd(descriptor, 1, "0xabc");
800        let tx = TransactionContext {
801            chain_id: 1,
802            to: "0xabc",
803            calldata: &calldata,
804            value: None,
805            from: None,
806            implementation_address: None,
807        };
808        let result = format_calldata(&descriptors, &tx, &EmptyDataProvider)
809            .await
810            .unwrap();
811
812        if let DisplayEntry::Item(ref item) = result.entries[0] {
813            assert_eq!(item.label, "Order Type");
814            assert_eq!(item.value, "Limit");
815        } else {
816            panic!("expected Item");
817        }
818    }
819
820    #[tokio::test]
821    async fn test_stakeweight_increase_unlock_time() {
822        let json = r#"{
823            "context": {
824                "contract": {
825                    "deployments": [
826                        { "chainId": 10, "address": "0x521B4C065Bbdbe3E20B3727340730936912DfA46" }
827                    ]
828                }
829            },
830            "metadata": {
831                "owner": "WalletConnect",
832                "contractName": "StakeWeight",
833                "enums": {},
834                "constants": {},
835                "addressBook": {},
836                "maps": {}
837            },
838            "display": {
839                "definitions": {},
840                "formats": {
841                    "increaseUnlockTime(uint256)": {
842                        "intent": "Increase Unlock Time",
843                        "interpolatedIntent": "Increase unlock time to ${@.0}",
844                        "fields": [
845                            {
846                                "path": "@.0",
847                                "label": "New Unlock Time",
848                                "format": "date"
849                            }
850                        ]
851                    }
852                }
853            }
854        }"#;
855
856        let descriptor = Descriptor::from_json(json).unwrap();
857        // Real calldata from yttrium test
858        let calldata =
859            hex::decode("7c616fe6000000000000000000000000000000000000000000000000000000006945563d")
860                .unwrap();
861
862        let addr = "0x521B4C065Bbdbe3E20B3727340730936912DfA46";
863        let descriptors = wrap_rd(descriptor, 10, addr);
864        let tx = TransactionContext {
865            chain_id: 10,
866            to: addr,
867            calldata: &calldata,
868            value: None,
869            from: None,
870            implementation_address: None,
871        };
872        let result = format_calldata(&descriptors, &tx, &EmptyDataProvider)
873            .await
874            .unwrap();
875
876        assert_eq!(result.intent, "Increase Unlock Time");
877        assert_eq!(result.entries.len(), 1);
878        if let DisplayEntry::Item(ref item) = result.entries[0] {
879            assert_eq!(item.label, "New Unlock Time");
880            assert_eq!(item.value, "2025-12-19 13:42:21 UTC");
881        } else {
882            panic!("expected Item");
883        }
884        assert_eq!(
885            result.interpolated_intent.as_deref(),
886            Some("Increase unlock time to 2025-12-19 13:42:21 UTC")
887        );
888        assert!(result.diagnostics().is_empty());
889    }
890
891    #[tokio::test]
892    async fn test_eip712_format() {
893        let json = r#"{
894            "context": {
895                "eip712": {
896                    "deployments": [
897                        { "chainId": 1, "address": "0xabc" }
898                    ]
899                }
900            },
901            "metadata": {
902                "owner": "test",
903                "enums": {},
904                "constants": {},
905                "addressBook": {},
906                "maps": {}
907            },
908            "display": {
909                "definitions": {},
910                "formats": {
911                    "Permit(address spender,uint256 value)": {
912                        "intent": "Permit token spending",
913                        "fields": [
914                            {
915                                "path": "spender",
916                                "label": "Spender",
917                                "format": "address"
918                            },
919                            {
920                                "path": "value",
921                                "label": "Amount",
922                                "format": "number"
923                            }
924                        ]
925                    }
926                }
927            }
928        }"#;
929
930        let descriptor = Descriptor::from_json(json).unwrap();
931        let typed_data = eip712::TypedData {
932            types: std::collections::HashMap::from([(
933                "Permit".to_string(),
934                vec![
935                    eip712::TypedDataField {
936                        name: "spender".to_string(),
937                        field_type: "address".to_string(),
938                    },
939                    eip712::TypedDataField {
940                        name: "value".to_string(),
941                        field_type: "uint256".to_string(),
942                    },
943                ],
944            )]),
945            primary_type: "Permit".to_string(),
946            domain: eip712::TypedDataDomain {
947                name: Some("USDT".to_string()),
948                version: Some("1".to_string()),
949                chain_id: Some(1),
950                verifying_contract: Some("0xabc".to_string()),
951                salt: None,
952                extra: std::collections::HashMap::new(),
953            },
954            container: None,
955            message: serde_json::json!({
956                "spender": "0x1234567890123456789012345678901234567890",
957                "value": "1000000"
958            }),
959        };
960
961        let descriptors = wrap_rd(descriptor, 1, "0xabc");
962        let result = format_typed_data(&descriptors, &typed_data, &EmptyDataProvider)
963            .await
964            .unwrap();
965        assert_eq!(result.intent, "Permit token spending");
966        assert_eq!(result.entries.len(), 2);
967
968        if let DisplayEntry::Item(ref item) = result.entries[0] {
969            assert_eq!(item.label, "Spender");
970            assert_eq!(item.value, "0x1234567890123456789012345678901234567890");
971        }
972
973        if let DisplayEntry::Item(ref item) = result.entries[1] {
974            assert_eq!(item.label, "Amount");
975            assert_eq!(item.value, "1000000");
976        }
977    }
978
979    #[tokio::test]
980    async fn test_proxy_implementation_address() {
981        let descriptor = Descriptor::from_json(test_descriptor_json()).unwrap();
982        let sig = decoder::parse_signature("transfer(address,uint256)").unwrap();
983
984        let mut calldata = Vec::new();
985        calldata.extend_from_slice(&sig.selector);
986        let mut addr_word = [0u8; 32];
987        addr_word[31] = 1;
988        calldata.extend_from_slice(&addr_word);
989        let mut amount_word = [0u8; 32];
990        amount_word[30] = 0x03;
991        amount_word[31] = 0xe8;
992        calldata.extend_from_slice(&amount_word);
993
994        // Descriptor is deployed at 0xdac17f...ec7 (the implementation).
995        // tx.to is a proxy address that does NOT match any descriptor.
996        // implementation_address points to the real implementation.
997        let impl_addr = "0xdac17f958d2ee523a2206206994597c13d831ec7";
998        let proxy_addr = "0x1111111111111111111111111111111111111111";
999        let descriptors = wrap_rd(descriptor, 1, impl_addr);
1000        let tx = TransactionContext {
1001            chain_id: 1,
1002            to: proxy_addr,
1003            calldata: &calldata,
1004            value: None,
1005            from: None,
1006            implementation_address: Some(impl_addr),
1007        };
1008        let result = format_calldata(&descriptors, &tx, &EmptyDataProvider)
1009            .await
1010            .unwrap();
1011
1012        // Should match the descriptor via implementation_address
1013        assert_eq!(result.intent, "Transfer tokens");
1014        assert_eq!(result.entries.len(), 2);
1015
1016        // @.to container value should be the proxy address (user-facing), not the implementation
1017        if let DisplayEntry::Item(ref item) = result.entries[0] {
1018            assert_eq!(item.label, "To");
1019        }
1020    }
1021
1022    #[tokio::test]
1023    async fn test_format_calldata_empty_descriptors_returns_raw_fallback() {
1024        let calldata =
1025            hex::decode("a9059cbb0000000000000000000000000000000000000000000000000000000000000001")
1026                .unwrap();
1027        let tx = TransactionContext {
1028            chain_id: 1,
1029            to: "0x0000000000000000000000000000000000000001",
1030            calldata: &calldata,
1031            value: None,
1032            from: None,
1033            implementation_address: None,
1034        };
1035
1036        let result = format_calldata(&[], &tx, &EmptyDataProvider)
1037            .await
1038            .expect("empty descriptors should fall back");
1039        assert!(result.intent.starts_with("Unknown function 0xa9059cbb"));
1040    }
1041
1042    #[tokio::test]
1043    async fn test_format_calldata_errors_when_descriptor_deployment_does_not_match() {
1044        let descriptor = Descriptor::from_json(test_descriptor_json()).unwrap();
1045        let sig = decoder::parse_signature("transfer(address,uint256)").unwrap();
1046
1047        let mut calldata = Vec::new();
1048        calldata.extend_from_slice(&sig.selector);
1049        calldata.extend_from_slice(&[0u8; 32]);
1050        calldata.extend_from_slice(&[0u8; 32]);
1051
1052        let descriptors = wrap_rd(descriptor, 1, "0xdac17f958d2ee523a2206206994597c13d831ec7");
1053        let tx = TransactionContext {
1054            chain_id: 1,
1055            to: "0x0000000000000000000000000000000000000001",
1056            calldata: &calldata,
1057            value: None,
1058            from: None,
1059            implementation_address: None,
1060        };
1061
1062        let err = format_calldata(&descriptors, &tx, &EmptyDataProvider)
1063            .await
1064            .expect_err("mismatched deployment should error");
1065        assert!(err
1066            .to_string()
1067            .contains("no outer descriptor matches chain_id=1 address=0x0000000000000000000000000000000000000001"));
1068    }
1069}