Skip to main content

prova_agent_sdk/
client.rs

1//! Cliente on-chain para el programa Prova en Solana.
2//!
3//! Envía transacciones reales a devnet/mainnet. Cada método construye,
4//! firma y envía la transacción al cluster configurado.
5//!
6//! # Ejemplo
7//! ```no_run
8//! use prova_agent_sdk::{ProvaClient, ProvaConfig, AttestationBuilder, ActionType};
9//! use solana_sdk::signature::Keypair;
10//!
11//! #[tokio::main]
12//! async fn main() -> Result<(), Box<dyn std::error::Error>> {
13//!     let operator = Keypair::new();
14//!     let agent = Keypair::new();
15//!     let config = ProvaConfig { rpc_url: "https://api.devnet.solana.com".into(), ..Default::default() };
16//!     let client = ProvaClient::new(agent, config);
17//!
18//!     // Registrar agente
19//!     let reg = client.register_agent(&operator, None).await?;
20//!     println!("Registered: {}", reg.explorer_url);
21//!
22//!     // Emitir atestación
23//!     let hash = ProvaClient::hash_action("swap 100 USDC for SOL");
24//!     let att = client.attest(&operator, hash, ActionType::Transaction, false).await?;
25//!     println!("Attested: {}", att.explorer_url);
26//!     Ok(())
27//! }
28//! ```
29
30use crate::errors::ProvaError;
31use crate::types::{ActionType, AgentAccount, AttestParams, AttestResult, ProvaConfig, RegisterAgentResult};
32use sha2::{Digest, Sha256};
33use solana_client::rpc_client::RpcClient;
34use solana_sdk::{
35    commitment_config::CommitmentConfig,
36    compute_budget::ComputeBudgetInstruction,
37    ed25519_instruction,
38    instruction::{AccountMeta, Instruction},
39    pubkey::Pubkey,
40    signature::Keypair,
41    signer::Signer,
42    system_program,
43    sysvar,
44    transaction::Transaction,
45};
46use std::str::FromStr;
47use std::sync::Arc;
48
49/// Program ID del contrato Prova desplegado en Solana devnet.
50pub const PROVA_PROGRAM_ID: &str = "G11dBAzLQaADtHHM2AZNz3ThCDnkY5nhX3Ujddu1CMM1";
51
52/// Seed para derivar la PDA del agente.
53pub const AGENT_SEED: &[u8] = b"prova_agent";
54
55/// Límite máximo de atestaciones por batch.
56pub const MAX_BATCH_ATTESTATIONS: usize = 100;
57
58/// Discriminadores de instrucciones Anchor (sha256("global:<method_name>")[..8]).
59const DISC_REGISTER_AGENT: [u8; 8] = [135, 157, 66, 195, 2, 113, 175, 30];
60const DISC_RECORD_ATTESTATIONS: [u8; 8] = [228, 78, 80, 123, 83, 203, 69, 235];
61const DISC_REVOKE_AGENT: [u8; 8] = [227, 60, 209, 125, 240, 117, 163, 73];
62const DISC_UPDATE_POLICY_ROOT: [u8; 8] = [204, 72, 225, 189, 180, 164, 143, 74];
63
64/// Discriminador de la cuenta AgentAccount en Anchor (sha256("account:AgentAccount")[..8]).
65const ACCOUNT_DISCRIMINATOR: [u8; 8] = [241, 119, 69, 140, 233, 9, 112, 50];
66
67pub struct ProvaClient {
68    rpc: RpcClient,
69    agent_keypair: Arc<Keypair>,
70    program_id: Pubkey,
71    network: String,
72}
73
74impl ProvaClient {
75    pub fn new(agent_keypair: Keypair, config: ProvaConfig) -> Self {
76        let network = if config.rpc_url.contains("mainnet") {
77            "mainnet"
78        } else {
79            "devnet"
80        }
81        .to_string();
82
83        let program_id = config
84            .program_id
85            .as_deref()
86            .and_then(|s| Pubkey::from_str(s).ok())
87            .unwrap_or_else(|| Pubkey::from_str(PROVA_PROGRAM_ID).unwrap());
88
89        let rpc = RpcClient::new_with_commitment(config.rpc_url, CommitmentConfig::confirmed());
90
91        Self {
92            rpc,
93            agent_keypair: Arc::new(agent_keypair),
94            program_id,
95            network,
96        }
97    }
98
99    // ─── Helpers públicos ─────────────────────────────────────────────────────
100
101    /// Deriva la PDA del agente a partir de la pubkey del operador.
102    pub fn derive_agent_pda(&self, operator: &Pubkey) -> (Pubkey, u8) {
103        Pubkey::find_program_address(&[AGENT_SEED, operator.as_ref()], &self.program_id)
104    }
105
106    /// Calcula el SHA-256 de un string de acción, devuelve 32 bytes.
107    pub fn hash_action(action: &str) -> [u8; 32] {
108        let mut h = Sha256::new();
109        h.update(action.as_bytes());
110        h.finalize().into()
111    }
112
113    /// URL del explorador para una firma de transacción.
114    pub fn explorer_url(&self, signature: &str) -> String {
115        format!(
116            "https://explorer.solana.com/tx/{}?cluster={}",
117            signature, self.network
118        )
119    }
120
121    // ─── Instrucciones on-chain ───────────────────────────────────────────────
122
123    /// Registra un nuevo agente on-chain.
124    ///
125    /// El `operator_keypair` paga la transacción y se convierte en el dueño del agente.
126    /// `policy_root` es opcional (32 bytes); si es `None`, se usa un root vacío.
127    pub async fn register_agent(
128        &self,
129        operator_keypair: &Keypair,
130        policy_root: Option<[u8; 32]>,
131    ) -> Result<RegisterAgentResult, ProvaError> {
132        let agent_id: [u8; 32] = self.agent_keypair.pubkey().to_bytes();
133        let policy = policy_root.unwrap_or([0u8; 32]);
134        let (agent_pda, _bump) = self.derive_agent_pda(&operator_keypair.pubkey());
135
136        // Serializar argumentos Anchor: discriminador + agent_id + policy_root
137        let mut data = Vec::with_capacity(8 + 32 + 32);
138        data.extend_from_slice(&DISC_REGISTER_AGENT);
139        data.extend_from_slice(&agent_id);
140        data.extend_from_slice(&policy);
141
142        let ix = Instruction {
143            program_id: self.program_id,
144            accounts: vec![
145                AccountMeta::new(agent_pda, false),
146                AccountMeta::new(operator_keypair.pubkey(), true),
147                AccountMeta::new_readonly(system_program::id(), false),
148            ],
149            data,
150        };
151
152        let sig = self
153            .send_with_priority(&[ix], operator_keypair, 100_000)
154            .await?;
155
156        Ok(RegisterAgentResult {
157            explorer_url: self.explorer_url(&sig),
158            tx_signature: sig,
159            agent_pda,
160        })
161    }
162
163    /// Emite una sola atestación on-chain.
164    pub async fn attest(
165        &self,
166        operator_keypair: &Keypair,
167        action_hash: [u8; 32],
168        action_type: ActionType,
169        privacy_mode: bool,
170    ) -> Result<AttestResult, ProvaError> {
171        self.send_attestations(
172            operator_keypair,
173            &[AttestParams {
174                action_hash,
175                action_type,
176                privacy_mode,
177            }],
178        )
179        .await
180    }
181
182    /// Emite un batch de atestaciones on-chain (máximo 100).
183    pub async fn batch_attest(
184        &self,
185        operator_keypair: &Keypair,
186        attestations: &[AttestParams],
187    ) -> Result<AttestResult, ProvaError> {
188        if attestations.is_empty() {
189            return Err(ProvaError::InvalidInput(
190                "attestations array cannot be empty".into(),
191            ));
192        }
193        if attestations.len() > MAX_BATCH_ATTESTATIONS {
194            return Err(ProvaError::BatchLimitExceeded(MAX_BATCH_ATTESTATIONS));
195        }
196        self.send_attestations(operator_keypair, attestations).await
197    }
198
199    /// Revoca un agente on-chain. Una vez revocado, no puede emitir más atestaciones.
200    pub async fn revoke_agent(
201        &self,
202        operator_keypair: &Keypair,
203    ) -> Result<AttestResult, ProvaError> {
204        let (agent_pda, _) = self.derive_agent_pda(&operator_keypair.pubkey());
205
206        let ix = Instruction {
207            program_id: self.program_id,
208            accounts: vec![
209                AccountMeta::new(agent_pda, false),
210                AccountMeta::new(operator_keypair.pubkey(), true),
211            ],
212            data: DISC_REVOKE_AGENT.to_vec(),
213        };
214
215        let sig = self
216            .send_with_priority(&[ix], operator_keypair, 50_000)
217            .await?;
218
219        Ok(AttestResult {
220            explorer_url: self.explorer_url(&sig),
221            tx_signature: sig,
222        })
223    }
224
225    /// Actualiza el policy root de un agente registrado.
226    pub async fn update_policy_root(
227        &self,
228        operator_keypair: &Keypair,
229        new_root: [u8; 32],
230    ) -> Result<AttestResult, ProvaError> {
231        let (agent_pda, _) = self.derive_agent_pda(&operator_keypair.pubkey());
232
233        let mut data = Vec::with_capacity(8 + 32);
234        data.extend_from_slice(&DISC_UPDATE_POLICY_ROOT);
235        data.extend_from_slice(&new_root);
236
237        let ix = Instruction {
238            program_id: self.program_id,
239            accounts: vec![
240                AccountMeta::new(agent_pda, false),
241                AccountMeta::new(operator_keypair.pubkey(), true),
242            ],
243            data,
244        };
245
246        let sig = self
247            .send_with_priority(&[ix], operator_keypair, 50_000)
248            .await?;
249
250        Ok(AttestResult {
251            explorer_url: self.explorer_url(&sig),
252            tx_signature: sig,
253        })
254    }
255
256    // ─── Lectura on-chain ─────────────────────────────────────────────────────
257
258    /// Lee la cuenta del agente directamente desde la blockchain.
259    pub async fn get_agent_account(
260        &self,
261        operator: &Pubkey,
262    ) -> Result<AgentAccount, ProvaError> {
263        let (pda, _) = self.derive_agent_pda(operator);
264        let account_data = self
265            .rpc
266            .get_account_data(&pda)
267            .map_err(|e| ProvaError::AgentNotFound(format!("{}: {}", pda, e)))?;
268
269        Self::deserialize_agent_account(&pda, &account_data)
270    }
271
272    /// Verifica si un agente está activo (registrado y no revocado).
273    pub async fn is_agent_active(&self, operator: &Pubkey) -> bool {
274        match self.get_agent_account(operator).await {
275            Ok(acc) => !acc.revoked,
276            Err(_) => false,
277        }
278    }
279
280    // ─── Privados ─────────────────────────────────────────────────────────────
281
282    fn deserialize_agent_account(
283        pda: &Pubkey,
284        data: &[u8],
285    ) -> Result<AgentAccount, ProvaError> {
286        // Anchor layout: 8 bytes discriminador + campos
287        // AgentAccount: operator(32) + agent_id(32) + policy_root(32) + attestation_count(8) + created_at(8) + revoked(1) + bump(1)
288        const MIN_LEN: usize = 8 + 32 + 32 + 32 + 8 + 8 + 1 + 1;
289        if data.len() < MIN_LEN {
290            return Err(ProvaError::AccountError(format!(
291                "Account data too short: {} < {}",
292                data.len(),
293                MIN_LEN
294            )));
295        }
296
297        // Verificar discriminador
298        if data[..8] != ACCOUNT_DISCRIMINATOR {
299            return Err(ProvaError::AccountError(
300                "Invalid account discriminator".into(),
301            ));
302        }
303
304        let d = &data[8..];
305        let operator = Pubkey::try_from(&d[0..32])
306            .map_err(|_| ProvaError::AccountError("Invalid operator pubkey".into()))?;
307
308        let mut agent_id = [0u8; 32];
309        agent_id.copy_from_slice(&d[32..64]);
310
311        let mut policy_root = [0u8; 32];
312        policy_root.copy_from_slice(&d[64..96]);
313
314        let attestation_count = u64::from_le_bytes(d[96..104].try_into().unwrap());
315        let created_at = i64::from_le_bytes(d[104..112].try_into().unwrap());
316        let revoked = d[112] != 0;
317        let bump = d[113];
318
319        Ok(AgentAccount {
320            address: *pda,
321            operator,
322            agent_id,
323            policy_root,
324            attestation_count,
325            created_at,
326            revoked,
327            bump,
328        })
329    }
330
331    /// Construye y envía las instrucciones de atestación con Ed25519 pre-verify.
332    async fn send_attestations(
333        &self,
334        operator_keypair: &Keypair,
335        entries: &[AttestParams],
336    ) -> Result<AttestResult, ProvaError> {
337        let (agent_pda, _) = self.derive_agent_pda(&operator_keypair.pubkey());
338
339        // Firmar cada action_hash con la keypair del agente y crear instrucciones Ed25519
340        let mut ed25519_ixs = Vec::with_capacity(entries.len());
341        let mut attestation_inputs_data = Vec::new();
342
343        // Serializar Vec<AttestationInput> para Anchor: u32 length prefix + items
344        let len_bytes = (entries.len() as u32).to_le_bytes();
345        attestation_inputs_data.extend_from_slice(&len_bytes);
346
347        for entry in entries {
348            // Firmar el action_hash con la keypair del agente usando solana_sdk
349            let sig = self.agent_keypair.sign_message(&entry.action_hash);
350            let sig_bytes: [u8; 64] = sig.into();
351
352            // Instrucción Ed25519 de pre-verify usando bytes crudos
353            let ed25519_ix = ed25519_instruction::new_ed25519_instruction_with_signature(
354                &entry.action_hash,
355                &sig_bytes,
356                &self.agent_keypair.pubkey().to_bytes(),
357            );
358            ed25519_ixs.push(ed25519_ix);
359
360            // Serializar AttestationInput para Anchor:
361            // action_type: u8 (enum index)
362            // action_hash: [u8; 32]
363            // privacy_mode: bool (u8)
364            // signature: [u8; 64]
365            attestation_inputs_data.push(entry.action_type as u8);
366            attestation_inputs_data.extend_from_slice(&entry.action_hash);
367            attestation_inputs_data.push(entry.privacy_mode as u8);
368            attestation_inputs_data.extend_from_slice(&sig_bytes);
369        }
370
371        // Construir instrucción principal: discriminador + attestations_data
372        let mut ix_data = Vec::with_capacity(8 + attestation_inputs_data.len());
373        ix_data.extend_from_slice(&DISC_RECORD_ATTESTATIONS);
374        ix_data.extend_from_slice(&attestation_inputs_data);
375
376        let record_ix = Instruction {
377            program_id: self.program_id,
378            accounts: vec![
379                AccountMeta::new(agent_pda, false),
380                AccountMeta::new(operator_keypair.pubkey(), true),
381                AccountMeta::new_readonly(sysvar::instructions::id(), false),
382            ],
383            data: ix_data,
384        };
385
386        // Ensamblar transacción: ComputeBudget + Ed25519 pre-verify ixs + record_attestations
387        let compute_units = 50_000 + (entries.len() as u32 * 15_000);
388        let mut all_ixs = Vec::with_capacity(2 + entries.len() + 1);
389        all_ixs.push(ComputeBudgetInstruction::set_compute_unit_limit(compute_units));
390        all_ixs.push(ComputeBudgetInstruction::set_compute_unit_price(100_000));
391        all_ixs.extend(ed25519_ixs);
392        all_ixs.push(record_ix);
393
394        let sig = self
395            .send_tx(&all_ixs, operator_keypair)
396            .await?;
397
398        Ok(AttestResult {
399            explorer_url: self.explorer_url(&sig),
400            tx_signature: sig,
401        })
402    }
403
404    /// Envía una transacción con ComputeBudget de prioridad.
405    async fn send_with_priority(
406        &self,
407        ixs: &[Instruction],
408        payer: &Keypair,
409        compute_units: u32,
410    ) -> Result<String, ProvaError> {
411        let mut all_ixs = Vec::with_capacity(2 + ixs.len());
412        all_ixs.push(ComputeBudgetInstruction::set_compute_unit_limit(compute_units));
413        all_ixs.push(ComputeBudgetInstruction::set_compute_unit_price(100_000));
414        all_ixs.extend_from_slice(ixs);
415        self.send_tx(&all_ixs, payer).await
416    }
417
418    /// Envía una transacción firmada al cluster.
419    async fn send_tx(
420        &self,
421        ixs: &[Instruction],
422        payer: &Keypair,
423    ) -> Result<String, ProvaError> {
424        let blockhash = self.rpc.get_latest_blockhash()?;
425
426        let tx = Transaction::new_signed_with_payer(
427            ixs,
428            Some(&payer.pubkey()),
429            &[payer],
430            blockhash,
431        );
432
433        let sig = self
434            .rpc
435            .send_and_confirm_transaction(&tx)
436            .map_err(|e| ProvaError::TransactionError(e.to_string()))?;
437
438        Ok(sig.to_string())
439    }
440}
441
442#[cfg(test)]
443mod tests {
444    use super::*;
445
446    #[test]
447    fn hash_action_is_deterministic() {
448        let h1 = ProvaClient::hash_action("swap 100 USDC");
449        let h2 = ProvaClient::hash_action("swap 100 USDC");
450        assert_eq!(h1, h2);
451        assert_ne!(h1, [0u8; 32]);
452    }
453
454    #[test]
455    fn derive_pda_is_consistent() {
456        let agent = Keypair::new();
457        let config = ProvaConfig::default();
458        let client = ProvaClient::new(agent, config);
459        let operator = Keypair::new();
460
461        let (pda1, bump1) = client.derive_agent_pda(&operator.pubkey());
462        let (pda2, bump2) = client.derive_agent_pda(&operator.pubkey());
463        assert_eq!(pda1, pda2);
464        assert_eq!(bump1, bump2);
465    }
466
467    #[test]
468    fn explorer_url_format() {
469        let agent = Keypair::new();
470        let config = ProvaConfig::default();
471        let client = ProvaClient::new(agent, config);
472        let url = client.explorer_url("abc123");
473        assert!(url.contains("abc123"));
474        assert!(url.contains("devnet"));
475    }
476
477    #[test]
478    fn batch_limit_enforced() {
479        let agent = Keypair::new();
480        let config = ProvaConfig::default();
481        let client = ProvaClient::new(agent, config);
482        let entries: Vec<AttestParams> = (0..101)
483            .map(|_| AttestParams {
484                action_hash: [0u8; 32],
485                action_type: ActionType::Transaction,
486                privacy_mode: false,
487            })
488            .collect();
489        let operator = Keypair::new();
490
491        let result = tokio::runtime::Runtime::new()
492            .unwrap()
493            .block_on(client.batch_attest(&operator, &entries));
494
495        assert!(result.is_err());
496        assert!(result.unwrap_err().to_string().contains("100"));
497    }
498
499    #[test]
500    fn deserialize_agent_account_rejects_short_data() {
501        let pda = Pubkey::new_unique();
502        let data = vec![0u8; 10];
503        assert!(ProvaClient::deserialize_agent_account(&pda, &data).is_err());
504    }
505
506    #[test]
507    fn deserialize_agent_account_rejects_bad_discriminator() {
508        let pda = Pubkey::new_unique();
509        let data = vec![0u8; 122]; // Suficiente largo pero discriminador incorrecto
510        assert!(ProvaClient::deserialize_agent_account(&pda, &data).is_err());
511    }
512}