Skip to main content

outlayer_cli/
api.rs

1use anyhow::{Context, Result};
2use serde::{Deserialize, Serialize};
3use serde_json::Value;
4
5use crate::config::NetworkConfig;
6
7pub struct ApiClient {
8    client: reqwest::Client,
9    base_url: String,
10}
11
12#[derive(Debug, Serialize)]
13pub struct HttpsCallRequest {
14    pub input: Value,
15    #[serde(rename = "async")]
16    pub is_async: bool,
17    #[serde(skip_serializing_if = "Option::is_none")]
18    pub version_key: Option<String>,
19    #[serde(skip_serializing_if = "Option::is_none")]
20    pub secrets_ref: Option<SecretsRef>,
21}
22
23#[derive(Debug, Clone, Serialize)]
24pub struct SecretsRef {
25    pub profile: String,
26    pub account_id: String,
27}
28
29#[derive(Debug, Deserialize)]
30pub struct HttpsCallResponse {
31    pub call_id: String,
32    pub status: String,
33    pub output: Option<Value>,
34    pub error: Option<String>,
35    pub compute_cost: Option<String>,
36    #[allow(dead_code)]
37    pub instructions: Option<u64>,
38    pub time_ms: Option<u64>,
39    pub poll_url: Option<String>,
40    #[allow(dead_code)]
41    pub attestation_url: Option<String>,
42}
43
44#[derive(Debug, Serialize)]
45pub struct GetPubkeyRequest {
46    pub accessor: Value,
47    pub owner: String,
48    #[serde(skip_serializing_if = "Option::is_none")]
49    pub profile: Option<String>,
50    pub secrets_json: String,
51}
52
53impl ApiClient {
54    pub fn new(network: &NetworkConfig) -> Self {
55        Self {
56            client: reqwest::Client::new(),
57            base_url: network.api_base_url.clone(),
58        }
59    }
60
61    /// POST /call/{owner}/{project} — execute agent
62    pub async fn call_project(
63        &self,
64        owner: &str,
65        project: &str,
66        payment_key: &str,
67        body: &HttpsCallRequest,
68        compute_limit: Option<u64>,
69        deposit: Option<&str>,
70    ) -> Result<HttpsCallResponse> {
71        let url = format!("{}/call/{}/{}", self.base_url, owner, project);
72
73        let mut req = self
74            .client
75            .post(&url)
76            .header("X-Payment-Key", payment_key)
77            .json(body);
78
79        if let Some(limit) = compute_limit {
80            req = req.header("X-Compute-Limit", limit.to_string());
81        }
82        if let Some(deposit) = deposit {
83            req = req.header("X-Attached-Deposit", deposit);
84        }
85
86        let response = req.send().await.context("Failed to call project")?;
87
88        if !response.status().is_success() {
89            let status = response.status();
90            let text = response.text().await.unwrap_or_default();
91            anyhow::bail!("API error ({status}): {text}");
92        }
93
94        response
95            .json()
96            .await
97            .context("Failed to parse call response")
98    }
99
100    /// GET /calls/{call_id} — poll async call status
101    pub async fn get_call_result(
102        &self,
103        call_id: &str,
104        payment_key: &str,
105    ) -> Result<HttpsCallResponse> {
106        let url = format!("{}/calls/{}", self.base_url, call_id);
107
108        let response = self
109            .client
110            .get(&url)
111            .header("X-Payment-Key", payment_key)
112            .send()
113            .await
114            .context("Failed to poll call status")?;
115
116        if !response.status().is_success() {
117            let status = response.status();
118            let text = response.text().await.unwrap_or_default();
119            anyhow::bail!("API error ({status}): {text}");
120        }
121
122        response.json().await.context("Failed to parse call result")
123    }
124
125    /// GET /public/payment-keys/{owner}/{nonce}/balance
126    pub async fn get_payment_key_balance(
127        &self,
128        owner: &str,
129        nonce: u32,
130    ) -> Result<PaymentKeyBalanceResponse> {
131        let url = format!(
132            "{}/public/payment-keys/{}/{}/balance",
133            self.base_url, owner, nonce
134        );
135
136        let response = self.client.get(&url).send().await?;
137
138        if !response.status().is_success() {
139            let status = response.status();
140            let text = response.text().await.unwrap_or_default();
141            anyhow::bail!("Failed to get balance ({status}): {text}");
142        }
143
144        response
145            .json()
146            .await
147            .context("Failed to parse balance response")
148    }
149
150    /// GET /public/payment-keys/{owner}/{nonce}/usage
151    pub async fn get_payment_key_usage(
152        &self,
153        owner: &str,
154        nonce: u32,
155        limit: i64,
156        offset: i64,
157    ) -> Result<PaymentKeyUsageResponse> {
158        let url = format!(
159            "{}/public/payment-keys/{}/{}/usage?limit={}&offset={}",
160            self.base_url, owner, nonce, limit, offset
161        );
162
163        let response = self.client.get(&url).send().await?;
164
165        if !response.status().is_success() {
166            let status = response.status();
167            let text = response.text().await.unwrap_or_default();
168            anyhow::bail!("Failed to get usage ({status}): {text}");
169        }
170
171        response
172            .json()
173            .await
174            .context("Failed to parse usage response")
175    }
176
177    /// GET /public/project-earnings/{project_owner}
178    pub async fn get_project_owner_earnings(
179        &self,
180        owner: &str,
181    ) -> Result<ProjectOwnerEarningsResponse> {
182        let url = format!("{}/public/project-earnings/{}", self.base_url, owner);
183
184        let response = self.client.get(&url).send().await?;
185
186        if !response.status().is_success() {
187            let status = response.status();
188            let text = response.text().await.unwrap_or_default();
189            anyhow::bail!("Failed to get earnings ({status}): {text}");
190        }
191
192        response
193            .json()
194            .await
195            .context("Failed to parse earnings response")
196    }
197
198    /// GET /public/project-earnings/{project_owner}/history
199    pub async fn get_earnings_history(
200        &self,
201        owner: &str,
202        source: Option<&str>,
203        limit: i64,
204        offset: i64,
205    ) -> Result<EarningsHistoryResponse> {
206        let mut url = format!(
207            "{}/public/project-earnings/{}/history?limit={}&offset={}",
208            self.base_url, owner, limit, offset
209        );
210        if let Some(source) = source {
211            url.push_str(&format!("&source={}", source));
212        }
213
214        let response = self.client.get(&url).send().await?;
215
216        if !response.status().is_success() {
217            let status = response.status();
218            let text = response.text().await.unwrap_or_default();
219            anyhow::bail!("Failed to get earnings history ({status}): {text}");
220        }
221
222        response
223            .json()
224            .await
225            .context("Failed to parse earnings history")
226    }
227
228    /// POST /secrets/add_generated_secret — generate PROTECTED_* in TEE
229    pub async fn add_generated_secret(
230        &self,
231        req: &Value,
232    ) -> Result<AddGeneratedSecretResponse> {
233        let url = format!("{}/secrets/add_generated_secret", self.base_url);
234
235        let response = self
236            .client
237            .post(&url)
238            .json(req)
239            .send()
240            .await
241            .context("Failed to call add_generated_secret")?;
242
243        if !response.status().is_success() {
244            let status = response.status();
245            let text = response.text().await.unwrap_or_default();
246            anyhow::bail!("Failed to generate secrets ({status}): {text}");
247        }
248
249        response
250            .json()
251            .await
252            .context("Failed to parse add_generated_secret response")
253    }
254
255    /// POST /secrets/update_user_secrets — merge/update secrets with NEP-413 auth
256    pub async fn update_user_secrets(
257        &self,
258        payload: &Value,
259    ) -> Result<UpdateUserSecretsResponse> {
260        let url = format!("{}/secrets/update_user_secrets", self.base_url);
261
262        let response = self
263            .client
264            .post(&url)
265            .json(payload)
266            .send()
267            .await
268            .context("Failed to call update_user_secrets")?;
269
270        if !response.status().is_success() {
271            let status = response.status();
272            let text = response.text().await.unwrap_or_default();
273            anyhow::bail!("Failed to update secrets ({status}): {text}");
274        }
275
276        response
277            .json()
278            .await
279            .context("Failed to parse update_user_secrets response")
280    }
281
282    /// POST /secrets/pubkey — get keystore pubkey for encryption
283    pub async fn get_secrets_pubkey(&self, request: &GetPubkeyRequest) -> Result<String> {
284        let url = format!("{}/secrets/pubkey", self.base_url);
285
286        let response = self
287            .client
288            .post(&url)
289            .json(request)
290            .send()
291            .await
292            .context("Failed to get secrets pubkey")?;
293
294        if !response.status().is_success() {
295            let status = response.status();
296            let text = response.text().await.unwrap_or_default();
297            anyhow::bail!("Failed to get pubkey ({status}): {text}");
298        }
299
300        #[derive(Deserialize)]
301        struct PubkeyResponse {
302            pubkey: String,
303        }
304
305        let resp: PubkeyResponse = response
306            .json()
307            .await
308            .context("Failed to parse pubkey response")?;
309
310        Ok(resp.pubkey)
311    }
312
313    // ── Payment Check Methods ──────────────────────────────────────────
314
315    /// POST /wallet/v1/payment-check/create
316    pub async fn create_payment_check(
317        &self,
318        api_key: &str,
319        token: &str,
320        amount: &str,
321        memo: Option<&str>,
322        expires_in: Option<u64>,
323    ) -> Result<PaymentCheckCreateResponse> {
324        let url = format!("{}/wallet/v1/payment-check/create", self.base_url);
325
326        let mut body = serde_json::json!({
327            "token": token,
328            "amount": amount,
329        });
330        if let Some(memo) = memo {
331            body["memo"] = serde_json::Value::String(memo.to_string());
332        }
333        if let Some(expires_in) = expires_in {
334            body["expires_in"] = serde_json::Value::Number(expires_in.into());
335        }
336
337        let response = self
338            .client
339            .post(&url)
340            .header("Authorization", format!("Bearer {}", api_key))
341            .json(&body)
342            .send()
343            .await
344            .context("Failed to create payment check")?;
345
346        if !response.status().is_success() {
347            let status = response.status();
348            let text = response.text().await.unwrap_or_default();
349            anyhow::bail!("Failed to create payment check ({status}): {text}");
350        }
351
352        response
353            .json()
354            .await
355            .context("Failed to parse create check response")
356    }
357
358    /// POST /wallet/v1/payment-check/batch-create
359    pub async fn batch_create_payment_checks(
360        &self,
361        api_key: &str,
362        checks: &[serde_json::Value],
363    ) -> Result<PaymentCheckBatchCreateResponse> {
364        let url = format!("{}/wallet/v1/payment-check/batch-create", self.base_url);
365
366        let response = self
367            .client
368            .post(&url)
369            .header("Authorization", format!("Bearer {}", api_key))
370            .json(&serde_json::json!({ "checks": checks }))
371            .send()
372            .await
373            .context("Failed to batch create payment checks")?;
374
375        if !response.status().is_success() {
376            let status = response.status();
377            let text = response.text().await.unwrap_or_default();
378            anyhow::bail!("Failed to batch create checks ({status}): {text}");
379        }
380
381        response
382            .json()
383            .await
384            .context("Failed to parse batch create response")
385    }
386
387    /// POST /wallet/v1/payment-check/claim
388    pub async fn claim_payment_check(
389        &self,
390        api_key: &str,
391        check_key: &str,
392        amount: Option<&str>,
393    ) -> Result<PaymentCheckClaimResponse> {
394        let url = format!("{}/wallet/v1/payment-check/claim", self.base_url);
395
396        let mut body = serde_json::json!({ "check_key": check_key });
397        if let Some(amount) = amount {
398            body["amount"] = serde_json::Value::String(amount.to_string());
399        }
400
401        let response = self
402            .client
403            .post(&url)
404            .header("Authorization", format!("Bearer {}", api_key))
405            .json(&body)
406            .send()
407            .await
408            .context("Failed to claim payment check")?;
409
410        if !response.status().is_success() {
411            let status = response.status();
412            let text = response.text().await.unwrap_or_default();
413            anyhow::bail!("Failed to claim check ({status}): {text}");
414        }
415
416        response
417            .json()
418            .await
419            .context("Failed to parse claim response")
420    }
421
422    /// POST /wallet/v1/payment-check/reclaim
423    pub async fn reclaim_payment_check(
424        &self,
425        api_key: &str,
426        check_id: &str,
427        amount: Option<&str>,
428    ) -> Result<PaymentCheckReclaimResponse> {
429        let url = format!("{}/wallet/v1/payment-check/reclaim", self.base_url);
430
431        let mut body = serde_json::json!({ "check_id": check_id });
432        if let Some(amount) = amount {
433            body["amount"] = serde_json::Value::String(amount.to_string());
434        }
435
436        let response = self
437            .client
438            .post(&url)
439            .header("Authorization", format!("Bearer {}", api_key))
440            .json(&body)
441            .send()
442            .await
443            .context("Failed to reclaim payment check")?;
444
445        if !response.status().is_success() {
446            let status = response.status();
447            let text = response.text().await.unwrap_or_default();
448            anyhow::bail!("Failed to reclaim check ({status}): {text}");
449        }
450
451        response
452            .json()
453            .await
454            .context("Failed to parse reclaim response")
455    }
456
457    /// GET /wallet/v1/payment-check/status?check_id=...
458    pub async fn get_payment_check_status(
459        &self,
460        api_key: &str,
461        check_id: &str,
462    ) -> Result<PaymentCheckStatusResponse> {
463        let url = format!(
464            "{}/wallet/v1/payment-check/status?check_id={}",
465            self.base_url, check_id
466        );
467
468        let response = self
469            .client
470            .get(&url)
471            .header("Authorization", format!("Bearer {}", api_key))
472            .send()
473            .await
474            .context("Failed to get check status")?;
475
476        if !response.status().is_success() {
477            let status = response.status();
478            let text = response.text().await.unwrap_or_default();
479            anyhow::bail!("Failed to get check status ({status}): {text}");
480        }
481
482        response
483            .json()
484            .await
485            .context("Failed to parse check status response")
486    }
487
488    /// GET /wallet/v1/payment-check/list
489    pub async fn list_payment_checks(
490        &self,
491        api_key: &str,
492        status_filter: Option<&str>,
493        limit: i64,
494    ) -> Result<PaymentCheckListResponse> {
495        let mut url = format!(
496            "{}/wallet/v1/payment-check/list?limit={}",
497            self.base_url, limit
498        );
499        if let Some(status) = status_filter {
500            url.push_str(&format!("&status={}", status));
501        }
502
503        let response = self
504            .client
505            .get(&url)
506            .header("Authorization", format!("Bearer {}", api_key))
507            .send()
508            .await
509            .context("Failed to list payment checks")?;
510
511        if !response.status().is_success() {
512            let status = response.status();
513            let text = response.text().await.unwrap_or_default();
514            anyhow::bail!("Failed to list checks ({status}): {text}");
515        }
516
517        response
518            .json()
519            .await
520            .context("Failed to parse check list response")
521    }
522
523    /// POST /wallet/v1/sign-message — NEP-413 message signing for external auth
524    pub async fn sign_message(
525        &self,
526        api_key: &str,
527        message: &str,
528        recipient: &str,
529        nonce: Option<&str>,
530    ) -> Result<SignMessageResponse> {
531        let url = format!("{}/wallet/v1/sign-message", self.base_url);
532
533        let mut body = serde_json::json!({
534            "message": message,
535            "recipient": recipient,
536        });
537        if let Some(nonce) = nonce {
538            body["nonce"] = serde_json::Value::String(nonce.to_string());
539        }
540
541        let response = self
542            .client
543            .post(&url)
544            .header("Authorization", format!("Bearer {}", api_key))
545            .json(&body)
546            .send()
547            .await
548            .context("Failed to sign message")?;
549
550        if !response.status().is_success() {
551            let status = response.status();
552            let text = response.text().await.unwrap_or_default();
553            anyhow::bail!("Failed to sign message ({status}): {text}");
554        }
555
556        response
557            .json()
558            .await
559            .context("Failed to parse sign message response")
560    }
561
562    /// POST /wallet/v1/call — sign and send a NEAR function call via custody wallet
563    pub async fn wallet_call(
564        &self,
565        wallet_key: &str,
566        receiver_id: &str,
567        method_name: &str,
568        args: serde_json::Value,
569        gas: u64,
570        deposit: u128,
571    ) -> Result<WalletCallResponse> {
572        let url = format!("{}/wallet/v1/call", self.base_url);
573
574        let body = serde_json::json!({
575            "receiver_id": receiver_id,
576            "method_name": method_name,
577            "args": args,
578            "gas": gas.to_string(),
579            "deposit": deposit.to_string(),
580        });
581
582        let response = self
583            .client
584            .post(&url)
585            .header("Authorization", format!("Bearer {}", wallet_key))
586            .json(&body)
587            .send()
588            .await
589            .context("Failed to call wallet API")?;
590
591        if !response.status().is_success() {
592            let status = response.status();
593            let text = response.text().await.unwrap_or_default();
594            anyhow::bail!("Wallet call failed ({status}): {text}");
595        }
596
597        response
598            .json()
599            .await
600            .context("Failed to parse wallet call response")
601    }
602
603    /// POST /wallet/v1/call with raw (Borsh) args as base64
604    pub async fn wallet_call_raw(
605        &self,
606        wallet_key: &str,
607        receiver_id: &str,
608        method_name: &str,
609        args_raw: &[u8],
610        gas: u64,
611        deposit: u128,
612    ) -> Result<WalletCallResponse> {
613        let url = format!("{}/wallet/v1/call", self.base_url);
614
615        use base64::Engine;
616        let args_b64 = base64::engine::general_purpose::STANDARD.encode(args_raw);
617
618        let body = serde_json::json!({
619            "receiver_id": receiver_id,
620            "method_name": method_name,
621            "args_base64": args_b64,
622            "gas": gas.to_string(),
623            "deposit": deposit.to_string(),
624        });
625
626        let response = self
627            .client
628            .post(&url)
629            .header("Authorization", format!("Bearer {}", wallet_key))
630            .json(&body)
631            .send()
632            .await
633            .context("Failed to call wallet API")?;
634
635        if !response.status().is_success() {
636            let status = response.status();
637            let text = response.text().await.unwrap_or_default();
638            anyhow::bail!("Wallet call failed ({status}): {text}");
639        }
640
641        response
642            .json()
643            .await
644            .context("Failed to parse wallet call response")
645    }
646
647    /// POST /wallet/v1/payment-check/peek
648    pub async fn peek_payment_check(
649        &self,
650        api_key: &str,
651        check_key: &str,
652    ) -> Result<PaymentCheckPeekResponse> {
653        let url = format!("{}/wallet/v1/payment-check/peek", self.base_url);
654
655        let response = self
656            .client
657            .post(&url)
658            .header("Authorization", format!("Bearer {}", api_key))
659            .json(&serde_json::json!({ "check_key": check_key }))
660            .send()
661            .await
662            .context("Failed to peek payment check")?;
663
664        if !response.status().is_success() {
665            let status = response.status();
666            let text = response.text().await.unwrap_or_default();
667            anyhow::bail!("Failed to peek check ({status}): {text}");
668        }
669
670        response
671            .json()
672            .await
673            .context("Failed to parse peek response")
674    }
675}
676
677// ── Response Types ─────────────────────────────────────────────────────
678
679#[derive(Debug, Deserialize)]
680#[allow(dead_code)]
681pub struct PaymentKeyBalanceResponse {
682    pub owner: String,
683    pub nonce: u32,
684    pub initial_balance: String,
685    pub spent: String,
686    pub reserved: String,
687    pub available: String,
688    pub last_used_at: Option<String>,
689}
690
691#[derive(Debug, Deserialize)]
692pub struct PaymentKeyUsageResponse {
693    pub usage: Vec<PaymentKeyUsageItem>,
694    pub total: i64,
695}
696
697#[derive(Debug, Deserialize)]
698#[allow(dead_code)]
699pub struct PaymentKeyUsageItem {
700    pub call_id: String,
701    pub project_id: String,
702    pub compute_cost: String,
703    pub attached_deposit: String,
704    pub status: String,
705    pub created_at: String,
706}
707
708#[derive(Debug, Deserialize)]
709#[allow(dead_code)]
710pub struct ProjectOwnerEarningsResponse {
711    pub project_owner: String,
712    pub balance: String,
713    pub total_earned: String,
714}
715
716#[derive(Debug, Deserialize)]
717pub struct EarningsHistoryResponse {
718    pub earnings: Vec<EarningRecord>,
719    pub total_count: i64,
720}
721
722#[derive(Debug, Deserialize)]
723pub struct EarningRecord {
724    pub project_id: String,
725    pub amount: String,
726    pub source: String,
727    pub created_at: i64,
728}
729
730#[derive(Debug, Deserialize)]
731pub struct AddGeneratedSecretResponse {
732    pub encrypted_data_base64: String,
733    #[allow(dead_code)]
734    pub all_keys: Vec<String>,
735}
736
737#[derive(Debug, Deserialize)]
738pub struct UpdateUserSecretsResponse {
739    pub encrypted_secrets_base64: String,
740}
741
742// ── Sign Message Response Type ────────────────────────────────────────
743
744#[derive(Debug, Deserialize)]
745#[allow(dead_code)]
746pub struct SignMessageResponse {
747    pub account_id: String,
748    /// Signature in NEAR format: "ed25519:<base58>"
749    pub signature: String,
750    /// Signature as raw base64 (NEP-413 standard)
751    pub signature_base64: String,
752    pub public_key: String,
753    pub nonce: String,
754}
755
756// ── Wallet Call Response Type ─────────────────────────────────────────
757
758#[derive(Debug, Deserialize)]
759#[allow(dead_code)]
760pub struct WalletCallResponse {
761    pub request_id: String,
762    pub status: String,
763    pub tx_hash: Option<String>,
764    pub result: Option<serde_json::Value>,
765    pub approval_id: Option<String>,
766}
767
768// ── Payment Check Response Types ──────────────────────────────────────
769
770#[derive(Debug, Deserialize)]
771#[allow(dead_code)]
772pub struct PaymentCheckCreateResponse {
773    pub check_id: String,
774    pub check_key: String,
775    pub token: String,
776    pub amount: String,
777    pub memo: Option<String>,
778    pub created_at: String,
779    pub expires_at: Option<String>,
780}
781
782#[derive(Debug, Deserialize)]
783pub struct PaymentCheckBatchCreateResponse {
784    pub checks: Vec<PaymentCheckCreateResponse>,
785}
786
787#[derive(Debug, Deserialize)]
788#[allow(dead_code)]
789pub struct PaymentCheckClaimResponse {
790    pub token: String,
791    pub amount_claimed: String,
792    pub remaining: String,
793    pub memo: Option<String>,
794    pub claimed_at: String,
795    pub intent_hash: Option<String>,
796}
797
798#[derive(Debug, Deserialize)]
799#[allow(dead_code)]
800pub struct PaymentCheckReclaimResponse {
801    pub token: String,
802    pub amount_reclaimed: String,
803    pub remaining: String,
804    pub reclaimed_at: String,
805    pub intent_hash: Option<String>,
806}
807
808#[derive(Debug, Deserialize)]
809#[allow(dead_code)]
810pub struct PaymentCheckStatusResponse {
811    pub check_id: String,
812    pub token: String,
813    pub amount: String,
814    pub claimed_amount: String,
815    pub reclaimed_amount: String,
816    pub status: String,
817    pub memo: Option<String>,
818    pub created_at: String,
819    pub expires_at: Option<String>,
820    pub claimed_at: Option<String>,
821    pub claimed_by: Option<String>,
822}
823
824#[derive(Debug, Deserialize)]
825pub struct PaymentCheckListResponse {
826    pub checks: Vec<PaymentCheckStatusResponse>,
827}
828
829#[derive(Debug, Deserialize)]
830#[allow(dead_code)]
831pub struct PaymentCheckPeekResponse {
832    pub token: String,
833    pub balance: String,
834    pub memo: Option<String>,
835    pub status: String,
836    pub expires_at: Option<String>,
837}