clear-signing 0.1.0

ERC-7730 v2 clear signing library: decodes and formats Ethereum calldata and EIP-712 typed data for human-readable display.
Documentation
use crate::error::Error;
use crate::types::display::DisplayFormat;

use super::source::ResolvedDescriptor;

pub(crate) struct SelectedTypedDescriptor<'a> {
    pub outer: &'a ResolvedDescriptor,
    pub format: &'a DisplayFormat,
}

pub(crate) struct TypedOuterNoMatch<'a> {
    pub domain_errors: Vec<String>,
    pub format_misses: Vec<&'a ResolvedDescriptor>,
}

pub(crate) enum TypedOuterSelection<'a> {
    Selected(SelectedTypedDescriptor<'a>),
    NoMatch(TypedOuterNoMatch<'a>),
}

pub(crate) fn select_typed_outer_descriptor<'a>(
    descriptors: &'a [ResolvedDescriptor],
    data: &crate::eip712::TypedData,
) -> Result<TypedOuterSelection<'a>, Error> {
    let Some(chain_id) = data.domain.chain_id else {
        return Ok(TypedOuterSelection::NoMatch(TypedOuterNoMatch {
            domain_errors: Vec::new(),
            format_misses: Vec::new(),
        }));
    };
    let Some(verifying_contract) = data.domain.verifying_contract.as_deref() else {
        return Ok(TypedOuterSelection::NoMatch(TypedOuterNoMatch {
            domain_errors: Vec::new(),
            format_misses: Vec::new(),
        }));
    };

    let mut matches = Vec::new();
    let mut domain_errors = Vec::new();
    let mut format_misses = Vec::new();

    for descriptor in descriptors {
        let deployment_matches = descriptor
            .descriptor
            .context
            .deployments()
            .iter()
            .any(|dep| {
                dep.chain_id == chain_id && dep.address.eq_ignore_ascii_case(verifying_contract)
            });
        if !deployment_matches {
            continue;
        }

        match crate::eip712::validate_descriptor_domain_binding(&descriptor.descriptor, data) {
            Ok(()) => {}
            Err(Error::Descriptor(message)) => {
                domain_errors.push(message);
                continue;
            }
            Err(err) => return Err(err),
        }

        match crate::eip712::find_typed_format_optional(&descriptor.descriptor, data)? {
            Some(format) => matches.push(SelectedTypedDescriptor {
                outer: descriptor,
                format,
            }),
            None => format_misses.push(descriptor),
        }
    }

    match matches.len() {
        1 => Ok(TypedOuterSelection::Selected(matches.pop().expect("single match"))),
        0 => Ok(TypedOuterSelection::NoMatch(TypedOuterNoMatch {
            domain_errors,
            format_misses,
        })),
        _ => Err(Error::Descriptor(format!(
            "multiple EIP-712 descriptors match chain_id={} verifying_contract={} after domain and encodeType validation",
            chain_id, verifying_contract
        ))),
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::types::display::intent_as_string;

    use super::super::test_support::{
        exclusive_dutch_order_typed_data, resolved_permit2_descriptor,
    };

    #[test]
    fn test_select_typed_outer_descriptor_returns_selected_format() {
        let typed_data = exclusive_dutch_order_typed_data();
        let format_key = "PermitWitnessTransferFrom(TokenPermissions permitted,address spender,uint256 nonce,uint256 deadline,ExclusiveDutchOrder witness)DutchOutput(address token,uint256 startAmount,uint256 endAmount,address recipient)ExclusiveDutchOrder(OrderInfo info,uint256 decayStartTime,uint256 decayEndTime,address exclusiveFiller,uint256 exclusivityOverrideBps,address inputToken,uint256 inputStartAmount,uint256 inputEndAmount,DutchOutput[] outputs)OrderInfo(address reactor,address swapper,uint256 nonce,uint256 deadline,address additionalValidationContract,bytes additionalValidationData)TokenPermissions(address token,uint256 amount)";
        let descriptors = vec![
            resolved_permit2_descriptor("Wrong Shape", "PermitWitnessTransferFrom(TokenPermissions permitted,address spender,uint256 nonce,uint256 deadline,DutchOrder witness)DutchOrder(OrderInfo info,uint256 decayStartTime,uint256 decayEndTime,address inputToken,uint256 inputStartAmount,uint256 inputEndAmount,DutchOutput[] outputs)DutchOutput(address token,uint256 startAmount,uint256 endAmount,address recipient)OrderInfo(address reactor,address swapper,uint256 nonce,uint256 deadline,address additionalValidationContract,bytes additionalValidationData)TokenPermissions(address token,uint256 amount)", None),
            resolved_permit2_descriptor("Exclusive Dutch Order", format_key, None),
        ];

        match select_typed_outer_descriptor(&descriptors, &typed_data).expect("selection") {
            TypedOuterSelection::Selected(selected) => {
                assert_eq!(
                    selected.outer.descriptor.metadata.owner.as_deref(),
                    Some("Exclusive Dutch Order")
                );
                assert_eq!(
                    selected
                        .format
                        .intent
                        .as_ref()
                        .map(intent_as_string)
                        .as_deref(),
                    Some("Exclusive Dutch Order")
                );
            }
            TypedOuterSelection::NoMatch(_) => panic!("expected selected match"),
        }
    }

    #[test]
    fn test_select_typed_outer_descriptor_reports_single_format_miss() {
        let typed_data = exclusive_dutch_order_typed_data();
        let descriptors = vec![resolved_permit2_descriptor(
            "Wrong Shape",
            "PermitWitnessTransferFrom(TokenPermissions permitted,address spender,uint256 nonce,uint256 deadline,DutchOrder witness)DutchOrder(OrderInfo info,uint256 decayStartTime,uint256 decayEndTime,address inputToken,uint256 inputStartAmount,uint256 inputEndAmount,DutchOutput[] outputs)DutchOutput(address token,uint256 startAmount,uint256 endAmount,address recipient)OrderInfo(address reactor,address swapper,uint256 nonce,uint256 deadline,address additionalValidationContract,bytes additionalValidationData)TokenPermissions(address token,uint256 amount)",
            None,
        )];

        match select_typed_outer_descriptor(&descriptors, &typed_data).expect("selection") {
            TypedOuterSelection::Selected(_) => panic!("expected no match"),
            TypedOuterSelection::NoMatch(no_match) => {
                assert!(no_match.domain_errors.is_empty());
                assert_eq!(no_match.format_misses.len(), 1);
                let err = crate::eip712::find_typed_format(
                    &no_match.format_misses[0].descriptor,
                    &typed_data,
                )
                .expect_err("single format miss should produce exact mismatch error");
                assert!(err.to_string().contains("expected encodeType"));
            }
        }
    }

    #[test]
    fn test_select_typed_outer_descriptor_retains_domain_errors() {
        let typed_data = exclusive_dutch_order_typed_data();
        let format_key = "PermitWitnessTransferFrom(TokenPermissions permitted,address spender,uint256 nonce,uint256 deadline,ExclusiveDutchOrder witness)DutchOutput(address token,uint256 startAmount,uint256 endAmount,address recipient)ExclusiveDutchOrder(OrderInfo info,uint256 decayStartTime,uint256 decayEndTime,address exclusiveFiller,uint256 exclusivityOverrideBps,address inputToken,uint256 inputStartAmount,uint256 inputEndAmount,DutchOutput[] outputs)OrderInfo(address reactor,address swapper,uint256 nonce,uint256 deadline,address additionalValidationContract,bytes additionalValidationData)TokenPermissions(address token,uint256 amount)";
        let descriptors = vec![resolved_permit2_descriptor(
            "Wrong Domain",
            format_key,
            Some(serde_json::json!({
                "domain": { "name": "Not Permit2" }
            })),
        )];

        match select_typed_outer_descriptor(&descriptors, &typed_data).expect("selection") {
            TypedOuterSelection::Selected(_) => panic!("expected no match"),
            TypedOuterSelection::NoMatch(no_match) => {
                assert!(no_match.format_misses.is_empty());
                assert_eq!(no_match.domain_errors.len(), 1);
                assert!(no_match.domain_errors[0].contains("domain.name mismatch"));
            }
        }
    }
}