#![allow(dead_code)]
use crate::{
cdk::{
candid::{CandidType, Nat},
icrc_ledger_types::icrc1::transfer::{Memo, TransferArg, TransferError},
types::{Account, Principal, Subaccount},
},
ids::BuildNetwork,
infra::{
InfraError,
ic::{
IcInfraError,
call::Call,
known::{CYCLES_MINTING_CANISTER, ICP_LEDGER_CANISTER},
},
},
};
use serde::{Deserialize, Serialize};
use thiserror::Error as ThisError;
pub type Icrc1TransferResult = Result<Nat, TransferError>;
pub type NotifyTopUpResult = Result<Nat, NotifyTopUpError>;
const CMC_TOPUP_MEMO_BYTES: &[u8] = b"TPUP\0\0\0\0";
const CMC_TOPUP_SUBACCOUNT_MAX_PRINCIPAL_BYTES: usize = 31;
#[derive(Debug, ThisError)]
pub enum IcpRefillInfraError {
#[error("ledger block index {value} does not fit in u64")]
LedgerBlockIndexOverflow { value: Nat },
#[error("network=ic rejects ICP ledger / CMC overrides without unsafe override flag")]
MainnetSystemCanisterOverrideRejected,
#[error("target principal is too long for CMC top-up subaccount: len={len}")]
PrincipalTooLongForCmcSubaccount { len: usize },
}
impl From<IcpRefillInfraError> for InfraError {
fn from(err: IcpRefillInfraError) -> Self {
IcInfraError::IcpRefillInfra(err).into()
}
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct IcpRefillCanisterOverrides {
pub ledger_canister_id: Option<Principal>,
pub cmc_canister_id: Option<Principal>,
pub allow_ic_overrides: bool,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct IcpRefillCanisters {
pub ledger_canister_id: Principal,
pub cmc_canister_id: Principal,
}
#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct NotifyTopUpArg {
pub block_index: u64,
pub canister_id: Principal,
}
#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub enum NotifyTopUpError {
Refunded {
block_index: Option<u64>,
reason: String,
},
InvalidTransaction(String),
Other {
error_code: u64,
error_message: String,
},
Processing,
TransactionTooOld(u64),
}
#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct IcpXdrConversionRate {
pub xdr_permyriad_per_icp: u64,
pub timestamp_seconds: u64,
}
#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct IcpXdrConversionRateResponse {
pub data: IcpXdrConversionRate,
pub hash_tree: Vec<u8>,
pub certificate: Vec<u8>,
}
pub struct IcpRefillInfra;
impl IcpRefillInfra {
#[must_use]
pub fn topup_memo() -> Vec<u8> {
CMC_TOPUP_MEMO_BYTES.to_vec()
}
pub fn cmc_topup_subaccount(target_canister: Principal) -> Result<Subaccount, InfraError> {
let bytes = target_canister.as_slice();
if bytes.len() > CMC_TOPUP_SUBACCOUNT_MAX_PRINCIPAL_BYTES {
return Err(
IcpRefillInfraError::PrincipalTooLongForCmcSubaccount { len: bytes.len() }.into(),
);
}
let mut subaccount = [0_u8; 32];
subaccount[0] = u8::try_from(bytes.len()).expect("principal length fits in u8");
subaccount[1..=bytes.len()].copy_from_slice(bytes);
Ok(subaccount)
}
pub fn cmc_topup_account(
cmc_canister_id: Principal,
target_canister: Principal,
) -> Result<Account, InfraError> {
Ok(Account {
owner: cmc_canister_id,
subaccount: Some(Self::cmc_topup_subaccount(target_canister)?),
})
}
#[must_use]
pub const fn source_account(
source_canister: Principal,
source_subaccount: Option<Subaccount>,
) -> Account {
Account {
owner: source_canister,
subaccount: source_subaccount,
}
}
#[must_use]
pub fn transfer_arg(
from_subaccount: Option<Subaccount>,
to: Account,
amount_e8s: u64,
fee_e8s: u64,
memo: Vec<u8>,
created_at_time_ns: u64,
) -> TransferArg {
TransferArg {
from_subaccount,
to,
fee: Some(Nat::from(fee_e8s)),
created_at_time: Some(created_at_time_ns),
memo: Some(Memo::from(memo)),
amount: Nat::from(amount_e8s),
}
}
pub fn checked_block_index(block_index: Nat) -> Result<u64, InfraError> {
u64::try_from(block_index.0.clone()).map_err(|_| {
IcpRefillInfraError::LedgerBlockIndexOverflow { value: block_index }.into()
})
}
pub fn resolve_canisters(
network: BuildNetwork,
overrides: IcpRefillCanisterOverrides,
) -> Result<IcpRefillCanisters, InfraError> {
if network == BuildNetwork::Ic
&& !overrides.allow_ic_overrides
&& (overrides.ledger_canister_id.is_some() || overrides.cmc_canister_id.is_some())
{
return Err(IcpRefillInfraError::MainnetSystemCanisterOverrideRejected.into());
}
Ok(IcpRefillCanisters {
ledger_canister_id: overrides.ledger_canister_id.unwrap_or(*ICP_LEDGER_CANISTER),
cmc_canister_id: overrides
.cmc_canister_id
.unwrap_or(*CYCLES_MINTING_CANISTER),
})
}
pub async fn icrc1_fee(ledger_id: Principal) -> Result<Nat, InfraError> {
Call::unbounded_wait(ledger_id, "icrc1_fee")
.execute()
.await?
.candid()
}
pub async fn icrc1_decimals(ledger_id: Principal) -> Result<u8, InfraError> {
Call::unbounded_wait(ledger_id, "icrc1_decimals")
.execute()
.await?
.candid()
}
pub async fn icrc1_transfer(
ledger_id: Principal,
args: TransferArg,
) -> Result<Icrc1TransferResult, InfraError> {
Call::unbounded_wait(ledger_id, "icrc1_transfer")
.with_arg(args)?
.execute()
.await?
.candid()
}
pub async fn notify_top_up(
cmc_id: Principal,
args: NotifyTopUpArg,
) -> Result<NotifyTopUpResult, InfraError> {
Call::unbounded_wait(cmc_id, "notify_top_up")
.with_arg(args)?
.execute()
.await?
.candid()
}
pub async fn get_icp_xdr_conversion_rate(
cmc_id: Principal,
) -> Result<IcpXdrConversionRateResponse, InfraError> {
Call::unbounded_wait(cmc_id, "get_icp_xdr_conversion_rate")
.execute()
.await?
.candid()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::str::FromStr;
fn principal(byte: u8) -> Principal {
Principal::from_slice(&[byte; 29])
}
#[test]
fn cmc_topup_subaccount_encodes_target_principal() {
let target = principal(7);
let subaccount = IcpRefillInfra::cmc_topup_subaccount(target)
.expect("principal should fit in CMC subaccount");
assert_eq!(subaccount[0], 29);
assert_eq!(&subaccount[1..30], &[7_u8; 29]);
assert_eq!(&subaccount[30..], &[0_u8; 2]);
}
#[test]
fn checked_block_index_accepts_u64() {
assert_eq!(
IcpRefillInfra::checked_block_index(Nat::from(u64::MAX)).expect("u64 fits"),
u64::MAX
);
}
#[test]
fn checked_block_index_rejects_overflow() {
let too_large = Nat::from_str("18446744073709551616").expect("valid nat");
assert!(matches!(
IcpRefillInfra::checked_block_index(too_large),
Err(InfraError::IcInfra(IcInfraError::IcpRefillInfra(
IcpRefillInfraError::LedgerBlockIndexOverflow { .. }
)))
));
}
#[test]
fn mainnet_resolution_rejects_overrides_without_flag() {
let overrides = IcpRefillCanisterOverrides {
ledger_canister_id: Some(principal(1)),
cmc_canister_id: None,
allow_ic_overrides: false,
};
assert!(matches!(
IcpRefillInfra::resolve_canisters(BuildNetwork::Ic, overrides),
Err(InfraError::IcInfra(IcInfraError::IcpRefillInfra(
IcpRefillInfraError::MainnetSystemCanisterOverrideRejected
)))
));
}
#[test]
fn mainnet_resolution_uses_canonical_ids_without_overrides() {
let canisters = IcpRefillInfra::resolve_canisters(
BuildNetwork::Ic,
IcpRefillCanisterOverrides::default(),
)
.expect("canonical mainnet IDs should resolve");
assert_eq!(canisters.ledger_canister_id, *ICP_LEDGER_CANISTER);
assert_eq!(canisters.cmc_canister_id, *CYCLES_MINTING_CANISTER);
}
#[test]
fn transfer_arg_preserves_recovery_identity() {
let to = Account {
owner: principal(9),
subaccount: Some([8_u8; 32]),
};
let args = IcpRefillInfra::transfer_arg(
Some([7_u8; 32]),
to,
100_000_000,
10_000,
IcpRefillInfra::topup_memo(),
42,
);
assert_eq!(args.from_subaccount, Some([7_u8; 32]));
assert_eq!(args.to, to);
assert_eq!(args.amount, Nat::from(100_000_000_u64));
assert_eq!(args.fee, Some(Nat::from(10_000_u64)));
assert_eq!(args.created_at_time, Some(42));
assert_eq!(args.memo, Some(Memo::from(b"TPUP\0\0\0\0".to_vec())));
}
}