sdk-4mica 1.1.0

Official Rust SDK for interacting with the 4Mica payment network.
Documentation
use alloy::primitives::{Address, B256, U256};
use axum::{Json, Router, routing::get};
use crypto::bls::{BLSCert, BlsClaims, KeyMaterial};
use rpc::{
    CorePublicParameters, PaymentGuaranteeRequest, PaymentGuaranteeRequestClaims,
    PaymentGuaranteeRequestClaimsV2, PaymentGuaranteeValidationPolicyV2, RpcProxy, SigningScheme,
    compute_validation_request_hash, compute_validation_subject_hash,
};
use serde_json::json;
use std::str::FromStr;
use tokio::net::TcpListener;

async fn spawn_router(
    router: Router,
) -> Result<(String, tokio::task::JoinHandle<()>), std::io::Error> {
    let listener = TcpListener::bind("127.0.0.1:0").await?;
    let addr = listener.local_addr()?;
    let handle = tokio::spawn(async move {
        if let Err(err) = axum::serve(listener, router.into_make_service()).await {
            eprintln!("test server stopped: {err}");
        }
    });
    Ok((format!("http://{}", addr), handle))
}

#[tokio::test]
#[serial_test::serial]
async fn rpc_proxy_get_public_params_round_trip() {
    let params = CorePublicParameters {
        public_key: vec![1, 2, 3],
        contract_address: "0x1234567890abcdef1234567890abcdef12345678".into(),
        ethereum_http_rpc_url: "http://localhost:8545".into(),
        eip712_name: "4mica".into(),
        eip712_version: "1".into(),
        chain_id: 1337,
        max_accepted_guarantee_version: 1,
        accepted_guarantee_versions: vec![1],
        active_guarantee_domain_separator:
            "0x0000000000000000000000000000000000000000000000000000000000000000".into(),
        trusted_validation_registries: vec![],
        validation_hash_canonicalization_version: "4MICA_VALIDATION_REQUEST_V2".into(),
    };

    let router = Router::new().route(
        "/core/public-params",
        get({
            let params = params.clone();
            move || {
                let params = params.clone();
                async move { Json(params) }
            }
        }),
    );
    let Ok((base, handle)) = spawn_router(router).await else {
        eprintln!("skipping test: failed to bind local port");
        return;
    };

    let proxy = RpcProxy::new(&base).expect("create proxy");
    let got = proxy.get_public_params().await.expect("get params");
    assert_eq!(got.chain_id, 1337);
    assert_eq!(got.contract_address, params.contract_address);

    handle.abort();
}

#[tokio::test]
#[serial_test::serial]
async fn rpc_proxy_surfaces_api_errors() {
    let router = Router::new().route(
        "/core/recipients/{recipient}/tabs",
        get(|| async {
            (
                axum::http::StatusCode::BAD_REQUEST,
                Json(json!({"error": "invalid settlement status: unknown"})),
            )
        }),
    );
    let Ok((base, handle)) = spawn_router(router).await else {
        eprintln!("skipping test: failed to bind local port");
        return;
    };

    let proxy = RpcProxy::new(&base).expect("create proxy");
    let err = proxy
        .list_recipient_tabs("0xdeadbeef".into(), Some(vec!["unknown".into()]))
        .await
        .expect_err("expected API error");
    match err {
        rpc::ApiClientError::Api { status, message } => {
            assert_eq!(status, axum::http::StatusCode::BAD_REQUEST);
            assert!(
                message.contains("invalid settlement status"),
                "unexpected message: {message}"
            );
        }
        other => panic!("unexpected error: {other:?}"),
    }

    handle.abort();
}

#[tokio::test]
#[serial_test::serial]
async fn rpc_proxy_returns_decode_error_on_invalid_json() {
    let router = Router::new().route(
        "/core/public-params",
        get(|| async { (axum::http::StatusCode::OK, "not-json") }),
    );
    let Ok((base, handle)) = spawn_router(router).await else {
        eprintln!("skipping test: failed to bind local port");
        return;
    };

    let proxy = RpcProxy::new(&base).expect("create proxy");
    let err = proxy
        .get_public_params()
        .await
        .expect_err("expected decode error");
    assert!(matches!(err, rpc::ApiClientError::Decode(_)));
    assert!(err.status().is_none());

    handle.abort();
}

#[tokio::test]
#[serial_test::serial]
async fn rpc_proxy_get_public_params_round_trip_v2_metadata() {
    let params = CorePublicParameters {
        public_key: vec![7, 8, 9],
        contract_address: "0x1234567890abcdef1234567890abcdef12345678".into(),
        ethereum_http_rpc_url: "http://localhost:8545".into(),
        eip712_name: "4mica".into(),
        eip712_version: "1".into(),
        chain_id: 84532,
        max_accepted_guarantee_version: 2,
        accepted_guarantee_versions: vec![1, 2],
        active_guarantee_domain_separator:
            "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".into(),
        trusted_validation_registries: vec![
            "0x1111111111111111111111111111111111111111".into(),
            "0x2222222222222222222222222222222222222222".into(),
        ],
        validation_hash_canonicalization_version: "4MICA_VALIDATION_REQUEST_V2".into(),
    };

    let router = Router::new().route(
        "/core/public-params",
        get({
            let params = params.clone();
            move || {
                let params = params.clone();
                async move { Json(params) }
            }
        }),
    );
    let Ok((base, handle)) = spawn_router(router).await else {
        eprintln!("skipping test: failed to bind local port");
        return;
    };

    let proxy = RpcProxy::new(&base).expect("create proxy");
    let got = proxy.get_public_params().await.expect("get params");
    assert_eq!(got.max_accepted_guarantee_version, 2);
    assert_eq!(
        got.active_guarantee_domain_separator,
        params.active_guarantee_domain_separator
    );
    assert_eq!(
        got.validation_hash_canonicalization_version,
        "4MICA_VALIDATION_REQUEST_V2"
    );
    assert_eq!(
        got.trusted_validation_registries,
        params.trusted_validation_registries
    );

    handle.abort();
}

fn build_test_bls_cert() -> BLSCert {
    let key =
        KeyMaterial::from_str("0x4573DBD225C8E065FC30FF774C9EF81BD29D34E559D80E2276EE7824812399D3")
            .expect("valid test key");
    BLSCert::sign(&key, BlsClaims::from_bytes(vec![0x01, 0x02, 0x03])).expect("valid cert")
}

fn build_v2_request() -> PaymentGuaranteeRequest {
    let user = "0x1234567890123456789012345678901234567890";
    let recipient = "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd";
    let asset = "0x0000000000000000000000000000000000000000";
    let tab_id = U256::from(7u64);
    let req_id = U256::from(3u64);
    let amount = U256::from(100u64);
    let timestamp = 1_736_000_000u64;

    let validation_subject_hash =
        compute_validation_subject_hash(user, recipient, tab_id, req_id, amount, asset, timestamp)
            .expect("subject hash");
    let mut policy = PaymentGuaranteeValidationPolicyV2 {
        validation_registry_address: Address::from_str(
            "0x1111111111111111111111111111111111111111",
        )
        .expect("registry"),
        validation_request_hash: B256::ZERO,
        validation_chain_id: 84532,
        validator_address: Address::from_str("0x2222222222222222222222222222222222222222")
            .expect("validator"),
        validator_agent_id: U256::from(99u64),
        min_validation_score: 80,
        validation_subject_hash: B256::from(validation_subject_hash),
        job_hash: B256::repeat_byte(0x11),
        required_validation_tag: "hard-finality".to_string(),
    };
    policy.validation_request_hash =
        B256::from(compute_validation_request_hash(&policy).expect("request hash"));

    let claims = PaymentGuaranteeRequestClaims::V2(Box::new(PaymentGuaranteeRequestClaimsV2 {
        user_address: user.to_string(),
        recipient_address: recipient.to_string(),
        tab_id,
        req_id,
        amount,
        asset_address: asset.to_string(),
        timestamp,
        validation_policy: policy,
    }));

    PaymentGuaranteeRequest::new(claims, "0x1234".to_string(), SigningScheme::Eip712)
}

#[tokio::test]
#[serial_test::serial]
async fn rpc_proxy_issue_guarantee_round_trip_v2_request() {
    let expected = build_v2_request();
    let cert = build_test_bls_cert();

    let router = Router::new().route(
        "/core/guarantees",
        axum::routing::post({
            let cert = cert.clone();
            let expected = expected.clone();
            move |Json(body): Json<PaymentGuaranteeRequest>| {
                let cert = cert.clone();
                let expected = expected.clone();
                async move {
                    match body.claims {
                        PaymentGuaranteeRequestClaims::V2(claims) => {
                            let PaymentGuaranteeRequestClaims::V2(expected_claims) =
                                expected.claims.clone()
                            else {
                                panic!("expected v2 claims");
                            };
                            assert_eq!(claims.tab_id, expected_claims.tab_id);
                            assert_eq!(claims.req_id, expected_claims.req_id);
                            assert_eq!(
                                claims.validation_policy.validation_request_hash,
                                expected_claims.validation_policy.validation_request_hash
                            );
                        }
                        PaymentGuaranteeRequestClaims::V1(_) => panic!("expected v2 claims"),
                    }
                    Json(cert)
                }
            }
        }),
    );

    let Ok((base, handle)) = spawn_router(router).await else {
        eprintln!("skipping test: failed to bind local port");
        return;
    };

    let proxy = RpcProxy::new(&base).expect("create proxy");
    let got = proxy
        .issue_guarantee(expected.clone())
        .await
        .expect("issue guarantee");
    assert_eq!(got.claims().to_hex(), cert.claims().to_hex());
    assert_eq!(got.signature().to_hex(), cert.signature().to_hex());

    handle.abort();
}