Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
54 commits
Select commit Hold shift + click to select a range
bcf34bf
feat: create scheduled task to consolidate deposit funds
lpahlavi Mar 11, 2026
e47b9e4
Schedule global timer for transaction consolidation
lpahlavi Mar 12, 2026
c842913
Merge branch 'main' into lpahlavi/consolidation-task
lpahlavi Mar 13, 2026
b55b8a2
Clippy
lpahlavi Mar 13, 2026
d787c74
schedule task for processing withdrawals
Mar 13, 2026
11329e9
clippy
Mar 13, 2026
3c9ab21
Merge branch 'main' into maciej-withdraw-send
Mar 13, 2026
6140a01
creating and signing the transaction
Mar 16, 2026
f6bf491
store the whole request
Mar 16, 2026
122cd02
remove txcreated
Mar 16, 2026
ac07f54
log errors
Mar 16, 2026
2b668af
Merge branch 'main' into maciej-withdraw-send
Mar 18, 2026
025f466
change create_signed_transfer_transaction target from Account to Address
Mar 18, 2026
4b936d5
canister_self in runtime
Mar 18, 2026
f7b8aa2
add block hash and slot stubs
Mar 18, 2026
9af949d
add schnorr signer to runtime
Mar 18, 2026
a788455
refactor MockSchnorrSigner into one implementation
Mar 18, 2026
94339b8
check events generated in the test
Mar 18, 2026
3ee8179
assert no events are recorded
Mar 18, 2026
aa0c0ac
test for failing hash calculation
Mar 18, 2026
7eb11f0
refactor
Mar 18, 2026
ac29698
add comment
Mar 18, 2026
6c576c8
check for status in tests
Mar 19, 2026
79fa9a4
test failed signing
Mar 19, 2026
1bdcc61
Merge branch 'main' into maciej-withdraw-send
Mar 19, 2026
a4ec853
up to max test
Mar 19, 2026
b921445
log info about guard, test
Mar 19, 2026
d3ea570
clippy
Mar 19, 2026
290a1ea
test if timer is started
Mar 19, 2026
0832d69
await master key
Mar 19, 2026
1cefdc3
Potential fix for pull request finding
maciejdfinity Mar 19, 2026
0e7644d
clippy
Mar 19, 2026
fcaac88
Potential fix for pull request finding
maciejdfinity Mar 19, 2026
865a179
clippy
Mar 19, 2026
8345a63
remove empty line and log statement
Mar 19, 2026
c07cc54
top level imports
Mar 19, 2026
4dc482e
sign transactions in parallel
Mar 19, 2026
27c930e
integration test for processing the withdrawal
Mar 20, 2026
500e445
Merge branch 'main' into maciej-withdraw-send
Mar 20, 2026
dcb8495
build fix
Mar 20, 2026
d2775ca
refactor event to contain only necessary fields
Mar 23, 2026
9450205
clippy
Mar 23, 2026
5a548dc
remove duplicate signer
Mar 24, 2026
61543a5
use constant for canister self
Mar 24, 2026
dc334d2
update comment
Mar 24, 2026
a908be2
Merge branch 'main' into maciej-withdraw-send
Mar 24, 2026
a0e3a37
remove chatty log, adapt guard log message
Mar 24, 2026
b0d0ad7
Merge branch 'main' into maciej-withdraw-send
Mar 25, 2026
25cabd2
use execute_http_mocks
Mar 25, 2026
6aa53e5
WITHDRAWAL_PROCESSING_DELAY
Mar 25, 2026
90d334e
use ready methods
Mar 25, 2026
db65a1f
change to vec of withdrawals
Mar 25, 2026
9aeca1e
remove next_pending_withdrawal_requests
Mar 25, 2026
cfcf254
clippy
Mar 25, 2026
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
76 changes: 75 additions & 1 deletion integration_tests/tests/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ use cksol_types::{
UpdateBalanceArgs, UpdateBalanceError, WithdrawSolArgs, WithdrawSolError, WithdrawSolStatus,
};
use cksol_types_internal::{UpgradeArgs, event::EventType, log::Priority};
use ic_pocket_canister_runtime::{JsonRpcResponse, MockHttpOutcalls, MockHttpOutcallsBuilder};
use ic_pocket_canister_runtime::{
JsonRpcRequestMatcher, JsonRpcResponse, MockHttpOutcalls, MockHttpOutcallsBuilder,
};
use icrc_ledger_types::icrc1::account::Subaccount;
use serde_json::json;
use sol_rpc_types::{CommitmentLevel, ConsensusStrategy, GetTransactionEncoding, RpcConfig};
Expand Down Expand Up @@ -209,6 +211,8 @@ mod withdraw_sol_tests {

use super::*;

const WITHDRAWAL_PROCESSING_DELAY: Duration = Duration::from_mins(1);

#[tokio::test]
async fn should_validate_solana_address() {
let setup = SetupBuilder::new().build().await;
Expand Down Expand Up @@ -556,6 +560,76 @@ mod withdraw_sol_tests {

setup.drop().await;
}

#[tokio::test]
Comment thread
lpahlavi marked this conversation as resolved.
async fn should_process_pending_withdrawals() {
const WITHDRAWAL_AMOUNT: u64 = 100_000_000;
const WITHDRAWAL_ADDRESS: &str = "E4MpwNnMWs2XtW5gVrxZvyS7fMq31QD5HvbxmwP45Tz3";

let setup = SetupBuilder::new()
.with_initial_ledger_balances(vec![(
DEFAULT_CALLER_ACCOUNT,
Nat::from(10 * WITHDRAWAL_AMOUNT),
)])
.build()
.await;

setup
.ledger()
.approve(
None,
WITHDRAWAL_AMOUNT,
Account {
owner: setup.minter_canister_id(),
subaccount: None,
},
)
.await;

let WithdrawSolOk { block_index } = setup
.minter()
.withdraw_sol(WithdrawSolArgs {
from_subaccount: None,
amount: WITHDRAWAL_AMOUNT,
address: WITHDRAWAL_ADDRESS.to_string(),
})
.await
.expect("withdraw_sol should succeed");

setup.advance_time(WITHDRAWAL_PROCESSING_DELAY).await;
setup
.execute_http_mocks(estimate_blockhash_http_mocks())
.await;

setup.minter().assert_that_events().await.satisfy(|events| {
check!(events.iter().any(|e| matches!(
e,
EventType::SentWithdrawalTransaction {
transactions,
..
} if transactions.iter().any(|(idx, _)| *idx == block_index)
)));
});

setup.drop().await;
}

fn estimate_blockhash_http_mocks() -> MockHttpOutcalls {
Comment thread
lpahlavi marked this conversation as resolved.
let mut builder = MockHttpOutcallsBuilder::new();
for id in 0..4u64 {
builder = builder
.given(JsonRpcRequestMatcher::with_method("getSlot").with_id(id))
.respond_with(get_slot_response(1).with_id(id))
}
for id in 4..8u64 {
builder = builder
.given(JsonRpcRequestMatcher::with_method("getBlock").with_id(id))
.respond_with(
get_block_response("4sGjMW1sUnHzSxGspuhpqLDx6wiyjNtZAMdL4VZHirAn").with_id(id),
)
}
builder.build()
}
}

mod update_balance_tests {
Expand Down
5 changes: 5 additions & 0 deletions libs/types-internal/src/event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,11 @@ pub enum EventType {
/// and the amount consolidated from each account.
deposits: Vec<(Account, Lamport)>,
},
/// A withdrawal transaction was signed and is ready to be sent to the network.
SentWithdrawalTransaction {
Comment thread
lpahlavi marked this conversation as resolved.
Comment thread
lpahlavi marked this conversation as resolved.
/// The burn block indices and corresponding transaction signatures.
transactions: Vec<(u64, Signature)>,
},
/// A previously submitted transaction was resubmitted with a new signature.
ResubmittedTransaction {
/// The signature of the old transaction being replaced.
Expand Down
5 changes: 5 additions & 0 deletions libs/types-internal/src/log.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ use std::{fmt, fmt::Formatter, str::FromStr};
/// The priority level of a log entry.
#[derive(LogPriorityLevels, Serialize, Deserialize, PartialEq, Debug, Copy, Clone)]
pub enum Priority {
/// Error log entries.
#[log_level(capacity = 1000, name = "ERROR")]
Error,
/// Informational log entries.
#[log_level(capacity = 1000, name = "INFO")]
Info,
Expand All @@ -26,6 +29,7 @@ impl FromStr for Priority {

fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"error" => Ok(Priority::Error),
"info" => Ok(Priority::Info),
"debug" => Ok(Priority::Debug),
_ => Err("could not recognize priority".to_string()),
Expand All @@ -36,6 +40,7 @@ impl FromStr for Priority {
impl fmt::Display for Priority {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
match self {
Priority::Error => write!(f, "ERROR"),
Priority::Info => write!(f, "INFO"),
Priority::Debug => write!(f, "DEBUG"),
}
Expand Down
4 changes: 0 additions & 4 deletions libs/types/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -207,10 +207,6 @@ pub enum WithdrawSolStatus {
/// Withdrawal request is waiting to be processed.
Pending,

Comment thread
maciejdfinity marked this conversation as resolved.
/// Transaction fees were estimated and a Solana transaction was created.
/// Transaction is not signed yet.
TxCreated,

/// Solana transaction was signed and is sent to the network.
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

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

The WithdrawSolStatus::TxSent docstring states the transaction "is sent to the network", but the minter currently sets this status right after signing while broadcast is deferred to a follow-up PR. To avoid misleading API consumers, update the docs/status model so TxSent only applies after successful broadcast, or add a separate status for "signed/ready".

Suggested change
/// Solana transaction was signed and is sent to the network.
/// Solana transaction was signed and is ready to be sent to the network.

Copilot uses AI. Check for mistakes.
TxSent(SolTransaction),

Expand Down
9 changes: 5 additions & 4 deletions minter/cksol-minter.did
Original file line number Diff line number Diff line change
Expand Up @@ -208,10 +208,6 @@ type WithdrawSolStatus = variant {
// Withdrawal request is waiting to be processed.
Pending;

Comment thread
maciejdfinity marked this conversation as resolved.
// Transaction fees were estimated and a Solana transaction was created.
// Transaction is not signed yet.
TxCreated;

// Solana transaction was signed and is sent to the network.
Comment thread
maciejdfinity marked this conversation as resolved.
TxSent : SolTransaction;

Comment on lines 211 to 213
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

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

The TxSent documentation says the transaction "is sent to the network", but in this PR withdrawals are marked as TxSent immediately after signing and before any broadcast is implemented (per PR description / TODOs). This makes the public status misleading; consider adjusting the docs and/or introducing a distinct status for "signed but not broadcast" and only using TxSent after successful submission.

Copilot uses AI. Check for mistakes.
Expand Down Expand Up @@ -299,6 +295,11 @@ type EventType = variant {
// and the amount consolidated from each account.
deposits: vec record { Account; Lamport };
};
// A withdrawal transaction was signed and is ready to be sent to the network.
SentWithdrawalTransaction : record {
// The burn block indices and corresponding transaction signatures.
transactions: vec record { nat64; Signature };
};
// A previously submitted transaction was resubmitted with a new signature.
ResubmittedTransaction : record {
// The signature of the old transaction being replaced.
Expand Down
9 changes: 8 additions & 1 deletion minter/src/consolidate/mod.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use crate::{
address::{derivation_path, derive_public_key, lazy_get_schnorr_master_key},
guard::TimerGuard,
runtime::CanisterRuntime,
sol_transfer::{CreateTransferError, MAX_SIGNATURES, create_signed_transfer_transaction},
Expand All @@ -9,6 +10,7 @@ use canlog::log;
use cksol_types_internal::log::Priority;
use icrc_ledger_types::icrc1::account::Account;
use sol_rpc_types::{Lamport, Slot};
use solana_address::Address;
use solana_hash::Hash;
use solana_signature::Signature;
use std::time::Duration;
Expand Down Expand Up @@ -101,10 +103,15 @@ async fn submit_consolidation_transaction<R: CanisterRuntime>(
owner: runtime.canister_self(),
subaccount: None,
};
let master_key = lazy_get_schnorr_master_key().await;
let minter_address = Address::from(
derive_public_key(&master_key, derivation_path(&minter_account)).serialize_raw(),
);

let (transaction, signers) = create_signed_transfer_transaction(
minter_account,
&funds_to_consolidate,
minter_account,
minter_address,
recent_blockhash,
&runtime.signer(),
)
Comment thread
maciejdfinity marked this conversation as resolved.
Expand Down
20 changes: 17 additions & 3 deletions 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::consolidate::{DEPOSIT_CONSOLIDATION_DELAY, consolidate_deposits};
use cksol_minter::monitor::{MONITOR_SUBMITTED_TRANSACTIONS_DELAY, monitor_submitted_transactions};
use cksol_minter::withdraw_sol::{WITHDRAWAL_PROCESSING_DELAY, process_pending_withdrawals};
use cksol_minter::{
address::lazy_get_schnorr_master_key, runtime::IcCanisterRuntime, state::read_state,
};
Expand Down Expand Up @@ -72,7 +73,7 @@ async fn withdraw_sol(args: WithdrawSolArgs) -> Result<WithdrawSolOk, WithdrawSo
let minter_account: Account = ic_cdk::api::canister_self().into();

cksol_minter::withdraw_sol::withdraw_sol(
IcCanisterRuntime::new(),
&IcCanisterRuntime::new(),
minter_account,
ic_cdk::api::msg_caller(),
args.from_subaccount,
Expand All @@ -83,8 +84,8 @@ async fn withdraw_sol(args: WithdrawSolArgs) -> Result<WithdrawSolOk, WithdrawSo
}

#[ic_cdk::update]
async fn withdraw_sol_status(block_index: u64) -> WithdrawSolStatus {
read_state(|s| s.withdrawal_status(block_index))
fn withdraw_sol_status(block_index: u64) -> WithdrawSolStatus {
cksol_minter::withdraw_sol::withdraw_sol_status(block_index)
}

#[ic_cdk::query]
Expand Down Expand Up @@ -153,6 +154,14 @@ fn get_events(
EventType::ConsolidatedDeposits { deposits } => {
event::EventType::ConsolidatedDeposits { deposits }
}
EventType::SentWithdrawalTransaction { transactions } => {
event::EventType::SentWithdrawalTransaction {
transactions: transactions
.iter()
.map(|(idx, sig)| (*idx.get(), sig.into()))
.collect(),
}
}
EventType::ResubmittedTransaction {
old_signature,
new_signature,
Expand Down Expand Up @@ -216,10 +225,12 @@ fn http_request(request: HttpRequest) -> HttpResponse {

match request.raw_query_param("priority").map(Priority::from_str) {
Some(Ok(priority)) => match priority {
Priority::Error => log.push_logs(Priority::Error),
Priority::Info => log.push_logs(Priority::Info),
Priority::Debug => log.push_logs(Priority::Debug),
},
Some(Err(_)) | None => {
log.push_logs(Priority::Error);
log.push_logs(Priority::Info);
log.push_logs(Priority::Debug);
}
Expand Down Expand Up @@ -277,6 +288,9 @@ fn setup_timers() {
ic_cdk_timers::set_timer_interval(DEPOSIT_CONSOLIDATION_DELAY, async || {
consolidate_deposits(IcCanisterRuntime::new()).await;
});
ic_cdk_timers::set_timer_interval(WITHDRAWAL_PROCESSING_DELAY, async || {
process_pending_withdrawals(&IcCanisterRuntime::new()).await;
Comment thread
lpahlavi marked this conversation as resolved.
Comment thread
lpahlavi marked this conversation as resolved.
});
ic_cdk_timers::set_timer_interval(MONITOR_SUBMITTED_TRANSACTIONS_DELAY, async || {
monitor_submitted_transactions(IcCanisterRuntime::new()).await;
});
Expand Down
7 changes: 3 additions & 4 deletions minter/src/sol_transfer/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,8 @@ pub enum CreateTransferError {
}

/// Creates a signed Solana transaction that transfers lamports from
/// each minter-controlled address (identified by its account) to the
/// destination account's derived address.
/// each minter-controlled address (identified by its account)
/// to `target_address` Solana address.
///
/// Returns the signed transaction and the list of signer accounts
/// (in signature order: fee payer first, then sources).
Expand All @@ -41,7 +41,7 @@ pub enum CreateTransferError {
pub async fn create_signed_transfer_transaction(
fee_payer_account: Account,
sources: &[(Account, Lamport)],
destination_account: Account,
target_address: Address,
recent_blockhash: Hash,
Comment on lines 41 to 45
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

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

The function-level documentation still describes transferring to a “destination account’s derived address”, but the API now takes a pre-derived Address (target_address). Please update the doc comment to match the new parameter and avoid implying that this function derives the destination address internally.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

done!

signer: &impl SchnorrSigner,
) -> Result<(Transaction, Vec<Account>), CreateTransferError> {
Expand All @@ -52,7 +52,6 @@ pub async fn create_signed_transfer_transaction(
(derivation_path, public_key.serialize_raw().into())
};

let (_, target_address) = derive_address(&destination_account);
let (fee_payer_derivation_path, fee_payer_address) = derive_address(&fee_payer_account);

let (source_derivation_paths, source_addresses): (Vec<_>, Vec<_>) = sources
Expand Down
32 changes: 21 additions & 11 deletions minter/src/sol_transfer/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ async fn should_create_signed_transaction_with_single_source() {
let (tx, signers) = create_signed_transfer_transaction(
source_account,
&[(source_account, amount)],
target_account,
target_address,
blockhash,
&signer,
)
Expand Down Expand Up @@ -100,7 +100,7 @@ async fn should_create_signed_transaction_with_multiple_sources() {
let (tx, signers) = create_signed_transfer_transaction(
account_1,
&[(account_1, amount), (account_2, amount)],
target_account,
derive_address(&target_account),
blockhash,
&signer,
)
Expand Down Expand Up @@ -155,7 +155,7 @@ async fn should_fail_when_signing_is_rejected() {
let result = create_signed_transfer_transaction(
source_account,
&[(source_account, 500_000_000)],
target_account,
derive_address(&target_account),
blockhash,
&signer,
)
Expand Down Expand Up @@ -191,7 +191,7 @@ async fn should_fail_when_second_signing_fails() {
let result = create_signed_transfer_transaction(
account_1,
&[(account_1, 100_000_000), (account_2, 100_000_000)],
target_account,
derive_address(&target_account),
blockhash,
&signer,
)
Expand Down Expand Up @@ -230,9 +230,14 @@ async fn should_fail_when_too_many_signatures() {
subaccount: None,
};

let result =
create_signed_transfer_transaction(fee_payer, &sources, target_account, blockhash, &signer)
.await;
let result = create_signed_transfer_transaction(
fee_payer,
&sources,
derive_address(&target_account),
blockhash,
&signer,
)
.await;

assert!(
matches!(result, Err(CreateTransferError::TooManySignatures { max: MAX_SIGNATURES, got }) if got == MAX_SIGNATURES + 1)
Expand Down Expand Up @@ -270,9 +275,14 @@ async fn should_not_fail_for_max_signatures() {

let signer = MockSchnorrSigner::with_signatures(vec![[0x11u8; 64]; MAX_SIGNATURES as usize]);

let result =
create_signed_transfer_transaction(fee_payer, &sources, target_account, blockhash, &signer)
.await;
let result = create_signed_transfer_transaction(
fee_payer,
&sources,
derive_address(&target_account),
blockhash,
&signer,
)
.await;

assert!(result.is_ok());
}
Expand Down Expand Up @@ -309,7 +319,7 @@ async fn should_create_signed_transaction_with_fee_payer() {
let (tx, signers) = create_signed_transfer_transaction(
fee_payer_account,
&sources,
target_account,
derive_address(&target_account),
blockhash,
&signer,
)
Expand Down
5 changes: 5 additions & 0 deletions minter/src/state/audit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,11 @@ fn apply_state_transition(state: &mut State, payload: &EventType) {
EventType::ConsolidatedDeposits { deposits } => {
state.process_consolidated_deposits(deposits);
}
EventType::SentWithdrawalTransaction { transactions } => {
for (burn_block_index, signature) in transactions {
state.process_sent_withdrawal_transaction(burn_block_index, signature);
}
}
EventType::ResubmittedTransaction {
old_signature,
new_signature,
Expand Down
Loading
Loading