Skip to content

Commit

Permalink
Client: balances rpc call (#6)
Browse files Browse the repository at this point in the history
* feat: balance rpc call

* feat: client funcs for rpc call

* chore: tests

* chore(balances): add default/skip to relevant options

* feat: add util folder for general rpc utils

* fix: deserialize string floats into f64

* chore: add chrono crate, des timestamp into chrono DateTime

* chore: fmt
  • Loading branch information
Evalir committed Apr 7, 2023
1 parent 8be41f6 commit 5bf96df
Show file tree
Hide file tree
Showing 6 changed files with 192 additions and 0 deletions.
1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ tokio-stream = "0.1.11"
tracing = "0.1.37"
tracing-futures = "0.2.5"
url = { version = "2.3.1", features = ["serde"] }
chrono = { version = "0.4.24", features = ["serde"] }

[dev-dependencies]
tokio = { version = "1.0.1", features = ["rt-multi-thread", "macros"] }
Expand Down
34 changes: 34 additions & 0 deletions src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ use crate::{
json_get, json_post,
networks::{self, TxService},
rpc::{
balances::{BalancesFilters, BalancesRequest, BalancesResponse},
common::ErrorResponse,
estimate::{EstimateRequest, EstimateResponse},
info::{SafeInfoRequest, SafeInfoResponse},
Expand Down Expand Up @@ -168,6 +169,39 @@ impl SafeClient {
.map(Option::unwrap)
}

/// Get information about the balances on a particular Safe from the API.
#[tracing::instrument(skip(self))]
pub async fn balances(&self, safe_address: Address) -> ClientResult<BalancesResponse> {
json_get!(
&self.client,
BalancesRequest::url(self.url(), safe_address),
BalancesResponse,
)
.map(Option::unwrap)
}

/// Get filtered information about the balances on a particular Safe from the API.
#[tracing::instrument(skip(self, filters))]
pub async fn filtered_balances(
&self,
safe_address: Address,
filters: impl IntoIterator<Item = (&'static str, String)>,
) -> ClientResult<BalancesResponse> {
json_get!(
&self.client,
BalancesRequest::url(self.url(), safe_address),
BalancesResponse,
filters,
)
.map(Option::unwrap)
}

/// Create a filter builder for balances
#[tracing::instrument(skip(self))]
pub fn balances_builder(&self, safe_address: Address) -> BalancesFilters<'_> {
BalancesFilters::new(self)
}

/// Get information about tokens available on the API
#[tracing::instrument(skip(self))]
pub async fn tokens(&self) -> ClientResult<TokenInfoResponse> {
Expand Down
117 changes: 117 additions & 0 deletions src/rpc/balances.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
use std::collections::HashMap;

use chrono::{DateTime, Utc};
use ethers::types::{Address, U256};
use reqwest::Url;

use crate::{client::ClientResult, rpc::util::string_as_f64, SafeClient};

/// Safe balances response
pub type BalancesResponse = Vec<BalanceResponse>;

/// Safe balances request
#[derive(Debug, Clone, serde::Serialize)]
pub struct BalancesRequest;

impl BalancesRequest {
/// Return the URL to which to dispatch this request
pub fn url(root: &Url, safe_address: Address) -> Url {
let mut url = root.clone();
url.set_path(&format!(
"api/v1/safes/{}/balances/usd/",
ethers::utils::to_checksum(&safe_address, None)
));
url
}
}

/// Safe Balances request filters
#[derive(Clone, serde::Serialize)]
pub struct BalancesFilters<'a> {
#[serde(flatten)]
pub(crate) filters: HashMap<&'static str, String>,
#[serde(skip)]
pub(crate) client: &'a SafeClient,
}

impl<'a> BalancesFilters<'a> {
/// Dispatch the request to the API, querying safe balances from the API
pub async fn query(self, safe_address: Address) -> ClientResult<BalancesResponse> {
self.client
.filtered_balances(safe_address, self.filters)
.await
}

/// Return the URL to which to dispatch this request
pub fn url(root: &Url, safe_address: Address) -> reqwest::Url {
let path = format!(
"api/v1/safes/{}/balances/usd/",
ethers::utils::to_checksum(&safe_address, None)
);
let mut url = root.clone();
url.set_path(&path);
url
}

/// Instantiate from a client
pub(crate) fn new(client: &'a SafeClient) -> Self {
Self {
filters: Default::default(),
client,
}
}

/// Filter by allowing only trusted tokens to be returned or not.
pub fn trusted(mut self, trusted: bool) -> Self {
self.filters.insert("trusted", trusted.to_string());
self
}

/// Filter by allowing known spam tokens to be returned or not.
pub fn exclude_spam(mut self, exclude_spam: bool) -> Self {
self.filters
.insert("exclude_spam", exclude_spam.to_string());
self
}
}

/// The individual response for every Safe token balance.
#[derive(Debug, Clone, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BalanceResponse {
/// The address of the token (null for native tokens)
pub token_address: Option<Address>,
/// The token info (null for native tokens)
pub token: Option<Erc20Info>,
/// The balance of the safe for the token
pub balance: U256,
/// The value in eth of the token
pub eth_value: String,
/// The timestamp of when the conversion was made
pub timestamp: DateTime<Utc>,
/// The balance in USD of the token
/// The conversion rate used to calculate the fiat balance
#[serde(default, deserialize_with = "string_as_f64")]
pub fiat_balance: f64,
/// The conversion rate used to calculate the fiat balance
#[serde(default, deserialize_with = "string_as_f64")]
pub fiat_conversion: f64,
/// The currency used to calculate the fiat balance
pub fiat_code: String,
}

/// The info about the token, if it's an ERC20 token.
#[derive(Debug, Clone, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Erc20Info {
/// The name of the token
pub name: String,
/// The token symbol
pub symbol: String,
/// The token decimals
#[serde(default, skip_serializing_if = "Option::is_none")]
pub decimals: Option<u32>,
/// The logo URI, if it exists.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub logo_uri: Option<String>,
}
5 changes: 5 additions & 0 deletions src/rpc/mod.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
mod util;

/// Common RPC types
pub mod common;

Expand All @@ -7,6 +9,9 @@ pub mod info;
/// Token Info
pub mod tokens;

/// Balances of a safe.
pub mod balances;

/// History of Safe msig transactions
pub mod msig_history;

Expand Down
29 changes: 29 additions & 0 deletions src/rpc/util.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
use serde::{
de::{Error, Unexpected, Visitor},
Deserializer,
};
use std::fmt;

/// Small utility function to deserialize a string into a f64.
pub(crate) fn string_as_f64<'de, D>(deserializer: D) -> Result<f64, D::Error>
where
D: Deserializer<'de>,
{
deserializer.deserialize_str(F64Visitor)
}

struct F64Visitor;
impl<'de> Visitor<'de> for F64Visitor {
type Value = f64;
fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("a string representation of a f64")
}
fn visit_str<E>(self, value: &str) -> Result<f64, E>
where
E: Error,
{
value.parse::<f64>().map_err(|_err| {
E::invalid_value(Unexpected::Str(value), &"a string representation of a f64")
})
}
}
6 changes: 6 additions & 0 deletions tests/integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,12 @@ async fn it_gets_tokens() {
dbg!(CLIENT.tokens().await.unwrap());
}

#[tokio::test]
#[tracing_test::traced_test]
async fn it_gets_balances() {
dbg!(CLIENT.balances(*SAFE).await.unwrap());
}

#[tokio::test]
#[tracing_test::traced_test]
async fn it_proposes() {
Expand Down

0 comments on commit 5bf96df

Please sign in to comment.