use crate::action::{
ActionError, CaptureResult, PermissionScope, ProofType, RiskTolerance,
SessionKey, StakingPosition, VaultAction, YieldRecommendation, RecommendedAction,
DISTRIBUTION, YIELD_RATES_BPS,
};
use crate::constants::{
Network, ProgramIds, TokenMints, MainnetState,
USER_SHARE_BPS, TREASURY_SHARE_BPS, STAKER_SHARE_BPS,
STAKING_APY, LAMPORTS_PER_CRED, rpc_endpoint,
};
use crate::ZkProof;
use borsh::{BorshDeserialize, BorshSerialize};
use solana_sdk::{
instruction::{AccountMeta, Instruction},
message::Message,
pubkey::Pubkey,
signature::{Keypair, Signature},
signer::Signer,
transaction::Transaction,
};
use solana_client::rpc_client::RpcClient;
use std::str::FromStr;
use tracing::{info, instrument, warn};
pub struct SolanaVaultAction {
rpc_client: RpcClient,
network: Network,
programs: ProgramIds,
mints: TokenMints,
state: MainnetState,
}
impl SolanaVaultAction {
pub fn new(network: Network) -> Result<Self, ActionError> {
let rpc_url = std::env::var("SOLANA_RPC_URL")
.unwrap_or_else(|_| rpc_endpoint(network).to_string());
Self::with_rpc(&rpc_url, network)
}
pub fn with_rpc(rpc_url: &str, network: Network) -> Result<Self, ActionError> {
let rpc_client = RpcClient::new(rpc_url.to_string());
Ok(Self {
rpc_client,
network,
programs: ProgramIds::for_network(network),
mints: TokenMints::for_network(network),
state: MainnetState::get(), })
}
pub fn from_env() -> Result<Self, ActionError> {
let network = std::env::var("SOLANA_NETWORK")
.map(|n| if n == "devnet" { Network::Devnet } else { Network::Mainnet })
.unwrap_or(Network::Mainnet);
Self::new(network)
}
pub fn network(&self) -> Network {
self.network
}
fn verify_scope(&self, session: &SessionKey, required: PermissionScope) -> Result<(), ActionError> {
let now = chrono::Utc::now().timestamp();
if session.expires_at < now {
return Err(ActionError::InvalidSession);
}
if !session.scopes.contains(&required) {
return Err(ActionError::InsufficientScope(required));
}
Ok(())
}
fn derive_user_cred_account(&self, user: &Pubkey) -> Pubkey {
spl_associated_token_account::get_associated_token_address(user, &self.mints.cred)
}
fn derive_position_pda(&self, user: &Pubkey, position_index: u64) -> (Pubkey, u8) {
Pubkey::find_program_address(
&[
b"position",
user.as_ref(),
&position_index.to_le_bytes(),
],
&self.programs.vault,
)
}
}
#[derive(BorshSerialize, BorshDeserialize)]
struct CaptureInstructionData {
discriminator: [u8; 8],
amount_cents: u64,
proof_type: u8,
proof_hash: [u8; 32],
timestamp: i64,
}
#[derive(BorshSerialize, BorshDeserialize)]
struct StakeInstructionData {
discriminator: [u8; 8],
amount: u64,
duration_days: u16,
auto_compound: bool,
}
#[derive(BorshSerialize, BorshDeserialize)]
struct ClaimInstructionData {
discriminator: [u8; 8],
restake: bool,
}
impl VaultAction for SolanaVaultAction {
#[instrument(skip(self, session, proof))]
fn capture_value(
&self,
session: &SessionKey,
merchant_id: Pubkey,
proof: ZkProof,
) -> Result<CaptureResult, ActionError> {
self.verify_scope(session, PermissionScope::Capture)?;
info!(
user = %session.vault,
merchant = %merchant_id,
amount_cents = proof.amount_cents,
"Executing capture"
);
let proof_valid = self.verify_proof(&proof)?;
if !proof_valid {
return Err(ActionError::InvalidProof("Proof verification failed".into()));
}
let total_cred_lamports = proof.amount_cents * 10_000_000;
let (user_amount, treasury_amount, staker_amount) = calculate_distribution(total_cred_lamports);
let proof_hash = self.hash_proof(&proof);
let instruction_data = CaptureInstructionData {
discriminator: [0x12, 0x34, 0x56, 0x78, 0x9a, 0xbc, 0xde, 0xf0], amount_cents: proof.amount_cents,
proof_type: proof.proof_type as u8,
proof_hash,
timestamp: proof.timestamp,
};
let accounts = vec![
AccountMeta::new(self.state.shopping_state, false),
AccountMeta::new_readonly(self.state.cred_config, false),
AccountMeta::new(self.mints.cred, false),
AccountMeta::new(self.derive_user_cred_account(&session.vault), false),
AccountMeta::new(self.state.treasury, false),
AccountMeta::new(self.state.staker_pool, false),
AccountMeta::new_readonly(merchant_id, false),
AccountMeta::new_readonly(session.key, true),
AccountMeta::new_readonly(session.vault, false),
AccountMeta::new_readonly(solana_sdk::system_program::id(), false),
AccountMeta::new_readonly(spl_token::id(), false),
];
let instruction = Instruction {
program_id: self.programs.shopping,
accounts,
data: borsh::to_vec(&instruction_data)
.map_err(|e| ActionError::TransactionFailed(e.to_string()))?,
};
let recent_blockhash = self.rpc_client.get_latest_blockhash()
.map_err(|e| ActionError::TransactionFailed(e.to_string()))?;
let message = Message::new(&[instruction], Some(&session.key));
let mut transaction = Transaction::new_unsigned(message);
transaction.message.recent_blockhash = recent_blockhash;
let signature = self.submit_capture_transaction(transaction, session)?;
info!(
signature = %signature,
cred_minted = total_cred_lamports,
user_share = user_amount,
"Capture transaction submitted"
);
Ok(CaptureResult {
signature,
cred_minted: total_cred_lamports,
user_amount,
treasury_amount,
staker_amount,
merchant: merchant_id,
})
}
#[instrument(skip(self, session))]
fn stake_cred(
&self,
session: &SessionKey,
amount: u64,
duration_days: u16,
auto_compound: bool,
) -> Result<StakingPosition, ActionError> {
self.verify_scope(session, PermissionScope::Stake)?;
let apy_bps = YIELD_RATES_BPS.iter()
.find(|(days, _)| *days == duration_days)
.map(|(_, apy)| *apy)
.ok_or_else(|| ActionError::InvalidProof(format!(
"Invalid duration: {}. Valid: 30, 90, 180, 365",
duration_days
)))?;
info!(
user = %session.vault,
amount = amount,
duration = duration_days,
apy_bps = apy_bps,
"Executing stake"
);
let instruction_data = StakeInstructionData {
discriminator: [0x98, 0x76, 0x54, 0x32, 0x10, 0xab, 0xcd, 0xef], amount,
duration_days,
auto_compound,
};
let position_index = self.get_next_position_index(&session.vault)?;
let (position_pda, _bump) = self.derive_position_pda(&session.vault, position_index);
let accounts = vec![
AccountMeta::new(self.state.reserve_vault, false),
AccountMeta::new(self.derive_user_cred_account(&session.vault), false),
AccountMeta::new(self.state.staker_pool, false),
AccountMeta::new(position_pda, false),
AccountMeta::new_readonly(session.key, true),
AccountMeta::new_readonly(session.vault, false),
AccountMeta::new_readonly(solana_sdk::system_program::id(), false),
AccountMeta::new_readonly(spl_token::id(), false),
];
let instruction = Instruction {
program_id: self.programs.vault,
accounts,
data: borsh::to_vec(&instruction_data)
.map_err(|e| ActionError::TransactionFailed(e.to_string()))?,
};
let recent_blockhash = self.rpc_client.get_latest_blockhash()
.map_err(|e| ActionError::TransactionFailed(e.to_string()))?;
let message = Message::new(&[instruction], Some(&session.key));
let mut transaction = Transaction::new_unsigned(message);
transaction.message.recent_blockhash = recent_blockhash;
let _signature = self.submit_stake_transaction(transaction, session)?;
let now = chrono::Utc::now().timestamp();
let unlock_time = now + (duration_days as i64 * 86400);
Ok(StakingPosition {
position_id: position_pda,
amount,
start_time: now,
duration_days,
unlock_time,
apy_bps,
auto_compound,
accrued_yield: 0,
})
}
#[instrument(skip(self, session))]
fn claim_yield(
&self,
session: &SessionKey,
position_id: Option<Pubkey>,
restake: bool,
) -> Result<u64, ActionError> {
self.verify_scope(session, PermissionScope::Stake)?;
info!(
user = %session.vault,
position = ?position_id,
restake = restake,
"Executing yield claim"
);
let positions = match position_id {
Some(pid) => vec![pid],
None => self.get_user_positions(&session.vault)?,
};
let mut total_claimed = 0u64;
for position in positions {
let position_data = self.get_position_data(&position)?;
let now = chrono::Utc::now().timestamp();
if position_data.unlock_time > now {
if position_id.is_some() {
return Err(ActionError::PositionLocked {
unlock_time: position_data.unlock_time,
});
}
continue;
}
let instruction_data = ClaimInstructionData {
discriminator: [0xab, 0xcd, 0xef, 0x01, 0x23, 0x45, 0x67, 0x89],
restake,
};
let accounts = vec![
AccountMeta::new(position, false),
AccountMeta::new(self.state.staker_pool, false),
AccountMeta::new(self.derive_user_cred_account(&session.vault), false),
AccountMeta::new_readonly(session.key, true),
AccountMeta::new_readonly(session.vault, false),
AccountMeta::new_readonly(spl_token::id(), false),
];
let instruction = Instruction {
program_id: self.programs.vault,
accounts,
data: borsh::to_vec(&instruction_data)
.map_err(|e| ActionError::TransactionFailed(e.to_string()))?,
};
let recent_blockhash = self.rpc_client.get_latest_blockhash()
.map_err(|e| ActionError::TransactionFailed(e.to_string()))?;
let message = Message::new(&[instruction], Some(&session.key));
let mut transaction = Transaction::new_unsigned(message);
transaction.message.recent_blockhash = recent_blockhash;
let claimed = self.submit_claim_transaction(transaction, session, &position)?;
total_claimed += claimed;
}
Ok(total_claimed)
}
fn request_yield_optimization(
&self,
session: &SessionKey,
risk_tolerance: RiskTolerance,
) -> Result<YieldRecommendation, ActionError> {
self.verify_scope(session, PermissionScope::Read)?;
let vault_state = self.get_vault_state(&session.vault)?;
let positions = self.get_user_positions(&session.vault)?;
let total_staked: u64 = positions.iter()
.filter_map(|p| self.get_position_data(p).ok())
.map(|pd| pd.amount)
.sum();
let weighted_apy: u64 = positions.iter()
.filter_map(|p| self.get_position_data(p).ok())
.map(|pd| (pd.amount as u128 * pd.apy_bps as u128 / total_staked.max(1) as u128) as u64)
.sum();
let current_apy = weighted_apy as u16;
let (recommended_action, projected_apy, reasoning) = if vault_state.cred_balance > 0 {
let duration = match risk_tolerance {
RiskTolerance::Conservative => 30,
RiskTolerance::Balanced => 90,
RiskTolerance::Aggressive => 365,
};
let new_apy = YIELD_RATES_BPS.iter()
.find(|(d, _)| *d == duration)
.map(|(_, a)| *a)
.unwrap_or(1200);
(
RecommendedAction::Stake {
amount: vault_state.cred_balance,
duration_days: duration,
},
new_apy,
format!(
"You have {:.2} idle Cred. Staking for {} days would earn {}% APY.",
vault_state.cred_balance as f64 / 1_000_000_000.0,
duration,
new_apy as f64 / 100.0
),
)
} else if positions.len() > 3 {
(
RecommendedAction::Consolidate {
from_positions: positions.clone(),
},
current_apy + 50, "You have multiple small positions. Consolidating could reduce gas costs and simplify management.".into(),
)
} else {
(
RecommendedAction::Hold,
current_apy,
"Your positions are well-optimized. Hold current strategy.".into(),
)
};
Ok(YieldRecommendation {
current_apy,
recommended_action,
projected_apy,
reasoning,
})
}
}
impl SolanaVaultAction {
fn verify_proof(&self, proof: &ZkProof) -> Result<bool, ActionError> {
match proof.proof_type {
ProofType::Reclaim => {
Ok(true)
}
ProofType::ZkTls => {
Ok(true)
}
ProofType::SquarePos | ProofType::StripeConnect => {
Ok(true)
}
ProofType::Fidel => {
Ok(true)
}
}
}
fn hash_proof(&self, proof: &ZkProof) -> [u8; 32] {
use solana_sdk::hash::hash;
let data = borsh::to_vec(proof).unwrap_or_default();
hash(&data).to_bytes()
}
fn submit_capture_transaction(
&self,
_transaction: Transaction,
_session: &SessionKey,
) -> Result<Signature, ActionError> {
Ok(Signature::default())
}
fn submit_stake_transaction(
&self,
_transaction: Transaction,
_session: &SessionKey,
) -> Result<Signature, ActionError> {
Ok(Signature::default())
}
fn submit_claim_transaction(
&self,
_transaction: Transaction,
_session: &SessionKey,
_position: &Pubkey,
) -> Result<u64, ActionError> {
Ok(0)
}
fn get_next_position_index(&self, _user: &Pubkey) -> Result<u64, ActionError> {
Ok(0)
}
fn get_user_positions(&self, _user: &Pubkey) -> Result<Vec<Pubkey>, ActionError> {
Ok(vec![])
}
fn get_position_data(&self, position: &Pubkey) -> Result<PositionData, ActionError> {
Ok(PositionData {
amount: 0,
unlock_time: 0,
apy_bps: 0,
})
}
fn get_vault_state(&self, user: &Pubkey) -> Result<VaultStateData, ActionError> {
Ok(VaultStateData {
cred_balance: 0,
oxo_balance: 0,
})
}
}
struct PositionData {
amount: u64,
unlock_time: i64,
apy_bps: u16,
}
struct VaultStateData {
cred_balance: u64,
oxo_balance: u64,
}
fn calculate_distribution(total: u64) -> (u64, u64, u64) {
let user = (total as u128 * USER_SHARE_BPS as u128 / 10000) as u64;
let treasury = (total as u128 * TREASURY_SHARE_BPS as u128 / 10000) as u64;
let stakers = total - user - treasury; (user, treasury, stakers)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn distribution_is_correct() {
let total = 1_000_000_000u64; let (user, treasury, stakers) = calculate_distribution(total);
assert_eq!(user, 800_000_000); assert_eq!(treasury, 140_000_000); assert_eq!(stakers, 60_000_000); assert_eq!(user + treasury + stakers, total);
}
#[test]
fn distribution_handles_small_amounts() {
let total = 100u64; let (user, treasury, stakers) = calculate_distribution(total);
assert_eq!(user + treasury + stakers, total);
}
}