Skip to main content

tap_msg/utils/
memo_hash.rs

1//! TAIP-20 on-chain transfer correlation via memo hash.
2//!
3//! TAIP-20 defines a chain-agnostic correlation primitive that ties a TAP
4//! transfer to its on-chain settlement. The primitive is `tap_hash =
5//! SHA-256(UTF8(transfer_id))`. The hash is then carried on-chain in either:
6//!
7//! - **Profile A (text):** the canonical form `tap:1:<64-lowercase-hex>` placed
8//!   in a UTF-8 memo / reference / comment field.
9//! - **Profile B (binary):** the raw 32-byte hash placed in a fixed-length
10//!   binary memo field.
11//!
12//! See `prds/taips/TAIPs/taip-20.md` for the full spec.
13
14use sha2::{Digest, Sha256};
15
16/// Compute the canonical TAP correlation hash for a transfer ID:
17/// `SHA-256(UTF8(transfer_id))`.
18pub fn tap_memo_hash(transfer_id: &str) -> [u8; 32] {
19    let mut hasher = Sha256::new();
20    hasher.update(transfer_id.as_bytes());
21    hasher.finalize().into()
22}
23
24/// Encode a transfer ID as a Profile A (text) memo: `tap:1:<64-lowercase-hex>`.
25pub fn encode_text_memo(transfer_id: &str) -> String {
26    format!("tap:1:{}", hex::encode(tap_memo_hash(transfer_id)))
27}
28
29/// Encode a transfer ID as a Profile B (binary) memo: the raw 32-byte hash.
30pub fn encode_binary_memo(transfer_id: &str) -> [u8; 32] {
31    tap_memo_hash(transfer_id)
32}
33
34/// Verify that a Profile A text memo correlates to the given transfer ID.
35///
36/// The memo MUST start with the literal prefix `tap:1:` and be followed by
37/// exactly 64 lowercase hex characters that match `SHA-256(transfer_id)`.
38/// Uppercase hex, truncated hex, or alternative version prefixes are
39/// rejected per the spec.
40pub fn verify_text_memo(memo: &str, transfer_id: &str) -> bool {
41    let Some(tail) = memo.strip_prefix("tap:1:") else {
42        return false;
43    };
44    if tail.len() != 64 {
45        return false;
46    }
47    if tail.chars().any(|c| c.is_ascii_uppercase()) {
48        return false;
49    }
50    let Ok(bytes) = hex::decode(tail) else {
51        return false;
52    };
53    bytes.as_slice() == tap_memo_hash(transfer_id).as_slice()
54}
55
56/// Verify that a Profile B binary memo (raw bytes) correlates to the given
57/// transfer ID. Memos that are not exactly 32 bytes long are rejected.
58pub fn verify_binary_memo(memo: &[u8], transfer_id: &str) -> bool {
59    memo.len() == 32 && memo == tap_memo_hash(transfer_id).as_slice()
60}
61
62#[cfg(test)]
63mod tests {
64    use super::*;
65
66    const SAMPLE_TRANSFER_ID: &str = "3fa85f64-5717-4562-b3fc-2c963f66afa6";
67    const SAMPLE_HEX: &str = "c7aa09cd25da8b6ab686f96da282d29ce9a7a2a0d7c27e1e359eb2cac6fbfaaf";
68
69    #[test]
70    fn text_and_binary_profiles_agree() {
71        let text = encode_text_memo(SAMPLE_TRANSFER_ID);
72        let bin = encode_binary_memo(SAMPLE_TRANSFER_ID);
73
74        assert_eq!(text, format!("tap:1:{}", SAMPLE_HEX));
75        assert_eq!(hex::encode(bin), SAMPLE_HEX);
76    }
77
78    #[test]
79    fn round_trip_text_verifies() {
80        let memo = encode_text_memo(SAMPLE_TRANSFER_ID);
81        assert!(verify_text_memo(&memo, SAMPLE_TRANSFER_ID));
82    }
83
84    #[test]
85    fn round_trip_binary_verifies() {
86        let memo = encode_binary_memo(SAMPLE_TRANSFER_ID);
87        assert!(verify_binary_memo(&memo, SAMPLE_TRANSFER_ID));
88    }
89}