near_socialdb_client/
lib.rs

1use eyre::WrapErr;
2
3pub mod types;
4
5use serde::de::{Deserialize, Deserializer};
6
7#[derive(Debug, Clone, serde::Deserialize)]
8pub struct StorageBalance {
9    #[serde(deserialize_with = "parse_u128_string")]
10    pub available: u128,
11    #[serde(deserialize_with = "parse_u128_string")]
12    pub total: u128,
13}
14
15fn parse_u128_string<'de, D>(deserializer: D) -> eyre::Result<u128, D::Error>
16where
17    D: Deserializer<'de>,
18{
19    <std::string::String as Deserialize>::deserialize(deserializer)?
20        .parse::<u128>()
21        .map_err(serde::de::Error::custom)
22}
23
24#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
25pub enum PermissionKey {
26    #[serde(rename = "predecessor_id")]
27    PredecessorId(near_primitives::types::AccountId),
28    #[serde(rename = "public_key")]
29    PublicKey(near_crypto::PublicKey),
30}
31
32impl From<near_primitives::types::AccountId> for PermissionKey {
33    fn from(predecessor_id: near_primitives::types::AccountId) -> Self {
34        Self::PredecessorId(predecessor_id)
35    }
36}
37
38impl From<near_crypto::PublicKey> for PermissionKey {
39    fn from(public_key: near_crypto::PublicKey) -> Self {
40        Self::PublicKey(public_key)
41    }
42}
43
44#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
45struct IsWritePermissionGrantedInputArgs {
46    key: String,
47    #[serde(flatten)]
48    permission_key: PermissionKey,
49}
50
51pub async fn is_write_permission_granted<P: Into<PermissionKey>>(
52    json_rpc_client: &near_jsonrpc_client::JsonRpcClient,
53    near_social_account_id: &near_primitives::types::AccountId,
54    permission_key: P,
55    key: String,
56) -> eyre::Result<bool> {
57    let function_args = serde_json::to_string(&IsWritePermissionGrantedInputArgs {
58        key,
59        permission_key: permission_key.into(),
60    })
61    .wrap_err("Internal error: could not serialize `is_write_permission_granted` input args")?;
62    let call_result = match json_rpc_client
63        .call(near_jsonrpc_client::methods::query::RpcQueryRequest {
64            block_reference: near_primitives::types::Finality::Final.into(),
65            request: near_primitives::views::QueryRequest::CallFunction {
66                account_id: near_social_account_id.clone(),
67                method_name: "is_write_permission_granted".to_string(),
68                args: near_primitives::types::FunctionArgs::from(function_args.into_bytes()),
69            },
70        })
71        .await
72        .wrap_err_with(|| "Failed to fetch query for view method: 'is_write_permission_granted'")?
73        .kind
74    {
75        near_jsonrpc_primitives::types::query::QueryResponseKind::CallResult(call_result) => {
76            call_result
77        }
78        _ => eyre::bail!("ERROR: unexpected response type from JSON RPC client"),
79    };
80
81    let serde_call_result: serde_json::Value = serde_json::from_slice(&call_result.result)
82        .wrap_err_with(|| {
83            format!(
84                "Failed to parse view-function call return value: {}",
85                String::from_utf8_lossy(&call_result.result)
86            )
87        })?;
88    let result = serde_call_result.as_bool().expect("Unexpected response");
89    Ok(result)
90}
91
92pub fn is_signer_access_key_function_call_access_can_call_set_on_social_db_account(
93    near_social_account_id: &near_primitives::types::AccountId,
94    access_key_permission: &near_primitives::views::AccessKeyPermissionView,
95) -> eyre::Result<bool> {
96    if let near_primitives::views::AccessKeyPermissionView::FunctionCall {
97        allowance: _,
98        receiver_id,
99        method_names,
100    } = access_key_permission
101    {
102        Ok(receiver_id == &near_social_account_id.to_string()
103            && (method_names.contains(&"set".to_string()) || method_names.is_empty()))
104    } else {
105        Ok(false)
106    }
107}
108
109pub async fn get_access_key_permission(
110    json_rpc_client: &near_jsonrpc_client::JsonRpcClient,
111    account_id: &near_primitives::types::AccountId,
112    public_key: &near_crypto::PublicKey,
113) -> eyre::Result<near_primitives::views::AccessKeyPermissionView> {
114    let permission = match json_rpc_client
115        .call(near_jsonrpc_client::methods::query::RpcQueryRequest {
116            block_reference: near_primitives::types::Finality::Final.into(),
117            request: near_primitives::views::QueryRequest::ViewAccessKey {
118                account_id: account_id.clone(),
119                public_key: public_key.clone(),
120            },
121        })
122        .await
123        .wrap_err_with(|| format!("Failed to fetch query 'view access key' for <{public_key}>",))?
124        .kind
125        {
126            near_jsonrpc_primitives::types::query::QueryResponseKind::AccessKey(
127                access_key_view,
128            ) => access_key_view.permission,
129            _ => eyre::bail!(
130                "Internal error: Received unexpected query kind in response to a View Access Key query call",
131            )
132        };
133
134    Ok(permission)
135}
136
137pub async fn get_deposit(
138    json_rpc_client: &near_jsonrpc_client::JsonRpcClient,
139    signer_account_id: &near_primitives::types::AccountId,
140    signer_public_key: &near_crypto::PublicKey,
141    account_id: &near_primitives::types::AccountId,
142    key: &str,
143    near_social_account_id: &near_primitives::types::AccountId,
144    required_deposit: near_token::NearToken,
145) -> eyre::Result<near_token::NearToken> {
146    let signer_access_key_permission =
147        get_access_key_permission(json_rpc_client, signer_account_id, signer_public_key).await?;
148
149    let is_signer_access_key_full_access = matches!(
150        signer_access_key_permission,
151        near_primitives::views::AccessKeyPermissionView::FullAccess
152    );
153
154    let is_write_permission_granted_to_public_key = is_write_permission_granted(
155        json_rpc_client,
156        near_social_account_id,
157        signer_public_key.clone(),
158        format!("{account_id}/{key}"),
159    )
160    .await?;
161
162    let is_write_permission_granted_to_signer = is_write_permission_granted(
163        json_rpc_client,
164        near_social_account_id,
165        signer_account_id.clone(),
166        format!("{account_id}/{key}"),
167    )
168    .await?;
169
170    let deposit = if is_signer_access_key_full_access
171        || is_signer_access_key_function_call_access_can_call_set_on_social_db_account(
172            near_social_account_id,
173            &signer_access_key_permission,
174        )? {
175        if is_write_permission_granted_to_public_key || is_write_permission_granted_to_signer {
176            if required_deposit.is_zero() {
177                near_token::NearToken::from_near(0)
178            } else if is_signer_access_key_full_access {
179                required_deposit
180            } else {
181                eyre::bail!("ERROR: Social DB requires more storage deposit, but we cannot cover it when signing transaction with a Function Call only access key")
182            }
183        } else if signer_account_id == account_id {
184            if is_signer_access_key_full_access {
185                if required_deposit.is_zero() {
186                    near_token::NearToken::from_yoctonear(1)
187                } else {
188                    required_deposit
189                }
190            } else if required_deposit.is_zero() {
191                required_deposit
192            } else {
193                eyre::bail!("ERROR: Social DB requires more storage deposit, but we cannot cover it when signing transaction with a Function Call only access key")
194            }
195        } else {
196            eyre::bail!(
197                "ERROR: the signer is not allowed to modify the components of this account_id."
198            )
199        }
200    } else {
201        eyre::bail!("ERROR: signer access key cannot be used to sign a transaction to update components in Social DB.")
202    };
203    Ok(deposit)
204}
205
206pub async fn required_deposit(
207    json_rpc_client: &near_jsonrpc_client::JsonRpcClient,
208    near_social_account_id: &near_primitives::types::AccountId,
209    account_id: &near_primitives::types::AccountId,
210    data: &serde_json::Value,
211    prev_data: Option<&serde_json::Value>,
212) -> eyre::Result<near_token::NearToken> {
213    const STORAGE_COST_PER_BYTE: i128 = 10i128.pow(19);
214    const MIN_STORAGE_BALANCE: u128 = STORAGE_COST_PER_BYTE as u128 * 2000;
215    const INITIAL_ACCOUNT_STORAGE_BALANCE: i128 = STORAGE_COST_PER_BYTE * 500;
216    const EXTRA_STORAGE_BALANCE: i128 = STORAGE_COST_PER_BYTE * 5000;
217
218    let call_result_storage_balance = match json_rpc_client
219        .call(near_jsonrpc_client::methods::query::RpcQueryRequest {
220            block_reference: near_primitives::types::Finality::Final.into(),
221            request: near_primitives::views::QueryRequest::CallFunction {
222                account_id: near_social_account_id.clone(),
223                method_name: "storage_balance_of".to_string(),
224                args: near_primitives::types::FunctionArgs::from(
225                    serde_json::json!({
226                        "account_id": account_id,
227                    })
228                    .to_string()
229                    .into_bytes(),
230                ),
231            },
232        })
233        .await
234        .wrap_err_with(|| "Failed to fetch query for view method: 'storage_balance_of'")?
235        .kind
236    {
237        near_jsonrpc_primitives::types::query::QueryResponseKind::CallResult(call_result) => {
238            call_result
239        }
240        _ => eyre::bail!("ERROR: unexpected response type from JSON RPC client"),
241    };
242
243    let storage_balance_result: eyre::Result<StorageBalance> =
244        serde_json::from_slice(&call_result_storage_balance.result).wrap_err_with(|| {
245            format!(
246                "Failed to parse view-function call return value: {}",
247                String::from_utf8_lossy(&call_result_storage_balance.result)
248            )
249        });
250
251    let (available_storage, initial_account_storage_balance, min_storage_balance) =
252        if let Ok(storage_balance) = storage_balance_result {
253            (storage_balance.available, 0, 0)
254        } else {
255            (0, INITIAL_ACCOUNT_STORAGE_BALANCE, MIN_STORAGE_BALANCE)
256        };
257
258    let estimated_storage_balance = u128::try_from(
259        STORAGE_COST_PER_BYTE * estimate_data_size(data, prev_data) as i128
260            + initial_account_storage_balance
261            + EXTRA_STORAGE_BALANCE,
262    )
263    .unwrap_or(0)
264    .saturating_sub(available_storage);
265    Ok(near_token::NearToken::from_yoctonear(std::cmp::max(
266        estimated_storage_balance,
267        min_storage_balance,
268    )))
269}
270
271/// https://github.com/NearSocial/VM/blob/24055641b53e7eeadf6efdb9c073f85f02463798/src/lib/data/utils.js#L182-L198
272fn estimate_data_size(data: &serde_json::Value, prev_data: Option<&serde_json::Value>) -> isize {
273    const ESTIMATED_KEY_VALUE_SIZE: isize = 40 * 3 + 8 + 12;
274    const ESTIMATED_NODE_SIZE: isize = 40 * 2 + 8 + 10;
275
276    match data {
277        serde_json::Value::Object(data) => {
278            let inner_data_size = data
279                .iter()
280                .map(|(key, value)| {
281                    let prev_value = if let Some(serde_json::Value::Object(prev_data)) = prev_data {
282                        prev_data.get(key)
283                    } else {
284                        None
285                    };
286                    if prev_value.is_some() {
287                        estimate_data_size(value, prev_value)
288                    } else {
289                        key.len() as isize * 2
290                            + estimate_data_size(value, None)
291                            + ESTIMATED_KEY_VALUE_SIZE
292                    }
293                })
294                .sum();
295            if prev_data.map(serde_json::Value::is_object).unwrap_or(false) {
296                inner_data_size
297            } else {
298                ESTIMATED_NODE_SIZE + inner_data_size
299            }
300        }
301        serde_json::Value::String(data) => {
302            data.len().max(8) as isize
303                - prev_data
304                    .and_then(serde_json::Value::as_str)
305                    .map(str::len)
306                    .unwrap_or(0) as isize
307        }
308        _ => {
309            unreachable!("estimate_data_size expects only Object or String values");
310        }
311    }
312}
313
314/// Helper function that marks SocialDB values to be deleted by setting `null` to the values
315pub fn mark_leaf_values_as_null(data: &mut serde_json::Value) {
316    match data {
317        serde_json::Value::Object(object_data) => {
318            for value in object_data.values_mut() {
319                mark_leaf_values_as_null(value);
320            }
321        }
322        data => {
323            *data = serde_json::Value::Null;
324        }
325    }
326}
327
328pub fn social_db_data_from_key(full_key: &str, data_to_set: &mut serde_json::Value) {
329    if let Some((prefix, key)) = full_key.rsplit_once('/') {
330        *data_to_set = serde_json::json!({ key: data_to_set });
331        social_db_data_from_key(prefix, data_to_set)
332    } else {
333        *data_to_set = serde_json::json!({ full_key: data_to_set });
334    }
335}
336
337#[cfg(test)]
338mod tests {
339    use httpmock::prelude::*;
340    use near_jsonrpc_client::JsonRpcClient;
341    use near_primitives::types::AccountId;
342    use near_token::NearToken;
343    use serde_json::{json, Value};
344
345    use crate::get_deposit;
346
347    fn mock_rpc(write_permission: bool) -> String {
348        let server = MockServer::start();
349
350        server.mock(|when, then| {
351            when.body_contains("view_access_key");
352            then.json_body(json!({
353              "jsonrpc": "2.0",
354              "result": {
355                "nonce": 85,
356                "permission": {
357                  "FunctionCall": {
358                    "allowance": "18501534631167209000000000",
359                    "receiver_id": "social.near",
360                    "method_names": ["set"]
361                  }
362                },
363                "block_height": 19884918,
364                "block_hash": "GGJQ8yjmo7aEoj8ZpAhGehnq9BSWFx4xswHYzDwwAP2n"
365              },
366              "id": "dontcare"
367            }));
368        });
369
370        server.mock(|when, then| {
371            when.body_contains("is_write_permission_granted");
372            let write_permission_json_str =
373                serde_json::to_string(&json!(write_permission)).unwrap();
374            let binary_write_permission = write_permission_json_str.as_bytes().to_vec();
375            then.json_body(json!({
376              "jsonrpc": "2.0",
377              "result": {
378                "result": binary_write_permission,
379                "logs": [],
380                "block_height": 17817336,
381                "block_hash": "4qkA4sUUG8opjH5Q9bL5mWJTnfR4ech879Db1BZXbx6P"
382              },
383              "id": "dontcare"
384            }));
385        });
386
387        server.mock(|when, then| {
388            when.matches(|req| {
389                if let Some(body_bytes) = &req.body {
390                    // Convert body to string
391                    let body_str = String::from_utf8_lossy(body_bytes);
392                    if let Ok(json_body) = serde_json::from_str::<Value>(&body_str) {
393                        println!(
394                            "No mock for request: {}",
395                            serde_json::to_string_pretty(&json_body).unwrap()
396                        );
397                    } else {
398                        println!("Failed to parse JSON body");
399                    }
400                }
401                true
402            });
403            then.status(500);
404        });
405        server.url("/")
406    }
407
408    #[tokio::test]
409    pub async fn test_get_deposit_own_account_explicit_write_permission() {
410        let key_pair = near_crypto::SecretKey::from_random(near_crypto::KeyType::ED25519);
411
412        let server_url = mock_rpc(true);
413        let json_rpc_client: JsonRpcClient = JsonRpcClient::connect(&server_url);
414
415        let signer_account_id: AccountId = "devhub.near".parse().unwrap();
416        let public_key = key_pair.public_key();
417
418        let deposit = get_deposit(
419            &json_rpc_client,
420            &signer_account_id,
421            &public_key,
422            &"devhub.near".parse().unwrap(),
423            "devhub.near/widget/app",
424            &"social.near".parse().unwrap(),
425            NearToken::from_near(0),
426        )
427        .await;
428
429        match deposit {
430            Ok(deposit_value) => {
431                assert_eq!(NearToken::from_near(0), deposit_value);
432            }
433            Err(e) => {
434                println!("Error: {:?}", e);
435                panic!("get_deposit failed");
436            }
437        }
438    }
439
440    #[tokio::test]
441    pub async fn test_get_deposit_own_account_no_explicit_write_permission() {
442        let key_pair = near_crypto::SecretKey::from_random(near_crypto::KeyType::ED25519);
443
444        let server_url = mock_rpc(false);
445        let json_rpc_client: JsonRpcClient = JsonRpcClient::connect(&server_url);
446
447        let signer_account_id: AccountId = "devhub.near".parse().unwrap();
448        let public_key = key_pair.public_key();
449
450        let deposit = get_deposit(
451            &json_rpc_client,
452            &signer_account_id,
453            &public_key,
454            &"devhub.near".parse().unwrap(),
455            "devhub.near/widget/app",
456            &"social.near".parse().unwrap(),
457            NearToken::from_near(0),
458        )
459        .await;
460
461        match deposit {
462            Ok(deposit_value) => {
463                assert_eq!(NearToken::from_near(0), deposit_value);
464            }
465            Err(e) => {
466                println!("Error: {:?}", e);
467                panic!("get_deposit should not fail when using a public key belonging to the target account, even without explicit write permission. Error Message:\n{}", e);
468            }
469        }
470    }
471
472    #[tokio::test]
473    pub async fn test_get_deposit_other_account_no_explicit_write_permission() {
474        let key_pair = near_crypto::SecretKey::from_random(near_crypto::KeyType::ED25519);
475
476        let server_url = mock_rpc(false);
477        let json_rpc_client: JsonRpcClient = JsonRpcClient::connect(&server_url);
478
479        let signer_account_id: AccountId = "notdevhub.near".parse().unwrap();
480        let public_key = key_pair.public_key();
481
482        let deposit = get_deposit(
483            &json_rpc_client,
484            &signer_account_id,
485            &public_key,
486            &"devhub.near".parse().unwrap(),
487            "devhub.near/widget/app",
488            &"social.near".parse().unwrap(),
489            NearToken::from_near(0),
490        )
491        .await;
492
493        match deposit {
494            Ok(_deposit_value) => {
495                panic!("get_deposit should fail when using a public key belonging to a different account without explicit write permission");
496            }
497            Err(e) => {
498                assert_eq!(
499                    "ERROR: the signer is not allowed to modify the components of this account_id.",
500                    e.to_string()
501                );
502            }
503        }
504    }
505
506    #[tokio::test]
507    pub async fn test_get_deposit_same_account_function_access_key_with_required_deposit() {
508        let key_pair = near_crypto::SecretKey::from_random(near_crypto::KeyType::ED25519);
509
510        let server_url = mock_rpc(false);
511        let json_rpc_client: JsonRpcClient = JsonRpcClient::connect(&server_url);
512
513        let signer_account_id: AccountId = "devhub.near".parse().unwrap();
514        let public_key = key_pair.public_key();
515
516        let deposit = get_deposit(
517            &json_rpc_client,
518            &signer_account_id,
519            &public_key,
520            &"devhub.near".parse().unwrap(),
521            "devhub.near/widget/app",
522            &"social.near".parse().unwrap(),
523            NearToken::from_near(1),
524        )
525        .await;
526
527        match deposit {
528            Ok(_deposit_value) => {
529                panic!("get_deposit should fail when using a public key belonging for a function access key from the owner account when there is a required deposit");
530            }
531            Err(e) => {
532                assert_eq!("ERROR: Social DB requires more storage deposit, but we cannot cover it when signing transaction with a Function Call only access key", e.to_string());
533            }
534        }
535    }
536
537    #[tokio::test]
538    pub async fn test_get_deposit_write_permission_function_access_key_with_required_deposit() {
539        let key_pair = near_crypto::SecretKey::from_random(near_crypto::KeyType::ED25519);
540
541        let server_url = mock_rpc(true);
542        let json_rpc_client: JsonRpcClient = JsonRpcClient::connect(&server_url);
543
544        let signer_account_id: AccountId = "devhub.near".parse().unwrap();
545        let public_key = key_pair.public_key();
546
547        let deposit = get_deposit(
548            &json_rpc_client,
549            &signer_account_id,
550            &public_key,
551            &"devhub.near".parse().unwrap(),
552            "devhub.near/widget/app",
553            &"social.near".parse().unwrap(),
554            NearToken::from_near(1),
555        )
556        .await;
557
558        match deposit {
559            Ok(_deposit_value) => {
560                panic!("get_deposit should fail when using a public key with write permission when there is a required deposit");
561            }
562            Err(e) => {
563                assert_eq!("ERROR: Social DB requires more storage deposit, but we cannot cover it when signing transaction with a Function Call only access key", e.to_string());
564            }
565        }
566    }
567}