Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions integration_tests/src/fixtures.rs
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,11 @@ impl MockBuilder {
get_signatures_for_address_response(signatures),
)
}

/// Mock for `getBalance` returning the given lamport balance.
pub fn get_balance(self, balance: Lamport) -> Self {
self.expect(get_balance_request(), get_balance_response(balance))
}
}

// ── JSON-RPC request matchers and response builders ─────────────────────────
Expand Down Expand Up @@ -387,3 +392,18 @@ fn get_signatures_for_address_response(signatures: Vec<serde_json::Value>) -> Js
"id": 1
}))
}

fn get_balance_request() -> JsonRpcRequestMatcher {
JsonRpcRequestMatcher::with_method("getBalance")
}

fn get_balance_response(balance: Lamport) -> JsonRpcResponse {
JsonRpcResponse::from(json!({
"jsonrpc": "2.0",
"result": {
"context": { "slot": 350_000_000_u64, "apiVersion": "2.1.9" },
"value": balance,
},
"id": 1
}))
}
56 changes: 56 additions & 0 deletions integration_tests/tests/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ const FINALIZE_TRANSACTIONS_DELAY: Duration = Duration::from_mins(2);
const RESUBMIT_TRANSACTIONS_DELAY: Duration = Duration::from_mins(3);
const DEPOSIT_CONSOLIDATION_DELAY: Duration = Duration::from_mins(10);
const POLL_MONITORED_ADDRESSES_DELAY: Duration = Duration::from_mins(1);
const REFRESH_REAL_BALANCE_DELAY: Duration = Duration::from_secs(24 * 60 * 60);

/// Deposits funds into the minter via `process_deposit`, consolidates them,
/// and finalizes the consolidation so the minter's internal balance is credited.
Expand Down Expand Up @@ -1289,6 +1290,61 @@ mod metrics_tests {
.drop()
.await;
}

#[tokio::test]
async fn should_report_balance_discrepancy_on_install_and_after_daily_refresh() {
const INITIAL_BALANCE: Lamport = 12_345_678;
const NEXT_BALANCE: Lamport = 99_999_999;

let setup = SetupBuilder::new().build().await;

// Until the install-time refresh completes, both gauges report zero.
let setup = setup
.check_metrics()
.await
.assert_contains_metric_matching(r"minter_balance_discrepancy_lamports 0 \d+")
.assert_contains_metric_matching(
r"minter_balance_discrepancy_last_refresh_timestamp_seconds 0 \d+",
)
.into();

// The minter schedules an immediate refresh on install. Respond to the
// resulting `getBalance` outcall and check the gauges update.
setup
.execute_http_mocks(MockBuilder::new().get_balance(INITIAL_BALANCE).build())
.await;

let setup = setup
.check_metrics()
.await
.assert_contains_metric_matching(format!(
r"minter_balance_discrepancy_lamports {INITIAL_BALANCE} \d+"
))
.assert_contains_metric_matching(
r"minter_balance_discrepancy_last_refresh_timestamp_seconds [1-9]\d* \d+",
)
.into();

// Advance time past the daily interval; the periodic refresh runs again.
setup.advance_time(REFRESH_REAL_BALANCE_DELAY).await;
setup
.execute_http_mocks(
MockBuilder::with_start_id(4)
.get_balance(NEXT_BALANCE)
.build(),
)
.await;

setup
.check_metrics()
.await
.assert_contains_metric_matching(format!(
r"minter_balance_discrepancy_lamports {NEXT_BALANCE} \d+"
))
.into()
.drop()
.await;
}
}

mod automated_deposit_flow_tests {
Expand Down
47 changes: 47 additions & 0 deletions minter/src/balance_check.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
use crate::{
address::{lazy_get_schnorr_master_key, minter_address},
guard::TimerGuard,
rpc::get_balance,
runtime::CanisterRuntime,
state::{TaskType, mutate_state},
};
use canlog::log;
use cksol_types_internal::log::Priority;
use std::time::Duration;

pub const REFRESH_REAL_BALANCE_DELAY: Duration = Duration::from_secs(24 * 60 * 60);

/// Refresh the cached on-chain balance of the minter's main account.
Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

refresh_real_balance does not actually cache/store the real on-chain balance; it only records the discrepancy (real_balance - tracked_balance) via record_balance_discrepancy. Either adjust this docstring to describe what is stored, or extend the state to also persist the observed real balance if that’s the intended behavior.

Suggested change
/// Refresh the cached on-chain balance of the minter's main account.
/// Refresh the observed on-chain balance of the minter's main account and
/// record its discrepancy relative to the tracked balance.

Copilot uses AI. Check for mistakes.
/// Each call makes an RPC request to the Solana network, so this runs at most
/// once per day (see [`REFRESH_REAL_BALANCE_DELAY`]).
Comment on lines +15 to +16
Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The function docs say this runs "at most once per day", but the canister also calls refresh_real_balance immediately on install/upgrade (in addition to the 24h interval). Please adjust the wording to avoid implying a strict daily maximum.

Suggested change
/// Each call makes an RPC request to the Solana network, so this runs at most
/// once per day (see [`REFRESH_REAL_BALANCE_DELAY`]).
/// Each call makes an RPC request to the Solana network, so periodic refreshes
/// are scheduled with a 24-hour delay (see [`REFRESH_REAL_BALANCE_DELAY`]).

Copilot uses AI. Check for mistakes.
pub async fn refresh_real_balance<R: CanisterRuntime>(runtime: R) {
let _guard = match TimerGuard::new(TaskType::RefreshRealBalance) {
Ok(guard) => guard,
Err(_) => return,
};

let master_key = lazy_get_schnorr_master_key(&runtime).await;
let address = minter_address(&master_key, &runtime);

match get_balance(&runtime, address).await {
Ok(balance) => {
let observed_at = runtime.time();
let diff = mutate_state(|s| {
s.record_balance_discrepancy(balance, observed_at);
s.last_balance_discrepancy()
.expect("BUG: just-recorded discrepancy must be present")
.diff_lamports
});
Comment on lines +17 to +34
Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New behavior here (daily balance RPC + state update) is currently untested. Please add unit tests covering: (1) successful refresh records last_balance_discrepancy using the runtime timestamp, (2) RPC error leaves last_balance_discrepancy unchanged, and (3) TimerGuard prevents concurrent refreshes.

Copilot uses AI. Check for mistakes.
log!(
Priority::Info,
"Refreshed real balance for minter {address}: {balance} lamports (discrepancy vs. tracked: {diff} lamports)"
);
}
Err(e) => {
log!(
Priority::Info,
"Failed to refresh real balance for minter {address}: {e}"
);
}
}
}
1 change: 1 addition & 0 deletions minter/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
pub mod address;
pub mod balance_check;
pub mod consolidate;
mod constants;
mod cycles;
Expand Down
10 changes: 9 additions & 1 deletion minter/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ use candid::Principal;
use canlog::{Log, Sort};
use cksol_minter::{
address::lazy_get_schnorr_master_key,
balance_check::{REFRESH_REAL_BALANCE_DELAY, refresh_real_balance},
consolidate::{DEPOSIT_CONSOLIDATION_DELAY, consolidate_deposits},
deposit::automatic::{POLL_MONITORED_ADDRESSES_DELAY, poll_monitored_addresses},
monitor::{
Expand Down Expand Up @@ -351,9 +352,13 @@ fn assert_non_anonymous_account(

fn setup_timers() {
ic_cdk_timers::set_timer(Duration::from_secs(0), async {
// Initialize the minter's Ed25519 public key
// Initialize the minter's Ed25519 public key, then run the first balance
// refresh so the discrepancy metric is populated without waiting for the
// daily interval. Sequencing them in a single timer avoids racing
// `set_once_minter_public_key`.
let runtime = IcCanisterRuntime::new();
let _ = lazy_get_schnorr_master_key(&runtime).await;
refresh_real_balance(runtime).await;
});
ic_cdk_timers::set_timer_interval(DEPOSIT_CONSOLIDATION_DELAY, async || {
consolidate_deposits(IcCanisterRuntime::new()).await;
Expand All @@ -370,6 +375,9 @@ fn setup_timers() {
ic_cdk_timers::set_timer_interval(POLL_MONITORED_ADDRESSES_DELAY, async || {
poll_monitored_addresses(IcCanisterRuntime::new()).await;
});
ic_cdk_timers::set_timer_interval(REFRESH_REAL_BALANCE_DELAY, async || {
refresh_real_balance(IcCanisterRuntime::new()).await;
});
}

fn main() {}
Expand Down
17 changes: 17 additions & 0 deletions minter/src/metrics.rs
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,23 @@ pub fn encode_metrics(w: &mut MetricsEncoder<Vec<u8>>, s: &State) -> std::io::Re
s.balance().metric_value(),
"Minter balance in Lamports.",
)?;
let (discrepancy_lamports, discrepancy_refresh_seconds) = match s.last_balance_discrepancy() {
Some(observation) => (
observation.diff_lamports as f64,
(observation.observed_at / 1_000_000_000) as f64,
),
None => (0.0, 0.0),
};
w.encode_gauge(
"minter_balance_discrepancy_lamports",
discrepancy_lamports,
"Difference (real on-chain balance minus tracked balance) of the minter's main account at the time of the last successful refresh, in lamports. Both sides are sampled together; refreshed once per day. Reports 0 until the first refresh succeeds.",
Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The metric help text says the discrepancy is "refreshed once per day", but the implementation also refreshes once shortly after install/upgrade. Consider updating the description so dashboards/users understand the extra initial refresh behavior.

Suggested change
"Difference (real on-chain balance minus tracked balance) of the minter's main account at the time of the last successful refresh, in lamports. Both sides are sampled together; refreshed once per day. Reports 0 until the first refresh succeeds.",
"Difference (real on-chain balance minus tracked balance) of the minter's main account at the time of the last successful refresh, in lamports. Both sides are sampled together; refreshed shortly after install/upgrade and then once per day. Reports 0 until the first refresh succeeds.",

Copilot uses AI. Check for mistakes.
)?;
w.encode_gauge(
"minter_balance_discrepancy_last_refresh_timestamp_seconds",
discrepancy_refresh_seconds,
"Unix timestamp (seconds) of the last successful computation of `minter_balance_discrepancy_lamports`. Reports 0 until the first refresh succeeds.",
)?;
w.encode_gauge(
"post_upgrade_instructions_consumed",
storage::with_unstable_metrics(|m| m.post_upgrade_instructions_consumed).metric_value(),
Expand Down
35 changes: 33 additions & 2 deletions minter/src/rpc/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,10 @@ use cksol_types::ProcessDepositError;
use derive_more::From;
use ic_canister_runtime::IcError;
use sol_rpc_types::{
CommitmentLevel, ConfirmedTransactionStatusWithSignature, GetSignaturesForAddressParams,
GetTransactionEncoding, MultiRpcResult, RpcError, Slot,
CommitmentLevel, ConfirmedTransactionStatusWithSignature, GetBalanceParams,
GetSignaturesForAddressParams, GetTransactionEncoding, Lamport, MultiRpcResult, RpcError, Slot,
};
use solana_address::Address;
use solana_hash::Hash;
use solana_signature::Signature;
use solana_transaction::Transaction;
Expand Down Expand Up @@ -162,3 +163,33 @@ pub enum GetSignaturesForAddressError {
#[error("Inconsistent RPC results for getSignaturesForAddress")]
InconsistentRpcResults,
}

pub async fn get_balance<R: CanisterRuntime>(
runtime: &R,
address: Address,
) -> Result<Lamport, GetBalanceError> {
let client = read_state(|state| state.sol_rpc_client(runtime.inter_canister_call_runtime()));
let result = client
.get_balance(GetBalanceParams {
pubkey: address.into(),
commitment: Some(CommitmentLevel::Finalized),
Comment on lines +167 to +175
Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

get_balance is a new RPC wrapper but minter/src/rpc/tests.rs doesn’t cover it yet (other RPC helpers in this module are tested). Please add tests for the Consistent(Ok), Consistent(Err), and Inconsistent cases to ensure error mapping stays correct.

Copilot uses AI. Check for mistakes.
min_context_slot: None,
})
.try_send()
.await;
match result? {
MultiRpcResult::Consistent(Ok(balance)) => Ok(balance),
MultiRpcResult::Consistent(Err(e)) => Err(GetBalanceError::RpcError(e)),
MultiRpcResult::Inconsistent(_) => Err(GetBalanceError::InconsistentRpcResults),
}
}

#[derive(Debug, PartialEq, Error)]
pub enum GetBalanceError {
#[error("Error while calling SOL RPC canister: {0}")]
IcError(#[from] IcError),
#[error("RPC error while fetching balance: {0}")]
RpcError(RpcError),
#[error("Inconsistent RPC results for getBalance")]
InconsistentRpcResults,
}
32 changes: 32 additions & 0 deletions minter/src/state/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,22 @@ pub struct State {
consolidation_transactions: InsertionOrderedMap<Signature, ConsolidationTransaction>,
active_tasks: BTreeSet<TaskType>,
balance: Lamport,
/// Last observed difference (`real_balance - tracked_balance`) between the
/// real on-chain balance of the minter's main account and the value
/// tracked by the minter's state, together with the timestamp
/// (nanoseconds since the Unix epoch) at which it was computed.
///
/// Both sides of the difference are sampled at the same time so that the
/// stored value remains meaningful even though `tracked_balance` keeps
/// changing afterwards. Refreshed once per day by a timer; `None` until
/// the first refresh succeeds.
Comment on lines +119 to +120
Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The doc comment says this observation is "Refreshed once per day", but setup_timers also triggers an immediate refresh on install/upgrade. Update the comment to reflect the actual refresh schedule (e.g., initial refresh shortly after install/upgrade, then daily).

Suggested change
/// changing afterwards. Refreshed once per day by a timer; `None` until
/// the first refresh succeeds.
/// changing afterwards. Refreshed shortly after install/upgrade, and then
/// once per day by a timer; `None` until the first refresh succeeds.

Copilot uses AI. Check for mistakes.
last_balance_discrepancy: Option<BalanceDiscrepancyObservation>,
}

#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub struct BalanceDiscrepancyObservation {
pub diff_lamports: i128,
pub observed_at: u64,
}

impl State {
Expand Down Expand Up @@ -246,6 +262,20 @@ impl State {
self.balance
}

pub fn last_balance_discrepancy(&self) -> Option<BalanceDiscrepancyObservation> {
self.last_balance_discrepancy
}

/// Records the difference between `real_balance` (just observed on-chain)
/// and the tracked balance, sampled at `observed_at`.
pub fn record_balance_discrepancy(&mut self, real_balance: Lamport, observed_at: u64) {
let diff_lamports = real_balance as i128 - self.balance as i128;
self.last_balance_discrepancy = Some(BalanceDiscrepancyObservation {
diff_lamports,
observed_at,
});
}

pub fn monitored_accounts(&self) -> &InsertionOrderedSet<Account> {
&self.monitored_accounts
}
Expand Down Expand Up @@ -796,6 +826,7 @@ impl TryFrom<InitArgs> for State {
consolidation_transactions: InsertionOrderedMap::new(),
active_tasks: BTreeSet::new(),
balance: 0,
last_balance_discrepancy: None,
};
state.validate()?;
Ok(state)
Expand Down Expand Up @@ -843,6 +874,7 @@ pub enum TaskType {
ResubmitTransactions,
WithdrawalProcessing,
PollMonitoredAddresses,
RefreshRealBalance,
}

/// Details about a consolidation transaction, capturing the individual
Expand Down
1 change: 1 addition & 0 deletions minter/src/state/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,7 @@ mod state_from_init_args {
consolidation_transactions: InsertionOrderedMap::new(),
active_tasks: BTreeSet::new(),
balance: 0,
last_balance_discrepancy: None,
}
);
}
Expand Down
Loading