Skip to main content

clear_signing/uniffi_compat/
mod.rs

1use std::future::Future;
2use std::pin::Pin;
3use std::sync::Arc;
4
5use crate::{
6    eip712::TypedData, error::FormatFailure, outcome::DescriptorResolutionOutcome,
7    outcome::FormatOutcome, outcome::ResolvedDescriptorResolution, provider::DataProvider,
8    resolver::ResolvedDescriptor, token::TokenMeta, types::descriptor::Descriptor,
9};
10
11#[cfg(feature = "github-registry")]
12use crate::resolver::{DescriptorSource, GitHubRegistrySource};
13
14#[cfg(feature = "github-registry")]
15const DEFAULT_REGISTRY_URL: &str =
16    "https://raw.githubusercontent.com/ethereum/clear-signing-erc7730-registry/master";
17
18#[cfg(feature = "github-registry")]
19static REGISTRY_SOURCE: tokio::sync::OnceCell<GitHubRegistrySource> =
20    tokio::sync::OnceCell::const_new();
21
22#[cfg(feature = "github-registry")]
23async fn get_registry_source() -> Result<&'static GitHubRegistrySource, FormatFailure> {
24    REGISTRY_SOURCE
25        .get_or_try_init(|| async {
26            GitHubRegistrySource::from_registry(DEFAULT_REGISTRY_URL)
27                .await
28                .map_err(|e| FormatFailure::ResolutionFailed {
29                    message: format!("failed to initialize registry: {e}"),
30                    retryable: true,
31                })
32        })
33        .await
34}
35
36// ---------------------------------------------------------------------------
37// FFI-safe token metadata record
38// ---------------------------------------------------------------------------
39
40#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)]
41pub struct TokenMetaFfi {
42    pub symbol: String,
43    pub decimals: u8,
44    pub name: String,
45}
46
47impl From<TokenMetaFfi> for TokenMeta {
48    fn from(ffi: TokenMetaFfi) -> Self {
49        TokenMeta {
50            symbol: ffi.symbol,
51            decimals: ffi.decimals,
52            name: ffi.name,
53        }
54    }
55}
56
57impl From<TokenMeta> for TokenMetaFfi {
58    fn from(meta: TokenMeta) -> Self {
59        TokenMetaFfi {
60            symbol: meta.symbol,
61            decimals: meta.decimals,
62            name: meta.name,
63        }
64    }
65}
66
67// ---------------------------------------------------------------------------
68// Foreign data-provider trait (wallet implements this in Swift/Kotlin)
69// ---------------------------------------------------------------------------
70
71/// Sync callback trait for wallet-side data resolution.
72///
73/// Wallets implement this protocol (Swift/Kotlin) to provide token metadata,
74/// ENS names, local contact names, and NFT collection names during clear-sign
75/// formatting. Methods are synchronous across the FFI boundary — the proxy
76/// bridges them to the async `DataProvider` trait used internally.
77#[uniffi::export(with_foreign)]
78pub trait DataProviderFfi: Send + Sync {
79    fn resolve_token(&self, chain_id: u64, address: String) -> Option<TokenMetaFfi>;
80    fn resolve_ens_name(
81        &self,
82        address: String,
83        chain_id: u64,
84        types: Option<Vec<String>>,
85    ) -> Option<String>;
86    fn resolve_local_name(
87        &self,
88        address: String,
89        chain_id: u64,
90        types: Option<Vec<String>>,
91    ) -> Option<String>;
92    fn resolve_nft_collection_name(
93        &self,
94        collection_address: String,
95        chain_id: u64,
96    ) -> Option<String>;
97    fn resolve_block_timestamp(&self, chain_id: u64, block_number: u64) -> Option<u64>;
98    /// Detect proxy contract implementation address.
99    ///
100    /// Called when descriptor resolution by `tx.to` fails. Wallets should read
101    /// EIP-1967 implementation slot and/or Safe storage slot 0 via `eth_getStorageAt`.
102    /// Return `None` if the address is not a known proxy.
103    fn get_implementation_address(&self, chain_id: u64, address: String) -> Option<String>;
104}
105
106// ---------------------------------------------------------------------------
107// Proxy: wraps Arc<dyn DataProviderFfi> → implements internal DataProvider
108// ---------------------------------------------------------------------------
109
110pub struct DataProviderFfiProxy(pub Arc<dyn DataProviderFfi>);
111
112impl DataProvider for DataProviderFfiProxy {
113    fn resolve_token(
114        &self,
115        chain_id: u64,
116        address: &str,
117    ) -> Pin<Box<dyn Future<Output = Option<TokenMeta>> + Send + '_>> {
118        let address = address.to_string();
119        let inner = Arc::clone(&self.0);
120        Box::pin(async move {
121            let result =
122                tokio::task::spawn_blocking(move || inner.resolve_token(chain_id, address)).await;
123            result.ok().flatten().map(Into::into)
124        })
125    }
126
127    fn resolve_ens_name(
128        &self,
129        address: &str,
130        chain_id: u64,
131        types: Option<&[String]>,
132    ) -> Pin<Box<dyn Future<Output = Option<String>> + Send + '_>> {
133        let address = address.to_string();
134        let types_owned = types.map(|t| t.to_vec());
135        let inner = Arc::clone(&self.0);
136        Box::pin(async move {
137            let result = tokio::task::spawn_blocking(move || {
138                inner.resolve_ens_name(address, chain_id, types_owned)
139            })
140            .await;
141            result.ok().flatten()
142        })
143    }
144
145    fn resolve_local_name(
146        &self,
147        address: &str,
148        chain_id: u64,
149        types: Option<&[String]>,
150    ) -> Pin<Box<dyn Future<Output = Option<String>> + Send + '_>> {
151        let address = address.to_string();
152        let types_owned = types.map(|t| t.to_vec());
153        let inner = Arc::clone(&self.0);
154        Box::pin(async move {
155            let result = tokio::task::spawn_blocking(move || {
156                inner.resolve_local_name(address, chain_id, types_owned)
157            })
158            .await;
159            result.ok().flatten()
160        })
161    }
162
163    fn resolve_nft_collection_name(
164        &self,
165        collection_address: &str,
166        chain_id: u64,
167    ) -> Pin<Box<dyn Future<Output = Option<String>> + Send + '_>> {
168        let collection_address = collection_address.to_string();
169        let inner = Arc::clone(&self.0);
170        Box::pin(async move {
171            let result = tokio::task::spawn_blocking(move || {
172                inner.resolve_nft_collection_name(collection_address, chain_id)
173            })
174            .await;
175            result.ok().flatten()
176        })
177    }
178
179    fn resolve_block_timestamp(
180        &self,
181        chain_id: u64,
182        block_number: u64,
183    ) -> Pin<Box<dyn Future<Output = Option<u64>> + Send + '_>> {
184        let inner = Arc::clone(&self.0);
185        Box::pin(async move {
186            let result = tokio::task::spawn_blocking(move || {
187                inner.resolve_block_timestamp(chain_id, block_number)
188            })
189            .await;
190            result.ok().flatten()
191        })
192    }
193}
194
195// ---------------------------------------------------------------------------
196// TransactionInput — FFI-safe transaction record
197// ---------------------------------------------------------------------------
198
199#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)]
200pub struct TransactionInput {
201    pub chain_id: u64,
202    pub to: String,
203    pub calldata_hex: String,
204    pub value_hex: Option<String>,
205    pub from_address: Option<String>,
206}
207
208// ---------------------------------------------------------------------------
209// FFI exports
210// ---------------------------------------------------------------------------
211
212/// Format contract calldata for clear signing display.
213///
214/// Takes pre-resolved descriptor JSON strings and a `TransactionInput`.
215/// The wallet is responsible for descriptor resolution (via `clear_signing_resolve_descriptor`
216/// or its own source). Proxy detection is automatic when `data_provider` is provided.
217#[uniffi::export(async_runtime = "tokio")]
218pub async fn clear_signing_format_calldata(
219    descriptors_json: Vec<String>,
220    transaction: TransactionInput,
221    data_provider: Option<Arc<dyn DataProviderFfi>>,
222) -> Result<FormatOutcome, FormatFailure> {
223    let descriptors = parse_descriptors(&descriptors_json, transaction.chain_id, &transaction.to)?;
224    let calldata = decode_hex(&transaction.calldata_hex, HexContext::Calldata)?;
225    let value = match transaction.value_hex {
226        Some(ref hex_value) => Some(decode_hex(hex_value, HexContext::Value)?),
227        None => None,
228    };
229    // Descriptors can be keyed at either the contract address (Aave V3 Pool) or the
230    // singleton implementation (Safe). Pre-check `tx.to` against the descriptor
231    // deployments and only ask the wallet for the implementation address when
232    // nothing matches — this avoids masking genuine render/descriptor errors with
233    // a misleading "no outer descriptor matches <impl>" message.
234    let to_has_match = descriptors.iter().any(|rd| {
235        rd.descriptor.context.deployments().iter().any(|dep| {
236            dep.chain_id == transaction.chain_id
237                && dep.address.eq_ignore_ascii_case(&transaction.to)
238        })
239    });
240    let impl_addr = if to_has_match {
241        None
242    } else {
243        data_provider.as_ref().and_then(|dp| {
244            dp.get_implementation_address(transaction.chain_id, transaction.to.clone())
245        })
246    };
247
248    let provider = build_data_provider(data_provider);
249    let tx = crate::TransactionContext {
250        chain_id: transaction.chain_id,
251        to: &transaction.to,
252        calldata: &calldata,
253        value: value.as_deref(),
254        from: transaction.from_address.as_deref(),
255        implementation_address: impl_addr.as_deref(),
256    };
257    crate::format_calldata(&descriptors, &tx, provider.as_ref()).await
258}
259
260/// Format EIP-712 typed data for clear signing display.
261///
262/// Takes pre-resolved descriptor JSON strings and the EIP-712 typed data JSON.
263#[uniffi::export(async_runtime = "tokio")]
264pub async fn clear_signing_format_typed_data(
265    descriptors_json: Vec<String>,
266    typed_data_json: String,
267    data_provider: Option<Arc<dyn DataProviderFfi>>,
268) -> Result<FormatOutcome, FormatFailure> {
269    let typed_data: TypedData = serde_json::from_str::<TypedData>(&typed_data_json)
270        .map_err(|e| invalid_input(format!("invalid typed data JSON: {e}")))?;
271
272    let chain_id = typed_data.domain.chain_id.unwrap_or(1);
273    let address = typed_data
274        .domain
275        .verifying_contract
276        .as_deref()
277        .unwrap_or("0x0000000000000000000000000000000000000000");
278    let descriptors = parse_descriptors(&descriptors_json, chain_id, address)?;
279    let provider = build_data_provider(data_provider);
280    crate::format_typed_data(&descriptors, &typed_data, provider.as_ref()).await
281}
282
283/// Resolve a calldata descriptor from the GitHub registry for a given chain + address.
284///
285/// Returns the descriptor JSON string, or `None` if no descriptor is found.
286/// Requires the `github-registry` feature.
287#[cfg(feature = "github-registry")]
288#[uniffi::export(async_runtime = "tokio")]
289pub async fn clear_signing_resolve_descriptor(
290    chain_id: u64,
291    address: String,
292) -> Result<DescriptorResolutionOutcome, FormatFailure> {
293    let source = get_registry_source().await?;
294    match source.resolve_calldata(chain_id, &address).await {
295        Ok(resolved) => {
296            let json = serde_json::to_string(&resolved.descriptor)
297                .map_err(|e| invalid_descriptor(format!("failed to serialize descriptor: {e}")))?;
298            Ok(DescriptorResolutionOutcome::Found(vec![json]))
299        }
300        Err(crate::error::ResolveError::NotFound { .. }) => {
301            Ok(DescriptorResolutionOutcome::NotFound)
302        }
303        Err(e) => Err(FormatFailure::from(e)),
304    }
305}
306
307/// Resolve all descriptors needed for EIP-712 typed data, including nested calldata.
308///
309/// Uses the GitHub registry. Returns descriptor JSON strings in dependency order.
310/// First element is the outer EIP-712 descriptor, subsequent are inner calldata descriptors.
311/// Returns empty vec if no descriptor is found for the outer verifying contract.
312/// Automatically detects proxy contracts via `data_provider.get_implementation_address`.
313#[cfg(feature = "github-registry")]
314#[uniffi::export(async_runtime = "tokio")]
315pub async fn clear_signing_resolve_descriptors_for_typed_data(
316    typed_data_json: String,
317    data_provider: Arc<dyn DataProviderFfi>,
318) -> Result<DescriptorResolutionOutcome, FormatFailure> {
319    let typed_data: crate::eip712::TypedData = serde_json::from_str(&typed_data_json)
320        .map_err(|e| invalid_input(format!("invalid typed data JSON: {e}")))?;
321
322    let chain_id = typed_data.domain.chain_id.unwrap_or(1);
323    let verifying_contract = typed_data
324        .domain
325        .verifying_contract
326        .as_deref()
327        .unwrap_or("0x0000000000000000000000000000000000000000");
328
329    let source = get_registry_source().await?;
330
331    // Try direct lookup
332    let mut descriptors = crate::resolver::resolve_descriptors_for_typed_data(&typed_data, source)
333        .await
334        .map_err(FormatFailure::from)?;
335
336    // Proxy detection fallback
337    if matches!(descriptors, ResolvedDescriptorResolution::NotFound) {
338        let impl_addr =
339            data_provider.get_implementation_address(chain_id, verifying_contract.to_string());
340        if let Some(impl_addr) = impl_addr {
341            let mut proxied = typed_data.clone();
342            proxied.domain.verifying_contract = Some(impl_addr.clone());
343            descriptors = crate::resolver::resolve_descriptors_for_typed_data(&proxied, source)
344                .await
345                .map_err(FormatFailure::from)?;
346        }
347    }
348
349    resolved_descriptor_json_outcome(descriptors)
350}
351
352/// Resolve all descriptors needed for a transaction, including nested calldata.
353///
354/// Uses the GitHub registry. Returns descriptor JSON strings in dependency order.
355/// First element is the outer descriptor, subsequent are inner callees.
356/// Returns empty vec if no descriptor is found for the outer address.
357/// Automatically detects proxy contracts via `data_provider.get_implementation_address`.
358#[cfg(feature = "github-registry")]
359#[uniffi::export(async_runtime = "tokio")]
360pub async fn clear_signing_resolve_descriptors_for_tx(
361    transaction: TransactionInput,
362    data_provider: Arc<dyn DataProviderFfi>,
363) -> Result<DescriptorResolutionOutcome, FormatFailure> {
364    let source = get_registry_source().await?;
365    let calldata = decode_hex(&transaction.calldata_hex, HexContext::Calldata)?;
366    let value = match transaction.value_hex {
367        Some(ref hex_value) => Some(decode_hex(hex_value, HexContext::Value)?),
368        None => None,
369    };
370    let tx = crate::TransactionContext {
371        chain_id: transaction.chain_id,
372        to: &transaction.to,
373        calldata: &calldata,
374        value: value.as_deref(),
375        from: transaction.from_address.as_deref(),
376        implementation_address: None,
377    };
378    let mut descriptors = crate::resolve_descriptors_for_tx(&tx, source)
379        .await
380        .map_err(FormatFailure::from)?;
381
382    // Proxy detection fallback
383    if matches!(descriptors, ResolvedDescriptorResolution::NotFound) {
384        let impl_addr =
385            data_provider.get_implementation_address(transaction.chain_id, transaction.to.clone());
386        if let Some(impl_addr) = impl_addr {
387            let tx_with_impl = crate::TransactionContext {
388                implementation_address: Some(impl_addr.as_str()),
389                ..tx
390            };
391            descriptors = crate::resolve_descriptors_for_tx(&tx_with_impl, source)
392                .await
393                .map_err(FormatFailure::from)?;
394        }
395    }
396
397    resolved_descriptor_json_outcome(descriptors)
398}
399
400/// Merge two descriptor JSON strings (including + included).
401///
402/// Returns merged JSON ready for use with `clear_signing_format_calldata` / `clear_signing_format_typed_data`.
403#[uniffi::export]
404pub fn clear_signing_merge_descriptors(
405    including_json: String,
406    included_json: String,
407) -> Result<String, FormatFailure> {
408    crate::merge::merge_descriptors(&including_json, &included_json).map_err(FormatFailure::from)
409}
410
411// ---------------------------------------------------------------------------
412// Internal helpers
413// ---------------------------------------------------------------------------
414
415enum HexContext {
416    Calldata,
417    Value,
418}
419
420fn decode_hex(input: &str, context: HexContext) -> Result<Vec<u8>, FormatFailure> {
421    let trimmed = input.trim();
422    let normalized = trimmed
423        .strip_prefix("0x")
424        .or_else(|| trimmed.strip_prefix("0X"))
425        .unwrap_or(trimmed);
426
427    // Pad odd-length hex strings with a leading zero (e.g. "0x0" → "00")
428    let padded;
429    let hex_str = if normalized.len() % 2 != 0 {
430        padded = format!("0{}", normalized);
431        &padded
432    } else {
433        normalized
434    };
435
436    hex::decode(hex_str).map_err(|err| match context {
437        HexContext::Calldata => invalid_input(format!("invalid calldata hex: {err}")),
438        HexContext::Value => invalid_input(format!("invalid value hex: {err}")),
439    })
440}
441
442fn parse_descriptors(
443    descriptors_json: &[String],
444    fallback_chain_id: u64,
445    fallback_address: &str,
446) -> Result<Vec<ResolvedDescriptor>, FormatFailure> {
447    let mut descriptors = Vec::with_capacity(descriptors_json.len());
448    for json_str in descriptors_json {
449        let descriptor = Descriptor::from_json(json_str)
450            .map_err(|e| invalid_descriptor(format!("invalid descriptor JSON: {e}")))?;
451        let (cid, addr) = descriptor
452            .context
453            .deployments()
454            .first()
455            .map(|dep| (dep.chain_id, dep.address.clone()))
456            .unwrap_or((fallback_chain_id, fallback_address.to_string()));
457        descriptors.push(ResolvedDescriptor {
458            descriptor,
459            chain_id: cid,
460            address: addr,
461        });
462    }
463    Ok(descriptors)
464}
465
466fn resolved_descriptor_json_outcome(
467    descriptors: ResolvedDescriptorResolution,
468) -> Result<DescriptorResolutionOutcome, FormatFailure> {
469    match descriptors {
470        ResolvedDescriptorResolution::Found(descriptors) => descriptors
471            .iter()
472            .map(|rd| {
473                serde_json::to_string(&rd.descriptor)
474                    .map_err(|e| invalid_descriptor(format!("failed to serialize descriptor: {e}")))
475            })
476            .collect::<Result<Vec<_>, _>>()
477            .map(DescriptorResolutionOutcome::Found),
478        ResolvedDescriptorResolution::NotFound => Ok(DescriptorResolutionOutcome::NotFound),
479    }
480}
481
482fn invalid_input(message: String) -> FormatFailure {
483    FormatFailure::InvalidInput {
484        message,
485        retryable: false,
486    }
487}
488
489fn invalid_descriptor(message: String) -> FormatFailure {
490    FormatFailure::InvalidDescriptor {
491        message,
492        retryable: false,
493    }
494}
495
496fn build_data_provider(ffi_provider: Option<Arc<dyn DataProviderFfi>>) -> Box<dyn DataProvider> {
497    match ffi_provider {
498        Some(ffi) => Box::new(DataProviderFfiProxy(ffi)),
499        None => Box::new(crate::provider::EmptyDataProvider),
500    }
501}
502
503#[cfg(test)]
504mod tests {
505    use super::*;
506    use crate::DisplayEntry;
507
508    fn calldata_descriptor_json() -> &'static str {
509        r#"{
510            "context": {
511                "contract": {
512                    "deployments": [
513                        { "chainId": 1, "address": "0xdac17f958d2ee523a2206206994597c13d831ec7" }
514                    ]
515                }
516            },
517            "metadata": {
518                "owner": "test",
519                "contractName": "Tether USD",
520                "enums": {},
521                "constants": {},
522                "addressBook": {},
523                "maps": {}
524            },
525            "display": {
526                "definitions": {},
527                "formats": {
528                    "transfer(address,uint256)": {
529                        "intent": "Transfer tokens",
530                        "fields": [
531                            {
532                                "path": "@.0",
533                                "label": "To",
534                                "format": "address"
535                            },
536                            {
537                                "path": "@.1",
538                                "label": "Amount",
539                                "format": "number"
540                            }
541                        ]
542                    }
543                }
544            }
545        }"#
546    }
547
548    fn typed_descriptor_json() -> &'static str {
549        r#"{
550            "context": {
551                "eip712": {
552                    "deployments": [
553                        { "chainId": 1, "address": "0x0000000000000000000000000000000000000001" }
554                    ]
555                }
556            },
557            "metadata": {
558                "owner": "test",
559                "enums": {},
560                "constants": {},
561                "addressBook": {},
562                "maps": {}
563            },
564            "display": {
565                "definitions": {},
566                "formats": {
567                    "Mail(address from,string contents)": {
568                        "intent": "Sign mail",
569                        "fields": [
570                            {
571                                "path": "@.from",
572                                "label": "From",
573                                "format": "address"
574                            },
575                            {
576                                "path": "contents",
577                                "label": "Contents",
578                                "format": "raw"
579                            }
580                        ]
581                    }
582                }
583            }
584        }"#
585    }
586
587    fn typed_data_json() -> &'static str {
588        r#"{
589            "types": {
590                "EIP712Domain": [
591                    { "name": "chainId", "type": "uint256" },
592                    { "name": "verifyingContract", "type": "address" }
593                ],
594                "Mail": [
595                    { "name": "from", "type": "address" },
596                    { "name": "contents", "type": "string" }
597                ]
598            },
599            "primaryType": "Mail",
600            "domain": {
601                "chainId": 1,
602                "verifyingContract": "0x0000000000000000000000000000000000000001"
603            },
604            "container": {
605                "from": "0x0000000000000000000000000000000000000002"
606            },
607            "message": {
608                "from": "0x0000000000000000000000000000000000000002",
609                "contents": "hello"
610            }
611        }"#
612    }
613
614    fn transfer_calldata_hex() -> &'static str {
615        "a9059cbb000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000003e8"
616    }
617
618    fn transfer_transaction() -> TransactionInput {
619        TransactionInput {
620            chain_id: 1,
621            to: "0xdac17f958d2ee523a2206206994597c13d831ec7".to_string(),
622            calldata_hex: transfer_calldata_hex().to_string(),
623            value_hex: None,
624            from_address: None,
625        }
626    }
627
628    #[tokio::test]
629    async fn format_calldata_success() {
630        let result = clear_signing_format_calldata(
631            vec![calldata_descriptor_json().to_string()],
632            transfer_transaction(),
633            None,
634        )
635        .await
636        .expect("calldata formatting should succeed");
637
638        assert_eq!(result.intent, "Transfer tokens");
639        assert_eq!(result.entries.len(), 2);
640
641        match &result.entries[0] {
642            DisplayEntry::Item(item) => {
643                assert_eq!(item.label, "To");
644            }
645            _ => {
646                panic!("expected item entry");
647            }
648        }
649    }
650
651    #[tokio::test]
652    async fn format_typed_success() {
653        let result = clear_signing_format_typed_data(
654            vec![typed_descriptor_json().to_string()],
655            typed_data_json().to_string(),
656            None,
657        )
658        .await
659        .expect("typed formatting should succeed");
660
661        assert_eq!(result.intent, "Sign mail");
662        assert_eq!(result.entries.len(), 2);
663    }
664
665    #[tokio::test]
666    async fn format_typed_blockheight_uses_data_provider_ffi() {
667        let descriptor_json = r#"{
668            "context": {
669                "eip712": {
670                    "deployments": [
671                        { "chainId": 1, "address": "0x0000000000000000000000000000000000000001" }
672                    ]
673                }
674            },
675            "metadata": {
676                "owner": "test",
677                "enums": {},
678                "constants": {},
679                "maps": {}
680            },
681            "display": {
682                "definitions": {},
683                "formats": {
684                    "Expiry(uint256 blockNumber)": {
685                        "intent": "Expiry",
686                        "fields": [
687                            {
688                                "path": "blockNumber",
689                                "label": "Expiry",
690                                "format": "date",
691                                "params": { "encoding": "blockheight" }
692                            }
693                        ]
694                    }
695                }
696            }
697        }"#;
698
699        let typed_data_json = r#"{
700            "types": {
701                "EIP712Domain": [
702                    { "name": "chainId", "type": "uint256" },
703                    { "name": "verifyingContract", "type": "address" }
704                ],
705                "Expiry": [
706                    { "name": "blockNumber", "type": "uint256" }
707                ]
708            },
709            "primaryType": "Expiry",
710            "domain": {
711                "chainId": 1,
712                "verifyingContract": "0x0000000000000000000000000000000000000001"
713            },
714            "message": {
715                "blockNumber": 19500000
716            }
717        }"#;
718
719        let mock_provider: Arc<dyn DataProviderFfi> = Arc::new(MockDataProviderFfi);
720        let result = clear_signing_format_typed_data(
721            vec![descriptor_json.to_string()],
722            typed_data_json.to_string(),
723            Some(mock_provider),
724        )
725        .await
726        .expect("typed blockheight formatting should succeed");
727
728        match &result.entries[0] {
729            DisplayEntry::Item(item) => assert_eq!(item.value, "2024-03-09 16:00:00 UTC"),
730            _ => panic!("expected item entry"),
731        }
732    }
733
734    #[tokio::test]
735    async fn format_calldata_invalid_descriptor_json() {
736        let err =
737            clear_signing_format_calldata(vec!["{".to_string()], transfer_transaction(), None)
738                .await
739                .expect_err("invalid descriptor should fail");
740
741        assert!(matches!(err, FormatFailure::InvalidDescriptor { .. }));
742    }
743
744    #[tokio::test]
745    async fn format_typed_invalid_typed_data_json() {
746        let err = clear_signing_format_typed_data(
747            vec![typed_descriptor_json().to_string()],
748            "{".to_string(),
749            None,
750        )
751        .await
752        .expect_err("invalid typed data should fail");
753
754        assert!(matches!(err, FormatFailure::InvalidInput { .. }));
755    }
756
757    #[tokio::test]
758    async fn format_calldata_invalid_calldata_hex() {
759        let mut tx = transfer_transaction();
760        tx.calldata_hex = "zz".to_string();
761
762        let err =
763            clear_signing_format_calldata(vec![calldata_descriptor_json().to_string()], tx, None)
764                .await
765                .expect_err("invalid calldata hex should fail");
766
767        assert!(matches!(err, FormatFailure::InvalidInput { .. }));
768    }
769
770    #[tokio::test]
771    async fn format_calldata_invalid_value_hex() {
772        let mut tx = transfer_transaction();
773        tx.value_hex = Some("zz".to_string());
774
775        let err =
776            clear_signing_format_calldata(vec![calldata_descriptor_json().to_string()], tx, None)
777                .await
778                .expect_err("invalid value hex should fail");
779
780        assert!(matches!(err, FormatFailure::InvalidInput { .. }));
781    }
782
783    #[tokio::test]
784    async fn format_calldata_accepts_0x_prefix() {
785        let no_prefix = clear_signing_format_calldata(
786            vec![calldata_descriptor_json().to_string()],
787            transfer_transaction(),
788            None,
789        )
790        .await
791        .expect("no-prefix calldata should succeed");
792
793        let mut tx_with_prefix = transfer_transaction();
794        tx_with_prefix.calldata_hex = format!("0x{}", transfer_calldata_hex());
795        tx_with_prefix.value_hex = Some("0x00".to_string());
796
797        let with_prefix = clear_signing_format_calldata(
798            vec![calldata_descriptor_json().to_string()],
799            tx_with_prefix,
800            None,
801        )
802        .await
803        .expect("prefixed calldata should succeed");
804
805        assert_eq!(no_prefix.intent, with_prefix.intent);
806        assert_eq!(no_prefix.entries.len(), with_prefix.entries.len());
807    }
808
809    // -----------------------------------------------------------------------
810    // Mock DataProviderFfi to validate end-to-end proxy wiring
811    // -----------------------------------------------------------------------
812
813    struct MockDataProviderFfi;
814
815    impl DataProviderFfi for MockDataProviderFfi {
816        fn resolve_token(&self, _chain_id: u64, _address: String) -> Option<TokenMetaFfi> {
817            None
818        }
819        fn resolve_ens_name(
820            &self,
821            _address: String,
822            _chain_id: u64,
823            _types: Option<Vec<String>>,
824        ) -> Option<String> {
825            None
826        }
827        fn resolve_local_name(
828            &self,
829            address: String,
830            _chain_id: u64,
831            _types: Option<Vec<String>>,
832        ) -> Option<String> {
833            if address.to_lowercase() == "0x0000000000000000000000000000000000000001".to_lowercase()
834            {
835                Some("My Contact".to_string())
836            } else {
837                None
838            }
839        }
840        fn resolve_nft_collection_name(
841            &self,
842            _collection_address: String,
843            _chain_id: u64,
844        ) -> Option<String> {
845            None
846        }
847        fn resolve_block_timestamp(&self, _chain_id: u64, block_number: u64) -> Option<u64> {
848            if block_number == 19_500_000 {
849                Some(1_710_000_000)
850            } else {
851                None
852            }
853        }
854        fn get_implementation_address(&self, _chain_id: u64, _address: String) -> Option<String> {
855            None
856        }
857    }
858
859    #[tokio::test]
860    async fn format_calldata_with_data_provider_ffi() {
861        // Descriptor that uses addressName format (triggers local name resolution)
862        let descriptor_json = r#"{
863            "context": {
864                "contract": {
865                    "deployments": [
866                        { "chainId": 1, "address": "0xdac17f958d2ee523a2206206994597c13d831ec7" }
867                    ]
868                }
869            },
870            "metadata": {
871                "owner": "test",
872                "contractName": "Tether USD",
873                "enums": {},
874                "constants": {},
875                "addressBook": {},
876                "maps": {}
877            },
878            "display": {
879                "definitions": {},
880                "formats": {
881                    "transfer(address,uint256)": {
882                        "intent": "Transfer tokens",
883                        "fields": [
884                            {
885                                "path": "@.0",
886                                "label": "To",
887                                "format": "addressName",
888                                "params": {
889                                    "sources": ["local"]
890                                }
891                            },
892                            {
893                                "path": "@.1",
894                                "label": "Amount",
895                                "format": "number"
896                            }
897                        ]
898                    }
899                }
900            }
901        }"#;
902
903        let mock_provider: Arc<dyn DataProviderFfi> = Arc::new(MockDataProviderFfi);
904
905        let result = clear_signing_format_calldata(
906            vec![descriptor_json.to_string()],
907            transfer_transaction(),
908            Some(mock_provider),
909        )
910        .await
911        .expect("calldata formatting with data provider should succeed");
912
913        assert_eq!(result.intent, "Transfer tokens");
914        assert_eq!(result.entries.len(), 2);
915
916        // The "To" address (0x...0001) should resolve to "My Contact" via mock provider
917        match &result.entries[0] {
918            DisplayEntry::Item(item) => {
919                assert_eq!(item.label, "To");
920                assert_eq!(item.value, "My Contact");
921            }
922            _ => panic!("expected item entry"),
923        }
924    }
925
926    /// DataProvider that reports a proxy → implementation mapping.
927    ///
928    /// Models the real Aave V3 Pool on Optimism: the descriptor in the registry is keyed
929    /// at the proxy address (0x794a6135…), but the wallet's proxy detection returns the
930    /// current implementation contract (0x9b8e56…). Regression coverage for the FFI path
931    /// in `clear_signing_format_calldata` when descriptor is keyed at the proxy.
932    struct ProxyAwareMockDataProviderFfi {
933        proxy: String,
934        implementation: String,
935    }
936
937    impl DataProviderFfi for ProxyAwareMockDataProviderFfi {
938        fn resolve_token(&self, _chain_id: u64, _address: String) -> Option<TokenMetaFfi> {
939            None
940        }
941        fn resolve_ens_name(
942            &self,
943            _address: String,
944            _chain_id: u64,
945            _types: Option<Vec<String>>,
946        ) -> Option<String> {
947            None
948        }
949        fn resolve_local_name(
950            &self,
951            _address: String,
952            _chain_id: u64,
953            _types: Option<Vec<String>>,
954        ) -> Option<String> {
955            None
956        }
957        fn resolve_nft_collection_name(
958            &self,
959            _collection_address: String,
960            _chain_id: u64,
961        ) -> Option<String> {
962            None
963        }
964        fn resolve_block_timestamp(&self, _chain_id: u64, _block_number: u64) -> Option<u64> {
965            None
966        }
967        fn get_implementation_address(&self, _chain_id: u64, address: String) -> Option<String> {
968            if address.to_lowercase() == self.proxy.to_lowercase() {
969                Some(self.implementation.clone())
970            } else {
971                None
972            }
973        }
974    }
975
976    /// Regression: descriptor keyed at proxy address + wallet resolves implementation.
977    ///
978    /// Mirrors the Aave V3 Pool scenario on Optimism:
979    ///   - registry descriptor deployment is `{ chainId: 10, address: PROXY }`
980    ///   - wallet's `DataProviderFfi::get_implementation_address(PROXY)` returns IMPL
981    ///   - user signs a call to PROXY (the `to` address)
982    ///
983    /// Expected: formatting succeeds using the proxy-keyed descriptor. If the FFI layer
984    /// unconditionally substitutes IMPL into `match_address`, matching fails and this
985    /// test errors with "no outer descriptor matches chain_id=10 address=IMPL".
986    #[tokio::test]
987    async fn format_calldata_proxy_keyed_descriptor_survives_impl_lookup() {
988        const PROXY: &str = "0x794a61358d6845594f94dc1db02a252b5b4814ad";
989        const IMPL: &str = "0x9b8e56d890bffbbd385fe8b0e73803a82fcef2f1";
990        const CHAIN_ID: u64 = 10;
991
992        let descriptor_json = format!(
993            r#"{{
994                "context": {{
995                    "contract": {{
996                        "deployments": [
997                            {{ "chainId": {CHAIN_ID}, "address": "{PROXY}" }}
998                        ]
999                    }}
1000                }},
1001                "metadata": {{
1002                    "owner": "Aave DAO",
1003                    "contractName": "Aave V3 Pool",
1004                    "enums": {{}},
1005                    "constants": {{}},
1006                    "addressBook": {{}},
1007                    "maps": {{}}
1008                }},
1009                "display": {{
1010                    "definitions": {{}},
1011                    "formats": {{
1012                        "transfer(address,uint256)": {{
1013                            "intent": "Transfer tokens",
1014                            "fields": [
1015                                {{ "path": "@.0", "label": "To", "format": "raw" }},
1016                                {{ "path": "@.1", "label": "Amount", "format": "number" }}
1017                            ]
1018                        }}
1019                    }}
1020                }}
1021            }}"#
1022        );
1023
1024        let tx = TransactionInput {
1025            chain_id: CHAIN_ID,
1026            to: PROXY.to_string(),
1027            calldata_hex: transfer_calldata_hex().to_string(),
1028            value_hex: None,
1029            from_address: Some("0xbf01daf454dce008d3e2bfd47d5e186f71477253".to_string()),
1030        };
1031
1032        let provider: Arc<dyn DataProviderFfi> = Arc::new(ProxyAwareMockDataProviderFfi {
1033            proxy: PROXY.to_string(),
1034            implementation: IMPL.to_string(),
1035        });
1036
1037        let result = clear_signing_format_calldata(vec![descriptor_json], tx, Some(provider))
1038            .await
1039            .expect(
1040                "proxy-keyed descriptor must format successfully even when the wallet \
1041                 resolves an implementation address for the proxy (Aave V3 Pool pattern)",
1042            );
1043
1044        assert_eq!(result.intent, "Transfer tokens");
1045        assert_eq!(result.entries.len(), 2);
1046    }
1047
1048    /// Regression guard against masking unrelated descriptor errors.
1049    ///
1050    /// When a descriptor is keyed at `tx.to`, the FFI must NOT call
1051    /// `get_implementation_address` nor retry against an implementation address on
1052    /// descriptor/render errors that are unrelated to proxy matching (e.g. duplicate
1053    /// selectors, malformed fields). Otherwise the caller sees a misleading
1054    /// "no outer descriptor matches <impl>" instead of the real error.
1055    #[tokio::test]
1056    async fn format_calldata_does_not_retry_on_unrelated_descriptor_error() {
1057        const CONTRACT: &str = "0xdac17f958d2ee523a2206206994597c13d831ec7";
1058        const CHAIN_ID: u64 = 1;
1059
1060        // Two format keys sharing selector 0xa9059cbb — the engine rejects this
1061        // with Error::Descriptor("duplicate selectors..."), which converts to
1062        // FormatFailure::InvalidDescriptor. The pre-check must see a match at
1063        // `tx.to` and skip the impl lookup entirely.
1064        let descriptor_json = format!(
1065            r#"{{
1066                "context": {{
1067                    "contract": {{
1068                        "deployments": [
1069                            {{ "chainId": {CHAIN_ID}, "address": "{CONTRACT}" }}
1070                        ]
1071                    }}
1072                }},
1073                "metadata": {{
1074                    "owner": "test",
1075                    "contractName": "Dup",
1076                    "enums": {{}},
1077                    "constants": {{}},
1078                    "addressBook": {{}},
1079                    "maps": {{}}
1080                }},
1081                "display": {{
1082                    "definitions": {{}},
1083                    "formats": {{
1084                        "transfer(address,uint256)": {{
1085                            "intent": "Transfer A",
1086                            "fields": [
1087                                {{ "path": "@.0", "label": "To", "format": "raw" }},
1088                                {{ "path": "@.1", "label": "Amount", "format": "number" }}
1089                            ]
1090                        }},
1091                        "transfer(address dst, uint256 wad)": {{
1092                            "intent": "Transfer B",
1093                            "fields": [
1094                                {{ "path": "dst", "label": "Dest", "format": "raw" }},
1095                                {{ "path": "wad", "label": "Wad", "format": "number" }}
1096                            ]
1097                        }}
1098                    }}
1099                }}
1100            }}"#
1101        );
1102
1103        struct PanicOnImplLookup;
1104        impl DataProviderFfi for PanicOnImplLookup {
1105            fn resolve_token(&self, _: u64, _: String) -> Option<TokenMetaFfi> {
1106                None
1107            }
1108            fn resolve_ens_name(
1109                &self,
1110                _: String,
1111                _: u64,
1112                _: Option<Vec<String>>,
1113            ) -> Option<String> {
1114                None
1115            }
1116            fn resolve_local_name(
1117                &self,
1118                _: String,
1119                _: u64,
1120                _: Option<Vec<String>>,
1121            ) -> Option<String> {
1122                None
1123            }
1124            fn resolve_nft_collection_name(&self, _: String, _: u64) -> Option<String> {
1125                None
1126            }
1127            fn resolve_block_timestamp(&self, _: u64, _: u64) -> Option<u64> {
1128                None
1129            }
1130            fn get_implementation_address(&self, _: u64, _: String) -> Option<String> {
1131                panic!(
1132                    "get_implementation_address must not be called when tx.to already \
1133                     matches a descriptor deployment"
1134                );
1135            }
1136        }
1137
1138        let provider: Arc<dyn DataProviderFfi> = Arc::new(PanicOnImplLookup);
1139
1140        let tx = TransactionInput {
1141            chain_id: CHAIN_ID,
1142            to: CONTRACT.to_string(),
1143            calldata_hex: transfer_calldata_hex().to_string(),
1144            value_hex: None,
1145            from_address: None,
1146        };
1147
1148        let err = clear_signing_format_calldata(vec![descriptor_json], tx, Some(provider))
1149            .await
1150            .expect_err("duplicate selectors must surface the real error");
1151
1152        match err {
1153            FormatFailure::InvalidDescriptor { message, .. } => {
1154                assert!(
1155                    message.contains("duplicate selectors"),
1156                    "expected duplicate-selector message, got: {message}"
1157                );
1158                assert!(
1159                    !message.contains("no outer descriptor matches"),
1160                    "FFI must not retry against impl address on unrelated descriptor errors; \
1161                     got: {message}"
1162                );
1163            }
1164            other => panic!("expected InvalidDescriptor, got {other:?}"),
1165        }
1166    }
1167
1168    /// Regression guard: descriptor keyed at the singleton implementation address
1169    /// (Safe pattern) — every deployed Safe proxy delegatecalls the same singleton,
1170    /// so the registry keys one descriptor at the impl address. The FFI must fall
1171    /// back from `tx.to` to the wallet-provided implementation address to find it.
1172    #[tokio::test]
1173    async fn format_calldata_safe_pattern_descriptor_resolves_via_implementation() {
1174        const PROXY: &str = "0x1111111111111111111111111111111111111111";
1175        const IMPL: &str = "0x6666666666666666666666666666666666666666";
1176        const CHAIN_ID: u64 = 1;
1177
1178        let descriptor_json = format!(
1179            r#"{{
1180                "context": {{
1181                    "contract": {{
1182                        "deployments": [
1183                            {{ "chainId": {CHAIN_ID}, "address": "{IMPL}" }}
1184                        ]
1185                    }}
1186                }},
1187                "metadata": {{
1188                    "owner": "Safe",
1189                    "contractName": "Safe Singleton",
1190                    "enums": {{}},
1191                    "constants": {{}},
1192                    "addressBook": {{}},
1193                    "maps": {{}}
1194                }},
1195                "display": {{
1196                    "definitions": {{}},
1197                    "formats": {{
1198                        "transfer(address,uint256)": {{
1199                            "intent": "Transfer tokens",
1200                            "fields": [
1201                                {{ "path": "@.0", "label": "To", "format": "raw" }},
1202                                {{ "path": "@.1", "label": "Amount", "format": "number" }}
1203                            ]
1204                        }}
1205                    }}
1206                }}
1207            }}"#
1208        );
1209
1210        let tx = TransactionInput {
1211            chain_id: CHAIN_ID,
1212            to: PROXY.to_string(),
1213            calldata_hex: transfer_calldata_hex().to_string(),
1214            value_hex: None,
1215            from_address: None,
1216        };
1217
1218        let provider: Arc<dyn DataProviderFfi> = Arc::new(ProxyAwareMockDataProviderFfi {
1219            proxy: PROXY.to_string(),
1220            implementation: IMPL.to_string(),
1221        });
1222
1223        let result = clear_signing_format_calldata(vec![descriptor_json], tx, Some(provider))
1224            .await
1225            .expect(
1226                "Safe-pattern descriptor (keyed at impl singleton) must format when \
1227                 `tx.to` is the proxy and the wallet resolves the impl address",
1228            );
1229
1230        assert_eq!(result.intent, "Transfer tokens");
1231        assert_eq!(result.entries.len(), 2);
1232    }
1233
1234    /// Simulates the exact wallet flow: descriptor JSON → serde round-trip → format_typed_data.
1235    /// Tests the encodeType format key matching through the FFI layer.
1236    #[tokio::test]
1237    async fn format_typed_data_velora_encode_type_key() {
1238        // Real descriptor from remote registry (with encodeType format key)
1239        let raw_descriptor_json = r#"{
1240            "context": {
1241                "eip712": {
1242                    "deployments": [
1243                        { "chainId": 1, "address": "0x0000000000bbf5c5fd284e657f01bd000933c96d" },
1244                        { "chainId": 10, "address": "0x0000000000bbf5c5fd284e657f01bd000933c96d" }
1245                    ],
1246                    "domain": { "name": "Portikus", "version": "2.0.0" }
1247                }
1248            },
1249            "metadata": { "owner": "Velora" },
1250            "display": {
1251                "formats": {
1252                    "Order(address owner,address beneficiary,address srcToken,address destToken,uint256 srcAmount,uint256 destAmount,uint256 expectedAmount,uint256 deadline,uint8 kind,uint256 nonce,uint256 partnerAndFee,bytes permit,bytes metadata,Bridge bridge)Bridge(bytes4 protocolSelector,uint256 destinationChainId,address outputToken,int8 scalingFactor,bytes protocolData)": {
1253                        "intent": "Swap order",
1254                        "fields": [
1255                            { "path": "srcAmount", "label": "Amount to send", "format": "tokenAmount", "params": { "tokenPath": "srcToken" } },
1256                            { "path": "destAmount", "label": "Minimum to receive", "format": "tokenAmount", "params": { "tokenPath": "destToken" } },
1257                            { "path": "beneficiary", "label": "Beneficiary", "format": "raw" },
1258                            { "path": "deadline", "label": "Expiration time", "format": "date", "params": { "encoding": "timestamp" } }
1259                        ]
1260                    }
1261                }
1262            }
1263        }"#;
1264
1265        // Simulate the resolve round-trip: parse → serialize (like clear_signing_resolve_descriptor does)
1266        let descriptor: Descriptor = serde_json::from_str(raw_descriptor_json).unwrap();
1267        let round_tripped_json = serde_json::to_string(&descriptor).unwrap();
1268
1269        // Verify the format key survives round-trip
1270        assert!(
1271            round_tripped_json.contains("Order(address owner"),
1272            "encodeType key lost during serde round-trip: {}",
1273            round_tripped_json
1274        );
1275
1276        let typed_data_json = r#"{
1277            "domain": {
1278                "chainId": 10,
1279                "name": "Portikus",
1280                "version": "2.0.0",
1281                "verifyingContract": "0x0000000000bbf5c5fd284e657f01bd000933c96d"
1282            },
1283            "message": {
1284                "owner": "0xbf01daf454dce008d3e2bfd47d5e186f71477253",
1285                "beneficiary": "0xbf01daf454dce008d3e2bfd47d5e186f71477253",
1286                "srcToken": "0x94b008aa00579c1307b0ef2c499ad98a8ce58e58",
1287                "destToken": "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
1288                "srcAmount": "38627265",
1289                "destAmount": "18805928711910788",
1290                "expectedAmount": "18900430866241998",
1291                "deadline": 1774258780,
1292                "nonce": "1774258180237",
1293                "permit": "0x",
1294                "partnerAndFee": "90631063861114836560958097440945986548822432573276877133894239693005947666959",
1295                "bridge": {
1296                    "protocolSelector": "0x00000000",
1297                    "destinationChainId": 0,
1298                    "outputToken": "0x0000000000000000000000000000000000000000",
1299                    "scalingFactor": 0,
1300                    "protocolData": "0x"
1301                },
1302                "kind": 0,
1303                "metadata": "0x"
1304            },
1305            "primaryType": "Order",
1306            "types": {
1307                "EIP712Domain": [
1308                    { "name": "name", "type": "string" },
1309                    { "name": "version", "type": "string" },
1310                    { "name": "chainId", "type": "uint256" },
1311                    { "name": "verifyingContract", "type": "address" }
1312                ],
1313                "Order": [
1314                    { "name": "owner", "type": "address" },
1315                    { "name": "beneficiary", "type": "address" },
1316                    { "name": "srcToken", "type": "address" },
1317                    { "name": "destToken", "type": "address" },
1318                    { "name": "srcAmount", "type": "uint256" },
1319                    { "name": "destAmount", "type": "uint256" },
1320                    { "name": "expectedAmount", "type": "uint256" },
1321                    { "name": "deadline", "type": "uint256" },
1322                    { "name": "kind", "type": "uint8" },
1323                    { "name": "nonce", "type": "uint256" },
1324                    { "name": "partnerAndFee", "type": "uint256" },
1325                    { "name": "permit", "type": "bytes" },
1326                    { "name": "metadata", "type": "bytes" },
1327                    { "name": "bridge", "type": "Bridge" }
1328                ],
1329                "Bridge": [
1330                    { "name": "protocolSelector", "type": "bytes4" },
1331                    { "name": "destinationChainId", "type": "uint256" },
1332                    { "name": "outputToken", "type": "address" },
1333                    { "name": "scalingFactor", "type": "int8" },
1334                    { "name": "protocolData", "type": "bytes" }
1335                ]
1336            }
1337        }"#;
1338
1339        // Call through the FFI function with the round-tripped descriptor
1340        let result = clear_signing_format_typed_data(
1341            vec![round_tripped_json],
1342            typed_data_json.to_string(),
1343            None,
1344        )
1345        .await
1346        .expect("typed data formatting should succeed");
1347
1348        assert_eq!(result.intent, "Swap order");
1349        assert!(
1350            result.diagnostics().is_empty(),
1351            "unexpected diagnostics: {:?}",
1352            result.diagnostics()
1353        );
1354        assert_eq!(result.entries.len(), 4);
1355
1356        match &result.entries[0] {
1357            DisplayEntry::Item(item) => assert_eq!(item.label, "Amount to send"),
1358            _ => panic!("expected Item"),
1359        }
1360        match &result.entries[1] {
1361            DisplayEntry::Item(item) => assert_eq!(item.label, "Minimum to receive"),
1362            _ => panic!("expected Item"),
1363        }
1364        match &result.entries[2] {
1365            DisplayEntry::Item(item) => assert_eq!(item.label, "Beneficiary"),
1366            _ => panic!("expected Item"),
1367        }
1368        match &result.entries[3] {
1369            DisplayEntry::Item(item) => assert_eq!(item.label, "Expiration time"),
1370            _ => panic!("expected Item"),
1371        }
1372    }
1373}