bsv-sdk 0.2.4

Pure Rust implementation of the BSV Blockchain SDK
Documentation
//! OverlayAdminTokenTemplate for SHIP/SLAP advertisement tokens.
//!
//! Translates the TS SDK OverlayAdminTokenTemplate.ts. Encodes and decodes
//! PushDrop-based tokens that advertise overlay service endpoints.

use crate::script::locking_script::LockingScript;
use crate::services::ServicesError;

/// Decoded SHIP or SLAP advertisement token.
#[derive(Debug, Clone, PartialEq)]
pub struct OverlayAdminTokenTemplate {
    /// Protocol type: "SHIP" or "SLAP".
    pub protocol: String,
    /// Hex-encoded identity public key.
    pub identity_key: String,
    /// Domain URL where the service is hosted.
    pub domain: String,
    /// Topic (for SHIP) or service (for SLAP) name.
    pub topic_or_service: String,
}

impl OverlayAdminTokenTemplate {
    /// Create a new admin token template.
    pub fn new(
        protocol: &str,
        identity_key: &str,
        domain: &str,
        topic_or_service: &str,
    ) -> Result<Self, ServicesError> {
        if protocol != "SHIP" && protocol != "SLAP" {
            return Err(ServicesError::Overlay(format!(
                "Invalid protocol: {} (expected SHIP or SLAP)",
                protocol
            )));
        }
        Ok(OverlayAdminTokenTemplate {
            protocol: protocol.to_string(),
            identity_key: identity_key.to_string(),
            domain: domain.to_string(),
            topic_or_service: topic_or_service.to_string(),
        })
    }

    /// Encode this template as a PushDrop locking script.
    ///
    /// Structure: `<protocol> <identityKey> <domain> <topicOrService> OP_2DROP OP_2DROP <pubkey> OP_CHECKSIG`
    ///
    /// Note: This creates a lock-only script (no signing key). For a full
    /// PushDrop with signing, use PushDrop::new() directly with the fields
    /// and a private key.
    pub fn encode_fields(&self) -> Vec<Vec<u8>> {
        vec![
            self.protocol.as_bytes().to_vec(),
            hex_decode(&self.identity_key).unwrap_or_default(),
            self.domain.as_bytes().to_vec(),
            self.topic_or_service.as_bytes().to_vec(),
        ]
    }

    /// Decode a SHIP or SLAP advertisement from a locking script.
    ///
    /// Extracts PushDrop data fields and parses them as an admin token.
    pub fn decode(script: &LockingScript) -> Result<Self, ServicesError> {
        let fields = extract_pushdrop_fields(script)?;
        if fields.len() < 4 {
            return Err(ServicesError::Overlay(
                "Invalid SHIP/SLAP advertisement: fewer than 4 fields".to_string(),
            ));
        }

        let protocol = String::from_utf8(fields[0].clone())
            .map_err(|_| ServicesError::Overlay("Invalid protocol field UTF-8".to_string()))?;

        if protocol != "SHIP" && protocol != "SLAP" {
            return Err(ServicesError::Overlay(format!(
                "Invalid protocol type: {}",
                protocol
            )));
        }

        let identity_key = hex_encode(&fields[1]);

        let domain = String::from_utf8(fields[2].clone())
            .map_err(|_| ServicesError::Overlay("Invalid domain field UTF-8".to_string()))?;

        let topic_or_service = String::from_utf8(fields[3].clone()).map_err(|_| {
            ServicesError::Overlay("Invalid topicOrService field UTF-8".to_string())
        })?;

        Ok(OverlayAdminTokenTemplate {
            protocol,
            identity_key,
            domain,
            topic_or_service,
        })
    }

    /// Decode from serialized transaction bytes and a specific output index.
    ///
    /// This is a convenience method that extracts the locking script from
    /// a serialized transaction at the given output index.
    pub fn decode_from_beef(tx_bytes: &[u8], output_index: usize) -> Result<Self, ServicesError> {
        // Parse the transaction from binary.
        let tx = crate::transaction::Transaction::from_binary(&mut &tx_bytes[..])
            .map_err(|e| ServicesError::Overlay(format!("Failed to parse transaction: {}", e)))?;

        let output = tx.outputs.get(output_index).ok_or_else(|| {
            ServicesError::Overlay(format!(
                "Output index {} out of range (tx has {} outputs)",
                output_index,
                tx.outputs.len()
            ))
        })?;

        Self::decode(&output.locking_script)
    }
}

/// Extract data fields from a PushDrop locking script.
///
/// Reads data push chunks until encountering OP_DROP or OP_2DROP.
fn extract_pushdrop_fields(script: &LockingScript) -> Result<Vec<Vec<u8>>, ServicesError> {
    use crate::script::op::Op;

    let chunks = script.chunks();
    let mut fields = Vec::new();

    for chunk in chunks {
        // Stop at DROP opcodes -- remaining chunks are the key + CHECKSIG.
        if chunk.op == Op::OpDrop || chunk.op == Op::Op2Drop {
            break;
        }
        // Skip non-push opcodes (should not appear before DROPs in valid PushDrop).
        if let Some(ref data) = chunk.data {
            fields.push(data.clone());
        }
    }

    Ok(fields)
}

/// Hex-encode bytes.
fn hex_encode(bytes: &[u8]) -> String {
    bytes.iter().map(|b| format!("{:02x}", b)).collect()
}

/// Hex-decode a string to bytes.
fn hex_decode(hex: &str) -> Result<Vec<u8>, ServicesError> {
    if !hex.len().is_multiple_of(2) {
        return Err(ServicesError::Serialization(
            "hex string has odd length".to_string(),
        ));
    }
    (0..hex.len())
        .step_by(2)
        .map(|i| {
            u8::from_str_radix(&hex[i..i + 2], 16)
                .map_err(|_| ServicesError::Serialization("invalid hex character".to_string()))
        })
        .collect()
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::primitives::private_key::PrivateKey;
    use crate::script::templates::push_drop::PushDrop;
    use crate::script::templates::ScriptTemplateLock;

    #[test]
    fn test_encode_decode_round_trip() {
        let template = OverlayAdminTokenTemplate::new(
            "SLAP",
            "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
            "https://overlay.example.com",
            "ls_test_service",
        )
        .unwrap();

        // Create a PushDrop locking script with the fields.
        let key = PrivateKey::from_hex("1").unwrap();
        let pd = PushDrop::new(template.encode_fields(), key);
        let lock_script = pd.lock().unwrap();

        // Decode the locking script.
        let decoded = OverlayAdminTokenTemplate::decode(&lock_script).unwrap();
        assert_eq!(decoded.protocol, "SLAP");
        assert_eq!(
            decoded.identity_key,
            "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"
        );
        assert_eq!(decoded.domain, "https://overlay.example.com");
        assert_eq!(decoded.topic_or_service, "ls_test_service");
    }

    #[test]
    fn test_encode_decode_ship_protocol() {
        let template = OverlayAdminTokenTemplate::new(
            "SHIP",
            "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
            "https://host.example.com",
            "tm_test_topic",
        )
        .unwrap();

        let key = PrivateKey::from_hex("1").unwrap();
        let pd = PushDrop::new(template.encode_fields(), key);
        let lock_script = pd.lock().unwrap();

        let decoded = OverlayAdminTokenTemplate::decode(&lock_script).unwrap();
        assert_eq!(decoded, template);
    }

    #[test]
    fn test_invalid_protocol_rejected() {
        let result = OverlayAdminTokenTemplate::new("INVALID", "key", "domain", "service");
        assert!(result.is_err());
    }

    #[test]
    fn test_hex_encode_decode_round_trip() {
        let original = vec![0xab, 0xcd, 0xef, 0x01];
        let hex = hex_encode(&original);
        let decoded = hex_decode(&hex).unwrap();
        assert_eq!(decoded, original);
    }
}