r402-protocol 0.21.0

Wire types, CAIP-2, scheme markers, and money for the x402 payment protocol.
Documentation
#![allow(unused_crate_dependencies, reason = "sibling tests consume them")]
#![allow(
    clippy::tests_outside_test_module,
    reason = "integration test binaries put #[test] fns at file scope"
)]
#![allow(
    clippy::unwrap_used,
    clippy::expect_used,
    clippy::panic,
    clippy::missing_panics_doc,
    reason = "idiomatic test-code patterns"
)]

//! Property-based payment-envelope round-trip tests.

use std::collections::HashMap;

use compact_str::CompactString;
use proptest::collection::{hash_map, vec};
use proptest::prelude::*;
use r402_protocol::payment::{
    ExtensionEntry, Extensions, PaymentRequired, PaymentRequirements, ResourceInfo, SettleResponse,
    SupportedPaymentKind, SupportedResponse, VerifyResponse,
};
use r402_protocol::{ChainId, ErrorReason};

fn arb_compact_string() -> impl Strategy<Value = CompactString> {
    "[a-zA-Z0-9_:./@\\- ]{0,64}".prop_map(CompactString::from)
}

fn arb_chain_id() -> impl Strategy<Value = ChainId> {
    prop_oneof![
        "(eip155|solana|aptos|stellar):[a-z0-9-]{1,32}",
        "(eip155|solana):\\*",
    ]
    .prop_filter_map("must parse as ChainId", |raw| raw.parse::<ChainId>().ok())
}

fn arb_resource_info() -> impl Strategy<Value = ResourceInfo> {
    (
        arb_compact_string(),
        proptest::option::of(arb_compact_string()),
        proptest::option::of(arb_compact_string()),
    )
        .prop_map(|(url, description, mime)| {
            let mut info = ResourceInfo::new(url);
            if let Some(d) = description {
                info = info.with_description(d);
            }
            if let Some(m) = mime {
                info = info.with_mime_type(m);
            }
            info
        })
}

fn arb_supported_payment_kind() -> impl Strategy<Value = SupportedPaymentKind> {
    let extra_value = prop_oneof![
        any::<bool>().prop_map(serde_json::Value::Bool),
        any::<i64>().prop_map(serde_json::Value::from),
        arb_compact_string().prop_map(|s| serde_json::Value::String(s.to_string())),
    ];
    (
        any::<u8>(),
        arb_compact_string(),
        arb_compact_string(),
        proptest::option::of(extra_value),
    )
        .prop_map(|(version, scheme, network, extra)| {
            SupportedPaymentKind::new(version, scheme, network).with_optional_extra(extra)
        })
}

fn arb_supported_response() -> impl Strategy<Value = SupportedResponse> {
    let kinds = vec(arb_supported_payment_kind(), 0..4);
    let extensions = vec(arb_compact_string(), 0..4);
    let signers = hash_map(arb_compact_string(), vec(arb_compact_string(), 0..3), 0..4);
    (kinds, extensions, signers).prop_map(|(kinds, extensions, signers)| {
        let map: HashMap<CompactString, Vec<CompactString>> = signers.into_iter().collect();
        SupportedResponse::new()
            .with_kinds(kinds)
            .with_extensions(extensions)
            .with_signers(map)
    })
}

fn arb_extensions() -> impl Strategy<Value = Extensions> {
    let entry = prop_oneof![
        any::<i64>().prop_map(|n| ExtensionEntry::info(serde_json::json!({ "n": n }))),
        any::<bool>().prop_map(|b| ExtensionEntry::raw(serde_json::Value::Bool(b))),
        Just(ExtensionEntry::with_schema(
            serde_json::json!({ "registered": true }),
            serde_json::json!({ "type": "object" }),
        )),
    ];
    vec(("[a-z][a-z0-9-]{0,16}", entry), 0..3)
        .prop_map(|kvs| kvs.into_iter().collect::<Extensions>())
}

fn arb_payment_requirements() -> impl Strategy<Value = PaymentRequirements> {
    (
        arb_compact_string(),
        arb_chain_id(),
        "[0-9]{1,20}".prop_map(CompactString::from),
        arb_compact_string(),
        arb_compact_string(),
        any::<u64>(),
    )
        .prop_map(|(scheme, network, amount, pay_to, asset, max_timeout)| {
            PaymentRequirements::new(scheme, network, amount, pay_to, asset, max_timeout)
        })
}

fn arb_payment_required() -> impl Strategy<Value = PaymentRequired> {
    (
        arb_resource_info(),
        vec(arb_payment_requirements(), 0..3),
        proptest::option::of(arb_compact_string()),
        arb_extensions(),
    )
        .prop_map(|(resource, accepts, error, extensions)| {
            let mut body = PaymentRequired::new(resource).with_accepts(accepts);
            if let Some(err) = error {
                body = body.with_error(err);
            }
            body.with_extensions(extensions)
        })
}

fn arb_error_reason() -> impl Strategy<Value = ErrorReason> {
    prop_oneof![
        Just(ErrorReason::InsufficientFunds),
        Just(ErrorReason::InvalidPayload),
        Just(ErrorReason::InvalidNetwork),
        Just(ErrorReason::UnexpectedSettleError),
        Just(ErrorReason::SettlementPending),
        Just(ErrorReason::DuplicateSettlement),
        Just(ErrorReason::Permit2AllowanceRequired),
        Just(ErrorReason::InvalidExactSolanaPayloadMemoMismatch),
        Just(ErrorReason::IncompatibleSettlementMode),
        Just(ErrorReason::SettlementAborted),
        "[a-z][a-z0-9_]{1,40}".prop_map(|s| ErrorReason::from_wire(&s)),
    ]
}

fn arb_extra() -> impl Strategy<Value = Option<serde_json::Value>> {
    let value = prop_oneof![
        any::<bool>().prop_map(serde_json::Value::Bool),
        any::<i64>().prop_map(serde_json::Value::from),
        arb_compact_string().prop_map(|s| serde_json::Value::String(s.to_string())),
    ];
    proptest::option::of(value)
}

fn arb_verify_response() -> impl Strategy<Value = VerifyResponse> {
    prop_oneof![
        (arb_compact_string(), arb_extensions(), arb_extra()).prop_map(
            |(payer, extensions, extra)| {
                VerifyResponse::Valid {
                    payer: Some(payer),
                    extensions,
                    extra,
                    extension_responses: Extensions::new(),
                }
            }
        ),
        (
            arb_error_reason(),
            proptest::option::of(arb_compact_string()),
            proptest::option::of(arb_compact_string()),
            arb_extensions(),
            arb_extra(),
        )
            .prop_map(|(reason, message, payer, extensions, extra)| {
                VerifyResponse::Invalid {
                    reason: Some(reason),
                    message,
                    payer,
                    extensions,
                    extra,
                    extension_responses: Extensions::new(),
                }
            }),
    ]
}

fn arb_non_empty_compact_string() -> impl Strategy<Value = CompactString> {
    "[a-zA-Z0-9_:./@\\- ]{1,64}".prop_map(CompactString::from)
}

fn arb_settle_response() -> impl Strategy<Value = SettleResponse> {
    prop_oneof![
        (
            arb_compact_string(),
            arb_non_empty_compact_string(),
            arb_compact_string(),
            proptest::option::of("[0-9]{1,20}".prop_map(CompactString::from)),
            arb_extensions(),
            arb_extra(),
        )
            .prop_map(|(payer, transaction, network, amount, extensions, extra)| {
                SettleResponse::Success {
                    payer: Some(payer),
                    transaction,
                    network,
                    amount,
                    extensions,
                    extra,
                    extension_responses: Extensions::new(),
                }
            }),
        (
            arb_error_reason(),
            proptest::option::of(arb_compact_string()),
            proptest::option::of(arb_compact_string()),
            arb_compact_string(),
            arb_compact_string(),
            arb_extensions(),
            arb_extra(),
        )
            .prop_map(
                |(reason, message, payer, transaction, network, extensions, extra)| {
                    let transaction =
                        if reason == ErrorReason::SettlementPending && transaction.is_empty() {
                            CompactString::from("0xpending")
                        } else {
                            transaction
                        };
                    SettleResponse::Failure {
                        reason,
                        message,
                        payer,
                        transaction,
                        network,
                        extensions,
                        extra,
                        extension_responses: Extensions::new(),
                    }
                }
            ),
    ]
}

proptest! {
    #![proptest_config(ProptestConfig {
        cases: 256,
        failure_persistence: Some(Box::new(
            proptest::test_runner::FileFailurePersistence::Direct(
                "../../tests/fixtures/payment_proptest.proptest-regressions",
            ),
        )),
        ..ProptestConfig::default()
    })]

    #[test]
    fn resource_info_roundtrips(info in arb_resource_info()) {
        let json = serde_json::to_vec(&info).unwrap();
        let back: ResourceInfo = serde_json::from_slice(&json).unwrap();
        prop_assert_eq!(back, info);
    }

    #[test]
    fn supported_payment_kind_roundtrips(kind in arb_supported_payment_kind()) {
        let json = serde_json::to_vec(&kind).unwrap();
        let back: SupportedPaymentKind = serde_json::from_slice(&json).unwrap();
        prop_assert_eq!(back, kind);
    }

    #[test]
    fn supported_response_roundtrips(resp in arb_supported_response()) {
        let json = serde_json::to_vec(&resp).unwrap();
        let back: SupportedResponse = serde_json::from_slice(&json).unwrap();
        prop_assert_eq!(back, resp);
    }

    #[test]
    fn extensions_roundtrip(ext in arb_extensions()) {
        let json = serde_json::to_vec(&ext).unwrap();
        let back: Extensions = serde_json::from_slice(&json).unwrap();
        prop_assert_eq!(back, ext);
    }

    #[test]
    fn payment_requirements_roundtrips(req in arb_payment_requirements()) {
        let json = serde_json::to_vec(&req).unwrap();
        let back: PaymentRequirements = serde_json::from_slice(&json).unwrap();
        prop_assert_eq!(back, req);
    }

    #[test]
    fn payment_required_roundtrips(body in arb_payment_required()) {
        let json = serde_json::to_vec(&body).unwrap();
        let back: PaymentRequired = serde_json::from_slice(&json).unwrap();
        let lhs = serde_json::to_value(&body).unwrap();
        let rhs = serde_json::to_value(&back).unwrap();
        prop_assert_eq!(lhs, rhs);
    }

    #[test]
    fn verify_response_roundtrips(resp in arb_verify_response()) {
        let json = serde_json::to_vec(&resp).unwrap();
        let back: VerifyResponse = serde_json::from_slice(&json).unwrap();
        prop_assert_eq!(back, resp);
    }

    #[test]
    fn settle_response_roundtrips(resp in arb_settle_response()) {
        let json = serde_json::to_vec(&resp).unwrap();
        let back: SettleResponse = serde_json::from_slice(&json).unwrap();
        prop_assert_eq!(back, resp);
    }

    #[test]
    fn error_reason_roundtrips(reason in arb_error_reason()) {
        let json = serde_json::to_vec(&reason).unwrap();
        let back: ErrorReason = serde_json::from_slice(&json).unwrap();
        prop_assert_eq!(back, reason);
    }

    #[test]
    fn error_reason_from_wire_is_total(input in "[\\x20-\\x7e]{0,64}") {
        let reason = ErrorReason::from_wire(&input);
        prop_assert_eq!(reason.as_str(), input.as_str());
    }
}