canic-core 0.58.3

Canic — a canister orchestration and management toolkit for the Internet Computer
Documentation
#![allow(dead_code)]

use crate::{
    InternalError,
    cdk::{
        candid::Nat,
        types::{Principal, Subaccount},
    },
    dto::icp_refill::{IcpRefillErrorCode, IcpRefillResponse, IcpRefillStatus},
    ops::storage::StorageOpsError,
    storage::stable::icp_refill::{IcpRefillRecord, IcpRefillRecordKey, IcpRefillRecords},
};
use thiserror::Error as ThisError;

const ERROR_MESSAGE_MAX_CHARS: usize = 512;

///
/// IcpRefillRecordOpsError
///

#[derive(Debug, ThisError)]
pub enum IcpRefillRecordOpsError {
    #[error("ICP refill record id space exhausted")]
    IdOverflow,

    #[error("ICP refill operation id conflicts with existing record {id}")]
    OperationConflict { id: u64 },

    #[error("ICP refill record {0} not found")]
    RecordNotFound(u64),
}

impl From<IcpRefillRecordOpsError> for InternalError {
    fn from(err: IcpRefillRecordOpsError) -> Self {
        StorageOpsError::from(err).into()
    }
}

///
/// IcpRefillRecordCreateInput
///

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct IcpRefillRecordCreateInput {
    pub operation_id: [u8; 32],
    pub source_canister: Principal,
    pub source_subaccount: Option<Subaccount>,
    pub target_canister: Principal,
    pub ledger_canister_id: Principal,
    pub cmc_canister_id: Principal,
    pub cmc_to_account_owner: Principal,
    pub cmc_to_account_subaccount: Option<Subaccount>,
    pub amount_e8s: u64,
    pub fee_e8s: u64,
    pub memo: Vec<u8>,
    pub created_at_time_ns: u64,
    pub now_ns: u64,
}

///
/// IcpRefillRecordOps
///

pub struct IcpRefillRecordOps;

impl IcpRefillRecordOps {
    pub fn insert(record: IcpRefillRecord) -> Option<IcpRefillRecord> {
        IcpRefillRecords::insert(record)
    }

    #[must_use]
    pub fn get(id: u64) -> Option<IcpRefillRecord> {
        IcpRefillRecords::get(id)
    }

    #[must_use]
    pub fn entries() -> Vec<(IcpRefillRecordKey, IcpRefillRecord)> {
        IcpRefillRecords::entries(0, usize::MAX)
    }

    pub fn find_by_operation_id(operation_id: [u8; 32]) -> Option<IcpRefillRecord> {
        Self::entries()
            .into_iter()
            .map(|(_key, record)| record)
            .find(|record| record.operation_id == operation_id)
    }

    pub fn create_or_get(
        input: IcpRefillRecordCreateInput,
    ) -> Result<IcpRefillRecord, InternalError> {
        if let Some(existing) = Self::find_by_operation_id(input.operation_id) {
            ensure_compatible_operation(&existing, &input)?;
            return Ok(existing);
        }

        let id = next_id()?;
        let record = IcpRefillRecord {
            id,
            operation_id: input.operation_id,
            source_canister: input.source_canister,
            source_subaccount: input.source_subaccount,
            target_canister: input.target_canister,
            ledger_canister_id: input.ledger_canister_id,
            cmc_canister_id: input.cmc_canister_id,
            cmc_to_account_owner: input.cmc_to_account_owner,
            cmc_to_account_subaccount: input.cmc_to_account_subaccount,
            amount_e8s: input.amount_e8s,
            fee_e8s: input.fee_e8s,
            memo: input.memo,
            created_at_time_ns: input.created_at_time_ns,
            ledger_block_index: None,
            notify_attempts: 0,
            cycles_sent: None,
            status: IcpRefillStatus::Requested,
            error_code: None,
            error_message: None,
            refund_block_index: None,
            transaction_too_old_min_block_index: None,
            created_at_ns: input.now_ns,
            updated_at_ns: input.now_ns,
        };

        Self::insert(record.clone());

        Ok(record)
    }

    pub fn mark_transferred(
        id: u64,
        ledger_block_index: u64,
        now_ns: u64,
    ) -> Result<IcpRefillRecord, InternalError> {
        update_record(id, now_ns, |record| {
            record.ledger_block_index = Some(ledger_block_index);
            record.status = IcpRefillStatus::Transferred;
            clear_error(record);
        })
    }

    pub fn mark_duplicate_transferred(
        id: u64,
        ledger_block_index: u64,
        now_ns: u64,
    ) -> Result<IcpRefillRecord, InternalError> {
        update_record(id, now_ns, |record| {
            record.ledger_block_index = Some(ledger_block_index);
            record.status = IcpRefillStatus::Transferred;
            record.error_code = Some(IcpRefillErrorCode::Duplicate);
            record.error_message =
                Some("ledger transfer duplicate; reusing duplicate block".to_string());
        })
    }

    pub fn mark_transfer_failed(
        id: u64,
        error_code: IcpRefillErrorCode,
        error_message: String,
        now_ns: u64,
    ) -> Result<IcpRefillRecord, InternalError> {
        update_record(id, now_ns, |record| {
            record.status = IcpRefillStatus::Failed;
            record.error_code = Some(error_code);
            record.error_message = Some(truncate_error(error_message));
        })
    }

    pub fn mark_bad_fee(
        id: u64,
        expected_fee_e8s: u64,
        error_message: String,
        now_ns: u64,
    ) -> Result<IcpRefillRecord, InternalError> {
        update_record(id, now_ns, |record| {
            record.fee_e8s = expected_fee_e8s;
            record.status = IcpRefillStatus::Failed;
            record.error_code = Some(IcpRefillErrorCode::BadFee);
            record.error_message = Some(truncate_error(error_message));
        })
    }

    pub fn mark_notify_attempt_started(
        id: u64,
        now_ns: u64,
    ) -> Result<IcpRefillRecord, InternalError> {
        update_record(id, now_ns, |record| {
            record.notify_attempts = record.notify_attempts.saturating_add(1);
        })
    }

    pub fn mark_notify_processing(id: u64, now_ns: u64) -> Result<IcpRefillRecord, InternalError> {
        update_record(id, now_ns, |record| {
            record.status = IcpRefillStatus::NotifyProcessing;
            record.error_code = Some(IcpRefillErrorCode::Processing);
            record.error_message = None;
        })
    }

    pub fn mark_completed(
        id: u64,
        cycles_sent: Nat,
        now_ns: u64,
    ) -> Result<IcpRefillRecord, InternalError> {
        update_record(id, now_ns, |record| {
            record.cycles_sent = Some(cycles_sent);
            record.status = IcpRefillStatus::Completed;
            clear_error(record);
        })
    }

    pub fn mark_refunded(
        id: u64,
        refund_block_index: Option<u64>,
        reason: String,
        now_ns: u64,
    ) -> Result<IcpRefillRecord, InternalError> {
        update_record(id, now_ns, |record| {
            record.refund_block_index = refund_block_index;
            record.status = IcpRefillStatus::Refunded;
            record.error_code = Some(IcpRefillErrorCode::Refunded);
            record.error_message = Some(truncate_error(reason));
        })
    }

    pub fn mark_invalid_transaction(
        id: u64,
        reason: String,
        now_ns: u64,
    ) -> Result<IcpRefillRecord, InternalError> {
        update_record(id, now_ns, |record| {
            record.status = IcpRefillStatus::InvalidTransaction;
            record.error_code = Some(IcpRefillErrorCode::InvalidTransaction);
            record.error_message = Some(truncate_error(reason));
        })
    }

    pub fn mark_transaction_too_old(
        id: u64,
        min_block_index: Option<u64>,
        now_ns: u64,
    ) -> Result<IcpRefillRecord, InternalError> {
        update_record(id, now_ns, |record| {
            record.transaction_too_old_min_block_index = min_block_index;
            record.status = IcpRefillStatus::TransactionTooOld;
            record.error_code = Some(IcpRefillErrorCode::TransactionTooOld);
            record.error_message = None;
        })
    }

    pub fn mark_transfer_window_stale(
        id: u64,
        now_ns: u64,
    ) -> Result<IcpRefillRecord, InternalError> {
        update_record(id, now_ns, |record| {
            record.status = IcpRefillStatus::Failed;
            record.error_code = Some(IcpRefillErrorCode::TransferWindowStale);
            record.error_message =
                Some("transfer retry window expired before ledger block was recorded".to_string());
        })
    }

    pub fn mark_notify_failed(
        id: u64,
        error_message: String,
        now_ns: u64,
    ) -> Result<IcpRefillRecord, InternalError> {
        update_record(id, now_ns, |record| {
            record.status = IcpRefillStatus::Failed;
            record.error_code = Some(IcpRefillErrorCode::NotifyFailed);
            record.error_message = Some(truncate_error(error_message));
        })
    }

    pub fn mark_notify_max_attempts(
        id: u64,
        error_message: String,
        now_ns: u64,
    ) -> Result<IcpRefillRecord, InternalError> {
        update_record(id, now_ns, |record| {
            record.status = IcpRefillStatus::Failed;
            record.error_code = Some(IcpRefillErrorCode::NotifyMaxAttempts);
            record.error_message = Some(truncate_error(error_message));
        })
    }

    #[must_use]
    pub fn to_response(record: &IcpRefillRecord) -> IcpRefillResponse {
        IcpRefillResponse {
            operation_id: record.operation_id,
            status: record.status,
            ledger_block_index: record.ledger_block_index,
            cycles_sent: record.cycles_sent.clone(),
            error_code: record.error_code,
            error_message: record.error_message.clone(),
        }
    }
}

fn next_id() -> Result<u64, IcpRefillRecordOpsError> {
    IcpRefillRecords::entries(0, usize::MAX)
        .into_iter()
        .map(|(key, _record)| key.0)
        .max()
        .unwrap_or(0)
        .checked_add(1)
        .ok_or(IcpRefillRecordOpsError::IdOverflow)
}

fn update_record(
    id: u64,
    now_ns: u64,
    update: impl FnOnce(&mut IcpRefillRecord),
) -> Result<IcpRefillRecord, InternalError> {
    let mut record =
        IcpRefillRecordOps::get(id).ok_or(IcpRefillRecordOpsError::RecordNotFound(id))?;
    update(&mut record);
    record.updated_at_ns = now_ns;
    IcpRefillRecordOps::insert(record.clone());
    Ok(record)
}

fn ensure_compatible_operation(
    existing: &IcpRefillRecord,
    input: &IcpRefillRecordCreateInput,
) -> Result<(), IcpRefillRecordOpsError> {
    if existing.source_canister == input.source_canister
        && existing.source_subaccount == input.source_subaccount
        && existing.target_canister == input.target_canister
        && existing.ledger_canister_id == input.ledger_canister_id
        && existing.cmc_canister_id == input.cmc_canister_id
        && existing.cmc_to_account_owner == input.cmc_to_account_owner
        && existing.cmc_to_account_subaccount == input.cmc_to_account_subaccount
        && existing.amount_e8s == input.amount_e8s
        && existing.fee_e8s == input.fee_e8s
        && existing.memo == input.memo
        && existing.created_at_time_ns == input.created_at_time_ns
    {
        return Ok(());
    }

    Err(IcpRefillRecordOpsError::OperationConflict { id: existing.id })
}

fn clear_error(record: &mut IcpRefillRecord) {
    record.error_code = None;
    record.error_message = None;
}

fn truncate_error(error: String) -> String {
    error.chars().take(ERROR_MESSAGE_MAX_CHARS).collect()
}