Skip to main content

loop_agent_sdk/
vault_action.rs

1//! Loop Agent SDK - Vault Action Implementation
2//! 
3//! Concrete implementation of VaultAction trait for Solana.
4//! Uses session keys for scoped permissions - agent never touches user's main keypair.
5
6use crate::action::{
7    ActionError, CaptureResult, PermissionScope, ProofType, RiskTolerance,
8    SessionKey, StakingPosition, VaultAction, YieldRecommendation, RecommendedAction,
9    DISTRIBUTION, YIELD_RATES_BPS,
10};
11use crate::constants::{
12    Network, ProgramIds, TokenMints, MainnetState,
13    USER_SHARE_BPS, TREASURY_SHARE_BPS, STAKER_SHARE_BPS,
14    STAKING_APY, LAMPORTS_PER_CRED, rpc_endpoint,
15};
16use crate::ZkProof;
17use borsh::{BorshDeserialize, BorshSerialize};
18use solana_sdk::{
19    instruction::{AccountMeta, Instruction},
20    message::Message,
21    pubkey::Pubkey,
22    signature::{Keypair, Signature},
23    signer::Signer,
24    transaction::Transaction,
25};
26use solana_client::rpc_client::RpcClient;
27use std::str::FromStr;
28use tracing::{info, instrument, warn};
29
30/// Solana-backed vault action implementation
31pub struct SolanaVaultAction {
32    rpc_client: RpcClient,
33    network: Network,
34    programs: ProgramIds,
35    mints: TokenMints,
36    state: MainnetState,
37}
38
39impl SolanaVaultAction {
40    /// Create new vault action client for specified network
41    pub fn new(network: Network) -> Result<Self, ActionError> {
42        let rpc_url = std::env::var("SOLANA_RPC_URL")
43            .unwrap_or_else(|_| rpc_endpoint(network).to_string());
44        Self::with_rpc(&rpc_url, network)
45    }
46    
47    /// Create with custom RPC endpoint
48    pub fn with_rpc(rpc_url: &str, network: Network) -> Result<Self, ActionError> {
49        let rpc_client = RpcClient::new(rpc_url.to_string());
50        
51        Ok(Self {
52            rpc_client,
53            network,
54            programs: ProgramIds::for_network(network),
55            mints: TokenMints::for_network(network),
56            state: MainnetState::get(), // TODO: Add devnet state
57        })
58    }
59    
60    /// Create from environment (defaults to mainnet)
61    pub fn from_env() -> Result<Self, ActionError> {
62        let network = std::env::var("SOLANA_NETWORK")
63            .map(|n| if n == "devnet" { Network::Devnet } else { Network::Mainnet })
64            .unwrap_or(Network::Mainnet);
65        Self::new(network)
66    }
67    
68    /// Get current network
69    pub fn network(&self) -> Network {
70        self.network
71    }
72    
73    /// Verify session key has required scope
74    fn verify_scope(&self, session: &SessionKey, required: PermissionScope) -> Result<(), ActionError> {
75        // Check expiration
76        let now = chrono::Utc::now().timestamp();
77        if session.expires_at < now {
78            return Err(ActionError::InvalidSession);
79        }
80        
81        // Check scope
82        if !session.scopes.contains(&required) {
83            return Err(ActionError::InsufficientScope(required));
84        }
85        
86        Ok(())
87    }
88    
89    /// Derive user's Cred token account
90    fn derive_user_cred_account(&self, user: &Pubkey) -> Pubkey {
91        spl_associated_token_account::get_associated_token_address(user, &self.mints.cred)
92    }
93    
94    /// Derive staking position PDA
95    fn derive_position_pda(&self, user: &Pubkey, position_index: u64) -> (Pubkey, u8) {
96        Pubkey::find_program_address(
97            &[
98                b"position",
99                user.as_ref(),
100                &position_index.to_le_bytes(),
101            ],
102            &self.programs.vault,
103        )
104    }
105}
106
107/// Shopping capture instruction data
108#[derive(BorshSerialize, BorshDeserialize)]
109struct CaptureInstructionData {
110    /// Instruction discriminator (from Anchor)
111    discriminator: [u8; 8],
112    /// Amount in cents (will be converted to Cred)
113    amount_cents: u64,
114    /// Proof type enum
115    proof_type: u8,
116    /// Proof data hash (we verify off-chain, store hash on-chain)
117    proof_hash: [u8; 32],
118    /// Timestamp of purchase
119    timestamp: i64,
120}
121
122/// Stake instruction data
123#[derive(BorshSerialize, BorshDeserialize)]
124struct StakeInstructionData {
125    discriminator: [u8; 8],
126    amount: u64,
127    duration_days: u16,
128    auto_compound: bool,
129}
130
131/// Claim instruction data
132#[derive(BorshSerialize, BorshDeserialize)]
133struct ClaimInstructionData {
134    discriminator: [u8; 8],
135    restake: bool,
136}
137
138impl VaultAction for SolanaVaultAction {
139    /// Capture value from a verified purchase
140    /// 
141    /// Flow:
142    /// 1. Verify session key has Capture scope
143    /// 2. Verify the ZK proof (off-chain)
144    /// 3. Build capture instruction
145    /// 4. Sign with session key (NOT user's main key)
146    /// 5. Submit transaction
147    /// 6. Contract enforces 80/14/6 distribution
148    #[instrument(skip(self, session, proof))]
149    fn capture_value(
150        &self,
151        session: &SessionKey,
152        merchant_id: Pubkey,
153        proof: ZkProof,
154    ) -> Result<CaptureResult, ActionError> {
155        // 1. Verify session
156        self.verify_scope(session, PermissionScope::Capture)?;
157        
158        info!(
159            user = %session.vault,
160            merchant = %merchant_id,
161            amount_cents = proof.amount_cents,
162            "Executing capture"
163        );
164        
165        // 2. Verify proof (off-chain verification)
166        let proof_valid = self.verify_proof(&proof)?;
167        if !proof_valid {
168            return Err(ActionError::InvalidProof("Proof verification failed".into()));
169        }
170        
171        // 3. Calculate amounts
172        // 1 cent = 0.01 Cred = 10,000,000 lamports (since 1 Cred = 1B lamports)
173        let total_cred_lamports = proof.amount_cents * 10_000_000;
174        let (user_amount, treasury_amount, staker_amount) = calculate_distribution(total_cred_lamports);
175        
176        // 4. Build instruction
177        let proof_hash = self.hash_proof(&proof);
178        let instruction_data = CaptureInstructionData {
179            discriminator: [0x12, 0x34, 0x56, 0x78, 0x9a, 0xbc, 0xde, 0xf0], // capture discriminator
180            amount_cents: proof.amount_cents,
181            proof_type: proof.proof_type as u8,
182            proof_hash,
183            timestamp: proof.timestamp,
184        };
185        
186        let accounts = vec![
187            // Shopping program state
188            AccountMeta::new(self.state.shopping_state, false),
189            // Cred config
190            AccountMeta::new_readonly(self.state.cred_config, false),
191            // Cred mint (for minting new Cred)
192            AccountMeta::new(self.mints.cred, false),
193            // User's Cred token account (receives 80%)
194            AccountMeta::new(self.derive_user_cred_account(&session.vault), false),
195            // Treasury account (receives 14%)
196            AccountMeta::new(self.state.treasury, false),
197            // Staker pool (receives 6%)
198            AccountMeta::new(self.state.staker_pool, false),
199            // Merchant (for attribution)
200            AccountMeta::new_readonly(merchant_id, false),
201            // Session key as signer (NOT user's main key!)
202            AccountMeta::new_readonly(session.key, true),
203            // User vault (for verification)
204            AccountMeta::new_readonly(session.vault, false),
205            // System program
206            AccountMeta::new_readonly(solana_sdk::system_program::id(), false),
207            // Token program
208            AccountMeta::new_readonly(spl_token::id(), false),
209        ];
210        
211        let instruction = Instruction {
212            program_id: self.programs.shopping,
213            accounts,
214            data: borsh::to_vec(&instruction_data)
215                .map_err(|e| ActionError::TransactionFailed(e.to_string()))?,
216        };
217        
218        // 5. Build and sign transaction with session key
219        let recent_blockhash = self.rpc_client.get_latest_blockhash()
220            .map_err(|e| ActionError::TransactionFailed(e.to_string()))?;
221        
222        // Note: In production, session key signing happens via secure key service
223        // The session key is a limited-permission key, NOT the user's main wallet
224        let message = Message::new(&[instruction], Some(&session.key));
225        let mut transaction = Transaction::new_unsigned(message);
226        transaction.message.recent_blockhash = recent_blockhash;
227        
228        // Session key signs (this would be via HSM/KMS in production)
229        // For now, we return what the signed transaction would produce
230        let signature = self.submit_capture_transaction(transaction, session)?;
231        
232        info!(
233            signature = %signature,
234            cred_minted = total_cred_lamports,
235            user_share = user_amount,
236            "Capture transaction submitted"
237        );
238        
239        Ok(CaptureResult {
240            signature,
241            cred_minted: total_cred_lamports,
242            user_amount,
243            treasury_amount,
244            staker_amount,
245            merchant: merchant_id,
246        })
247    }
248    
249    /// Stake Cred for yield
250    #[instrument(skip(self, session))]
251    fn stake_cred(
252        &self,
253        session: &SessionKey,
254        amount: u64,
255        duration_days: u16,
256        auto_compound: bool,
257    ) -> Result<StakingPosition, ActionError> {
258        self.verify_scope(session, PermissionScope::Stake)?;
259        
260        // Validate duration
261        let apy_bps = YIELD_RATES_BPS.iter()
262            .find(|(days, _)| *days == duration_days)
263            .map(|(_, apy)| *apy)
264            .ok_or_else(|| ActionError::InvalidProof(format!(
265                "Invalid duration: {}. Valid: 30, 90, 180, 365",
266                duration_days
267            )))?;
268        
269        info!(
270            user = %session.vault,
271            amount = amount,
272            duration = duration_days,
273            apy_bps = apy_bps,
274            "Executing stake"
275        );
276        
277        // Build stake instruction
278        let instruction_data = StakeInstructionData {
279            discriminator: [0x98, 0x76, 0x54, 0x32, 0x10, 0xab, 0xcd, 0xef], // stake discriminator
280            amount,
281            duration_days,
282            auto_compound,
283        };
284        
285        // Get next position index for user
286        let position_index = self.get_next_position_index(&session.vault)?;
287        let (position_pda, _bump) = self.derive_position_pda(&session.vault, position_index);
288        
289        let accounts = vec![
290            // Vault program state
291            AccountMeta::new(self.state.reserve_vault, false),
292            // User's Cred token account (source)
293            AccountMeta::new(self.derive_user_cred_account(&session.vault), false),
294            // Staking pool (destination)
295            AccountMeta::new(self.state.staker_pool, false),
296            // Position account (to be created)
297            AccountMeta::new(position_pda, false),
298            // Session key as signer
299            AccountMeta::new_readonly(session.key, true),
300            // User vault
301            AccountMeta::new_readonly(session.vault, false),
302            // System program
303            AccountMeta::new_readonly(solana_sdk::system_program::id(), false),
304            // Token program
305            AccountMeta::new_readonly(spl_token::id(), false),
306        ];
307        
308        let instruction = Instruction {
309            program_id: self.programs.vault,
310            accounts,
311            data: borsh::to_vec(&instruction_data)
312                .map_err(|e| ActionError::TransactionFailed(e.to_string()))?,
313        };
314        
315        // Build and submit
316        let recent_blockhash = self.rpc_client.get_latest_blockhash()
317            .map_err(|e| ActionError::TransactionFailed(e.to_string()))?;
318        
319        let message = Message::new(&[instruction], Some(&session.key));
320        let mut transaction = Transaction::new_unsigned(message);
321        transaction.message.recent_blockhash = recent_blockhash;
322        
323        let _signature = self.submit_stake_transaction(transaction, session)?;
324        
325        let now = chrono::Utc::now().timestamp();
326        let unlock_time = now + (duration_days as i64 * 86400);
327        
328        Ok(StakingPosition {
329            position_id: position_pda,
330            amount,
331            start_time: now,
332            duration_days,
333            unlock_time,
334            apy_bps,
335            auto_compound,
336            accrued_yield: 0,
337        })
338    }
339    
340    /// Claim yield from positions
341    #[instrument(skip(self, session))]
342    fn claim_yield(
343        &self,
344        session: &SessionKey,
345        position_id: Option<Pubkey>,
346        restake: bool,
347    ) -> Result<u64, ActionError> {
348        self.verify_scope(session, PermissionScope::Stake)?;
349        
350        info!(
351            user = %session.vault,
352            position = ?position_id,
353            restake = restake,
354            "Executing yield claim"
355        );
356        
357        // Get positions to claim from
358        let positions = match position_id {
359            Some(pid) => vec![pid],
360            None => self.get_user_positions(&session.vault)?,
361        };
362        
363        let mut total_claimed = 0u64;
364        
365        for position in positions {
366            // Fetch position data
367            let position_data = self.get_position_data(&position)?;
368            
369            // Check if unlocked
370            let now = chrono::Utc::now().timestamp();
371            if position_data.unlock_time > now {
372                // Position still locked, skip (or error if specific position requested)
373                if position_id.is_some() {
374                    return Err(ActionError::PositionLocked {
375                        unlock_time: position_data.unlock_time,
376                    });
377                }
378                continue;
379            }
380            
381            // Build claim instruction
382            let instruction_data = ClaimInstructionData {
383                discriminator: [0xab, 0xcd, 0xef, 0x01, 0x23, 0x45, 0x67, 0x89],
384                restake,
385            };
386            
387            let accounts = vec![
388                AccountMeta::new(position, false),
389                AccountMeta::new(self.state.staker_pool, false),
390                AccountMeta::new(self.derive_user_cred_account(&session.vault), false),
391                AccountMeta::new_readonly(session.key, true),
392                AccountMeta::new_readonly(session.vault, false),
393                AccountMeta::new_readonly(spl_token::id(), false),
394            ];
395            
396            let instruction = Instruction {
397                program_id: self.programs.vault,
398                accounts,
399                data: borsh::to_vec(&instruction_data)
400                    .map_err(|e| ActionError::TransactionFailed(e.to_string()))?,
401            };
402            
403            let recent_blockhash = self.rpc_client.get_latest_blockhash()
404                .map_err(|e| ActionError::TransactionFailed(e.to_string()))?;
405            
406            let message = Message::new(&[instruction], Some(&session.key));
407            let mut transaction = Transaction::new_unsigned(message);
408            transaction.message.recent_blockhash = recent_blockhash;
409            
410            // Submit and add to total
411            let claimed = self.submit_claim_transaction(transaction, session, &position)?;
412            total_claimed += claimed;
413        }
414        
415        Ok(total_claimed)
416    }
417    
418    /// Get yield optimization recommendation
419    fn request_yield_optimization(
420        &self,
421        session: &SessionKey,
422        risk_tolerance: RiskTolerance,
423    ) -> Result<YieldRecommendation, ActionError> {
424        self.verify_scope(session, PermissionScope::Read)?;
425        
426        // Fetch user's vault state
427        let vault_state = self.get_vault_state(&session.vault)?;
428        let positions = self.get_user_positions(&session.vault)?;
429        
430        // Calculate current effective APY
431        let total_staked: u64 = positions.iter()
432            .filter_map(|p| self.get_position_data(p).ok())
433            .map(|pd| pd.amount)
434            .sum();
435        
436        let weighted_apy: u64 = positions.iter()
437            .filter_map(|p| self.get_position_data(p).ok())
438            .map(|pd| (pd.amount as u128 * pd.apy_bps as u128 / total_staked.max(1) as u128) as u64)
439            .sum();
440        
441        let current_apy = weighted_apy as u16;
442        
443        // Determine recommendation based on state and risk tolerance
444        let (recommended_action, projected_apy, reasoning) = if vault_state.cred_balance > 0 {
445            // Has idle Cred - recommend staking
446            let duration = match risk_tolerance {
447                RiskTolerance::Conservative => 30,
448                RiskTolerance::Balanced => 90,
449                RiskTolerance::Aggressive => 365,
450            };
451            let new_apy = YIELD_RATES_BPS.iter()
452                .find(|(d, _)| *d == duration)
453                .map(|(_, a)| *a)
454                .unwrap_or(1200);
455            
456            (
457                RecommendedAction::Stake {
458                    amount: vault_state.cred_balance,
459                    duration_days: duration,
460                },
461                new_apy,
462                format!(
463                    "You have {:.2} idle Cred. Staking for {} days would earn {}% APY.",
464                    vault_state.cred_balance as f64 / 1_000_000_000.0,
465                    duration,
466                    new_apy as f64 / 100.0
467                ),
468            )
469        } else if positions.len() > 3 {
470            // Many small positions - recommend consolidation
471            (
472                RecommendedAction::Consolidate {
473                    from_positions: positions.clone(),
474                },
475                current_apy + 50, // Slight improvement from reduced overhead
476                "You have multiple small positions. Consolidating could reduce gas costs and simplify management.".into(),
477            )
478        } else {
479            // Already optimized
480            (
481                RecommendedAction::Hold,
482                current_apy,
483                "Your positions are well-optimized. Hold current strategy.".into(),
484            )
485        };
486        
487        Ok(YieldRecommendation {
488            current_apy,
489            recommended_action,
490            projected_apy,
491            reasoning,
492        })
493    }
494}
495
496// Helper implementations
497impl SolanaVaultAction {
498    fn verify_proof(&self, proof: &ZkProof) -> Result<bool, ActionError> {
499        // TODO: Implement actual proof verification
500        // For Reclaim: verify ZK proof against Reclaim verifier
501        // For zkTLS: verify TLS proof
502        // For POS: verify webhook signature
503        match proof.proof_type {
504            ProofType::Reclaim => {
505                // Verify Reclaim protocol proof
506                // This would call the Reclaim verifier contract or service
507                Ok(true)
508            }
509            ProofType::ZkTls => {
510                // Verify zkTLS proof
511                Ok(true)
512            }
513            ProofType::SquarePos | ProofType::StripeConnect => {
514                // These come from trusted webhook - already verified upstream
515                Ok(true)
516            }
517            ProofType::Fidel => {
518                // Fidel webhooks are signed - verify signature
519                Ok(true)
520            }
521        }
522    }
523    
524    fn hash_proof(&self, proof: &ZkProof) -> [u8; 32] {
525        use solana_sdk::hash::hash;
526        let data = borsh::to_vec(proof).unwrap_or_default();
527        hash(&data).to_bytes()
528    }
529    
530    fn submit_capture_transaction(
531        &self,
532        _transaction: Transaction,
533        _session: &SessionKey,
534    ) -> Result<Signature, ActionError> {
535        // TODO: Sign with session key and submit
536        // In production, this calls a signing service that holds session keys
537        // Session keys are limited-permission keys created for agent use
538        Ok(Signature::default())
539    }
540    
541    fn submit_stake_transaction(
542        &self,
543        _transaction: Transaction,
544        _session: &SessionKey,
545    ) -> Result<Signature, ActionError> {
546        Ok(Signature::default())
547    }
548    
549    fn submit_claim_transaction(
550        &self,
551        _transaction: Transaction,
552        _session: &SessionKey,
553        _position: &Pubkey,
554    ) -> Result<u64, ActionError> {
555        // Returns amount claimed
556        Ok(0)
557    }
558    
559    fn get_next_position_index(&self, _user: &Pubkey) -> Result<u64, ActionError> {
560        // TODO: Query on-chain for user's position count
561        Ok(0)
562    }
563    
564    fn get_user_positions(&self, _user: &Pubkey) -> Result<Vec<Pubkey>, ActionError> {
565        // TODO: Query positions via getProgramAccounts or indexer
566        Ok(vec![])
567    }
568    
569    fn get_position_data(&self, position: &Pubkey) -> Result<PositionData, ActionError> {
570        // TODO: Fetch and deserialize position account
571        Ok(PositionData {
572            amount: 0,
573            unlock_time: 0,
574            apy_bps: 0,
575        })
576    }
577    
578    fn get_vault_state(&self, user: &Pubkey) -> Result<VaultStateData, ActionError> {
579        // TODO: Fetch user's token balances
580        Ok(VaultStateData {
581            cred_balance: 0,
582            oxo_balance: 0,
583        })
584    }
585}
586
587/// Position account data
588struct PositionData {
589    amount: u64,
590    unlock_time: i64,
591    apy_bps: u16,
592}
593
594/// Vault state data
595struct VaultStateData {
596    cred_balance: u64,
597    oxo_balance: u64,
598}
599
600/// Calculate 80/14/6 distribution
601fn calculate_distribution(total: u64) -> (u64, u64, u64) {
602    let user = (total as u128 * USER_SHARE_BPS as u128 / 10000) as u64;
603    let treasury = (total as u128 * TREASURY_SHARE_BPS as u128 / 10000) as u64;
604    let stakers = total - user - treasury; // Remainder to avoid rounding loss
605    (user, treasury, stakers)
606}
607
608#[cfg(test)]
609mod tests {
610    use super::*;
611    
612    #[test]
613    fn distribution_is_correct() {
614        let total = 1_000_000_000u64; // 1 Cred
615        let (user, treasury, stakers) = calculate_distribution(total);
616        
617        assert_eq!(user, 800_000_000);      // 80%
618        assert_eq!(treasury, 140_000_000);  // 14%
619        assert_eq!(stakers, 60_000_000);    // 6%
620        assert_eq!(user + treasury + stakers, total);
621    }
622    
623    #[test]
624    fn distribution_handles_small_amounts() {
625        let total = 100u64; // Very small amount
626        let (user, treasury, stakers) = calculate_distribution(total);
627        
628        assert_eq!(user + treasury + stakers, total);
629    }
630}