use crate::{
errors::Error,
types::{HttpRequest, HttpResponse},
utils::{cycles_nat_to_u128, cycles_str_to_u128},
};
use candid::Principal;
use ic_cdk::call::{Call, RejectCode};
use ic_cdk::management_canister::{CanisterId, CanisterStatusArgs, CanisterStatusResult};
#[async_trait::async_trait]
pub trait FetchCyclesBalance: Sync + Send {
async fn fetch_cycles_balance(&self, canister_id: CanisterId) -> Result<u128, Error>;
}
#[derive(Clone)]
pub struct FetchCyclesBalanceFromCanisterStatus {
canister: Principal,
method: String,
}
impl FetchCyclesBalanceFromCanisterStatus {
pub fn new() -> Self {
Self {
canister: Principal::management_canister(),
method: "canister_status".to_string(),
}
}
pub fn with_proxy(mut self, proxy: Principal) -> Self {
self.canister = proxy;
self
}
pub fn with_method(mut self, method: String) -> Self {
self.method = method;
self
}
}
impl Default for FetchCyclesBalanceFromCanisterStatus {
fn default() -> Self {
FetchCyclesBalanceFromCanisterStatus::new()
}
}
#[async_trait::async_trait]
impl FetchCyclesBalance for FetchCyclesBalanceFromCanisterStatus {
async fn fetch_cycles_balance(&self, canister_id: CanisterId) -> Result<u128, Error> {
let response = Call::unbounded_wait(self.canister, &self.method)
.with_arg(&CanisterStatusArgs { canister_id });
match response.await {
Ok(response) => {
let CanisterStatusResult {
cycles,
settings,
idle_cycles_burned_per_day,
..
} = response
.candid()
.map_err(|e| Error::GetCanisterCycleBalanceFailed {
rejection_code: RejectCode::CanisterError,
rejection_message: e.to_string(),
})?;
cycles_nat_to_u128(cycles).map(|cycles| {
cycles.saturating_sub(calc_freezing_balance(
cycles_nat_to_u128(settings.freezing_threshold).unwrap_or(0),
cycles_nat_to_u128(idle_cycles_burned_per_day).unwrap_or(0),
))
})
}
Err(error) => {
if error.to_string().to_lowercase().contains("out of cycles") {
return Ok(0);
}
Err(Error::GetCanisterCycleBalanceFailed {
rejection_code: RejectCode::CanisterError,
rejection_message: error.to_string(),
})
}
}
}
}
#[derive(Clone)]
pub struct FetchCyclesBalanceFromPrometheusMetrics {
path: String,
metric_name: String,
}
impl Default for FetchCyclesBalanceFromPrometheusMetrics {
fn default() -> Self {
FetchCyclesBalanceFromPrometheusMetrics {
path: "/metrics".to_string(),
metric_name: "canister_cycles".to_string(),
}
}
}
impl FetchCyclesBalanceFromPrometheusMetrics {
pub fn new(path: String, metric_name: String) -> Self {
Self { path, metric_name }
}
pub fn with_path(mut self, path: String) -> Self {
self.path = path;
self
}
pub fn with_metric_name(mut self, metric_name: String) -> Self {
self.metric_name = metric_name;
self
}
pub fn path(&self) -> &str {
&self.path
}
pub fn metric_name(&self) -> &str {
&self.metric_name
}
}
#[async_trait::async_trait]
impl FetchCyclesBalance for FetchCyclesBalanceFromPrometheusMetrics {
async fn fetch_cycles_balance(&self, canister_id: CanisterId) -> Result<u128, Error> {
let response: Result<HttpResponse, _> = Call::unbounded_wait(canister_id, "http_request")
.with_arg(HttpRequest {
method: "GET".to_string(),
url: self.path.clone(),
headers: vec![],
body: vec![],
})
.await
.map_err(|e| Error::MetricsHttpRequestFailed {
code: RejectCode::CanisterError,
reason: e.to_string(),
})?
.candid();
match response {
Err(error) => Err(Error::MetricsHttpRequestFailed {
code: RejectCode::CanisterError,
reason: error.to_string(),
}),
Ok(HttpResponse {
status_code, body, ..
}) => {
if status_code != 200 {
return Err(Error::MetricsHttpRequestFailed {
code: RejectCode::CanisterError,
reason: format!(
"HTTP code unexpected {}: {}",
status_code,
String::from_utf8(body).unwrap_or_default()
),
});
}
extract_cycles_from_http_response_body(
&String::from_utf8(body)
.map_err(|_| Error::MetricsResponseDeserializationFailed)?,
&self.metric_name,
)
}
}
}
}
fn extract_cycles_from_http_response_body(body: &str, metric_name: &str) -> Result<u128, Error> {
let cycles: String = body
.lines()
.find(|line| line.trim().starts_with(metric_name))
.and_then(|line| {
let parsed_line = match (line.find('{'), line.rfind('}')) {
(Some(label_start), Some(label_end)) => {
let mut line = line.to_string();
line.replace_range(label_start..=label_end, "");
line
}
_ => line.to_string(),
};
parsed_line
.split_whitespace()
.nth(1)
.map(|cycles| cycles.to_string())
})
.ok_or(Error::CyclesBalanceMetricNotFound {
metric_name: metric_name.to_string(),
})?;
cycles_str_to_u128(cycles.as_str())
}
fn calc_freezing_balance(freezing_threshold: u128, idle_cycles_burned_per_day: u128) -> u128 {
idle_cycles_burned_per_day * freezing_threshold / 86_400
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_cycles_from_http_response_body() {
let body = r#"
# HELP canister_cycles The cycles balance of the canister.
# TYPE canister_cycles gauge
canister_cycles 100
"#;
assert_eq!(
extract_cycles_from_http_response_body(body, "canister_cycles").unwrap(),
100
);
}
#[test]
fn test_extract_cycles_from_http_response_with_time_series() {
let body = r#"
# HELP canister_cycles The cycles balance of the canister.
# TYPE canister_cycles gauge
canister_cycles 100 1620000000
"#;
assert_eq!(
extract_cycles_from_http_response_body(body, "canister_cycles").unwrap(),
100
);
}
#[test]
fn test_extract_cycles_from_http_response_with_labels() {
let body = r#"
# HELP canister_cycles The cycles balance of the canister.
# TYPE canister_cycles gauge
canister_cycles{method="GET", handler="/test"} 100
"#;
assert_eq!(
extract_cycles_from_http_response_body(body, "canister_cycles").unwrap(),
100
);
}
#[test]
fn test_extract_cycles_from_http_response_body_not_found() {
let body = r#"
# HELP canister_cycles The cycles balance of the canister.
# TYPE canister_cycles gauge
"#;
assert_eq!(
extract_cycles_from_http_response_body(body, "canister_cycles").unwrap_err(),
Error::CyclesBalanceMetricNotFound {
metric_name: "canister_cycles".to_string()
}
);
}
#[test]
fn test_extract_cycles_from_http_response_body_invalid() {
let body = r#"
# HELP canister_cycles The cycles balance of the canister.
# TYPE canister_cycles gauge
canister_cycles invalid
"#;
assert_eq!(
extract_cycles_from_http_response_body(body, "canister_cycles").unwrap_err(),
Error::FailedCyclesConversion {
cycles: "invalid".to_string()
}
);
}
#[test]
fn test_calc_needed_cycles() {
assert_eq!(calc_freezing_balance(24 * 60 * 60, 1), 1);
assert_eq!(calc_freezing_balance(12 * 60 * 60, 100), 50);
assert_eq!(calc_freezing_balance(10 * 24 * 60 * 60, 50_000), 500_000);
assert_eq!(calc_freezing_balance(30 * 24 * 60 * 60, 123456), 3_703_680);
}
}