use crate::errors::ProvaError;
use crate::types::{ActionType, AgentAccount, AttestParams, AttestResult, ProvaConfig, RegisterAgentResult};
use sha2::{Digest, Sha256};
use solana_client::rpc_client::RpcClient;
use solana_sdk::{
commitment_config::CommitmentConfig,
compute_budget::ComputeBudgetInstruction,
ed25519_instruction,
instruction::{AccountMeta, Instruction},
pubkey::Pubkey,
signature::Keypair,
signer::Signer,
system_program,
sysvar,
transaction::Transaction,
};
use std::str::FromStr;
use std::sync::Arc;
pub const PROVA_PROGRAM_ID: &str = "G11dBAzLQaADtHHM2AZNz3ThCDnkY5nhX3Ujddu1CMM1";
pub const AGENT_SEED: &[u8] = b"prova_agent";
pub const MAX_BATCH_ATTESTATIONS: usize = 100;
const DISC_REGISTER_AGENT: [u8; 8] = [135, 157, 66, 195, 2, 113, 175, 30];
const DISC_RECORD_ATTESTATIONS: [u8; 8] = [228, 78, 80, 123, 83, 203, 69, 235];
const DISC_REVOKE_AGENT: [u8; 8] = [227, 60, 209, 125, 240, 117, 163, 73];
const DISC_UPDATE_POLICY_ROOT: [u8; 8] = [204, 72, 225, 189, 180, 164, 143, 74];
const ACCOUNT_DISCRIMINATOR: [u8; 8] = [241, 119, 69, 140, 233, 9, 112, 50];
pub struct ProvaClient {
rpc: RpcClient,
agent_keypair: Arc<Keypair>,
program_id: Pubkey,
network: String,
}
impl ProvaClient {
pub fn new(agent_keypair: Keypair, config: ProvaConfig) -> Self {
let network = if config.rpc_url.contains("mainnet") {
"mainnet"
} else {
"devnet"
}
.to_string();
let program_id = config
.program_id
.as_deref()
.and_then(|s| Pubkey::from_str(s).ok())
.unwrap_or_else(|| Pubkey::from_str(PROVA_PROGRAM_ID).unwrap());
let rpc = RpcClient::new_with_commitment(config.rpc_url, CommitmentConfig::confirmed());
Self {
rpc,
agent_keypair: Arc::new(agent_keypair),
program_id,
network,
}
}
pub fn derive_agent_pda(&self, operator: &Pubkey) -> (Pubkey, u8) {
Pubkey::find_program_address(&[AGENT_SEED, operator.as_ref()], &self.program_id)
}
pub fn hash_action(action: &str) -> [u8; 32] {
let mut h = Sha256::new();
h.update(action.as_bytes());
h.finalize().into()
}
pub fn explorer_url(&self, signature: &str) -> String {
format!(
"https://explorer.solana.com/tx/{}?cluster={}",
signature, self.network
)
}
pub async fn register_agent(
&self,
operator_keypair: &Keypair,
policy_root: Option<[u8; 32]>,
) -> Result<RegisterAgentResult, ProvaError> {
let agent_id: [u8; 32] = self.agent_keypair.pubkey().to_bytes();
let policy = policy_root.unwrap_or([0u8; 32]);
let (agent_pda, _bump) = self.derive_agent_pda(&operator_keypair.pubkey());
let mut data = Vec::with_capacity(8 + 32 + 32);
data.extend_from_slice(&DISC_REGISTER_AGENT);
data.extend_from_slice(&agent_id);
data.extend_from_slice(&policy);
let ix = Instruction {
program_id: self.program_id,
accounts: vec![
AccountMeta::new(agent_pda, false),
AccountMeta::new(operator_keypair.pubkey(), true),
AccountMeta::new_readonly(system_program::id(), false),
],
data,
};
let sig = self
.send_with_priority(&[ix], operator_keypair, 100_000)
.await?;
Ok(RegisterAgentResult {
explorer_url: self.explorer_url(&sig),
tx_signature: sig,
agent_pda,
})
}
pub async fn attest(
&self,
operator_keypair: &Keypair,
action_hash: [u8; 32],
action_type: ActionType,
privacy_mode: bool,
) -> Result<AttestResult, ProvaError> {
self.send_attestations(
operator_keypair,
&[AttestParams {
action_hash,
action_type,
privacy_mode,
}],
)
.await
}
pub async fn batch_attest(
&self,
operator_keypair: &Keypair,
attestations: &[AttestParams],
) -> Result<AttestResult, ProvaError> {
if attestations.is_empty() {
return Err(ProvaError::InvalidInput(
"attestations array cannot be empty".into(),
));
}
if attestations.len() > MAX_BATCH_ATTESTATIONS {
return Err(ProvaError::BatchLimitExceeded(MAX_BATCH_ATTESTATIONS));
}
self.send_attestations(operator_keypair, attestations).await
}
pub async fn revoke_agent(
&self,
operator_keypair: &Keypair,
) -> Result<AttestResult, ProvaError> {
let (agent_pda, _) = self.derive_agent_pda(&operator_keypair.pubkey());
let ix = Instruction {
program_id: self.program_id,
accounts: vec![
AccountMeta::new(agent_pda, false),
AccountMeta::new(operator_keypair.pubkey(), true),
],
data: DISC_REVOKE_AGENT.to_vec(),
};
let sig = self
.send_with_priority(&[ix], operator_keypair, 50_000)
.await?;
Ok(AttestResult {
explorer_url: self.explorer_url(&sig),
tx_signature: sig,
})
}
pub async fn update_policy_root(
&self,
operator_keypair: &Keypair,
new_root: [u8; 32],
) -> Result<AttestResult, ProvaError> {
let (agent_pda, _) = self.derive_agent_pda(&operator_keypair.pubkey());
let mut data = Vec::with_capacity(8 + 32);
data.extend_from_slice(&DISC_UPDATE_POLICY_ROOT);
data.extend_from_slice(&new_root);
let ix = Instruction {
program_id: self.program_id,
accounts: vec![
AccountMeta::new(agent_pda, false),
AccountMeta::new(operator_keypair.pubkey(), true),
],
data,
};
let sig = self
.send_with_priority(&[ix], operator_keypair, 50_000)
.await?;
Ok(AttestResult {
explorer_url: self.explorer_url(&sig),
tx_signature: sig,
})
}
pub async fn get_agent_account(
&self,
operator: &Pubkey,
) -> Result<AgentAccount, ProvaError> {
let (pda, _) = self.derive_agent_pda(operator);
let account_data = self
.rpc
.get_account_data(&pda)
.map_err(|e| ProvaError::AgentNotFound(format!("{}: {}", pda, e)))?;
Self::deserialize_agent_account(&pda, &account_data)
}
pub async fn is_agent_active(&self, operator: &Pubkey) -> bool {
match self.get_agent_account(operator).await {
Ok(acc) => !acc.revoked,
Err(_) => false,
}
}
fn deserialize_agent_account(
pda: &Pubkey,
data: &[u8],
) -> Result<AgentAccount, ProvaError> {
const MIN_LEN: usize = 8 + 32 + 32 + 32 + 8 + 8 + 1 + 1;
if data.len() < MIN_LEN {
return Err(ProvaError::AccountError(format!(
"Account data too short: {} < {}",
data.len(),
MIN_LEN
)));
}
if data[..8] != ACCOUNT_DISCRIMINATOR {
return Err(ProvaError::AccountError(
"Invalid account discriminator".into(),
));
}
let d = &data[8..];
let operator = Pubkey::try_from(&d[0..32])
.map_err(|_| ProvaError::AccountError("Invalid operator pubkey".into()))?;
let mut agent_id = [0u8; 32];
agent_id.copy_from_slice(&d[32..64]);
let mut policy_root = [0u8; 32];
policy_root.copy_from_slice(&d[64..96]);
let attestation_count = u64::from_le_bytes(d[96..104].try_into().unwrap());
let created_at = i64::from_le_bytes(d[104..112].try_into().unwrap());
let revoked = d[112] != 0;
let bump = d[113];
Ok(AgentAccount {
address: *pda,
operator,
agent_id,
policy_root,
attestation_count,
created_at,
revoked,
bump,
})
}
async fn send_attestations(
&self,
operator_keypair: &Keypair,
entries: &[AttestParams],
) -> Result<AttestResult, ProvaError> {
let (agent_pda, _) = self.derive_agent_pda(&operator_keypair.pubkey());
let mut ed25519_ixs = Vec::with_capacity(entries.len());
let mut attestation_inputs_data = Vec::new();
let len_bytes = (entries.len() as u32).to_le_bytes();
attestation_inputs_data.extend_from_slice(&len_bytes);
for entry in entries {
let sig = self.agent_keypair.sign_message(&entry.action_hash);
let sig_bytes: [u8; 64] = sig.into();
let ed25519_ix = ed25519_instruction::new_ed25519_instruction_with_signature(
&entry.action_hash,
&sig_bytes,
&self.agent_keypair.pubkey().to_bytes(),
);
ed25519_ixs.push(ed25519_ix);
attestation_inputs_data.push(entry.action_type as u8);
attestation_inputs_data.extend_from_slice(&entry.action_hash);
attestation_inputs_data.push(entry.privacy_mode as u8);
attestation_inputs_data.extend_from_slice(&sig_bytes);
}
let mut ix_data = Vec::with_capacity(8 + attestation_inputs_data.len());
ix_data.extend_from_slice(&DISC_RECORD_ATTESTATIONS);
ix_data.extend_from_slice(&attestation_inputs_data);
let record_ix = Instruction {
program_id: self.program_id,
accounts: vec![
AccountMeta::new(agent_pda, false),
AccountMeta::new(operator_keypair.pubkey(), true),
AccountMeta::new_readonly(sysvar::instructions::id(), false),
],
data: ix_data,
};
let compute_units = 50_000 + (entries.len() as u32 * 15_000);
let mut all_ixs = Vec::with_capacity(2 + entries.len() + 1);
all_ixs.push(ComputeBudgetInstruction::set_compute_unit_limit(compute_units));
all_ixs.push(ComputeBudgetInstruction::set_compute_unit_price(100_000));
all_ixs.extend(ed25519_ixs);
all_ixs.push(record_ix);
let sig = self
.send_tx(&all_ixs, operator_keypair)
.await?;
Ok(AttestResult {
explorer_url: self.explorer_url(&sig),
tx_signature: sig,
})
}
async fn send_with_priority(
&self,
ixs: &[Instruction],
payer: &Keypair,
compute_units: u32,
) -> Result<String, ProvaError> {
let mut all_ixs = Vec::with_capacity(2 + ixs.len());
all_ixs.push(ComputeBudgetInstruction::set_compute_unit_limit(compute_units));
all_ixs.push(ComputeBudgetInstruction::set_compute_unit_price(100_000));
all_ixs.extend_from_slice(ixs);
self.send_tx(&all_ixs, payer).await
}
async fn send_tx(
&self,
ixs: &[Instruction],
payer: &Keypair,
) -> Result<String, ProvaError> {
let blockhash = self.rpc.get_latest_blockhash()?;
let tx = Transaction::new_signed_with_payer(
ixs,
Some(&payer.pubkey()),
&[payer],
blockhash,
);
let sig = self
.rpc
.send_and_confirm_transaction(&tx)
.map_err(|e| ProvaError::TransactionError(e.to_string()))?;
Ok(sig.to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hash_action_is_deterministic() {
let h1 = ProvaClient::hash_action("swap 100 USDC");
let h2 = ProvaClient::hash_action("swap 100 USDC");
assert_eq!(h1, h2);
assert_ne!(h1, [0u8; 32]);
}
#[test]
fn derive_pda_is_consistent() {
let agent = Keypair::new();
let config = ProvaConfig::default();
let client = ProvaClient::new(agent, config);
let operator = Keypair::new();
let (pda1, bump1) = client.derive_agent_pda(&operator.pubkey());
let (pda2, bump2) = client.derive_agent_pda(&operator.pubkey());
assert_eq!(pda1, pda2);
assert_eq!(bump1, bump2);
}
#[test]
fn explorer_url_format() {
let agent = Keypair::new();
let config = ProvaConfig::default();
let client = ProvaClient::new(agent, config);
let url = client.explorer_url("abc123");
assert!(url.contains("abc123"));
assert!(url.contains("devnet"));
}
#[test]
fn batch_limit_enforced() {
let agent = Keypair::new();
let config = ProvaConfig::default();
let client = ProvaClient::new(agent, config);
let entries: Vec<AttestParams> = (0..101)
.map(|_| AttestParams {
action_hash: [0u8; 32],
action_type: ActionType::Transaction,
privacy_mode: false,
})
.collect();
let operator = Keypair::new();
let result = tokio::runtime::Runtime::new()
.unwrap()
.block_on(client.batch_attest(&operator, &entries));
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("100"));
}
#[test]
fn deserialize_agent_account_rejects_short_data() {
let pda = Pubkey::new_unique();
let data = vec![0u8; 10];
assert!(ProvaClient::deserialize_agent_account(&pda, &data).is_err());
}
#[test]
fn deserialize_agent_account_rejects_bad_discriminator() {
let pda = Pubkey::new_unique();
let data = vec![0u8; 122]; assert!(ProvaClient::deserialize_agent_account(&pda, &data).is_err());
}
}