feat: DEFI-2671: schedule task for processing withdrawals#48
feat: DEFI-2671: schedule task for processing withdrawals#48maciejdfinity merged 54 commits intomainfrom
Conversation
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 19 out of 19 changed files in this pull request and generated 5 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| pub async fn process_pending_withdrawals<R: CanisterRuntime>(runtime: &R) { | ||
| log!(Priority::Info, "processing pending withdrawals"); | ||
|
|
There was a problem hiding this comment.
process_pending_withdrawals logs an Info message on every timer tick, even when there are no pending withdrawals. Given this runs every minute, it can generate sustained log volume; consider logging only when there is actual work (after the pending check), or lowering this to Debug.
There was a problem hiding this comment.
I removed this log.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 18 out of 18 changed files in this pull request and generated 4 comments.
Comments suppressed due to low confidence (2)
libs/types/src/lib.rs:214
- The
WithdrawSolStatus::TxSentdoc comment still implies the transaction has been sent to the network, but the minter now sets this status as soon as the transaction is signed (before any broadcast). Please align the public type documentation (or rename the variant) with the actual semantics, or delay settingTxSentuntil after submission succeeds.
/// Retrieve the status of a withdrawal request.
#[derive(Clone, Eq, PartialEq, Hash, Debug, CandidType, Deserialize)]
pub enum WithdrawSolStatus {
/// Withdrawal request is not found.
NotFound,
/// Withdrawal request is waiting to be processed.
Pending,
/// Solana transaction was signed and is sent to the network.
TxSent(SolTransaction),
/// Solana transaction is finalized.
TxFinalized(TxFinalizedStatus),
minter/src/main.rs:83
withdraw_sol(&IcCanisterRuntime::new(), ...)takes a reference to a temporary runtime and then awaits, which is likely to fail to compile with a "temporary value dropped while borrowed" error (the runtime reference must live across.await). Create a localIcCanisterRuntimebinding (or pass the runtime by value) and pass a reference to that instead.
cksol_minter::withdraw_sol::withdraw_sol(
&IcCanisterRuntime::new(),
minter_account,
ic_cdk::api::msg_caller(),
args.from_subaccount,
args.amount,
args.address,
)
.await
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
lpahlavi
left a comment
There was a problem hiding this comment.
Thanks for the changes @maciejdfinity! I missed an important point in the first round of reviews, but we should actually batch multiple withdrawals in a single transaction, see my comments below and the design document.
| }; | ||
|
|
||
| let Some(pending_requests) = | ||
| read_state(|state| state.next_pending_withdrawal_requests(MAX_WITHDRAWALS_PER_BATCH)) |
There was a problem hiding this comment.
Sorry, I wasn't clear enough. The thing is, we want to read the pending withdrawal requests from the state once, chunk it into batches, and submit a withdrawal transaction just once per pending withdrawal in a single timer execution. The problem with the current way is that if a specific withdrawal is problematic and e.g. we can't build a withdrawal transaction with it, we will keep fetching it from the state and the timer execution will never stop.
Instead, I would read all the pending withdrawal requests from the state once, chunk them into withdrawal batches, and submit them each exactly once.
| Ok(WithdrawSolOk { block_index }) | ||
| } | ||
|
|
||
| pub async fn process_pending_withdrawals<R: CanisterRuntime>(runtime: &R) { |
There was a problem hiding this comment.
Sorry, I missed this on the initial reviews, but this actually needs a bit of refactoring because we want to batch multiple withdrawals into a single transaction. So we should basically batch at two levels:
- we want to send withdrawal transactions with 10 withdrawals per transaction
- we want to make at most 10
submitTransactioncalls to the SOL RPC canister concurrently
You can have a look at monitor.rs in #66 where I cleaned things up a bit using itertools.
Also, note that we should fetch a new recent blockhash for every round of transactions we send out to make sure it doesn't expire in the meantime.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 17 out of 17 changed files in this pull request and generated 1 comment.
Comments suppressed due to low confidence (1)
minter/src/main.rs:82
withdraw_solis async and now takesruntime: &R, but this call borrows a temporary (&IcCanisterRuntime::new()). That reference will need to live across.await, which typically fails to compile (temporary value dropped while borrowed). Create a locallet runtime = IcCanisterRuntime::new();and pass&runtime, or change the API back to takingRby value.
cksol_minter::withdraw_sol::withdraw_sol(
&IcCanisterRuntime::new(),
minter_account,
ic_cdk::api::msg_caller(),
args.from_subaccount,
args.amount,
args.address,
)
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 18 out of 18 changed files in this pull request and generated 3 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| let sign_futures: Vec<_> = withdrawal_params | ||
| .iter() | ||
| .map(|(sources, destination)| { | ||
| create_signed_transfer_transaction( | ||
| minter_account, | ||
| sources, | ||
| *destination, | ||
| recent_blockhash, | ||
| &signer, | ||
| ) | ||
| }) | ||
| .collect(); | ||
|
|
||
| let results = futures::future::join_all(sign_futures).await; | ||
|
|
||
| for (request, result) in pending_requests.into_iter().zip(results) { |
There was a problem hiding this comment.
process_pending_withdrawals signs all pending withdrawals concurrently (join_all). Each create_signed_transfer_transaction calls lazy_get_schnorr_master_key().await, which can race on a fresh install/upgrade when the key is still None: multiple concurrent calls will all reach the schnorr_public_key().await and then the second set_once_minter_public_key will panic. To avoid a canister trap, initialize the master key once before spawning the signing futures (or refactor signing to accept a pre-fetched master key / make the key-setting idempotent).
| let sign_futures: Vec<_> = withdrawal_params | |
| .iter() | |
| .map(|(sources, destination)| { | |
| create_signed_transfer_transaction( | |
| minter_account, | |
| sources, | |
| *destination, | |
| recent_blockhash, | |
| &signer, | |
| ) | |
| }) | |
| .collect(); | |
| let results = futures::future::join_all(sign_futures).await; | |
| for (request, result) in pending_requests.into_iter().zip(results) { | |
| for (request, (sources, destination)) in | |
| pending_requests.into_iter().zip(withdrawal_params.into_iter()) | |
| { | |
| let result = create_signed_transfer_transaction( | |
| minter_account, | |
| &sources, | |
| destination, | |
| recent_blockhash, | |
| &signer, | |
| ) | |
| .await; |
| let transfer_amount = request | ||
| .withdrawal_amount | ||
| .checked_sub(request.withdrawal_fee) | ||
| .expect("BUG: withdrawal_amount must be >= withdrawal_fee"); | ||
| let sources = vec![(minter_account, transfer_amount)]; |
There was a problem hiding this comment.
checked_sub(...).expect(...) will trap the canister if a withdrawal request ever has withdrawal_amount < withdrawal_fee (e.g., due to a future config/validation change or corrupted state). Since this is processing code that runs in a timer, it would be safer to handle None by logging an error and skipping (or marking the request as invalid) rather than panicking.
| let len = d.array()?.ok_or_else(|| Error::message("expected definite-length array"))?; | ||
| let mut result = Vec::with_capacity(len as usize); | ||
| for _ in 0..len { | ||
| d.array()?; | ||
| let burn_index = LedgerBurnIndex::new(d.u64()?); | ||
| let sig_bytes = d.bytes()?; | ||
| let signature = | ||
| Signature::try_from(sig_bytes).map_err(|e| Error::message(e.to_string()))?; | ||
| result.push((burn_index, signature)); |
There was a problem hiding this comment.
burn_index_signature_vec::decode reads each inner tuple with d.array()? but doesn’t verify the tuple length (and doesn’t handle indefinite-length arrays). Since encode always writes an array of length 2, consider validating that the inner array length is exactly 2 (and/or consuming the break for indefinite arrays) to make decoding robust and fail with a clearer error on malformed data.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 18 out of 18 changed files in this pull request and generated 5 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| prop::collection::vec((arb_account(), any::<Lamport>()), 1..10) | ||
| .prop_map(|deposits| EventType::ConsolidatedDeposits { deposits }), | ||
| prop::collection::vec((arb_ledger_burn_index(), arb_signature()), 1..10) | ||
| .prop_map(|transactions| EventType::SentWithdrawalTransaction { transactions },), |
There was a problem hiding this comment.
The .prop_map closure has an extra trailing comma after the EventType::SentWithdrawalTransaction { transactions } expression, which changes the closure to return a 1-tuple and should fail to compile/typecheck. Remove the trailing comma so the closure returns EventType.
| .prop_map(|transactions| EventType::SentWithdrawalTransaction { transactions },), | |
| .prop_map(|transactions| EventType::SentWithdrawalTransaction { transactions }), |
| SentWithdrawalTransaction { | ||
| /// The burn block indices and corresponding transaction signatures. | ||
| #[cbor(n(0), with = "cbor::burn_index_signature_vec")] | ||
| transactions: Vec<(LedgerBurnIndex, Signature)>, |
There was a problem hiding this comment.
SentWithdrawalTransaction only records burn index + signature. Since a Solana signature is tied to the exact message/blockhash, without also persisting the signed transaction bytes/message (or at least enough data to deterministically reconstruct the exact transaction), the system cannot broadcast the already-signed transaction in a later timer tick/PR. Consider storing the full signed transaction (e.g., serialized Transaction/Message + required signer accounts) or reusing the existing SubmittedTransaction event shape for withdrawals.
| transactions: Vec<(LedgerBurnIndex, Signature)>, | |
| transactions: Vec<(LedgerBurnIndex, Signature)>, | |
| /// The serialized bytes of the signed Solana transactions, in the same | |
| /// order as the `transactions` field. When present, these bytes can be | |
| /// used to (re)broadcast the exact transaction(s) later. | |
| #[n(1)] | |
| signed_transactions: Option<Vec<Vec<u8>>>, |
| fn process_sent_withdrawal_transaction( | ||
| &mut self, | ||
| burn_block_index: &LedgerBurnIndex, | ||
| signature: &Signature, | ||
| ) { | ||
| assert!( | ||
| self.pending_withdrawal_requests | ||
| .remove(burn_block_index) | ||
| .is_some(), | ||
| "Attempted to send transaction for unknown withdrawal request: {:?}", | ||
| burn_block_index | ||
| ); | ||
| assert_eq!( | ||
| self.sent_withdrawal_requests | ||
| .insert(*burn_block_index, *signature), | ||
| None, | ||
| "Attempted to send transaction for already sent withdrawal request: {:?}", | ||
| burn_block_index | ||
| ); |
There was a problem hiding this comment.
process_sent_withdrawal_transaction removes the pending withdrawal request and only retains the signature. Given this PR explicitly does not broadcast transactions yet, this transition drops the information needed to later broadcast/retry the withdrawal (destination address, amount, fee, etc.) and effectively makes the withdrawal irrecoverable without replaying the audit log. Consider keeping the full request (and/or the signed transaction) in state until broadcast is successfully completed, and only then move it to a "sent/submitted" tracking structure.
| // Solana transaction was signed and is sent to the network. | ||
| TxSent : SolTransaction; | ||
|
|
There was a problem hiding this comment.
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.
| /// Transaction is not signed yet. | ||
| TxCreated, | ||
|
|
||
| /// Solana transaction was signed and is sent to the network. |
There was a problem hiding this comment.
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".
| /// Solana transaction was signed and is sent to the network. | |
| /// Solana transaction was signed and is ready to be sent to the network. |
lpahlavi
left a comment
There was a problem hiding this comment.
Thanks for the PR @maciejdfinity!
This PR adds pending withdrawal processing. It creates signed transactions for the withdrawals. The status of the withdrawal is changed to sent. The code to actually broadcast the transaction will be added in a follow up PR.