Skip to main content

loop_agent_sdk/
state.rs

1//! Loop Agent SDK - State Interface
2//! 
3//! Context persistence for stateless Lambda execution.
4//! Handles "memory" across invocations in <100ms reload time.
5
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8
9/// User context - reloaded at start of each Lambda invocation
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct UserContext {
12    /// User's Solana pubkey
13    pub pubkey: String,
14    
15    /// Current vault state (cached, refresh on demand)
16    pub vault: VaultState,
17    
18    /// User preferences
19    pub preferences: UserPreferences,
20    
21    /// Recent activity (last 10 events)
22    pub recent_events: Vec<ContextEvent>,
23    
24    /// Agent-specific memory (key-value)
25    pub agent_memory: HashMap<String, serde_json::Value>,
26    
27    /// Last context update timestamp
28    pub updated_at: i64,
29    
30    /// Context version for optimistic locking
31    pub version: u64,
32}
33
34#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct VaultState {
36    pub cred_balance: u64,
37    pub oxo_balance: u64,
38    pub total_staked: u64,
39    pub pending_yield: u64,
40    pub active_positions: u16,
41    /// Cached at
42    pub snapshot_at: i64,
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct UserPreferences {
47    /// Auto-stake captured Cred
48    pub auto_stake: bool,
49    /// Preferred stake duration
50    pub default_duration_days: u16,
51    /// Auto-compound yield
52    pub auto_compound: bool,
53    /// Yield threshold for auto-compound (lamports)
54    pub compound_threshold: u64,
55    /// Risk tolerance for recommendations
56    pub risk_tolerance: String,
57    /// Notification preferences
58    pub notifications: NotificationPrefs,
59}
60
61#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct NotificationPrefs {
63    pub on_capture: bool,
64    pub on_stake: bool,
65    pub on_yield: bool,
66    pub on_unlock: bool,
67    /// Minimum amount to notify (in Cred lamports)
68    pub min_notify_amount: u64,
69}
70
71#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct ContextEvent {
73    pub event_type: String,
74    pub timestamp: i64,
75    pub summary: String,
76    pub amount: Option<u64>,
77}
78
79impl Default for UserPreferences {
80    fn default() -> Self {
81        Self {
82            auto_stake: true,
83            default_duration_days: 90,
84            auto_compound: true,
85            compound_threshold: 10_000_000_000, // 10 Cred
86            risk_tolerance: "balanced".to_string(),
87            notifications: NotificationPrefs {
88                on_capture: true,
89                on_stake: false,
90                on_yield: true,
91                on_unlock: true,
92                min_notify_amount: 1_000_000_000, // 1 Cred
93            },
94        }
95    }
96}
97
98/// State storage backend trait
99/// Can be backed by DynamoDB, Redis, or Solana account data
100pub trait StateStore {
101    /// Fetch user context (<100ms target)
102    fn context_fetch(&self, user_pubkey: &str) -> Result<UserContext, StateError>;
103    
104    /// Push updated context (with optimistic locking)
105    fn context_push(&self, context: UserContext) -> Result<(), StateError>;
106    
107    /// Fetch multiple users (for batch operations)
108    fn context_batch_fetch(&self, user_pubkeys: &[String]) -> Result<Vec<UserContext>, StateError>;
109    
110    /// Update specific field without full fetch/push
111    fn context_patch(
112        &self,
113        user_pubkey: &str,
114        path: &str,
115        value: serde_json::Value,
116    ) -> Result<(), StateError>;
117    
118    /// Add event to recent events (ring buffer)
119    fn push_event(&self, user_pubkey: &str, event: ContextEvent) -> Result<(), StateError>;
120    
121    /// Get or initialize context for new user
122    fn get_or_init(&self, user_pubkey: &str) -> Result<UserContext, StateError>;
123}
124
125#[derive(Debug, Clone)]
126pub enum StateError {
127    /// User context not found
128    NotFound,
129    /// Version conflict (optimistic lock failed)
130    VersionConflict { expected: u64, actual: u64 },
131    /// Storage backend error
132    StorageError(String),
133    /// Serialization error
134    SerializationError(String),
135    /// Timeout fetching state
136    Timeout,
137}
138
139/// DynamoDB-backed state store configuration
140#[derive(Debug, Clone)]
141pub struct DynamoStateConfig {
142    pub table_name: String,
143    pub region: String,
144    /// TTL for cached vault state (seconds)
145    pub vault_cache_ttl: u32,
146    /// Max recent events to keep
147    pub max_events: usize,
148}
149
150impl Default for DynamoStateConfig {
151    fn default() -> Self {
152        Self {
153            table_name: "loop-agent-state".to_string(),
154            region: "us-east-1".to_string(),
155            vault_cache_ttl: 60,  // Refresh vault every 60s
156            max_events: 10,
157        }
158    }
159}
160
161/// DynamoDB table schema
162/// 
163/// Primary Key: pk = "USER#{pubkey}"
164/// Sort Key: sk = "CONTEXT"
165/// 
166/// GSI1: For batch queries by last update
167/// GSI1PK: "ACTIVE"
168/// GSI1SK: updated_at
169/// 
170/// Example item:
171/// ```json
172/// {
173///   "pk": "USER#5A7E...YKTZ",
174///   "sk": "CONTEXT",
175///   "pubkey": "5A7E...YKTZ",
176///   "vault": { ... },
177///   "preferences": { ... },
178///   "recent_events": [ ... ],
179///   "agent_memory": { ... },
180///   "updated_at": 1711468800,
181///   "version": 42
182/// }
183/// ```
184
185/// Helper to create initial context for new user
186pub fn init_user_context(pubkey: &str) -> UserContext {
187    UserContext {
188        pubkey: pubkey.to_string(),
189        vault: VaultState {
190            cred_balance: 0,
191            oxo_balance: 0,
192            total_staked: 0,
193            pending_yield: 0,
194            active_positions: 0,
195            snapshot_at: 0,
196        },
197        preferences: UserPreferences::default(),
198        recent_events: Vec::new(),
199        agent_memory: HashMap::new(),
200        updated_at: chrono::Utc::now().timestamp(),
201        version: 1,
202    }
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208
209    #[test]
210    fn default_preferences_sensible() {
211        let prefs = UserPreferences::default();
212        assert!(prefs.auto_stake);
213        assert!(prefs.auto_compound);
214        assert_eq!(prefs.default_duration_days, 90);
215    }
216
217    #[test]
218    fn context_serializes() {
219        let ctx = init_user_context("5A7ETEST");
220        let json = serde_json::to_string(&ctx).unwrap();
221        let parsed: UserContext = serde_json::from_str(&json).unwrap();
222        assert_eq!(parsed.pubkey, "5A7ETEST");
223    }
224}