use std::{str::FromStr, sync::Arc};
use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
use rwa_kyc_hook_api::{
sdk::HookSdk,
state::{
config_pda, global_kyc_record_pda, issuer_config_pda, mint_config_pda,
offering_kyc_record_pda, parse_issuer_id_hex, write_offering_id, Cluster, Config,
IssuerConfig, IssuerStatus, KycPolicy, KycRecord, MintConfig, RecordKind, RegistrationMode,
},
};
use solana_client::nonblocking::rpc_client::RpcClient;
use solana_sdk::{
commitment_config::CommitmentConfig,
pubkey::Pubkey,
signature::{read_keypair_file, Keypair, Signer},
transaction::Transaction,
};
#[derive(Parser, Debug)]
#[command(
name = "rwa-kyc-hook",
about = "CLI for the RWA KYC Transfer Hook program (v2 multi-issuer)"
)]
struct Cli {
#[arg(long, env = "RWA_KYC_HOOK_PROGRAM_ID")]
program_id: String,
#[arg(long, env = "RWA_KYC_HOOK_ISSUER_ID")]
issuer_id: Option<String>,
#[arg(long, env = "RPC_URL", default_value = "https://api.devnet.solana.com")]
rpc_url: String,
#[arg(long, env = "KEYPAIR", help = "Default signer (ops or platform admin)")]
keypair: Option<String>,
#[arg(
long,
env = "PAYER_KEYPAIR",
help = "Rent/fee payer when different from signer"
)]
payer_keypair: Option<String>,
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand, Debug)]
enum Commands {
ProgramId,
Initialize {
#[arg(long, value_parser = parse_cluster)]
cluster: Cluster,
#[arg(long)]
platform_admin: Option<String>,
#[arg(long)]
fee_recipient: Option<String>,
#[arg(long, default_value = "both", value_parser = parse_registration_mode)]
registration_mode: RegistrationMode,
#[arg(long, default_value_t = 0)]
registration_fee_lamports: u64,
},
UpdatePlatformConfig {
#[arg(long, value_parser = parse_registration_mode)]
registration_mode: RegistrationMode,
#[arg(long)]
fee_recipient: String,
#[arg(long, default_value_t = 0)]
registration_fee_lamports: u64,
#[arg(long, default_value_t = 0)]
paused: u8,
},
RegisterIssuer {
#[arg(long)]
issuer_id: String,
#[arg(long)]
ops_authority: String,
#[arg(long, default_value = "")]
identity: String,
#[arg(long, default_value_t = false)]
self_serve: bool,
#[arg(
long,
help = "Required for self-serve; defaults to platform fee_recipient"
)]
fee_recipient: Option<String>,
},
CreateGlobalKyc {
#[arg(long)]
user: String,
},
CreateOfferingKyc {
#[arg(long)]
user: String,
#[arg(long)]
offering_id: String,
},
UpdateGlobalKyc {
#[arg(long)]
user: String,
#[arg(long, action = clap::ArgAction::Set, value_parser = parse_bool_flag)]
verified: bool,
},
UpdateOfferingKyc {
#[arg(long)]
user: String,
#[arg(long)]
offering_id: String,
#[arg(long, action = clap::ArgAction::Set, value_parser = parse_bool_flag)]
verified: bool,
},
RegisterMint {
#[arg(long)]
mint: String,
#[arg(long, value_parser = parse_policy)]
policy: KycPolicy,
#[arg(long)]
offering_id: String,
},
InitExtraAccountMetas {
#[arg(long)]
mint: String,
},
RotateOpsAuthority {
#[arg(long)]
new_ops_authority: String,
},
UpdateIssuerStatus {
#[arg(long, value_parser = parse_issuer_status)]
status: IssuerStatus,
},
UpdateIssuerIdentity {
#[arg(long)]
new_identity: String,
},
UpdatePlatformAdmin {
#[arg(long)]
new_admin: String,
},
AcceptPlatformAdmin,
CancelPlatformAdminProposal,
AdminTransfer,
Addresses {
#[arg(long, help = "Mint pubkey to derive mint-config + extra-account-metas")]
mint: Option<String>,
#[arg(long, help = "User wallet to derive KYC-record PDAs")]
user: Option<String>,
#[arg(long, help = "Offering id to derive offering KYC-record PDA")]
offering_id: Option<String>,
},
Config,
Issuer,
KycRecord {
#[arg(long)]
user: String,
#[arg(long, help = "Set for an offering-scoped record")]
offering_id: Option<String>,
},
MintConfig {
#[arg(long)]
mint: String,
},
}
fn parse_cluster(s: &str) -> Result<Cluster, String> {
match s.to_lowercase().as_str() {
"mainnet" | "mainnet-beta" => Ok(Cluster::MainnetBeta),
"devnet" => Ok(Cluster::Devnet),
"testnet" => Ok(Cluster::Testnet),
other => Err(format!("unknown cluster: {other}")),
}
}
fn parse_registration_mode(s: &str) -> Result<RegistrationMode, String> {
match s.to_lowercase().as_str() {
"admin" | "admin-only" => Ok(RegistrationMode::AdminOnly),
"self-serve" | "selfserve" => Ok(RegistrationMode::SelfServe),
"both" => Ok(RegistrationMode::Both),
other => Err(format!("unknown registration mode: {other}")),
}
}
fn parse_issuer_status(s: &str) -> Result<IssuerStatus, String> {
match s.to_lowercase().as_str() {
"active" => Ok(IssuerStatus::Active),
"paused" => Ok(IssuerStatus::Paused),
"closed" => Ok(IssuerStatus::Closed),
other => Err(format!("unknown issuer status: {other}")),
}
}
fn parse_policy(s: &str) -> Result<KycPolicy, String> {
match s.to_lowercase().as_str() {
"global" | "global-only" => Ok(KycPolicy::GlobalOnly),
"offering" | "offering-only" => Ok(KycPolicy::OfferingOnly),
"both" => Ok(KycPolicy::Both),
other => Err(format!("unknown policy: {other}")),
}
}
fn parse_bool_flag(s: &str) -> Result<bool, String> {
match s.to_lowercase().as_str() {
"true" | "1" | "yes" | "y" => Ok(true),
"false" | "0" | "no" | "n" => Ok(false),
other => Err(format!("expected true/false, got: {other}")),
}
}
#[tokio::main]
async fn main() -> Result<()> {
let cli = Cli::parse();
let rpc_explicit = std::env::args().any(|a| a == "--rpc-url" || a.starts_with("--rpc-url="));
if std::env::var("RPC_URL").is_err()
&& !rpc_explicit
&& cli.rpc_url == "https://api.devnet.solana.com"
{
eprintln!(
"warning: using default devnet RPC ({}) — set --rpc-url or RPC_URL for other clusters",
cli.rpc_url
);
}
let program_id = Pubkey::from_str(&cli.program_id).context("invalid program id")?;
let rpc = Arc::new(RpcClient::new_with_commitment(
cli.rpc_url.clone(),
CommitmentConfig::confirmed(),
));
let needs_signer = !matches!(
cli.command,
Commands::ProgramId
| Commands::Addresses { .. }
| Commands::Config
| Commands::Issuer
| Commands::KycRecord { .. }
| Commands::MintConfig { .. }
| Commands::AdminTransfer
);
let signer = if needs_signer {
load_keypair(cli.keypair.as_deref())?
} else {
load_keypair(cli.keypair.as_deref()).unwrap_or_else(|_| Keypair::new())
};
let payer = match cli.payer_keypair.as_deref() {
Some(path) => load_keypair(Some(path))?,
None if needs_signer => load_keypair(cli.keypair.as_deref())?,
None => signer.insecure_clone(),
};
match cli.command {
Commands::ProgramId => {
println!("{program_id}");
}
Commands::Initialize {
cluster,
platform_admin,
fee_recipient,
registration_mode,
registration_fee_lamports,
} => {
let admin = platform_admin
.map(|s| Pubkey::from_str(&s))
.transpose()?
.unwrap_or_else(|| payer.pubkey());
let treasury = fee_recipient
.map(|s| Pubkey::from_str(&s))
.transpose()?
.unwrap_or(admin);
let ix = HookSdk::initialize(
&program_id,
&payer.pubkey(),
&admin,
&treasury,
cluster,
registration_mode as u8,
registration_fee_lamports,
);
send(&rpc, &payer, vec![ix]).await?;
println!("initialized platform config cluster={cluster:?}");
}
Commands::UpdatePlatformConfig {
registration_mode,
fee_recipient,
registration_fee_lamports,
paused,
} => {
let treasury = Pubkey::from_str(&fee_recipient)?;
let ix = HookSdk::update_platform_config(
&program_id,
&signer.pubkey(),
&treasury,
registration_fee_lamports,
registration_mode as u8,
paused,
);
send(&rpc, &signer, vec![ix]).await?;
println!("updated platform config");
}
Commands::RegisterIssuer {
issuer_id,
ops_authority,
identity,
self_serve,
fee_recipient,
} => {
let issuer_bytes = parse_issuer_id_hex(&issuer_id)
.map_err(|e| anyhow::anyhow!("invalid issuer id: {e:?}"))?;
let ops = Pubkey::from_str(&ops_authority)?;
let identity_pk = if identity.is_empty() {
Pubkey::default()
} else {
Pubkey::from_str(&identity)?
};
if self_serve {
let treasury = match fee_recipient {
Some(s) => Pubkey::from_str(&s)?,
None => read_config_fee_recipient(&rpc, &program_id).await?,
};
let ix = HookSdk::register_issuer_self_serve(
&program_id,
&payer.pubkey(),
&treasury,
&issuer_bytes,
&identity_pk,
&ops,
);
send(&rpc, &payer, vec![ix]).await?;
} else {
let ix = HookSdk::register_issuer_admin(
&program_id,
&payer.pubkey(),
&signer.pubkey(),
&issuer_bytes,
&identity_pk,
&ops,
);
send_multi(&rpc, &payer, &[&payer, &signer], vec![ix]).await?;
}
println!("registered issuer {issuer_id}");
}
Commands::CreateGlobalKyc { user } => {
let issuer_id = require_issuer_id(&cli.issuer_id)?;
let user = Pubkey::from_str(&user)?;
let ix = HookSdk::create_global_kyc_record(
&program_id,
&payer.pubkey(),
&signer.pubkey(),
&issuer_id,
&user,
);
send_payer_and_signer(&rpc, &payer, &signer, vec![ix]).await?;
println!("created global kyc record for {user}");
}
Commands::CreateOfferingKyc { user, offering_id } => {
let issuer_id = require_issuer_id(&cli.issuer_id)?;
let user = Pubkey::from_str(&user)?;
let ix = HookSdk::create_offering_kyc_record(
&program_id,
&payer.pubkey(),
&signer.pubkey(),
&issuer_id,
&user,
&offering_id,
);
send_payer_and_signer(&rpc, &payer, &signer, vec![ix]).await?;
println!("created offering kyc record for {user} offering={offering_id}");
}
Commands::UpdateGlobalKyc { user, verified } => {
let issuer_id = require_issuer_id(&cli.issuer_id)?;
let user = Pubkey::from_str(&user)?;
let ix = HookSdk::update_global_kyc_verified(
&program_id,
&signer.pubkey(),
&issuer_id,
&user,
verified,
);
send_payer_and_signer(&rpc, &payer, &signer, vec![ix]).await?;
println!("updated global kyc verified={verified} user={user}");
}
Commands::UpdateOfferingKyc {
user,
offering_id,
verified,
} => {
let issuer_id = require_issuer_id(&cli.issuer_id)?;
let user = Pubkey::from_str(&user)?;
let ix = HookSdk::update_offering_kyc_verified(
&program_id,
&signer.pubkey(),
&issuer_id,
&user,
&offering_id,
verified,
);
send_payer_and_signer(&rpc, &payer, &signer, vec![ix]).await?;
println!("updated offering kyc verified={verified} user={user} offering={offering_id}");
}
Commands::RegisterMint {
mint,
policy,
offering_id,
} => {
let issuer_id = require_issuer_id(&cli.issuer_id)?;
let mint = Pubkey::from_str(&mint)?;
let ix = HookSdk::register_mint(
&program_id,
&payer.pubkey(),
&signer.pubkey(),
&issuer_id,
&mint,
policy,
&offering_id,
);
send_payer_and_signer(&rpc, &payer, &signer, vec![ix]).await?;
println!("registered mint {mint} policy={policy:?}");
}
Commands::InitExtraAccountMetas { mint } => {
let issuer_id = require_issuer_id(&cli.issuer_id)?;
let mint = Pubkey::from_str(&mint)?;
let ix = HookSdk::initialize_extra_account_meta_list(
&program_id,
&signer.pubkey(),
&issuer_id,
&mint,
);
send_payer_and_signer(&rpc, &payer, &signer, vec![ix]).await?;
println!("initialized extra account metas for mint {mint}");
}
Commands::RotateOpsAuthority { new_ops_authority } => {
let issuer_id = require_issuer_id(&cli.issuer_id)?;
let new_ops = Pubkey::from_str(&new_ops_authority)?;
let ix =
HookSdk::rotate_ops_authority(&program_id, &signer.pubkey(), &issuer_id, &new_ops);
send_payer_and_signer(&rpc, &payer, &signer, vec![ix]).await?;
println!("rotated ops authority to {new_ops}");
}
Commands::UpdateIssuerStatus { status } => {
let issuer_id = require_issuer_id(&cli.issuer_id)?;
let ix = HookSdk::update_issuer_status(
&program_id,
&signer.pubkey(),
&issuer_id,
status as u8,
);
send_payer_and_signer(&rpc, &payer, &signer, vec![ix]).await?;
println!("updated issuer status to {status:?}");
}
Commands::UpdateIssuerIdentity { new_identity } => {
let issuer_id = require_issuer_id(&cli.issuer_id)?;
let new_id = Pubkey::from_str(&new_identity)?;
let ix =
HookSdk::update_issuer_identity(&program_id, &signer.pubkey(), &issuer_id, &new_id);
send_payer_and_signer(&rpc, &payer, &signer, vec![ix]).await?;
println!("updated issuer identity to {new_id}");
}
Commands::UpdatePlatformAdmin { new_admin } => {
let new_admin = Pubkey::from_str(&new_admin)?;
let ix = HookSdk::update_platform_admin(&program_id, &signer.pubkey(), &new_admin);
send_payer_and_signer(&rpc, &payer, &signer, vec![ix]).await?;
println!("proposed new platform admin {new_admin} (awaiting timelock + accept)");
}
Commands::AcceptPlatformAdmin => {
let ix = HookSdk::accept_platform_admin(&program_id, &signer.pubkey());
send_payer_and_signer(&rpc, &payer, &signer, vec![ix]).await?;
println!("accepted platform admin transfer to {}", signer.pubkey());
}
Commands::CancelPlatformAdminProposal => {
let ix = HookSdk::cancel_platform_admin_proposal(&program_id, &signer.pubkey());
send_payer_and_signer(&rpc, &payer, &signer, vec![ix]).await?;
println!("cancelled pending platform admin proposal");
}
Commands::AdminTransfer => {
let (key, _) = rwa_kyc_hook_api::state::authority_transfer_pda(&program_id);
match fetch_account::<rwa_kyc_hook_api::state::AuthorityTransfer>(&rpc, &key).await {
Ok(t) => {
println!("AUTHORITY_TRANSFER_PDA={key}");
println!("PROPOSED_ADMIN={}", t.proposed_admin);
println!("PROPOSED_AT={}", t.proposed_at);
}
Err(_) => println!("NO_PENDING_TRANSFER=1 (pda {key})"),
}
}
Commands::Addresses {
mint,
user,
offering_id,
} => {
let (config, config_bump) = config_pda(&program_id);
println!("PROGRAM_ID={program_id}");
println!("CONFIG_PDA={config}");
println!("CONFIG_BUMP={config_bump}");
if let Some(issuer_hex) = cli.issuer_id.as_deref() {
if let Ok(issuer_id) = parse_issuer_id_hex(issuer_hex) {
let (issuer_config, issuer_bump) = issuer_config_pda(&program_id, &issuer_id);
println!("ISSUER_ID={issuer_hex}");
println!("ISSUER_CONFIG_PDA={issuer_config}");
println!("ISSUER_CONFIG_BUMP={issuer_bump}");
if let Some(user_str) = user.as_deref() {
let user_pk = Pubkey::from_str(user_str)?;
let (g, gb) = global_kyc_record_pda(&program_id, &issuer_id, &user_pk);
println!("GLOBAL_KYC_RECORD_PDA={g}");
println!("GLOBAL_KYC_RECORD_BUMP={gb}");
if let Some(off) = offering_id.as_deref() {
let mut bytes = [0u8; 32];
write_offering_id(&mut bytes, off)
.map_err(|e| anyhow::anyhow!("invalid offering id: {e:?}"))?;
let (o, ob) =
offering_kyc_record_pda(&program_id, &issuer_id, &bytes, &user_pk);
println!("OFFERING_KYC_RECORD_PDA={o}");
println!("OFFERING_KYC_RECORD_BUMP={ob}");
}
}
}
}
if let Some(mint_str) = mint.as_deref() {
let mint_pk = Pubkey::from_str(mint_str)?;
let (mint_config, mint_bump) = mint_config_pda(&program_id, &mint_pk);
let extra_metas = spl_extra_account_metas_address(&mint_pk, &program_id);
println!("MINT={mint_pk}");
println!("MINT_CONFIG_PDA={mint_config}");
println!("MINT_CONFIG_BUMP={mint_bump}");
println!("EXTRA_ACCOUNT_METAS_PDA={extra_metas}");
}
}
Commands::Config => {
let (config_key, _) = config_pda(&program_id);
let config = fetch_account::<Config>(&rpc, &config_key)
.await
.context("Config not found — has the program been initialized?")?;
println!("CONFIG_PDA={config_key}");
println!("PLATFORM_ADMIN={}", config.platform_admin);
println!("FEE_RECIPIENT={}", config.fee_recipient);
println!(
"ISSUER_REGISTRATION_FEE_LAMPORTS={}",
config.issuer_registration_fee_lamports
);
println!("CLUSTER={}", config.cluster);
println!("REGISTRATION_MODE={}", config.registration_mode);
println!("PAUSED={}", config.paused);
}
Commands::Issuer => {
let issuer_id = require_issuer_id(&cli.issuer_id)?;
let (issuer_key, _) = issuer_config_pda(&program_id, &issuer_id);
let issuer = fetch_account::<IssuerConfig>(&rpc, &issuer_key)
.await
.context("IssuerConfig not found — has the issuer been registered?")?;
println!("ISSUER_CONFIG_PDA={issuer_key}");
println!("ISSUER_ID={}", hex_encode(&issuer.issuer_id));
println!("IDENTITY={}", issuer.identity);
println!("OPS_AUTHORITY={}", issuer.ops_authority);
println!("STATUS={}", issuer.status);
println!("REGISTERED_BY={}", issuer.registered_by);
}
Commands::KycRecord { user, offering_id } => {
let issuer_id = require_issuer_id(&cli.issuer_id)?;
let user_pk = Pubkey::from_str(&user)?;
let record_key = match offering_id.as_deref() {
None => global_kyc_record_pda(&program_id, &issuer_id, &user_pk).0,
Some(off) => {
let mut bytes = [0u8; 32];
write_offering_id(&mut bytes, off)
.map_err(|e| anyhow::anyhow!("invalid offering id: {e:?}"))?;
offering_kyc_record_pda(&program_id, &issuer_id, &bytes, &user_pk).0
}
};
let record = fetch_account::<KycRecord>(&rpc, &record_key)
.await
.context("KycRecord not found — has it been created?")?;
let kind = RecordKind::try_from(record.record_kind)
.map(|k| format!("{k:?}"))
.unwrap_or_else(|_| "Unknown".to_string());
println!("KYC_RECORD_PDA={record_key}");
println!("USER={}", record.user);
println!("ISSUER_ID={}", hex_encode(&record.issuer_id));
println!("RECORD_KIND={kind}");
println!("IS_KYC_VERIFIED={}", record.is_kyc_verified);
println!("OFFERING_ID_LEN={}", record.offering_id_len);
if record.offering_id_len > 0 {
let len = record.offering_id_len as usize;
let off = String::from_utf8_lossy(&record.offering_id[..len.min(32)]);
println!("OFFERING_ID={off}");
}
}
Commands::MintConfig { mint } => {
let mint_pk = Pubkey::from_str(&mint)?;
let (mint_key, _) = mint_config_pda(&program_id, &mint_pk);
let mc = fetch_account::<MintConfig>(&rpc, &mint_key)
.await
.context("MintConfig not found — has the mint been registered?")?;
let policy = KycPolicy::try_from(mc.kyc_policy)
.map(|p| format!("{p:?}"))
.unwrap_or_else(|_| "Unknown".to_string());
println!("MINT_CONFIG_PDA={mint_key}");
println!("MINT={}", mc.mint);
println!("ISSUER_ID={}", hex_encode(&mc.issuer_id));
println!("KYC_POLICY={policy}");
println!("OFFERING_ID_LEN={}", mc.offering_id_len);
if mc.offering_id_len > 0 {
let len = mc.offering_id_len as usize;
let off = String::from_utf8_lossy(&mc.offering_id[..len.min(32)]);
println!("OFFERING_ID={off}");
}
}
}
Ok(())
}
fn require_issuer_id(raw: &Option<String>) -> Result<[u8; 16]> {
let hex = raw
.as_deref()
.context("set --issuer-id or RWA_KYC_HOOK_ISSUER_ID")?;
parse_issuer_id_hex(hex).map_err(|e| anyhow::anyhow!("invalid issuer id: {e:?}"))
}
async fn read_config_fee_recipient(rpc: &RpcClient, program_id: &Pubkey) -> Result<Pubkey> {
let (config_key, _) = config_pda(program_id);
let config = fetch_account::<Config>(rpc, &config_key).await?;
Ok(config.fee_recipient)
}
async fn fetch_account<T: bytemuck::Pod>(rpc: &RpcClient, key: &Pubkey) -> Result<T> {
let account = rpc
.get_account(key)
.await
.with_context(|| format!("account {key} not found"))?;
let body = account
.data
.get(8..8 + std::mem::size_of::<T>())
.context("account data too small for expected type")?;
let value: &T = bytemuck::try_from_bytes(body)
.map_err(|_| anyhow::anyhow!("failed to deserialize account {key}"))?;
Ok(*value)
}
fn hex_encode(bytes: &[u8]) -> String {
let mut s = String::with_capacity(bytes.len() * 2);
for b in bytes {
s.push_str(&format!("{b:02x}"));
}
s
}
fn spl_extra_account_metas_address(mint: &Pubkey, program_id: &Pubkey) -> Pubkey {
spl_transfer_hook_interface::get_extra_account_metas_address(mint, program_id)
}
fn load_keypair(path: Option<&str>) -> Result<Keypair> {
let path = path
.map(|p| p.to_string())
.or_else(|| {
std::env::var("HOME")
.ok()
.map(|h| format!("{h}/.config/solana/id.json"))
})
.context("set --keypair or KEYPAIR")?;
read_keypair_file(&path).map_err(|e| anyhow::anyhow!("read keypair {path}: {e}"))
}
async fn send(
rpc: &RpcClient,
payer: &Keypair,
instructions: Vec<solana_sdk::instruction::Instruction>,
) -> Result<()> {
send_multi(rpc, payer, &[payer], instructions).await
}
async fn send_payer_and_signer(
rpc: &RpcClient,
payer: &Keypair,
signer: &Keypair,
instructions: Vec<solana_sdk::instruction::Instruction>,
) -> Result<()> {
if payer.pubkey() == signer.pubkey() {
send_multi(rpc, payer, &[payer], instructions).await
} else {
send_multi(rpc, payer, &[payer, signer], instructions).await
}
}
async fn send_multi(
rpc: &RpcClient,
fee_payer: &Keypair,
signers: &[&Keypair],
instructions: Vec<solana_sdk::instruction::Instruction>,
) -> Result<()> {
let blockhash = rpc.get_latest_blockhash().await?;
let tx = Transaction::new_signed_with_payer(
&instructions,
Some(&fee_payer.pubkey()),
signers,
blockhash,
);
let sig = rpc.send_and_confirm_transaction(&tx).await?;
println!("signature: {sig}");
Ok(())
}