use crate::cycles::CyclesCommandError;
use candid::decode_one;
use canic_core::{
cdk::utils::hash::{decode_hex, hex_bytes},
dto::{
error::Error,
icp_refill::{IcpRefillEndpointResponse, IcpRefillResponse},
},
shared_support::icp_refill::icp_refill_outcome_is_resumable,
};
#[derive(Debug)]
pub(super) struct DecodedIcpRefillResponse {
response: IcpRefillResponse,
}
impl DecodedIcpRefillResponse {
#[must_use]
pub(super) const fn is_resumable(&self) -> bool {
icp_refill_outcome_is_resumable(
self.response.status,
self.response.error_code,
self.response.ledger_block_index.is_some(),
)
}
#[must_use]
pub(super) fn render(&self, json: bool) -> String {
if json {
return serde_json::json!({
"operation_id": hex_bytes(self.response.operation_id),
"status": format!("{:?}", self.response.status),
"ledger_block_index": self.response.ledger_block_index,
"cycles_sent": self.response.cycles_sent.as_ref().map(|cycles| cycles.0.to_string()),
"error_code": self.response.error_code.map(|code| format!("{code:?}")),
"error_message": self.response.error_message,
})
.to_string();
}
format!(
"operation_id={}\nstatus={:?}\nledger_block_index={}\ncycles_sent={}\nerror_code={}\nerror_message={:?}",
hex_bytes(self.response.operation_id),
self.response.status,
optional_display(self.response.ledger_block_index.as_ref()),
optional_display(self.response.cycles_sent.as_ref()),
optional_debug(self.response.error_code),
self.response.error_message,
)
}
}
pub(super) fn decode_icp_refill_response(
output: &str,
expected_operation_id: [u8; 32],
) -> Result<DecodedIcpRefillResponse, CyclesCommandError> {
let bytes = decode_hex(output.trim()).map_err(CyclesCommandError::IcpRefillResponseHex)?;
let response = decode_one::<Result<IcpRefillEndpointResponse, Error>>(&bytes)
.map_err(CyclesCommandError::IcpRefillResponseCandid)?;
let response = match response {
Ok(IcpRefillEndpointResponse::Refill(response)) => response,
Ok(IcpRefillEndpointResponse::DryRun(_)) => {
return Err(CyclesCommandError::IcpRefillUnexpectedResponse);
}
Err(error) => {
return Err(CyclesCommandError::IcpRefillRejected {
code: error.code,
message: error.message,
});
}
};
if response.operation_id != expected_operation_id {
return Err(CyclesCommandError::IcpRefillOperationIdMismatch {
expected: hex_bytes(expected_operation_id),
actual: hex_bytes(response.operation_id),
});
}
Ok(DecodedIcpRefillResponse { response })
}
fn optional_display<T: ToString>(value: Option<&T>) -> String {
value.map_or_else(|| "none".to_string(), ToString::to_string)
}
fn optional_debug<T: std::fmt::Debug>(value: Option<T>) -> String {
value.map_or_else(|| "none".to_string(), |value| format!("{value:?}"))
}
#[cfg(test)]
mod tests {
use super::*;
use candid::{Nat, encode_one};
use canic_core::dto::{
error::ErrorCode,
icp_refill::{IcpRefillErrorCode, IcpRefillStatus},
};
#[test]
fn terminal_refill_response_is_typed_and_operation_bound() {
let output = encoded_response(Ok(IcpRefillEndpointResponse::Refill(sample_response(
[7; 32],
IcpRefillStatus::Completed,
Some(42),
None,
))));
let response = decode_icp_refill_response(&output, [7; 32]).expect("decode response");
assert!(!response.is_resumable());
assert!(response.render(false).contains("status=Completed"));
assert_eq!(
serde_json::from_str::<serde_json::Value>(&response.render(true))
.expect("parse JSON response"),
serde_json::json!({
"operation_id": hex_bytes([7; 32]),
"status": "Completed",
"ledger_block_index": 42,
"cycles_sent": "1000",
"error_code": null,
"error_message": null,
})
);
}
#[test]
fn transferred_refill_response_remains_resumable() {
let output = encoded_response(Ok(IcpRefillEndpointResponse::Refill(sample_response(
[8; 32],
IcpRefillStatus::Transferred,
Some(42),
None,
))));
let response = decode_icp_refill_response(&output, [8; 32]).expect("decode response");
assert!(response.is_resumable());
}
#[test]
fn endpoint_error_preserves_typed_code() {
let output = encoded_response(Err(Error::conflict("active operation")));
let error = decode_icp_refill_response(&output, [7; 32]).expect_err("reject response");
std::assert_matches!(
error,
CyclesCommandError::IcpRefillRejected {
code: ErrorCode::Conflict,
message,
} if message == "active operation"
);
}
#[test]
fn mismatched_operation_id_is_rejected() {
let output = encoded_response(Ok(IcpRefillEndpointResponse::Refill(sample_response(
[8; 32],
IcpRefillStatus::Completed,
Some(42),
None,
))));
let error = decode_icp_refill_response(&output, [7; 32]).expect_err("reject mismatch");
assert!(matches!(
error,
CyclesCommandError::IcpRefillOperationIdMismatch { .. }
));
}
fn encoded_response(response: Result<IcpRefillEndpointResponse, Error>) -> String {
hex_bytes(encode_one(response).expect("encode response"))
}
fn sample_response(
operation_id: [u8; 32],
status: IcpRefillStatus,
ledger_block_index: Option<u64>,
error_code: Option<IcpRefillErrorCode>,
) -> IcpRefillResponse {
IcpRefillResponse {
operation_id,
status,
ledger_block_index,
cycles_sent: Some(Nat::from(1_000_u64)),
error_code,
error_message: None,
}
}
}