Skip to main content

outlayer_cli/
near.rs

1use anyhow::{Context, Result};
2use near_crypto::{InMemorySigner, SecretKey};
3use near_jsonrpc_client::{methods, JsonRpcClient};
4use near_primitives::hash::CryptoHash;
5use near_primitives::transaction::{Action, FunctionCallAction, Transaction, TransactionV0};
6use near_primitives::types::{AccountId, BlockReference, Finality};
7use near_primitives::views::FinalExecutionOutcomeView;
8use serde_json::Value;
9
10use crate::api::ApiClient;
11use crate::config::{self, Credentials, NetworkConfig};
12
13// ── NearClient (view calls, no auth) ───────────────────────────────────
14
15pub struct NearClient {
16    client: JsonRpcClient,
17    pub network: NetworkConfig,
18}
19
20impl NearClient {
21    pub fn new(network: &NetworkConfig) -> Self {
22        let client = JsonRpcClient::connect(&network.rpc_url);
23        Self {
24            client,
25            network: network.clone(),
26        }
27    }
28
29    pub async fn view_call<T: serde::de::DeserializeOwned>(
30        &self,
31        method: &str,
32        args: Value,
33    ) -> Result<T> {
34        let contract_id: AccountId = self
35            .network
36            .contract_id
37            .parse()
38            .context("Invalid contract_id")?;
39
40        let request = methods::query::RpcQueryRequest {
41            block_reference: BlockReference::Finality(Finality::Final),
42            request: near_primitives::views::QueryRequest::CallFunction {
43                account_id: contract_id,
44                method_name: method.to_string(),
45                args: args.to_string().into_bytes().into(),
46            },
47        };
48
49        let response = self
50            .client
51            .call(request)
52            .await
53            .with_context(|| format!("Failed to call view method '{method}'"))?;
54
55        if let near_jsonrpc_primitives::types::query::QueryResponseKind::CallResult(result) =
56            response.kind
57        {
58            serde_json::from_slice(&result.result)
59                .with_context(|| format!("Failed to parse response from '{method}'"))
60        } else {
61            anyhow::bail!("Unexpected response kind from '{method}'");
62        }
63    }
64
65    pub async fn get_project(&self, project_id: &str) -> Result<Option<ProjectView>> {
66        let result: Option<ProjectView> = self
67            .view_call("get_project", serde_json::json!({ "project_id": project_id }))
68            .await?;
69        Ok(result)
70    }
71
72    pub async fn get_next_payment_key_nonce(&self, account_id: &str) -> Result<u32> {
73        let result: u32 = self
74            .view_call(
75                "get_next_payment_key_nonce",
76                serde_json::json!({ "account_id": account_id }),
77            )
78            .await?;
79        Ok(result)
80    }
81
82    pub async fn list_user_secrets(&self, account_id: &str) -> Result<Vec<UserSecretInfo>> {
83        self.view_call(
84            "list_user_secrets",
85            serde_json::json!({ "account_id": account_id }),
86        )
87        .await
88    }
89
90    pub async fn list_versions(
91        &self,
92        project_id: &str,
93        from_index: Option<u64>,
94        limit: Option<u64>,
95    ) -> Result<Vec<VersionView>> {
96        self.view_call(
97            "list_versions",
98            serde_json::json!({
99                "project_id": project_id,
100                "from_index": from_index,
101                "limit": limit
102            }),
103        )
104        .await
105    }
106
107    pub async fn get_developer_earnings(&self, account_id: &str) -> Result<String> {
108        self.view_call(
109            "get_developer_earnings",
110            serde_json::json!({ "account_id": account_id }),
111        )
112        .await
113    }
114
115    pub async fn estimate_execution_cost(
116        &self,
117        resource_limits: Option<Value>,
118    ) -> Result<String> {
119        self.view_call(
120            "estimate_execution_cost",
121            serde_json::json!({ "resource_limits": resource_limits }),
122        )
123        .await
124    }
125
126    pub async fn get_version(
127        &self,
128        project_id: &str,
129        version_key: &str,
130    ) -> Result<Option<VersionView>> {
131        self.view_call(
132            "get_version",
133            serde_json::json!({
134                "project_id": project_id,
135                "version_key": version_key
136            }),
137        )
138        .await
139    }
140}
141
142// ── NearSigner (mutations, requires auth) ──────────────────────────────
143
144pub struct NearSigner {
145    client: JsonRpcClient,
146    signer: InMemorySigner,
147    contract_id: AccountId,
148}
149
150impl NearSigner {
151    pub fn new(network: &NetworkConfig, account_id: &str, private_key: &str) -> Result<Self> {
152        let account_id: AccountId = account_id.parse().context("Invalid account_id")?;
153        let contract_id: AccountId = network.contract_id.parse().context("Invalid contract_id")?;
154        let secret_key: SecretKey = private_key.parse().context("Invalid private key")?;
155        let signer = InMemorySigner::from_secret_key(account_id, secret_key);
156        let client = JsonRpcClient::connect(&network.rpc_url);
157
158        Ok(Self {
159            client,
160            signer,
161            contract_id,
162        })
163    }
164
165    pub async fn call_contract(
166        &self,
167        method_name: &str,
168        args: Value,
169        gas: u64,
170        deposit: u128,
171    ) -> Result<FinalExecutionOutcomeView> {
172        // Get access key nonce
173        let access_key_query = methods::query::RpcQueryRequest {
174            block_reference: BlockReference::Finality(Finality::Final),
175            request: near_primitives::views::QueryRequest::ViewAccessKey {
176                account_id: self.signer.account_id.clone(),
177                public_key: self.signer.public_key(),
178            },
179        };
180
181        let access_key_response = self
182            .client
183            .call(access_key_query)
184            .await
185            .context("Failed to query access key")?;
186
187        let current_nonce = match access_key_response.kind {
188            near_jsonrpc_primitives::types::query::QueryResponseKind::AccessKey(access_key) => {
189                access_key.nonce
190            }
191            _ => anyhow::bail!("Unexpected query response for access key"),
192        };
193
194        // Get latest block hash
195        let block = self
196            .client
197            .call(methods::block::RpcBlockRequest {
198                block_reference: BlockReference::Finality(Finality::Final),
199            })
200            .await
201            .context("Failed to query block")?;
202
203        let block_hash = block.header.hash;
204
205        // Build TransactionV0
206        let transaction_v0 = TransactionV0 {
207            signer_id: self.signer.account_id.clone(),
208            public_key: self.signer.public_key(),
209            nonce: current_nonce + 1,
210            receiver_id: self.contract_id.clone(),
211            block_hash,
212            actions: vec![Action::FunctionCall(Box::new(FunctionCallAction {
213                method_name: method_name.to_string(),
214                args: args.to_string().into_bytes(),
215                gas,
216                deposit,
217            }))],
218        };
219
220        let transaction = Transaction::V0(transaction_v0);
221
222        // Sign
223        let signature = self
224            .signer
225            .sign(transaction.get_hash_and_size().0.as_ref());
226        let signed_transaction =
227            near_primitives::transaction::SignedTransaction::new(signature, transaction);
228
229        // Broadcast with commit
230        let outcome = self
231            .client
232            .call(methods::broadcast_tx_commit::RpcBroadcastTxCommitRequest {
233                signed_transaction,
234            })
235            .await
236            .context("Transaction failed")?;
237
238        Ok(outcome)
239    }
240
241    /// Get current access key nonce and latest block hash for building transactions.
242    pub async fn get_tx_context(&self) -> Result<(u64, CryptoHash)> {
243        let access_key_query = methods::query::RpcQueryRequest {
244            block_reference: BlockReference::Finality(Finality::Final),
245            request: near_primitives::views::QueryRequest::ViewAccessKey {
246                account_id: self.signer.account_id.clone(),
247                public_key: self.signer.public_key(),
248            },
249        };
250
251        let access_key_response = self
252            .client
253            .call(access_key_query)
254            .await
255            .context("Failed to query access key")?;
256
257        let current_nonce = match access_key_response.kind {
258            near_jsonrpc_primitives::types::query::QueryResponseKind::AccessKey(access_key) => {
259                access_key.nonce
260            }
261            _ => anyhow::bail!("Unexpected query response for access key"),
262        };
263
264        let block = self
265            .client
266            .call(methods::block::RpcBlockRequest {
267                block_reference: BlockReference::Finality(Finality::Final),
268            })
269            .await
270            .context("Failed to query block")?;
271
272        Ok((current_nonce, block.header.hash))
273    }
274
275    /// Send a raw function call to an arbitrary receiver (broadcast async, does not wait).
276    /// Returns the transaction hash.
277    pub async fn send_function_call_async(
278        &self,
279        receiver_id: &AccountId,
280        method_name: &str,
281        args: Vec<u8>,
282        gas: u64,
283        deposit: u128,
284        nonce: u64,
285        block_hash: CryptoHash,
286    ) -> Result<CryptoHash> {
287        let transaction_v0 = TransactionV0 {
288            signer_id: self.signer.account_id.clone(),
289            public_key: self.signer.public_key(),
290            nonce,
291            receiver_id: receiver_id.clone(),
292            block_hash,
293            actions: vec![Action::FunctionCall(Box::new(FunctionCallAction {
294                method_name: method_name.to_string(),
295                args,
296                gas,
297                deposit,
298            }))],
299        };
300
301        let transaction = Transaction::V0(transaction_v0);
302        let signature = self
303            .signer
304            .sign(transaction.get_hash_and_size().0.as_ref());
305        let signed_transaction =
306            near_primitives::transaction::SignedTransaction::new(signature, transaction);
307
308        let tx_hash = self
309            .client
310            .call(methods::broadcast_tx_async::RpcBroadcastTxAsyncRequest {
311                signed_transaction,
312            })
313            .await
314            .context("Failed to broadcast transaction")?;
315
316        Ok(tx_hash)
317    }
318}
319
320// ── ContractCaller (local key or wallet API) ────────────────────────
321
322/// Result of a contract call — includes the parsed return value and tx hash.
323pub struct ContractCallResult {
324    pub value: Option<Value>,
325    pub tx_hash: Option<String>,
326}
327
328/// Unified interface for calling NEAR contracts — either with a local key
329/// (NearSigner) or via the coordinator wallet API (wallet_key).
330pub enum ContractCaller {
331    Local(NearSigner),
332    Wallet {
333        api: ApiClient,
334        wallet_key: String,
335        contract_id: String,
336    },
337}
338
339impl ContractCaller {
340    /// Build a ContractCaller from stored credentials.
341    pub fn from_credentials(creds: &Credentials, network: &NetworkConfig) -> Result<Self> {
342        if creds.is_wallet_key() {
343            let wk = creds
344                .wallet_key
345                .as_ref()
346                .context("wallet_key missing from credentials")?;
347            Ok(Self::Wallet {
348                api: ApiClient::new(network),
349                wallet_key: wk.clone(),
350                contract_id: network.contract_id.clone(),
351            })
352        } else {
353            let pk = config::load_private_key(&network.network_id, &creds.account_id, creds)?;
354            Ok(Self::Local(NearSigner::new(
355                network,
356                &creds.account_id,
357                &pk,
358            )?))
359        }
360    }
361
362    /// Call a method on the OutLayer contract. Returns parsed result value and tx hash.
363    pub async fn call_contract(
364        &self,
365        method_name: &str,
366        args: Value,
367        gas: u64,
368        deposit: u128,
369    ) -> Result<ContractCallResult> {
370        match self {
371            Self::Local(signer) => {
372                let outcome = signer.call_contract(method_name, args, gas, deposit).await?;
373                let tx_hash = Some(outcome.transaction_outcome.id.to_string());
374                match &outcome.status {
375                    near_primitives::views::FinalExecutionStatus::SuccessValue(bytes) => {
376                        Ok(ContractCallResult {
377                            value: serde_json::from_slice::<Value>(bytes).ok(),
378                            tx_hash,
379                        })
380                    }
381                    near_primitives::views::FinalExecutionStatus::Failure(err) => {
382                        anyhow::bail!(
383                            "Transaction failed (tx: {}): {:?}",
384                            outcome.transaction_outcome.id, err
385                        );
386                    }
387                    _ => Ok(ContractCallResult {
388                        value: None,
389                        tx_hash,
390                    }),
391                }
392            }
393            Self::Wallet {
394                api,
395                wallet_key,
396                contract_id,
397            } => {
398                let resp = api
399                    .wallet_call(wallet_key, contract_id, method_name, args, gas, deposit)
400                    .await?;
401                if resp.status == "pending_approval" {
402                    if let Some(id) = &resp.approval_id {
403                        anyhow::bail!(
404                            "Transaction requires approval (approval_id: {}). \
405                             Approve it in the wallet dashboard.",
406                            id
407                        );
408                    }
409                    anyhow::bail!("Transaction requires approval.");
410                }
411                if resp.status != "success" {
412                    let detail = resp.result.as_ref().map(|v| v.to_string()).unwrap_or_default();
413                    let tx_info = resp.tx_hash.as_deref().unwrap_or("unknown");
414                    anyhow::bail!(
415                        "Transaction failed (tx: {}, status: {}): {}",
416                        tx_info, resp.status, detail
417                    );
418                }
419                Ok(ContractCallResult {
420                    tx_hash: resp.tx_hash,
421                    value: resp.result,
422                })
423            }
424        }
425    }
426}
427
428// ── Types ──────────────────────────────────────────────────────────────
429
430#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
431pub struct ProjectView {
432    pub uuid: String,
433    pub owner: String,
434    pub name: String,
435    pub project_id: String,
436    pub active_version: String,
437    #[serde(default)]
438    pub created_at: Option<u64>,
439    #[serde(default)]
440    pub storage_deposit: Option<String>,
441}
442
443#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
444pub struct VersionView {
445    pub wasm_hash: String,
446    pub source: Value,
447    pub added_at: u64,
448    pub is_active: bool,
449}
450
451#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
452pub struct UserSecretInfo {
453    pub accessor: Value,
454    pub profile: String,
455    pub created_at: u64,
456    pub updated_at: u64,
457    pub storage_deposit: String,
458    pub access: Value,
459}