rwa-kyc-hook-api 0.2.0

Token-2022 KYC Transfer Hook for RWA primary issuance on x402
Documentation
use solana_program::pubkey::Pubkey;

use crate::state::{KycPolicy, KycRecord};

/// Pure KYC gate used by transfer-hook Execute (unit-testable).
pub fn verify_kyc_for_transfer(
    policy: KycPolicy,
    destination_owner: &Pubkey,
    global: Option<&KycRecord>,
    offering: Option<&KycRecord>,
) -> Result<(), crate::error::HookError> {
    let check_global = matches!(policy, KycPolicy::GlobalOnly | KycPolicy::Both);
    let check_offering = matches!(policy, KycPolicy::OfferingOnly | KycPolicy::Both);

    if check_global {
        let record = global.ok_or(crate::error::HookError::KycRecordNotFound)?;
        if record.user != *destination_owner || !record.is_verified() {
            return Err(crate::error::HookError::RecipientNotKycVerified);
        }
    }

    if check_offering {
        let record = offering.ok_or(crate::error::HookError::KycRecordNotFound)?;
        if record.user != *destination_owner || !record.is_verified() {
            return Err(crate::error::HookError::RecipientNotKycVerified);
        }
    }

    Ok(())
}

/// Read owner pubkey from classic / Token-2022 base token account layout.
pub fn token_account_owner(data: &[u8]) -> Result<Pubkey, crate::error::HookError> {
    if data.len() < crate::consts::TOKEN_ACCOUNT_OWNER_OFFSET + 32 {
        return Err(crate::error::HookError::InvalidTokenAccountOwner);
    }
    Pubkey::try_from(
        &data[crate::consts::TOKEN_ACCOUNT_OWNER_OFFSET
            ..crate::consts::TOKEN_ACCOUNT_OWNER_OFFSET + 32],
    )
    .map_err(|_| crate::error::HookError::InvalidTokenAccountOwner)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::state::{KycPolicy, RecordKind};
    use crate::test_utils::kyc_record as record;

    #[test]
    fn both_requires_two_verified_records() {
        let user = Pubkey::new_unique();
        let issuer_id = [9u8; 16];
        let g = record(user, issuer_id, true, RecordKind::Global);
        let o = record(user, issuer_id, true, RecordKind::Offering);
        verify_kyc_for_transfer(KycPolicy::Both, &user, Some(&g), Some(&o)).unwrap();
    }

    #[test]
    fn global_only_rejects_unverified() {
        let user = Pubkey::new_unique();
        let issuer_id = [9u8; 16];
        let g = record(user, issuer_id, false, RecordKind::Global);
        assert_eq!(
            verify_kyc_for_transfer(KycPolicy::GlobalOnly, &user, Some(&g), None),
            Err(crate::error::HookError::RecipientNotKycVerified)
        );
    }

    #[test]
    fn token_account_owner_rejects_short_data() {
        assert_eq!(
            token_account_owner(&[0u8; 32]),
            Err(crate::error::HookError::InvalidTokenAccountOwner)
        );
    }

    #[test]
    fn token_account_owner_reads_correct_offset() {
        let owner = Pubkey::new_unique();
        let mut data = vec![0u8; 64];
        data[32..64].copy_from_slice(owner.as_ref());
        assert_eq!(token_account_owner(&data).unwrap(), owner);
    }
}