mpp-br 0.8.1

Rust SDK for the Machine Payments Protocol (MPP)
Documentation
//! MPP attribution memo encoding for TIP-20 `transferWithMemo`.
//!
//! When no user-provided memo is present, the SDK auto-generates an
//! attribution memo so MPP transactions are identifiable on-chain.
//!
//! ## Byte Layout (32 bytes)
//!
//! | Offset | Size | Field                                     |
//! |--------|------|-------------------------------------------|
//! | 0..3   | 4    | TAG = keccak256("mpp")\[0..3\]            |
//! | 4      | 1    | version (0x01)                            |
//! | 5..14  | 10   | serverId = keccak256(serverId)\[0..9\]    |
//! | 15..24 | 10   | clientId = keccak256(clientId)\[0..9\] or 0s |
//! | 25..31 | 7    | nonce (random bytes)                      |
//!
//! The TAG prefix makes MPP transactions trivially distinguishable
//! from arbitrary memos via `TransferWithMemo` event topic filtering.

use alloy::primitives::keccak256;
use rand::Rng;
use std::sync::LazyLock;

/// First 4 bytes of keccak256("mpp") — the on-chain MPP tag.
pub static TAG: LazyLock<[u8; 4]> = LazyLock::new(|| {
    let hash = keccak256(b"mpp");
    [hash[0], hash[1], hash[2], hash[3]]
});

/// Current memo version.
pub const VERSION: u8 = 0x01;

/// 10 zero bytes representing an anonymous (no clientId) client.
pub const ANONYMOUS: [u8; 10] = [0u8; 10];

/// Decoded MPP attribution memo.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DecodedMemo {
    /// Memo version (currently always 1).
    pub version: u8,
    /// 10-byte server fingerprint (keccak256(serverId)[0..9]).
    pub server_fingerprint: [u8; 10],
    /// 10-byte client fingerprint, or `None` if anonymous.
    pub client_fingerprint: Option<[u8; 10]>,
    /// 7-byte random nonce.
    pub nonce: [u8; 7],
}

/// Computes a 10-byte fingerprint from a string via keccak256.
fn fingerprint(value: &str) -> [u8; 10] {
    let hash = keccak256(value.as_bytes());
    let mut fp = [0u8; 10];
    fp.copy_from_slice(&hash[..10]);
    fp
}

/// Encodes an MPP attribution memo as a 32-byte array.
pub fn encode(server_id: &str, client_id: Option<&str>) -> [u8; 32] {
    let mut buf = [0u8; 32];

    buf[..4].copy_from_slice(&*TAG);
    buf[4] = VERSION;
    buf[5..15].copy_from_slice(&fingerprint(server_id));
    if let Some(cid) = client_id {
        buf[15..25].copy_from_slice(&fingerprint(cid));
    }

    let mut rng = rand::rng();
    rng.fill(&mut buf[25..32]);

    buf
}

/// Encodes an MPP attribution memo as a hex string (without `0x` prefix).
pub fn encode_hex(server_id: &str, client_id: Option<&str>) -> String {
    hex::encode(encode(server_id, client_id))
}

/// Checks whether a memo was generated by the MPP attribution system.
pub fn is_mpp_memo(memo: &[u8; 32]) -> bool {
    memo[..4] == *TAG && memo[4] == VERSION
}

/// Verifies that a memo's server fingerprint matches the given `server_id`.
///
/// Checks TAG, version byte, and that bytes 5–14 equal keccak256(serverId)\[0..9\].
pub fn verify_server(memo: &[u8; 32], server_id: &str) -> bool {
    if !is_mpp_memo(memo) {
        return false;
    }
    memo[5..15] == fingerprint(server_id)
}

/// Decodes an MPP attribution memo into its constituent parts.
///
/// Returns `None` if the memo is not a valid MPP memo.
pub fn decode(memo: &[u8; 32]) -> Option<DecodedMemo> {
    if !is_mpp_memo(memo) {
        return None;
    }

    let version = memo[4];

    let mut server_fingerprint = [0u8; 10];
    server_fingerprint.copy_from_slice(&memo[5..15]);

    let mut client_bytes = [0u8; 10];
    client_bytes.copy_from_slice(&memo[15..25]);
    let client_fingerprint = if client_bytes == ANONYMOUS {
        None
    } else {
        Some(client_bytes)
    };

    let mut nonce = [0u8; 7];
    nonce.copy_from_slice(&memo[25..32]);

    Some(DecodedMemo {
        version,
        server_fingerprint,
        client_fingerprint,
        nonce,
    })
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_tag_matches_keccak() {
        let hash = keccak256(b"mpp");
        assert_eq!(*TAG, hash[..4]);
    }

    #[test]
    fn test_encode_is_32_bytes() {
        let memo = encode("api.example.com", None);
        assert_eq!(memo.len(), 32);
    }

    #[test]
    fn test_is_mpp_memo() {
        let memo = encode("api.example.com", None);
        assert!(is_mpp_memo(&memo));

        let zeros = [0u8; 32];
        assert!(!is_mpp_memo(&zeros));
    }

    #[test]
    fn test_verify_server() {
        let memo = encode("api.example.com", None);
        assert!(verify_server(&memo, "api.example.com"));
        assert!(!verify_server(&memo, "other.example.com"));
    }

    #[test]
    fn test_decode_without_client() {
        let memo = encode("api.example.com", None);
        let decoded = decode(&memo).unwrap();
        assert_eq!(decoded.version, VERSION);
        assert_eq!(decoded.server_fingerprint, fingerprint("api.example.com"));
        assert_eq!(decoded.client_fingerprint, None);
    }

    #[test]
    fn test_decode_with_client() {
        let memo = encode("api.example.com", Some("my-app"));
        let decoded = decode(&memo).unwrap();
        assert_eq!(decoded.version, VERSION);
        assert_eq!(decoded.server_fingerprint, fingerprint("api.example.com"));
        assert_eq!(decoded.client_fingerprint, Some(fingerprint("my-app")));
    }

    #[test]
    fn test_decode_invalid_memo() {
        let zeros = [0u8; 32];
        assert!(decode(&zeros).is_none());
    }

    #[test]
    fn test_encode_hex() {
        let hex_str = encode_hex("api.example.com", None);
        assert_eq!(hex_str.len(), 64);
    }

    #[test]
    fn test_round_trip() {
        let memo = encode("srv", Some("cli"));
        assert!(is_mpp_memo(&memo));
        assert!(verify_server(&memo, "srv"));

        let decoded = decode(&memo).unwrap();
        assert_eq!(decoded.server_fingerprint, fingerprint("srv"));
        assert_eq!(decoded.client_fingerprint, Some(fingerprint("cli")));
    }
}