Skip to main content

loop_agent_sdk/
supabase.rs

1//! Supabase Integration for Reputation Engine
2//!
3//! Fetches attestations from the Supabase `attestations` table
4//! and hydrates the ReputationEngine for score calculation.
5
6use crate::reputation_engine::{
7    AttestationMetadata, AttestationRecord, CaptureLayer, ReputationEngine,
8};
9use reqwest::Client;
10use serde::{Deserialize, Serialize};
11use std::collections::HashMap;
12use tracing::{debug, error, info, warn};
13
14/// Supabase configuration
15#[derive(Debug, Clone)]
16pub struct SupabaseConfig {
17    /// Supabase project URL (e.g., https://xxx.supabase.co)
18    pub url: String,
19    /// Service role key (for server-side access)
20    pub service_role_key: String,
21}
22
23impl SupabaseConfig {
24    /// Create from environment variables
25    pub fn from_env() -> Result<Self, String> {
26        let url = std::env::var("SUPABASE_URL")
27            .map_err(|_| "Missing SUPABASE_URL environment variable")?;
28        let service_role_key = std::env::var("SUPABASE_SERVICE_ROLE_KEY")
29            .map_err(|_| "Missing SUPABASE_SERVICE_ROLE_KEY environment variable")?;
30
31        Ok(Self {
32            url,
33            service_role_key,
34        })
35    }
36}
37
38/// Raw attestation row from Supabase
39#[derive(Debug, Deserialize)]
40pub struct SupabaseAttestation {
41    pub id: String,
42    pub user_id: String,
43    pub layer_id: Option<i32>,
44    pub layer_group: Option<String>,
45    #[serde(rename = "type")]
46    pub attestation_type: String,
47    pub weight: i32,
48    pub is_positive: bool,
49    pub metadata_json: Option<serde_json::Value>,
50    pub verified: bool,
51    pub source: String,
52    pub source_tx: Option<String>,
53    pub created_at: String,
54    pub expires_at: Option<String>,
55    pub deleted_at: Option<String>,
56}
57
58/// Reputation score response from Supabase function
59#[derive(Debug, Deserialize)]
60pub struct SupabaseReputationScore {
61    pub composite: i32,
62    pub reliability: i32,
63    pub skill: i32,
64    pub social: i32,
65    pub tenure: i32,
66    pub infrastructure: i32,
67    pub tier: String,
68}
69
70/// Supabase client for reputation data
71pub struct SupabaseClient {
72    config: SupabaseConfig,
73    http: Client,
74}
75
76impl SupabaseClient {
77    /// Create new Supabase client
78    pub fn new(config: SupabaseConfig) -> Self {
79        Self {
80            config,
81            http: Client::new(),
82        }
83    }
84
85    /// Create from environment variables
86    pub fn from_env() -> Result<Self, String> {
87        let config = SupabaseConfig::from_env()?;
88        Ok(Self::new(config))
89    }
90
91    /// Fetch all attestations for a user
92    pub async fn fetch_attestations(
93        &self,
94        user_id: &str,
95    ) -> Result<Vec<SupabaseAttestation>, String> {
96        let url = format!(
97            "{}/rest/v1/attestations?user_id=eq.{}&deleted_at=is.null&order=created_at.desc",
98            self.config.url, user_id
99        );
100
101        let response = self
102            .http
103            .get(&url)
104            .header("apikey", &self.config.service_role_key)
105            .header("Authorization", format!("Bearer {}", self.config.service_role_key))
106            .header("Content-Type", "application/json")
107            .send()
108            .await
109            .map_err(|e| format!("HTTP request failed: {}", e))?;
110
111        if !response.status().is_success() {
112            let status = response.status();
113            let body = response.text().await.unwrap_or_default();
114            return Err(format!("Supabase error {}: {}", status, body));
115        }
116
117        let attestations: Vec<SupabaseAttestation> = response
118            .json()
119            .await
120            .map_err(|e| format!("JSON parse error: {}", e))?;
121
122        debug!("Fetched {} attestations for user {}", attestations.len(), user_id);
123        Ok(attestations)
124    }
125
126    /// Fetch reputation score using the Supabase function
127    pub async fn fetch_reputation_score(
128        &self,
129        user_id: &str,
130    ) -> Result<Option<SupabaseReputationScore>, String> {
131        let url = format!(
132            "{}/rest/v1/rpc/calculate_reputation_score",
133            self.config.url
134        );
135
136        let body = serde_json::json!({
137            "p_user_id": user_id
138        });
139
140        let response = self
141            .http
142            .post(&url)
143            .header("apikey", &self.config.service_role_key)
144            .header("Authorization", format!("Bearer {}", self.config.service_role_key))
145            .header("Content-Type", "application/json")
146            .json(&body)
147            .send()
148            .await
149            .map_err(|e| format!("HTTP request failed: {}", e))?;
150
151        if !response.status().is_success() {
152            let status = response.status();
153            let body = response.text().await.unwrap_or_default();
154            return Err(format!("Supabase error {}: {}", status, body));
155        }
156
157        // The RPC returns an array with one row
158        let scores: Vec<SupabaseReputationScore> = response
159            .json()
160            .await
161            .map_err(|e| format!("JSON parse error: {}", e))?;
162
163        Ok(scores.into_iter().next())
164    }
165
166    /// Add a new attestation
167    pub async fn add_attestation(
168        &self,
169        user_id: &str,
170        layer_id: Option<i32>,
171        attestation_type: &str,
172        weight: i32,
173        is_positive: bool,
174        metadata: Option<serde_json::Value>,
175        source: &str,
176        source_tx: Option<&str>,
177        verified: bool,
178    ) -> Result<String, String> {
179        let url = format!("{}/rest/v1/attestations", self.config.url);
180
181        // Map layer_id to layer_group
182        let layer_group = layer_id.map(|id| match id {
183            1..=6 => "passive_utility",
184            7..=11 => "infrastructure",
185            12..=16 => "intelligence",
186            17..=22 => "aggressive_autopilot",
187            _ => "passive_utility",
188        });
189
190        let body = serde_json::json!({
191            "user_id": user_id,
192            "layer_id": layer_id,
193            "layer_group": layer_group,
194            "type": attestation_type,
195            "weight": weight,
196            "is_positive": is_positive,
197            "metadata_json": metadata.unwrap_or(serde_json::json!({})),
198            "source": source,
199            "source_tx": source_tx,
200            "verified": verified,
201            "verified_at": if verified { Some(chrono::Utc::now().to_rfc3339()) } else { None },
202            "verified_by": if verified { Some("system") } else { None },
203        });
204
205        let response = self
206            .http
207            .post(&url)
208            .header("apikey", &self.config.service_role_key)
209            .header("Authorization", format!("Bearer {}", self.config.service_role_key))
210            .header("Content-Type", "application/json")
211            .header("Prefer", "return=representation")
212            .json(&body)
213            .send()
214            .await
215            .map_err(|e| format!("HTTP request failed: {}", e))?;
216
217        if !response.status().is_success() {
218            let status = response.status();
219            let body = response.text().await.unwrap_or_default();
220            return Err(format!("Supabase error {}: {}", status, body));
221        }
222
223        let created: Vec<serde_json::Value> = response
224            .json()
225            .await
226            .map_err(|e| format!("JSON parse error: {}", e))?;
227
228        let id = created
229            .first()
230            .and_then(|v| v.get("id"))
231            .and_then(|v| v.as_str())
232            .unwrap_or("unknown")
233            .to_string();
234
235        info!("Created attestation {} for user {}", id, user_id);
236        Ok(id)
237    }
238}
239
240/// Convert Supabase attestation type string to CaptureLayer
241fn map_attestation_type_to_layer(attestation_type: &str, layer_id: Option<i32>) -> CaptureLayer {
242    // If layer_id is provided, use it directly
243    if let Some(id) = layer_id {
244        return match id {
245            1 => CaptureLayer::Shopping,
246            2 => CaptureLayer::Referral,
247            3 => CaptureLayer::Attention,
248            4 => CaptureLayer::Data,
249            5 => CaptureLayer::Insurance,
250            6 => CaptureLayer::Compute,
251            7 => CaptureLayer::Network,
252            8 => CaptureLayer::Energy,
253            9 => CaptureLayer::DePINAggregator,
254            10 => CaptureLayer::InferenceArbitrage,
255            11 => CaptureLayer::StorageDePIN,
256            12 => CaptureLayer::Skill,
257            13 => CaptureLayer::CurationSignal,
258            14 => CaptureLayer::Social,
259            15 => CaptureLayer::KnowledgeAPI,
260            16 => CaptureLayer::PersonalModelLicensing,
261            17 => CaptureLayer::Liquidity,
262            18 => CaptureLayer::GovernanceProxy,
263            19 => CaptureLayer::InventoryArbitrage,
264            20 => CaptureLayer::SubAgentManager,
265            21 => CaptureLayer::ReputationCollateral,
266            22 => CaptureLayer::SwarmCoordinationFee,
267            _ => CaptureLayer::Shopping,
268        };
269    }
270
271    // Otherwise, infer from attestation type
272    match attestation_type {
273        // Reliability types → Shopping layer
274        "vault_created" | "stack_initiated" | "stack_completed" | "yield_claimed"
275        | "yield_compounded" | "early_withdrawal" | "transaction_failed" => CaptureLayer::Shopping,
276
277        // Skill types → Skill layer
278        "certification_submitted" | "certification_verified" | "expertise_demonstrated"
279        | "api_contribution" => CaptureLayer::Skill,
280
281        // Social types → Social layer
282        "referral_given" | "referral_received" | "community_contribution" | "governance_vote" => {
283            CaptureLayer::Social
284        }
285
286        // Tenure types → Shopping (base activity)
287        "daily_login" | "weekly_active" | "monthly_milestone" | "anniversary" => {
288            CaptureLayer::Shopping
289        }
290
291        // Infrastructure types → Network layer
292        "node_registered" | "bandwidth_contributed" | "compute_contributed"
293        | "storage_contributed" => CaptureLayer::Network,
294
295        // Default
296        _ => CaptureLayer::Shopping,
297    }
298}
299
300/// Convert Supabase attestation to SDK AttestationRecord
301fn convert_to_attestation_record(supabase: &SupabaseAttestation) -> AttestationRecord {
302    let layer = map_attestation_type_to_layer(&supabase.attestation_type, supabase.layer_id);
303
304    // Parse timestamp
305    let timestamp = chrono::DateTime::parse_from_rfc3339(&supabase.created_at)
306        .map(|dt| dt.timestamp() as u64)
307        .unwrap_or(0);
308
309    // Parse metadata
310    let metadata = supabase.metadata_json.as_ref().and_then(|json| {
311        let mut meta = AttestationMetadata::default();
312
313        if let Some(obj) = json.as_object() {
314            if let Some(days) = obj.get("durationDays").and_then(|v| v.as_i64()) {
315                meta.lock_duration_days = Some(days as u16);
316            }
317            if let Some(days) = obj.get("lock_duration_days").and_then(|v| v.as_i64()) {
318                meta.lock_duration_days = Some(days as u16);
319            }
320            if let Some(held) = obj.get("held_to_maturity").and_then(|v| v.as_bool()) {
321                meta.held_to_maturity = Some(held);
322            }
323            if let Some(acc) = obj.get("accuracy_percent").and_then(|v| v.as_i64()) {
324                meta.accuracy_percent = Some(acc as u8);
325            }
326            if let Some(uptime) = obj.get("uptime_percent").and_then(|v| v.as_i64()) {
327                meta.uptime_percent = Some(uptime as u8);
328            }
329            // VPA fields
330            if let Some(tier) = obj.get("difficulty_tier").and_then(|v| v.as_i64()) {
331                meta.difficulty_tier = Some(tier as u8);
332            }
333            if let Some(mult) = obj.get("verification_multiplier").and_then(|v| v.as_f64()) {
334                meta.verification_multiplier = Some(mult as f32);
335            }
336        }
337
338        Some(meta)
339    });
340
341    // Use weight directly as magnitude (Supabase weights are typically 10-200)
342    let magnitude = (supabase.weight as u64) * 10_000; // Scale up for scoring
343
344    AttestationRecord {
345        layer,
346        timestamp,
347        positive: supabase.is_positive,
348        magnitude,
349        metadata,
350    }
351}
352
353/// Extension trait for ReputationEngine to load from Supabase
354impl ReputationEngine {
355    /// Load attestations from Supabase and process them
356    pub async fn load_from_supabase(&mut self, client: &SupabaseClient) -> Result<usize, String> {
357        let attestations = client.fetch_attestations(&self.user_pubkey).await?;
358        let count = attestations.len();
359
360        for supabase_att in &attestations {
361            let record = convert_to_attestation_record(supabase_att);
362            self.process_attestation(&record);
363        }
364
365        info!(
366            "Loaded {} attestations from Supabase for user {}",
367            count, self.user_pubkey
368        );
369        Ok(count)
370    }
371
372    /// Create engine and load from Supabase in one step
373    pub async fn from_supabase(
374        user_pubkey: String,
375        client: &SupabaseClient,
376    ) -> Result<Self, String> {
377        let mut engine = Self::new(user_pubkey);
378        engine.load_from_supabase(client).await?;
379        Ok(engine)
380    }
381}
382
383// ============================================================================
384// TESTS
385// ============================================================================
386
387#[cfg(test)]
388mod tests {
389    use super::*;
390
391    #[test]
392    fn test_layer_mapping() {
393        assert_eq!(
394            map_attestation_type_to_layer("stack_initiated", None),
395            CaptureLayer::Shopping
396        );
397        assert_eq!(
398            map_attestation_type_to_layer("certification_verified", None),
399            CaptureLayer::Skill
400        );
401        assert_eq!(
402            map_attestation_type_to_layer("referral_given", None),
403            CaptureLayer::Social
404        );
405        assert_eq!(
406            map_attestation_type_to_layer("node_registered", None),
407            CaptureLayer::Network
408        );
409    }
410
411    #[test]
412    fn test_layer_id_override() {
413        // layer_id should override the type-based mapping
414        assert_eq!(
415            map_attestation_type_to_layer("stack_initiated", Some(12)),
416            CaptureLayer::Skill
417        );
418    }
419
420    #[test]
421    fn test_convert_attestation() {
422        let supabase = SupabaseAttestation {
423            id: "test-id".to_string(),
424            user_id: "test-user".to_string(),
425            layer_id: Some(1),
426            layer_group: Some("passive_utility".to_string()),
427            attestation_type: "stack_initiated".to_string(),
428            weight: 50,
429            is_positive: true,
430            metadata_json: Some(serde_json::json!({
431                "durationDays": 90,
432                "amount": 100000000
433            })),
434            verified: true,
435            source: "api".to_string(),
436            source_tx: None,
437            created_at: "2024-03-27T00:00:00Z".to_string(),
438            expires_at: None,
439            deleted_at: None,
440        };
441
442        let record = convert_to_attestation_record(&supabase);
443        assert_eq!(record.layer, CaptureLayer::Shopping);
444        assert!(record.positive);
445        assert!(record.metadata.is_some());
446        assert_eq!(record.metadata.unwrap().lock_duration_days, Some(90));
447    }
448}