rwa-kyc-hook-api 0.2.0

Token-2022 KYC Transfer Hook for RWA primary issuance on x402
Documentation
mod authority_transfer;
mod config;
mod issuer_config;
mod kyc_record;
mod mint_config;

pub use authority_transfer::*;
pub use config::*;
pub use issuer_config::*;
pub use kyc_record::*;
pub use mint_config::*;

use solana_program::pubkey::Pubkey;
use steel::*;

use crate::consts::{
    AUTHORITY_TRANSFER, CONFIG, ISSUER, KYC_RECORD, MAX_ISSUER_ID_LEN, MAX_OFFERING_ID_LEN,
    MINT_CONFIG,
};

#[repr(u8)]
#[derive(Clone, Copy, Debug, Eq, PartialEq, IntoPrimitive, TryFromPrimitive)]
pub enum HookAccount {
    Config = 0,
    KycRecord = 1,
    MintConfig = 2,
    IssuerConfig = 3,
    AuthorityTransfer = 4,
}

#[repr(u8)]
#[derive(Clone, Copy, Debug, Eq, PartialEq, IntoPrimitive, TryFromPrimitive)]
pub enum Cluster {
    MainnetBeta = 0,
    Devnet = 1,
    Testnet = 2,
}

impl Cluster {
    /// Mandatory delay between `UpdatePlatformAdmin` (propose) and
    /// `AcceptPlatformAdmin`, derived at runtime from `Config.cluster`.
    ///
    /// One SBF artifact serves all clusters (PDAs use the runtime `program_id`), so the
    /// timelock cannot be a compile-time `cfg(feature)` without forking the binary.
    /// Instead it is selected from the on-chain `cluster`, which is set once at
    /// `Initialize` and is immutable thereafter — so a compromised admin key cannot
    /// shorten the mainnet timelock. Test clusters use a short delay for fast E2E.
    pub const fn authority_transfer_delay_seconds(self) -> i64 {
        match self {
            Cluster::MainnetBeta => crate::consts::AUTHORITY_TRANSFER_DELAY_MAINNET_SECONDS,
            Cluster::Devnet | Cluster::Testnet => {
                crate::consts::AUTHORITY_TRANSFER_DELAY_TEST_SECONDS
            }
        }
    }

    /// Resolve a delay from a raw `Config.cluster` byte, failing safe to the mainnet
    /// (longest) delay for any unrecognized value.
    pub fn authority_transfer_delay_for_raw(cluster: u8) -> i64 {
        match Cluster::try_from(cluster) {
            Ok(c) => c.authority_transfer_delay_seconds(),
            Err(_) => crate::consts::AUTHORITY_TRANSFER_DELAY_MAINNET_SECONDS,
        }
    }
}

#[repr(u8)]
#[derive(Clone, Copy, Debug, Eq, PartialEq, IntoPrimitive, TryFromPrimitive)]
pub enum RegistrationMode {
    AdminOnly = 0,
    SelfServe = 1,
    Both = 2,
}

#[repr(u8)]
#[derive(Clone, Copy, Debug, Eq, PartialEq, IntoPrimitive, TryFromPrimitive)]
pub enum IssuerStatus {
    Active = 0,
    Paused = 1,
    Closed = 2,
}

#[repr(u8)]
#[derive(Clone, Copy, Debug, Eq, PartialEq, IntoPrimitive, TryFromPrimitive)]
pub enum RegisteredBy {
    Platform = 0,
    SelfServe = 1,
}

#[repr(u8)]
#[derive(Clone, Copy, Debug, Eq, PartialEq, IntoPrimitive, TryFromPrimitive)]
pub enum RegistrationPath {
    Admin = 0,
    SelfServe = 1,
}

#[repr(u8)]
#[derive(Clone, Copy, Debug, Eq, PartialEq, IntoPrimitive, TryFromPrimitive)]
pub enum KycPolicy {
    GlobalOnly = 0,
    OfferingOnly = 1,
    Both = 2,
}

#[repr(u8)]
#[derive(Clone, Copy, Debug, Eq, PartialEq, IntoPrimitive, TryFromPrimitive)]
pub enum RecordKind {
    Global = 0,
    Offering = 1,
}

pub fn config_pda(program_id: &Pubkey) -> (Pubkey, u8) {
    Pubkey::find_program_address(&[CONFIG], program_id)
}

/// PDA for the singleton pending platform-admin transfer proposal.
pub fn authority_transfer_pda(program_id: &Pubkey) -> (Pubkey, u8) {
    Pubkey::find_program_address(&[AUTHORITY_TRANSFER], program_id)
}

pub fn issuer_config_pda(program_id: &Pubkey, issuer_id: &[u8; 16]) -> (Pubkey, u8) {
    Pubkey::find_program_address(&[ISSUER, issuer_id.as_ref()], program_id)
}

pub fn global_kyc_record_pda(
    program_id: &Pubkey,
    issuer_id: &[u8; 16],
    user: &Pubkey,
) -> (Pubkey, u8) {
    Pubkey::find_program_address(&[KYC_RECORD, issuer_id.as_ref(), user.as_ref()], program_id)
}

pub fn offering_kyc_record_pda(
    program_id: &Pubkey,
    issuer_id: &[u8; 16],
    offering_id: &[u8; 32],
    user: &Pubkey,
) -> (Pubkey, u8) {
    Pubkey::find_program_address(
        &[
            KYC_RECORD,
            issuer_id.as_ref(),
            offering_id.as_ref(),
            user.as_ref(),
        ],
        program_id,
    )
}

pub fn mint_config_pda(program_id: &Pubkey, mint: &Pubkey) -> (Pubkey, u8) {
    Pubkey::find_program_address(&[MINT_CONFIG, mint.as_ref()], program_id)
}

pub fn parse_issuer_id_hex(hex: &str) -> Result<[u8; 16], crate::error::HookError> {
    let hex = hex.trim().trim_start_matches("0x");
    if hex.len() != MAX_ISSUER_ID_LEN * 2 {
        return Err(crate::error::HookError::InvalidIssuerId);
    }
    let mut out = [0u8; 16];
    for (i, chunk) in hex.as_bytes().chunks(2).enumerate() {
        if chunk.len() != 2 {
            return Err(crate::error::HookError::InvalidIssuerId);
        }
        let hi = hex_nibble(chunk[0])?;
        let lo = hex_nibble(chunk[1])?;
        out[i] = (hi << 4) | lo;
    }
    Ok(out)
}

fn hex_nibble(b: u8) -> Result<u8, crate::error::HookError> {
    match b {
        b'0'..=b'9' => Ok(b - b'0'),
        b'a'..=b'f' => Ok(b - b'a' + 10),
        b'A'..=b'F' => Ok(b - b'A' + 10),
        _ => Err(crate::error::HookError::InvalidIssuerId),
    }
}

pub fn write_offering_id(
    out: &mut [u8; 32],
    offering_id: &str,
) -> Result<u8, crate::error::HookError> {
    if offering_id.is_empty() || offering_id.len() > MAX_OFFERING_ID_LEN {
        return Err(crate::error::HookError::InvalidOfferingId);
    }
    out.fill(0);
    out[..offering_id.len()].copy_from_slice(offering_id.as_bytes());
    Ok(offering_id.len() as u8)
}

pub fn offering_id_matches(stored: &[u8; 32], stored_len: u8, expected: &str) -> bool {
    let len = stored_len as usize;
    if len == 0 || len > MAX_OFFERING_ID_LEN {
        return false;
    }
    expected.len() == len && stored[..len] == *expected.as_bytes()
}

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

    #[test]
    fn pda_differs_by_program_id() {
        let user = Pubkey::new_unique();
        let issuer_id = [1u8; 16];
        let program_a = Pubkey::new_unique();
        let program_b = Pubkey::new_unique();
        let (a, _) = global_kyc_record_pda(&program_a, &issuer_id, &user);
        let (b, _) = global_kyc_record_pda(&program_b, &issuer_id, &user);
        assert_ne!(a, b);
    }

    #[test]
    fn offering_pda_differs_from_global() {
        let user = Pubkey::new_unique();
        let issuer_id = [2u8; 16];
        let program_id = Pubkey::new_unique();
        let mut offering = [0u8; 32];
        offering[..8].copy_from_slice(b"series-a");
        let (global, _) = global_kyc_record_pda(&program_id, &issuer_id, &user);
        let (offering_pda, _) = offering_kyc_record_pda(&program_id, &issuer_id, &offering, &user);
        assert_ne!(global, offering_pda);
    }

    #[test]
    fn issuer_pdas_isolate_tenants() {
        let user = Pubkey::new_unique();
        let program_id = Pubkey::new_unique();
        let issuer_a = [1u8; 16];
        let issuer_b = [2u8; 16];
        let (a, _) = global_kyc_record_pda(&program_id, &issuer_a, &user);
        let (b, _) = global_kyc_record_pda(&program_id, &issuer_b, &user);
        assert_ne!(a, b);
    }

    #[test]
    fn parse_issuer_id_hex_accepts_uuid() {
        let id = parse_issuer_id_hex("550e8400e29b41d4a716446655440000").unwrap();
        assert_eq!(id[0], 0x55);
        assert_eq!(id[15], 0x00);
    }

    #[test]
    fn parse_issuer_id_hex_rejects_short() {
        assert_eq!(
            parse_issuer_id_hex("550e8400"),
            Err(crate::error::HookError::InvalidIssuerId)
        );
    }

    #[test]
    fn write_offering_id_rejects_empty() {
        let mut out = [1u8; 32];
        assert_eq!(
            write_offering_id(&mut out, ""),
            Err(crate::error::HookError::InvalidOfferingId)
        );
    }

    #[test]
    fn write_offering_id_rejects_too_long() {
        let mut out = [0u8; 32];
        assert_eq!(
            write_offering_id(&mut out, &"a".repeat(32)),
            Err(crate::error::HookError::InvalidOfferingId)
        );
    }

    #[test]
    fn write_offering_id_accepts_max_len() {
        let mut out = [0u8; 32];
        let id = "a".repeat(MAX_OFFERING_ID_LEN);
        assert_eq!(write_offering_id(&mut out, &id).unwrap(), 31);
        assert_eq!(&out[..31], id.as_bytes());
        assert_eq!(out[31], 0);
    }

    #[test]
    fn offering_id_matches_exact() {
        let mut stored = [0u8; 32];
        stored[..8].copy_from_slice(b"series-a");
        assert!(offering_id_matches(&stored, 8, "series-a"));
    }

    #[test]
    fn offering_id_matches_rejects_shorter_expected() {
        let mut stored = [0u8; 32];
        stored[..8].copy_from_slice(b"series-a");
        assert!(!offering_id_matches(&stored, 8, "series"));
    }

    #[test]
    fn offering_id_matches_rejects_longer_expected() {
        let mut stored = [0u8; 32];
        stored[..8].copy_from_slice(b"series-a");
        assert!(!offering_id_matches(&stored, 8, "series-ab"));
    }

    #[test]
    fn authority_delay_is_cluster_derived() {
        assert_eq!(
            Cluster::MainnetBeta.authority_transfer_delay_seconds(),
            crate::consts::AUTHORITY_TRANSFER_DELAY_MAINNET_SECONDS
        );
        assert_eq!(
            Cluster::Devnet.authority_transfer_delay_seconds(),
            crate::consts::AUTHORITY_TRANSFER_DELAY_TEST_SECONDS
        );
        assert_eq!(
            Cluster::Testnet.authority_transfer_delay_seconds(),
            crate::consts::AUTHORITY_TRANSFER_DELAY_TEST_SECONDS
        );
        // Mainnet delay must be the longest (no shorter test value on prod).
        let mainnet = crate::consts::AUTHORITY_TRANSFER_DELAY_MAINNET_SECONDS;
        let test = crate::consts::AUTHORITY_TRANSFER_DELAY_TEST_SECONDS;
        assert_eq!(
            mainnet.max(test),
            mainnet,
            "mainnet delay must be the longest"
        );
    }

    #[test]
    fn authority_delay_raw_fails_safe_to_mainnet() {
        // Unknown / corrupt cluster byte must fall back to the longest (mainnet) delay.
        assert_eq!(
            Cluster::authority_transfer_delay_for_raw(99),
            crate::consts::AUTHORITY_TRANSFER_DELAY_MAINNET_SECONDS
        );
        assert_eq!(
            Cluster::authority_transfer_delay_for_raw(Cluster::Devnet as u8),
            crate::consts::AUTHORITY_TRANSFER_DELAY_TEST_SECONDS
        );
    }

    #[test]
    fn authority_transfer_pda_is_stable_and_program_scoped() {
        let a = Pubkey::new_unique();
        let b = Pubkey::new_unique();
        assert_ne!(authority_transfer_pda(&a).0, authority_transfer_pda(&b).0);
        assert_eq!(authority_transfer_pda(&a).0, authority_transfer_pda(&a).0);
    }
}