1use 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
30pub struct SolanaVaultAction {
32 rpc_client: RpcClient,
33 network: Network,
34 programs: ProgramIds,
35 mints: TokenMints,
36 state: MainnetState,
37}
38
39impl SolanaVaultAction {
40 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 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(), })
58 }
59
60 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 pub fn network(&self) -> Network {
70 self.network
71 }
72
73 fn verify_scope(&self, session: &SessionKey, required: PermissionScope) -> Result<(), ActionError> {
75 let now = chrono::Utc::now().timestamp();
77 if session.expires_at < now {
78 return Err(ActionError::InvalidSession);
79 }
80
81 if !session.scopes.contains(&required) {
83 return Err(ActionError::InsufficientScope(required));
84 }
85
86 Ok(())
87 }
88
89 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 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#[derive(BorshSerialize, BorshDeserialize)]
109struct CaptureInstructionData {
110 discriminator: [u8; 8],
112 amount_cents: u64,
114 proof_type: u8,
116 proof_hash: [u8; 32],
118 timestamp: i64,
120}
121
122#[derive(BorshSerialize, BorshDeserialize)]
124struct StakeInstructionData {
125 discriminator: [u8; 8],
126 amount: u64,
127 duration_days: u16,
128 auto_compound: bool,
129}
130
131#[derive(BorshSerialize, BorshDeserialize)]
133struct ClaimInstructionData {
134 discriminator: [u8; 8],
135 restake: bool,
136}
137
138impl VaultAction for SolanaVaultAction {
139 #[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 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 let proof_valid = self.verify_proof(&proof)?;
167 if !proof_valid {
168 return Err(ActionError::InvalidProof("Proof verification failed".into()));
169 }
170
171 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 let proof_hash = self.hash_proof(&proof);
178 let instruction_data = CaptureInstructionData {
179 discriminator: [0x12, 0x34, 0x56, 0x78, 0x9a, 0xbc, 0xde, 0xf0], 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 AccountMeta::new(self.state.shopping_state, false),
189 AccountMeta::new_readonly(self.state.cred_config, false),
191 AccountMeta::new(self.mints.cred, false),
193 AccountMeta::new(self.derive_user_cred_account(&session.vault), false),
195 AccountMeta::new(self.state.treasury, false),
197 AccountMeta::new(self.state.staker_pool, false),
199 AccountMeta::new_readonly(merchant_id, false),
201 AccountMeta::new_readonly(session.key, true),
203 AccountMeta::new_readonly(session.vault, false),
205 AccountMeta::new_readonly(solana_sdk::system_program::id(), false),
207 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 let recent_blockhash = self.rpc_client.get_latest_blockhash()
220 .map_err(|e| ActionError::TransactionFailed(e.to_string()))?;
221
222 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 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 #[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 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 let instruction_data = StakeInstructionData {
279 discriminator: [0x98, 0x76, 0x54, 0x32, 0x10, 0xab, 0xcd, 0xef], amount,
281 duration_days,
282 auto_compound,
283 };
284
285 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 AccountMeta::new(self.state.reserve_vault, false),
292 AccountMeta::new(self.derive_user_cred_account(&session.vault), false),
294 AccountMeta::new(self.state.staker_pool, false),
296 AccountMeta::new(position_pda, false),
298 AccountMeta::new_readonly(session.key, true),
300 AccountMeta::new_readonly(session.vault, false),
302 AccountMeta::new_readonly(solana_sdk::system_program::id(), false),
304 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 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 #[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 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 let position_data = self.get_position_data(&position)?;
368
369 let now = chrono::Utc::now().timestamp();
371 if position_data.unlock_time > now {
372 if position_id.is_some() {
374 return Err(ActionError::PositionLocked {
375 unlock_time: position_data.unlock_time,
376 });
377 }
378 continue;
379 }
380
381 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 let claimed = self.submit_claim_transaction(transaction, session, &position)?;
412 total_claimed += claimed;
413 }
414
415 Ok(total_claimed)
416 }
417
418 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 let vault_state = self.get_vault_state(&session.vault)?;
428 let positions = self.get_user_positions(&session.vault)?;
429
430 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 let (recommended_action, projected_apy, reasoning) = if vault_state.cred_balance > 0 {
445 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 (
472 RecommendedAction::Consolidate {
473 from_positions: positions.clone(),
474 },
475 current_apy + 50, "You have multiple small positions. Consolidating could reduce gas costs and simplify management.".into(),
477 )
478 } else {
479 (
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
496impl SolanaVaultAction {
498 fn verify_proof(&self, proof: &ZkProof) -> Result<bool, ActionError> {
499 match proof.proof_type {
504 ProofType::Reclaim => {
505 Ok(true)
508 }
509 ProofType::ZkTls => {
510 Ok(true)
512 }
513 ProofType::SquarePos | ProofType::StripeConnect => {
514 Ok(true)
516 }
517 ProofType::Fidel => {
518 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 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 Ok(0)
557 }
558
559 fn get_next_position_index(&self, _user: &Pubkey) -> Result<u64, ActionError> {
560 Ok(0)
562 }
563
564 fn get_user_positions(&self, _user: &Pubkey) -> Result<Vec<Pubkey>, ActionError> {
565 Ok(vec![])
567 }
568
569 fn get_position_data(&self, position: &Pubkey) -> Result<PositionData, ActionError> {
570 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 Ok(VaultStateData {
581 cred_balance: 0,
582 oxo_balance: 0,
583 })
584 }
585}
586
587struct PositionData {
589 amount: u64,
590 unlock_time: i64,
591 apy_bps: u16,
592}
593
594struct VaultStateData {
596 cred_balance: u64,
597 oxo_balance: u64,
598}
599
600fn 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; (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; let (user, treasury, stakers) = calculate_distribution(total);
616
617 assert_eq!(user, 800_000_000); assert_eq!(treasury, 140_000_000); assert_eq!(stakers, 60_000_000); assert_eq!(user + treasury + stakers, total);
621 }
622
623 #[test]
624 fn distribution_handles_small_amounts() {
625 let total = 100u64; let (user, treasury, stakers) = calculate_distribution(total);
627
628 assert_eq!(user + treasury + stakers, total);
629 }
630}