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#[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#[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 fn get_implementation_address(&self, chain_id: u64, address: String) -> Option<String>;
104}
105
106pub 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#[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#[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 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#[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#[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#[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 let mut descriptors = crate::resolver::resolve_descriptors_for_typed_data(&typed_data, source)
333 .await
334 .map_err(FormatFailure::from)?;
335
336 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#[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 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#[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
411enum 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 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 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 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 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 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 #[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 #[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 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 #[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 #[tokio::test]
1237 async fn format_typed_data_velora_encode_type_key() {
1238 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 let descriptor: Descriptor = serde_json::from_str(raw_descriptor_json).unwrap();
1267 let round_tripped_json = serde_json::to_string(&descriptor).unwrap();
1268
1269 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 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}