diff --git a/minter/src/lifecycle.rs b/minter/src/lifecycle.rs index e4ad8f48..68cefdc1 100644 --- a/minter/src/lifecycle.rs +++ b/minter/src/lifecycle.rs @@ -6,11 +6,22 @@ use crate::{ event::EventType, init_once_state, mutate_state, }, - storage::{record_event, total_event_count, with_event_iter, with_unstable_metrics_mut}, + storage::{ + migrate_event_log, record_event, total_event_count, with_event_iter, + with_unstable_metrics_mut, + }, }; use canlog::log; use cksol_types_internal::{InitArgs, UpgradeArgs, log::Priority}; +/// One-time migration: converts legacy `AcceptedManualDeposit` events (CBOR index 2, +/// no `source` field) to `AcceptedDeposit { source: Manual }` (same CBOR index). +/// +/// Safe to call multiple times. Remove after the migration has been confirmed. +fn migrate_accepted_manual_deposit_events() { + migrate_event_log(); +} + pub fn init(init_args: InitArgs, runtime: R) { log!( Priority::Info, @@ -23,6 +34,7 @@ pub fn init(init_args: InitArgs, runtime: R) { pub fn post_upgrade(upgrade_args: Option, runtime: R) { let start = runtime.instruction_counter(); + migrate_accepted_manual_deposit_events(); init_once_state(with_event_iter(|events| replay_events(events))); if let Some(args) = upgrade_args { log!( diff --git a/minter/src/state/event.rs b/minter/src/state/event.rs index 0e25a919..686b5a4b 100644 --- a/minter/src/state/event.rs +++ b/minter/src/state/event.rs @@ -225,3 +225,111 @@ impl Storable for Event { const BOUND: Bound = Bound::Unbounded; } + +/// Legacy event type matching the CBOR encoding of events written before `AcceptedDeposit` +/// was introduced. Used only by the one-time migration in `post_upgrade`. +/// Remove after the migration has run. +#[derive(Decode, Encode)] +pub(crate) enum LegacyEventType { + #[n(0)] + Init(#[n(0)] InitArgs), + #[n(1)] + Upgrade(#[n(0)] UpgradeArgs), + /// Old form of `AcceptedDeposit` — no `source` field. + #[n(2)] + AcceptedManualDeposit { + #[n(0)] + deposit_id: DepositId, + #[n(1)] + deposit_amount: Lamport, + #[n(2)] + amount_to_mint: Lamport, + }, + #[n(3)] + QuarantinedDeposit(#[n(0)] DepositId), + #[n(4)] + Minted { + #[n(0)] + deposit_id: DepositId, + #[cbor(n(1), with = "cbor::id")] + mint_block_index: LedgerMintIndex, + }, + #[n(5)] + AcceptedWithdrawalRequest(#[n(0)] WithdrawalRequest), + #[n(6)] + SubmittedTransaction { + #[cbor(n(0), with = "cbor::signature")] + signature: Signature, + #[n(1)] + message: VersionedMessage, + #[n(2)] + signers: Vec, + #[n(3)] + slot: Slot, + #[n(4)] + purpose: TransactionPurpose, + }, + #[n(7)] + ResubmittedTransaction { + #[cbor(n(0), with = "cbor::signature")] + old_signature: Signature, + #[cbor(n(1), with = "cbor::signature")] + new_signature: Signature, + #[n(2)] + new_slot: Slot, + }, + #[n(8)] + SucceededTransaction { + #[cbor(n(0), with = "cbor::signature")] + signature: Signature, + }, + #[n(9)] + FailedTransaction { + #[cbor(n(0), with = "cbor::signature")] + signature: Signature, + }, + #[n(10)] + ExpiredTransaction { + #[cbor(n(0), with = "cbor::signature")] + signature: Signature, + }, + #[n(11)] + StartedMonitoringAccount { + #[n(0)] + account: Account, + }, + #[n(12)] + StoppedMonitoringAccount { + #[n(0)] + account: Account, + }, +} + +/// Legacy event wrapper for migration. See `LegacyEventType`. +/// Remove after the migration has run. +#[derive(Decode, Encode)] +pub(crate) struct LegacyEvent { + #[n(0)] + pub timestamp: u64, + #[n(1)] + pub payload: LegacyEventType, +} + +impl Storable for LegacyEvent { + fn to_bytes(&self) -> Cow<'_, [u8]> { + let mut buf = vec![]; + minicbor::encode(self, &mut buf).expect("event encoding should always succeed"); + Cow::Owned(buf) + } + + fn from_bytes(bytes: Cow<[u8]>) -> Self { + minicbor::decode(bytes.as_ref()).unwrap_or_else(|e| { + panic!( + "failed to decode legacy event bytes {}: {e}", + hex::encode(bytes) + ) + }) + } + + const BOUND: Bound = Bound::Unbounded; +} diff --git a/minter/src/storage/mod.rs b/minter/src/storage/mod.rs index d59d3093..c9c4af0d 100644 --- a/minter/src/storage/mod.rs +++ b/minter/src/storage/mod.rs @@ -1,6 +1,6 @@ use crate::{ runtime::CanisterRuntime, - state::event::{Event, EventType}, + state::event::{DepositSource, Event, EventType, LegacyEvent, LegacyEventType}, }; use ic_stable_structures::{ DefaultMemoryImpl, StableLog, @@ -13,6 +13,7 @@ const EVENT_LOG_DATA_MEMORY_ID: MemoryId = MemoryId::new(1); type VMem = VirtualMemory; type EventLog = StableLog; +type LegacyEventLog = StableLog; thread_local! { static MEMORY_MANAGER: RefCell> = RefCell::new( @@ -84,6 +85,108 @@ where EVENTS.with(|events| f(Box::new(events.borrow().iter()))) } +/// One-time migration: reads all stored events using `LegacyEvent` (which can decode +/// the old `AcceptedManualDeposit` format at CBOR index 2), converts them to the new +/// `Event` encoding, and rebuilds the stable log. +/// +/// Safe to call multiple times: events already in the new format decode correctly via +/// `LegacyEventType` because all non-`AcceptedManualDeposit` variants are identical. +/// +/// Remove after the migration has been confirmed on the deployed canister. +pub fn migrate_event_log() { + let migrated: Vec = MEMORY_MANAGER.with(|m| { + let legacy_log: LegacyEventLog = StableLog::init( + m.borrow().get(EVENT_LOG_INDEX_MEMORY_ID), + m.borrow().get(EVENT_LOG_DATA_MEMORY_ID), + ) + .expect("failed to init legacy event log for migration"); + legacy_log.iter().map(legacy_event_to_event).collect() + }); + + MEMORY_MANAGER.with(|m| { + EVENTS.with(|events| { + let new_log = StableLog::new( + m.borrow().get(EVENT_LOG_INDEX_MEMORY_ID), + m.borrow().get(EVENT_LOG_DATA_MEMORY_ID), + ); + for event in migrated { + new_log + .append(&event) + .expect("event migration should succeed"); + } + *events.borrow_mut() = new_log; + }); + }); +} + +fn legacy_event_to_event(legacy: LegacyEvent) -> Event { + Event { + timestamp: legacy.timestamp, + payload: match legacy.payload { + LegacyEventType::Init(args) => EventType::Init(args), + LegacyEventType::Upgrade(args) => EventType::Upgrade(args), + LegacyEventType::AcceptedManualDeposit { + deposit_id, + deposit_amount, + amount_to_mint, + } => EventType::AcceptedDeposit { + deposit_id, + deposit_amount, + amount_to_mint, + source: DepositSource::Manual, + }, + LegacyEventType::QuarantinedDeposit(id) => EventType::QuarantinedDeposit(id), + LegacyEventType::Minted { + deposit_id, + mint_block_index, + } => EventType::Minted { + deposit_id, + mint_block_index, + }, + LegacyEventType::AcceptedWithdrawalRequest(r) => { + EventType::AcceptedWithdrawalRequest(r) + } + LegacyEventType::SubmittedTransaction { + signature, + message, + signers, + slot, + purpose, + } => EventType::SubmittedTransaction { + signature, + message, + signers, + slot, + purpose, + }, + LegacyEventType::ResubmittedTransaction { + old_signature, + new_signature, + new_slot, + } => EventType::ResubmittedTransaction { + old_signature, + new_signature, + new_slot, + }, + LegacyEventType::SucceededTransaction { signature } => { + EventType::SucceededTransaction { signature } + } + LegacyEventType::FailedTransaction { signature } => { + EventType::FailedTransaction { signature } + } + LegacyEventType::ExpiredTransaction { signature } => { + EventType::ExpiredTransaction { signature } + } + LegacyEventType::StartedMonitoringAccount { account } => { + EventType::StartedMonitoringAccount { account } + } + LegacyEventType::StoppedMonitoringAccount { account } => { + EventType::StoppedMonitoringAccount { account } + } + }, + } +} + #[cfg(any(test, feature = "canbench-rs"))] pub(crate) fn reset_events() { MEMORY_MANAGER.with(|m| { @@ -95,3 +198,132 @@ pub(crate) fn reset_events() { }); }); } + +/// Writes legacy-format events to the stable log for testing the migration. +#[cfg(test)] +pub(crate) fn write_legacy_events_for_test(events: Vec) { + MEMORY_MANAGER.with(|m| { + let log: LegacyEventLog = StableLog::new( + m.borrow().get(EVENT_LOG_INDEX_MEMORY_ID), + m.borrow().get(EVENT_LOG_DATA_MEMORY_ID), + ); + for event in events { + log.append(&event) + .expect("writing legacy event should succeed"); + } + EVENTS.with(|ev| { + *ev.borrow_mut() = StableLog::init( + m.borrow().get(EVENT_LOG_INDEX_MEMORY_ID), + m.borrow().get(EVENT_LOG_DATA_MEMORY_ID), + ) + .expect("failed to re-init EVENTS after writing legacy events"); + }); + }); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::state::event::{DepositId, DepositSource, LegacyEvent, LegacyEventType}; + use candid::Principal; + use icrc_ledger_types::icrc1::account::Account; + use solana_signature::Signature; + + #[test] + fn migrate_event_log_converts_accepted_manual_deposit() { + reset_events(); + + let deposit_id = DepositId { + signature: Signature::default(), + account: Account { + owner: Principal::anonymous(), + subaccount: None, + }, + }; + + write_legacy_events_for_test(vec![ + LegacyEvent { + timestamp: 100, + payload: LegacyEventType::AcceptedManualDeposit { + deposit_id, + deposit_amount: 2_000_000, + amount_to_mint: 1_800_000, + }, + }, + LegacyEvent { + timestamp: 200, + payload: LegacyEventType::QuarantinedDeposit(DepositId { + signature: Signature::default(), + account: Account { + owner: Principal::anonymous(), + subaccount: None, + }, + }), + }, + ]); + + migrate_event_log(); + + let events: Vec = with_event_iter(|iter| iter.collect()); + + assert_eq!(events.len(), 2); + + assert_eq!(events[0].timestamp, 100); + assert!( + matches!( + &events[0].payload, + EventType::AcceptedDeposit { + deposit_amount, + amount_to_mint, + source: DepositSource::Manual, + .. + } + if *deposit_amount == 2_000_000 && *amount_to_mint == 1_800_000 + ), + "expected AcceptedDeposit with source=Manual, got {:?}", + events[0].payload + ); + + assert_eq!(events[1].timestamp, 200); + assert!(matches!( + &events[1].payload, + EventType::QuarantinedDeposit(_) + )); + } + + #[test] + fn migrate_event_log_is_idempotent() { + reset_events(); + + let deposit_id = DepositId { + signature: Signature::default(), + account: Account { + owner: Principal::anonymous(), + subaccount: None, + }, + }; + + write_legacy_events_for_test(vec![LegacyEvent { + timestamp: 42, + payload: LegacyEventType::AcceptedManualDeposit { + deposit_id, + deposit_amount: 1_000_000, + amount_to_mint: 900_000, + }, + }]); + + // Run migration twice — second run should be a no-op + migrate_event_log(); + migrate_event_log(); + + let events: Vec = with_event_iter(|iter| iter.collect()); + assert_eq!(events.len(), 1); + assert!(matches!( + &events[0].payload, + EventType::AcceptedDeposit { + source: DepositSource::Manual, + .. + } + )); + } +}