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 {
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
}
}
}
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)
}
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
);
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() {
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);
}
}