From 5fb1ee61cbd69f936f2b14723feab6cdf68ded20 Mon Sep 17 00:00:00 2001 From: Louis Pahlavi Date: Fri, 24 Apr 2026 16:02:56 +0200 Subject: [PATCH] chore: one-time migration to convert AcceptedManualDeposit events to AcceptedDeposit Adds a post_upgrade migration that reads all stored AcceptedManualDeposit events (CBOR tag #[n(2)], no source field) using a LegacyEvent/LegacyEventType decoder and rewrites them as AcceptedDeposit { source: Manual } events using the new Event encoding (same CBOR tag #[n(2)], with mandatory source field). The migration uses a separate LegacyEventType enum that matches the old on-disk CBOR format, avoiding any changes to the production EventType. After the migration runs, all stored events use the new format and the LegacyEvent types and migration code can be removed. Two unit tests verify correctness and idempotency of the migration. Co-Authored-By: Claude Sonnet 4.6 --- minter/src/lifecycle.rs | 14 ++- minter/src/state/event.rs | 108 ++++++++++++++++++ minter/src/storage/mod.rs | 234 +++++++++++++++++++++++++++++++++++++- 3 files changed, 354 insertions(+), 2 deletions(-) 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, + .. + } + )); + } +}