Skip to main content

vaea_flash_sdk/
client.rs

1use solana_sdk::{
2    instruction::{AccountMeta, Instruction},
3    pubkey::Pubkey,
4    signature::Keypair,
5    signer::Signer,
6    transaction::VersionedTransaction,
7    message::{v0, VersionedMessage},
8    commitment_config::CommitmentConfig,
9};
10use solana_client::nonblocking::rpc_client::RpcClient;
11use std::str::FromStr;
12use std::sync::Arc;
13use base64::{Engine as _, engine::general_purpose::STANDARD};
14use reqwest::Client;
15use thiserror::Error;
16use crate::types::*;
17
18// ═══════════════════════════════════════════════════════════
19//  Error
20// ═══════════════════════════════════════════════════════════
21
22#[derive(Error, Debug)]
23pub enum VaeaError {
24    #[error("[{code}] {message}")]
25    Protocol { code: VaeaErrorCode, message: String },
26
27    #[error("HTTP request failed: {0}")]
28    Network(#[from] reqwest::Error),
29
30    #[error("Invalid pubkey: {0}")]
31    InvalidPubkey(String),
32
33    #[error("RPC error: {0}")]
34    Rpc(String),
35
36    #[error("Transaction failed: {0}")]
37    Transaction(String),
38}
39
40impl VaeaError {
41    pub fn protocol(code: VaeaErrorCode, msg: impl Into<String>) -> Self {
42        Self::Protocol { code, message: msg.into() }
43    }
44}
45
46// ═══════════════════════════════════════════════════════════
47//  Client
48// ═══════════════════════════════════════════════════════════
49
50/// VAEA Flash — Universal Flash Loan SDK for Solana (Rust)
51///
52/// # Example
53/// ```rust,no_run
54/// use vaea_flash::{VaeaFlash, BorrowParams, VaeaConfig, Source};
55///
56/// #[tokio::main]
57/// async fn main() -> Result<(), Box<dyn std::error::Error>> {
58///     let payer = solana_sdk::signature::Keypair::new();
59///     let flash = VaeaFlash::new("https://api.devnet.vaea.fi", &payer)?;
60///
61///     let capacity = flash.get_capacity().await?;
62///     println!("Available tokens: {}", capacity.tokens.len());
63///
64///     let quote = flash.get_quote("SOL", 1000.0).await?;
65///     println!("Fee: {}%", quote.fee_breakdown.total_fee_pct);
66///     Ok(())
67/// }
68/// ```
69pub struct VaeaFlash {
70    api_url: String,
71    source: Source,
72    http: Client,
73    rpc: Option<Arc<RpcClient>>,
74    payer: Option<Arc<Keypair>>,
75}
76
77impl VaeaFlash {
78    /// Create a new VaeaFlash client.
79    pub fn new(api_url: &str, payer: &Keypair) -> Result<Self, VaeaError> {
80        Ok(Self {
81            api_url: api_url.to_string(),
82            source: Source::Sdk,
83            http: Client::new(),
84            rpc: None,
85            payer: Some(Arc::new(Keypair::try_from(payer.to_bytes().as_ref()).unwrap())),
86        })
87    }
88
89    /// Create with full config including RPC for execute().
90    pub fn with_rpc(api_url: &str, rpc_url: &str, payer: &Keypair) -> Result<Self, VaeaError> {
91        Ok(Self {
92            api_url: api_url.to_string(),
93            source: Source::Sdk,
94            http: Client::new(),
95            rpc: Some(Arc::new(RpcClient::new(rpc_url.to_string()))),
96            payer: Some(Arc::new(Keypair::try_from(payer.to_bytes().as_ref()).unwrap())),
97        })
98    }
99
100    /// Create a read-only client (no wallet, no execute).
101    pub fn read_only(api_url: &str) -> Self {
102        Self {
103            api_url: api_url.to_string(),
104            source: Source::Sdk,
105            http: Client::new(),
106            rpc: None,
107            payer: None,
108        }
109    }
110
111    /// Set fee source (sdk or ui).
112    pub fn with_source(mut self, source: Source) -> Self {
113        self.source = source;
114        self
115    }
116
117    // ═══════════════════════════════════════════════════════
118    //  API methods
119    // ═══════════════════════════════════════════════════════
120
121    /// Get real-time capacity for all dynamically discovered tokens (120+).
122    pub async fn get_capacity(&self) -> Result<CapacityResponse, VaeaError> {
123        self.api_get("/v1/capacity").await
124    }
125
126    /// Get a detailed quote with fee breakdown.
127    pub async fn get_quote(&self, token: &str, amount: f64) -> Result<QuoteResponse, VaeaError> {
128        if amount <= 0.0 {
129            return Err(VaeaError::protocol(VaeaErrorCode::InvalidAmount, "Amount must be > 0"));
130        }
131        let path = format!(
132            "/v1/quote?token={}&amount={}&source={}",
133            token, amount, self.source
134        );
135        self.api_get(&path).await
136    }
137
138    /// Build prefix + suffix instructions for a flash loan.
139    pub async fn build(&self, request: &BuildRequest) -> Result<BuildResponse, VaeaError> {
140        self.api_post("/v1/build", request).await
141    }
142
143    /// Check system health.
144    pub async fn get_health(&self) -> Result<HealthResponse, VaeaError> {
145        self.api_get("/v1/health").await
146    }
147
148    /// Get the full liquidity matrix — per-protocol per-token available liquidity.
149    pub async fn get_matrix(&self) -> Result<MatrixResponse, VaeaError> {
150        self.api_get("/v1/matrix").await
151    }
152
153    /// Get discovery summary — how many tokens were found during boot-time scanning.
154    pub async fn get_discovery(&self) -> Result<DiscoverySummary, VaeaError> {
155        self.api_get("/v1/discovery").await
156    }
157
158    /// Resolve the optimal flash loan route for any supported token.
159    ///
160    /// The Smart Router evaluates all direct routes across Marginfi, Kamino,
161    /// and Jupiter Lend, then returns the cheapest feasible path.
162    pub async fn get_route(
163        &self,
164        token: &str,
165        amount: f64,
166        max_fee_bps: u16,
167    ) -> Result<ResolvedRoute, VaeaError> {
168        if amount <= 0.0 {
169            return Err(VaeaError::protocol(VaeaErrorCode::InvalidAmount, "Amount must be > 0"));
170        }
171        let path = format!(
172            "/v1/vte?token={}&amount={}&source={}&max_fee_bps={}&alternatives=true",
173            token, amount, self.source, max_fee_bps,
174        );
175        self.api_get(&path).await
176    }
177
178    /// Get information about all supported lending protocols.
179    pub async fn get_sources(&self) -> Result<SourcesResponse, VaeaError> {
180        self.api_get("/v1/sources").await
181    }
182
183    /// Get capacity breakdown per source (Marginfi, Kamino, Jupiter Lend) for each token.
184    pub async fn get_aggregated_capacity(&self) -> Result<AggregatedCapacityResponse, VaeaError> {
185        self.api_get("/v1/capacity/aggregated").await
186    }
187
188    /// Build flash loan instructions with user instructions sandwiched.
189    pub async fn borrow(&self, params: &BorrowParams) -> Result<Vec<Instruction>, VaeaError> {
190        let payer = self.payer.as_ref()
191            .ok_or_else(|| VaeaError::protocol(VaeaErrorCode::ApiError, "Payer keypair required"))?;
192
193        // Fee guard — ONLY fetch quote if user set max_fee_bps (saves ~100ms for bots)
194        if let Some(max_bps) = params.max_fee_bps {
195            let quote = self.get_quote(&params.token, params.amount).await?;
196            let actual_bps = (quote.fee_breakdown.total_fee_pct * 100.0) as u16;
197            if actual_bps > max_bps {
198                return Err(VaeaError::protocol(
199                    VaeaErrorCode::FeeTooHigh,
200                    format!("Fee {} bps exceeds max {} bps", actual_bps, max_bps),
201                ));
202            }
203        }
204
205        let request = BuildRequest {
206            token: params.token.clone(),
207            amount: params.amount,
208            user_pubkey: payer.pubkey().to_string(),
209            source: Some(self.source.to_string()),
210            slippage_bps: params.slippage_bps,
211            max_fee_bps: params.max_fee_bps,
212        };
213
214        let build = self.build(&request).await?;
215        let mut all_ixs = Vec::new();
216
217        for api_ix in &build.prefix_instructions {
218            all_ixs.push(Self::parse_api_instruction(api_ix)?);
219        }
220        for ix in &params.instructions {
221            all_ixs.push(ix.clone());
222        }
223        for api_ix in &build.suffix_instructions {
224            all_ixs.push(Self::parse_api_instruction(api_ix)?);
225        }
226
227        Ok(all_ixs)
228    }
229
230    /// Build, sign, and send a flash loan transaction.
231    /// Automatically uses the VAEA Address Lookup Table for TX compression.
232    pub async fn execute(&self, params: BorrowParams) -> Result<String, VaeaError> {
233        let rpc = self.rpc.as_ref()
234            .ok_or_else(|| VaeaError::protocol(VaeaErrorCode::ApiError, "RPC client required for execute(). Use VaeaFlash::with_rpc()"))?;
235        let payer = self.payer.as_ref()
236            .ok_or_else(|| VaeaError::protocol(VaeaErrorCode::ApiError, "Payer keypair required"))?;
237
238        let all_ixs = self.borrow(&params).await?;
239
240        let blockhash = rpc.get_latest_blockhash_with_commitment(CommitmentConfig::confirmed())
241            .await
242            .map_err(|e| VaeaError::Rpc(e.to_string()))?
243            .0;
244
245        // Fetch our pre-loaded ALT for TX compression (~124 bytes saved)
246        let lookup_tables = self.fetch_lookup_table(rpc).await;
247
248        let msg = v0::Message::try_compile(
249            &payer.pubkey(),
250            &all_ixs,
251            &lookup_tables,
252            blockhash,
253        ).map_err(|e| VaeaError::Transaction(e.to_string()))?;
254
255        let tx = VersionedTransaction::try_new(VersionedMessage::V0(msg), &[payer.as_ref()])
256            .map_err(|e| VaeaError::Transaction(e.to_string()))?;
257
258        let sig = rpc.send_and_confirm_transaction(&tx)
259            .await
260            .map_err(|e| VaeaError::Transaction(e.to_string()))?;
261
262        Ok(sig.to_string())
263    }
264
265    // ═══════════════════════════════════════════════════════
266    //  simulate() — Dry Run
267    // ═══════════════════════════════════════════════════════
268
269    /// Simulate a flash loan transaction without sending it.
270    /// Returns success/failure, CU consumption, and program logs.
271    pub async fn simulate(&self, params: &BorrowParams) -> Result<SimulateResult, VaeaError> {
272        let rpc = self.rpc.as_ref()
273            .ok_or_else(|| VaeaError::protocol(VaeaErrorCode::ApiError, "RPC required for simulate()"))?;
274        let payer = self.payer.as_ref()
275            .ok_or_else(|| VaeaError::protocol(VaeaErrorCode::ApiError, "Payer required for simulate()"))?;
276
277        let all_ixs = self.borrow(params).await?;
278        let blockhash = rpc.get_latest_blockhash_with_commitment(CommitmentConfig::confirmed())
279            .await
280            .map_err(|e| VaeaError::Rpc(e.to_string()))?
281            .0;
282
283        let lookup_tables = self.fetch_lookup_table(rpc).await;
284        let msg = v0::Message::try_compile(
285            &payer.pubkey(),
286            &all_ixs,
287            &lookup_tables,
288            blockhash,
289        ).map_err(|e| VaeaError::Transaction(e.to_string()))?;
290
291        let tx = VersionedTransaction::try_new(VersionedMessage::V0(msg), &[payer.as_ref()])
292            .map_err(|e| VaeaError::Transaction(e.to_string()))?;
293
294        let sim = rpc.simulate_transaction(&tx)
295            .await
296            .map_err(|e| VaeaError::Rpc(e.to_string()))?;
297
298        Ok(SimulateResult {
299            success: sim.value.err.is_none(),
300            error: sim.value.err.map(|e| format!("{:?}", e)),
301            compute_units: sim.value.units_consumed.unwrap_or(0),
302            logs: sim.value.logs.unwrap_or_default(),
303        })
304    }
305
306    // ═══════════════════════════════════════════════════════
307    //  borrow_multi() — Multi-Token Atomic Flash Loans
308    // ═══════════════════════════════════════════════════════
309
310    /// Build a multi-token atomic flash loan with nested sandwich pattern:
311    ///   prefix_A → prefix_B → [user IXs] → suffix_B → suffix_A
312    pub async fn borrow_multi(&self, params: &BorrowMultiParams) -> Result<Vec<Instruction>, VaeaError> {
313        let payer = self.payer.as_ref()
314            .ok_or_else(|| VaeaError::protocol(VaeaErrorCode::ApiError, "Payer required for borrow_multi()"))?;
315
316        // Build prefix/suffix for each loan
317        let mut all_builds = Vec::new();
318        for loan in &params.loans {
319            let request = BuildRequest {
320                token: loan.token.clone(),
321                amount: loan.amount,
322                user_pubkey: payer.pubkey().to_string(),
323                source: Some(self.source.to_string()),
324                slippage_bps: params.slippage_bps,
325                max_fee_bps: params.max_fee_bps,
326            };
327            all_builds.push(self.build(&request).await?);
328        }
329
330        let mut all_ixs = Vec::new();
331
332        // All prefixes in order
333        for build in &all_builds {
334            for api_ix in &build.prefix_instructions {
335                all_ixs.push(Self::parse_api_instruction(api_ix)?);
336            }
337        }
338
339        // User instructions
340        for ix in &params.instructions {
341            all_ixs.push(ix.clone());
342        }
343
344        // All suffixes in reverse order (nested sandwich)
345        for build in all_builds.iter().rev() {
346            for api_ix in &build.suffix_instructions {
347                all_ixs.push(Self::parse_api_instruction(api_ix)?);
348            }
349        }
350
351        Ok(all_ixs)
352    }
353
354    // ═══════════════════════════════════════════════════════
355    //  is_profitable() — Profitability Check
356    // ═══════════════════════════════════════════════════════
357
358    /// Check if a flash loan strategy is profitable after all fees.
359    pub async fn is_profitable(
360        &self,
361        params: &crate::profitability::ProfitabilityParams,
362    ) -> Result<crate::profitability::ProfitabilityResult, VaeaError> {
363        let quote = self.get_quote(&params.token, params.amount).await?;
364        Ok(crate::profitability::calculate_profitability(&quote, params))
365    }
366
367    // ═══════════════════════════════════════════════════════
368    //  borrow_local() — Zero HTTP Instruction Building
369    // ═══════════════════════════════════════════════════════
370
371    /// Build flash loan instructions 100% locally — NO API call.
372    ///
373    /// ~0.1ms vs ~80ms for borrow(). Use this for latency-critical bots.
374    /// The instructions are identical to what /v1/build returns.
375    ///
376    /// ```rust,no_run
377    /// # use vaea_flash_sdk::*;
378    /// # async fn example(flash: &VaeaFlash) -> Result<(), Box<dyn std::error::Error>> {
379    /// let ixs = flash.borrow_local(&BorrowParams {
380    ///     token: "SOL".to_string(),
381    ///     amount: 1000.0,
382    ///     instructions: vec![/* your arb IXs */],
383    ///     slippage_bps: None,
384    ///     max_fee_bps: None,
385    /// })?;
386    /// # Ok(())
387    /// # }
388    /// ```
389    pub fn borrow_local(&self, params: &BorrowParams) -> Result<Vec<Instruction>, VaeaError> {
390        let payer = self.payer.as_ref()
391            .ok_or_else(|| VaeaError::protocol(VaeaErrorCode::ApiError, "Payer required for borrow_local()"))?;
392
393        let tier = match self.source {
394            Source::Sdk => crate::types::FlashTier::Sdk,
395            Source::Ui => crate::types::FlashTier::Ui,
396            Source::Protocol => crate::types::FlashTier::Protocol,
397        };
398
399        let result = crate::local_builder::local_build(crate::local_builder::LocalBuildParams {
400            payer: payer.pubkey(),
401            token: crate::local_builder::TokenId::Symbol(params.token.clone()),
402            amount: params.amount,
403            tier,
404        }).map_err(|e| VaeaError::protocol(VaeaErrorCode::ApiError, e))?;
405
406        let mut all_ixs = vec![result.begin_flash];
407        all_ixs.extend(params.instructions.iter().cloned());
408        all_ixs.push(result.end_flash);
409
410        Ok(all_ixs)
411    }
412
413    // ═══════════════════════════════════════════════════════
414    //  execute_local() — Zero HTTP Execution
415    // ═══════════════════════════════════════════════════════
416
417    /// Build, sign, and send a flash loan in ~100ms — NO API call.
418    ///
419    /// Uses local instruction building + direct RPC.
420    /// Critical path: localBuild(<1ms) → getBlockhash(~50ms) → send(~50ms)
421    pub async fn execute_local(&self, params: BorrowParams) -> Result<String, VaeaError> {
422        let rpc = self.rpc.as_ref()
423            .ok_or_else(|| VaeaError::protocol(VaeaErrorCode::ApiError, "RPC required for execute_local()"))?;
424        let payer = self.payer.as_ref()
425            .ok_or_else(|| VaeaError::protocol(VaeaErrorCode::ApiError, "Payer required for execute_local()"))?;
426
427        let all_ixs = self.borrow_local(&params)?;
428
429        let blockhash = rpc.get_latest_blockhash_with_commitment(CommitmentConfig::confirmed())
430            .await
431            .map_err(|e| VaeaError::Rpc(e.to_string()))?
432            .0;
433
434        let lookup_tables = self.fetch_lookup_table(rpc).await;
435
436        let msg = v0::Message::try_compile(
437            &payer.pubkey(),
438            &all_ixs,
439            &lookup_tables,
440            blockhash,
441        ).map_err(|e| VaeaError::Transaction(e.to_string()))?;
442
443        let tx = VersionedTransaction::try_new(VersionedMessage::V0(msg), &[payer.as_ref()])
444            .map_err(|e| VaeaError::Transaction(e.to_string()))?;
445
446        let sig = rpc.send_and_confirm_transaction(&tx)
447            .await
448            .map_err(|e| VaeaError::Transaction(e.to_string()))?;
449
450        Ok(sig.to_string())
451    }
452
453    // ═══════════════════════════════════════════════════════
454    //  execute_smart() — Route-Aware Smart Execution
455    // ═══════════════════════════════════════════════════════
456
457    /// Build, sign, and send a flash loan with intelligent route selection.
458    ///
459    /// The Smart Router evaluates ALL available paths across Marginfi, Kamino,
460    /// and Jupiter Lend, then selects the cheapest feasible route automatically.
461    /// Falls back to execute_local() if the API is unavailable.
462    pub async fn execute_smart(&self, params: BorrowParams) -> Result<String, VaeaError> {
463        // Try smart route first
464        match self.get_route(&params.token, params.amount, params.max_fee_bps.unwrap_or(0)).await {
465            Ok(route) => {
466                let has_feasible = route.candidates.iter().any(|c| c.feasible);
467                if !has_feasible && !route.candidates.is_empty() {
468                    let best = &route.candidates[0];
469                    if !best.sufficient_liquidity {
470                        return Err(VaeaError::protocol(
471                            VaeaErrorCode::InsufficientLiquidity,
472                            format!(
473                                "Insufficient liquidity for {} {}. Best: {:.2} on {}.",
474                                params.amount, route.token_symbol,
475                                best.available_liquidity, best.protocol,
476                            ),
477                        ));
478                    }
479                }
480                // Route resolved — execute via standard path
481                self.execute(params).await
482            }
483            Err(VaeaError::Network(_)) | Err(VaeaError::Protocol { .. }) => {
484                // API unavailable — fallback to local execution
485                self.execute_local(params).await
486            }
487            Err(e) => Err(e),
488        }
489    }
490
491    // ═══════════════════════════════════════════════════════
492    //  read_flash_state() — Zero-CPI On-Chain State Reader
493    // ═══════════════════════════════════════════════════════
494
495    /// Read an active FlashState PDA from the chain.
496    ///
497    /// Use this to verify a flash loan is active without CPI — just pass
498    /// the PDA as a read-only account.
499    ///
500    /// Cost: 1 RPC call (~5ms). On-chain equivalent: ~2K CU via vaea-flash-ctx crate.
501    pub async fn read_flash_state(
502        &self,
503        payer_key: &Pubkey,
504        token_mint: &Pubkey,
505    ) -> Result<Option<FlashStateInfo>, VaeaError> {
506        let rpc = self.rpc.as_ref()
507            .ok_or_else(|| VaeaError::protocol(VaeaErrorCode::ApiError, "RPC required for read_flash_state()"))?;
508
509        let program_id = Pubkey::from_str(VAEA_PROGRAM_ID)
510            .map_err(|_| VaeaError::InvalidPubkey(VAEA_PROGRAM_ID.to_string()))?;
511
512        let (flash_state_pda, _) = Pubkey::find_program_address(
513            &[b"flash", payer_key.as_ref(), token_mint.as_ref()],
514            &program_id,
515        );
516
517        let account_info = match rpc.get_account(&flash_state_pda).await {
518            Ok(acc) => acc,
519            Err(_) => return Ok(None),
520        };
521
522        if account_info.data.len() < 99 {
523            return Ok(None);
524        }
525        if account_info.owner != program_id {
526            return Ok(None);
527        }
528
529        let data = &account_info.data;
530        // Skip 8-byte Anchor discriminator
531        let payer = Pubkey::try_from(&data[8..40])
532            .map_err(|_| VaeaError::protocol(VaeaErrorCode::ApiError, "Invalid payer in FlashState"))?;
533        let mint = Pubkey::try_from(&data[40..72])
534            .map_err(|_| VaeaError::protocol(VaeaErrorCode::ApiError, "Invalid mint in FlashState"))?;
535        let amount = u64::from_le_bytes(data[72..80].try_into().unwrap());
536        let fee = u64::from_le_bytes(data[80..88].try_into().unwrap());
537        let source_tier = data[88];
538        let slot_created = u64::from_le_bytes(data[89..97].try_into().unwrap());
539        let bump = data[97];
540
541        let tier = FlashTier::from_u8(source_tier).unwrap_or(FlashTier::Sdk);
542
543        Ok(Some(FlashStateInfo {
544            payer,
545            token_mint: mint,
546            amount,
547            fee,
548            source_tier,
549            tier,
550            slot_created,
551            bump,
552        }))
553    }
554
555    /// Fetch the VAEA Address Lookup Table for TX compression.
556    async fn fetch_lookup_table(&self, rpc: &RpcClient) -> Vec<solana_sdk::address_lookup_table::AddressLookupTableAccount> {
557        use solana_sdk::address_lookup_table::AddressLookupTableAccount;
558        use crate::types::VAEA_LOOKUP_TABLE;
559
560        match rpc.get_account(&VAEA_LOOKUP_TABLE).await {
561            Ok(account) => {
562                match solana_sdk::address_lookup_table::state::AddressLookupTable::deserialize(&account.data) {
563                    Ok(table) => vec![AddressLookupTableAccount {
564                        key: VAEA_LOOKUP_TABLE,
565                        addresses: table.addresses.to_vec(),
566                    }],
567                    Err(_) => vec![],
568                }
569            }
570            Err(_) => vec![], // Graceful fallback: TX works without ALT
571        }
572    }
573
574    // ═══════════════════════════════════════════════════════
575    //  Helpers
576    // ═══════════════════════════════════════════════════════
577
578    fn parse_api_instruction(api_ix: &ApiInstructionData) -> Result<Instruction, VaeaError> {
579        let program_id = Pubkey::from_str(&api_ix.program_id)
580            .map_err(|_| VaeaError::InvalidPubkey(api_ix.program_id.clone()))?;
581
582        let accounts: Vec<AccountMeta> = api_ix.accounts.iter().map(|acc| {
583            let pubkey = Pubkey::from_str(&acc.pubkey)
584                .unwrap_or_default();
585            if acc.is_writable {
586                AccountMeta::new(pubkey, acc.is_signer)
587            } else {
588                AccountMeta::new_readonly(pubkey, acc.is_signer)
589            }
590        }).collect();
591
592        let data = STANDARD.decode(&api_ix.data)
593            .map_err(|e| VaeaError::protocol(VaeaErrorCode::ApiError, format!("Base64 decode: {}", e)))?;
594
595        Ok(Instruction { program_id, accounts, data })
596    }
597
598    /// GET request with proper error handling
599    async fn api_get<T: serde::de::DeserializeOwned>(&self, path: &str) -> Result<T, VaeaError> {
600        let url = format!("{}{}", self.api_url, path);
601        let res = self.http.get(&url).send().await?;
602        if !res.status().is_success() {
603            let status = res.status();
604            let body = res.text().await.unwrap_or_default();
605            return Err(VaeaError::protocol(
606                VaeaErrorCode::ApiError,
607                format!("API returned HTTP {}: {}", status, body),
608            ));
609        }
610        Ok(res.json().await?)
611    }
612
613    /// POST request with proper error handling
614    async fn api_post<T: serde::de::DeserializeOwned, B: serde::Serialize>(&self, path: &str, body: &B) -> Result<T, VaeaError> {
615        let url = format!("{}{}", self.api_url, path);
616        let res = self.http.post(&url).json(body).send().await?;
617        if !res.status().is_success() {
618            let status = res.status();
619            let body = res.text().await.unwrap_or_default();
620            return Err(VaeaError::protocol(
621                VaeaErrorCode::ApiError,
622                format!("API returned HTTP {}: {}", status, body),
623            ));
624        }
625        Ok(res.json().await?)
626    }
627}