diff --git a/docs/deployment.md b/docs/deployment.md new file mode 100644 index 00000000..4e3966d1 --- /dev/null +++ b/docs/deployment.md @@ -0,0 +1,370 @@ +# Deployment + +## Minter's address on Solana + +Given a canister ID, the address controlled by the minter can be derived offline without installing any code. +See `should_derive_mainnet_minter_addresses_offline` for an example. + +| Environment | Canister ID | Solana address | +|-------------|--------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------| +| Production | [`lh22c-kyaaa-aaaar-qb5nq-cai`](https://dashboard.internetcomputer.org/canister/lh22c-kyaaa-aaaar-qb5nq-cai) | [`GXVewvv6HehcLFYCmwpqh5CrMjqN9zJ8rqzGq3HEt9Ax`](https://explorer.solana.com/address/GXVewvv6HehcLFYCmwpqh5CrMjqN9zJ8rqzGq3HEt9Ax) | +| Staging | [`ljyxk-riaaa-aaaar-qb5mq-cai`](https://dashboard.internetcomputer.org/canister/ljyxk-riaaa-aaaar-qb5mq-cai) | [`Br8eRkeya8hy3sHCYqtWqGeNNZ349aPSUKGVYFWKKer1`](https://explorer.solana.com/address/Br8eRkeya8hy3sHCYqtWqGeNNZ349aPSUKGVYFWKKer1?cluster=devnet) | + +## Create nonce accounts + +Withdrawals are sent as durable-nonce transactions, so the minter needs a pool of durable nonce accounts whose nonce authority is the minter's address. +Without them, `withdraw_sol` returns `TemporarilyUnavailable("The durable nonce account pool is empty, no withdrawal can be processed")`. + +The commands below use the [Solana CLI](https://solana.com/docs/intro/installation) and the staging minter on Devnet. +For production, use the production minter's address and `--url mainnet-beta`. + +Fund a fee payer that pays for the rent of the nonce accounts: + +```shell +solana-keygen new --no-bip39-passphrase -o ~/.config/solana/devnet-payer.json +solana airdrop 1 --keypair ~/.config/solana/devnet-payer.json --url devnet +``` + +Create the nonce accounts with the minter as nonce authority: + +```shell +MINTER_ADDRESS=Br8eRkeya8hy3sHCYqtWqGeNNZ349aPSUKGVYFWKKer1 +mkdir -p nonce-accounts +for i in 1 2 3; do + solana-keygen new --no-bip39-passphrase --silent -o nonce-accounts/nonce-$i.json + solana create-nonce-account nonce-accounts/nonce-$i.json 0.002 \ + --nonce-authority $MINTER_ADDRESS \ + --keypair ~/.config/solana/devnet-payer.json \ + --url devnet +done +``` + +Each account is funded with 0.002 SOL, above the rent-exempt minimum for the 80 bytes of a nonce account (see `solana rent 80`). +The `nonce-$i.json` keypairs are only needed to create the accounts: afterwards, only the nonce authority can advance the nonce or withdraw from the account. +Each in-flight withdrawal transaction occupies one nonce account until it is finalized, and a transaction batches up to 10 withdrawals. + +Check that the nonce authority of each account is the minter's address: + +```shell +for i in 1 2 3; do + ADDRESS=$(solana-keygen pubkey nonce-accounts/nonce-$i.json) + echo $ADDRESS + solana nonce-account $ADDRESS --url devnet +done +``` + +The minter only parses the addresses when they are added and verifies each account when it creates a withdrawal transaction, so a wrong nonce authority only surfaces once a withdrawal is processed. + +Add the accounts to the minter, either in `nonce_accounts` of the [initialization arguments](#minter) or with an upgrade: + +```candid +( + variant { + Upgrade = record { + nonce_accounts_to_add = opt vec { ""; ""; "" }; + } + }, +) +``` + +The other fields of `UpgradeArgs` are optional and can be omitted. + +## Minter + +Initialization arguments: + + + + + + + + + + +
ProductionStaging
+ +```candid +( + variant { + Init = record { + sol_rpc_canister_id = principal "tghme-zyaaa-aaaar-qarca-cai"; + ledger_canister_id = principal "ls5lp-lqaaa-aaaar-qb5oa-cai"; + master_key_name = variant { MainnetProdKey1 }; + solana_network = variant { Mainnet }; + deposit_sol_fee = 45_000_000_000 : nat64; + deposit_sol_required_cycles = 1_000_000_000_000 : nat64; + minimum_deposit_amount = 20_000_000 : nat64; + withdrawal_fee = 1_000_000 : nat64; + minimum_withdrawal_amount = 2_000_000 : nat64; + nonce_accounts = vec {}; + } + }, +) +``` + + + +```candid +( + variant { + Init = record { + sol_rpc_canister_id = principal "tghme-zyaaa-aaaar-qarca-cai"; + ledger_canister_id = principal "la34w-haaaa-aaaar-qb5na-cai"; + master_key_name = variant { MainnetProdKey1 }; + solana_network = variant { Devnet }; + deposit_sol_fee = 45_000_000_000 : nat64; + deposit_sol_required_cycles = 1_000_000_000_000 : nat64; + minimum_deposit_amount = 20_000_000 : nat64; + withdrawal_fee = 1_000_000 : nat64; + minimum_withdrawal_amount = 2_000_000 : nat64; + nonce_accounts = vec {}; + } + }, +) +``` + +
+ +The fees and minimum amounts follow [Section 3.3 of the design](design.md#33-fees--minimum-swap-amounts): + +| Argument | Value | Rationale | +|-------------------------------|-----------------------|-------------------------------------------------------------------------------------------------| +| `deposit_sol_fee` | 45B cycles | Covers the threshold signature and the RPC calls of a sweep containing a single deposit. | +| `deposit_sol_required_cycles` | 1T cycles | Must be at least `GET_BALANCE_CYCLES` (10B) plus `deposit_sol_fee`; unused cycles are refunded. | +| `minimum_deposit_amount` | 0.02 SOL (20,000,000) | Must be at least twice the rent exemption threshold plus the fee of one signature. | +| `withdrawal_fee` | 0.001 SOL (1,000,000) | Covers `getAccountInfo`, `sendTransaction`, `getTransaction` and the threshold signature. | +| `minimum_withdrawal_amount` | 0.002 SOL (2,000,000) | Must be at least the withdrawal fee plus the rent exemption threshold. | +| `nonce_accounts` | empty | Replace with the addresses created in [Create nonce accounts](#create-nonce-accounts). | + +## Ledger + +Initialization arguments: + + + + + + + + + + +
ProductionStaging
+ +```candid +( + variant { + Init = record { + minting_account = record { owner = principal "lh22c-kyaaa-aaaar-qb5nq-cai"; subaccount = null }; + fee_collector_account = opt record { + owner = principal "lh22c-kyaaa-aaaar-qb5nq-cai"; + subaccount = opt blob "\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\0f\ee"; + }; + transfer_fee = 500 : nat; + decimals = opt (9 : nat8); + max_memo_length = opt (80 : nat16); + token_symbol = "ckSOL"; + token_name = "ckSOL"; + metadata = vec { + record { "icrc1:logo"; variant { Text = "data:image/svg+xml;base64,${LOGO}" } }; + }; + initial_balances = vec {}; + feature_flags = opt record { icrc2 = true }; + archive_options = record { + num_blocks_to_archive = 1_000 : nat64; + trigger_threshold = 4_200_000_000 : nat64; + node_max_memory_size_bytes = opt (3_221_225_472 : nat64); + max_message_size_bytes = null; + max_transactions_per_response = null; + cycles_for_archive_creation = opt (100_000_000_000_000 : nat64); + controller_id = principal "r7inp-6aaaa-aaaaa-aaabq-cai"; + more_controller_ids = null; + }; + index_principal = opt principal "2ezyf-hqaaa-aaaar-qb6ga-cai"; + } + }, +) +``` + + + +```candid +( + variant { + Init = record { + minting_account = record { owner = principal "ljyxk-riaaa-aaaar-qb5mq-cai"; subaccount = null }; + fee_collector_account = opt record { + owner = principal "ljyxk-riaaa-aaaar-qb5mq-cai"; + subaccount = opt blob "\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\0f\ee"; + }; + transfer_fee = 500 : nat; + decimals = opt (9 : nat8); + max_memo_length = opt (80 : nat16); + token_symbol = "ckDevnetSOL"; + token_name = "ckDevnetSOL"; + metadata = vec { + record { "icrc1:logo"; variant { Text = "data:image/svg+xml;base64,${LOGO}" } }; + }; + initial_balances = vec {}; + feature_flags = opt record { icrc2 = true }; + archive_options = record { + num_blocks_to_archive = 1_000 : nat64; + trigger_threshold = 4_200_000_000 : nat64; + node_max_memory_size_bytes = opt (3_221_225_472 : nat64); + max_message_size_bytes = null; + max_transactions_per_response = null; + cycles_for_archive_creation = opt (100_000_000_000_000 : nat64); + controller_id = principal "cmqvo-qqaaa-aaaai-q3waa-cai"; + more_controller_ids = null; + }; + index_principal = opt principal "2r6ji-gyaaa-aaaar-qb6fq-cai"; + } + }, +) +``` + +
+ +`LOGO` is the base64 encoding of the token logo, without line breaks. +For production, use `LOGO=$(base64 -i static/images/cksol-token.svg | tr -d '\n')`. +For staging, use `LOGO=$(base64 -i static/images/ckdevnetsol-token.svg | tr -d '\n')`. + +The minting account is the minter's default account and the fee collector is the minter's subaccount `0x0fee`. +The transfer fee of 500 lamports follows [Section 3.3.1 of the design](design.md#331-cksol-ledger-fees). +Archiving is effectively disabled by setting `trigger_threshold` to 4,200,000,000 blocks. +The other archive options match those of the ckETH ledger (proposal [126170](https://dashboard.internetcomputer.org/proposal/126170)), so that archives are controlled by the NNS root canister if archiving is enabled by a later upgrade. + +## Index + +Initialization arguments: + + + + + + + + + + +
ProductionStaging
+ +```candid +( + opt variant { + Init = record { + ledger_id = principal "ls5lp-lqaaa-aaaar-qb5oa-cai"; + retrieve_blocks_from_ledger_interval_seconds = null; + } + }, +) +``` + + + +```candid +( + opt variant { + Init = record { + ledger_id = principal "la34w-haaaa-aaaar-qb5na-cai"; + retrieve_blocks_from_ledger_interval_seconds = null; + } + }, +) +``` + +
+ +The index takes an optional argument, hence the leading `opt`. +Leaving `retrieve_blocks_from_ledger_interval_seconds` unset uses the index's default polling interval. + +## Test + +The commands below use the staging minter and ledger. +For production, replace the canister IDs with those of the production minter and ledger. + +```shell +MINTER=ljyxk-riaaa-aaaar-qb5mq-cai +LEDGER=la34w-haaaa-aaaar-qb5na-cai +OWNER=$(icp identity principal --identity demo) +``` + +### Deposit SOL + +#### Get the deposit address + +```shell +icp canister call $MINTER get_deposit_address "(record { owner = opt principal \"$OWNER\"; subaccount = null })" -n ic +``` + +Send SOL to the returned address ([faucet](https://faucet.solana.com/)). +The balance of the deposit address must be at least `minimum_deposit_amount`, which `get_minter_info` returns: + +```shell +icp canister call $MINTER get_minter_info '()' --query -n ic +``` + +#### Queue the deposit + +`deposit_sol` requires `deposit_sol_required_cycles` (1T cycles) to be attached to the call. +Since an identity cannot attach cycles, route the call through a proxy canister that holds cycles: + +```shell +icp canister call $MINTER deposit_sol "(record { owner = opt principal \"$OWNER\"; subaccount = null })" \ + --proxy h35ft-riaaa-aaaar-qb37a-cai \ + --cycles 1000000000000 \ + --identity hsm \ + --identity-password-file ~/.config/icp/hsm.pin \ + -n ic +``` + +The minter sees the proxy canister as the caller, so `owner` must be set explicitly. +Otherwise, the ckSOL would be minted to the proxy canister. +The call returns the deposit ID, e.g. `(variant { Ok = 0 : nat64 })`. + +#### Check the deposit status + +```shell +icp canister call $MINTER deposit_sol_status '(0 : nat64)' --query -n ic +``` + +The status goes through `Queued`, `Swept`, `Finalized` and `Minted`. +Once minted, the ckSOL balance of the owner is: + +```shell +icp canister call $LEDGER icrc1_balance_of "(record { owner = principal \"$OWNER\"; subaccount = null })" --query -n ic +``` + +### Withdraw SOL + +Withdrawals require nonce accounts, see [Create nonce accounts](#create-nonce-accounts). +Without them, `withdraw_sol` returns `TemporarilyUnavailable` and burns nothing. + +#### Approve the minter + +The minter burns the withdrawn ckSOL with `icrc2_transfer_from`, so the owner must first approve the minter for the amount to withdraw. +The approval costs the ledger transfer fee of 500 lamports, so withdrawing the whole balance means withdrawing the balance minus 500 lamports: + +```shell +AMOUNT=2000000 +DESTINATION=6TqNg48mSd5evmY66JVfGeGTwszrU1YLCeSw3GJ2qsUC +icp canister call $LEDGER icrc2_approve "(record { spender = record { owner = principal \"$MINTER\"; subaccount = null }; amount = $AMOUNT : nat; from_subaccount = null; expected_allowance = null; expires_at = null; fee = null; memo = null; created_at_time = null })" --identity demo -n ic +``` + +#### Withdraw + +```shell +icp canister call $MINTER withdraw_sol "(record { from_subaccount = null; amount = $AMOUNT : nat64; address = \"$DESTINATION\" })" --identity demo -n ic +``` + +The amount must be at least `minimum_withdrawal_amount`, and the destination receives the amount minus `withdrawal_fee`. +The call returns the index of the burn transaction on the ledger, e.g. `(variant { Ok = record { block_index = 2 : nat64 } })`. + +#### Check the withdrawal status + +```shell +icp canister call $MINTER withdraw_sol_status '(record { block_index = 2 : nat64 })' -n ic +``` + +The status goes through `Pending`, `TxSent` and `TxFinalized`. diff --git a/integration_tests/src/fixtures.rs b/integration_tests/src/fixtures.rs index 90d6dc8e..ff0d93e1 100644 --- a/integration_tests/src/fixtures.rs +++ b/integration_tests/src/fixtures.rs @@ -12,7 +12,7 @@ use icrc_ledger_types::{ }; use pocket_ic::nonblocking::PocketIc; use serde_json::json; -use sol_rpc_types::Lamport; +use sol_rpc_types::{GetBlockCommitmentLevel, Lamport}; use solana_address::{Address, address}; use solana_hash::Hash; use solana_nonce::{ @@ -147,11 +147,15 @@ impl MockBuilder { /// Mocks for a timer submitting a transaction built on the block at `block_height`: /// `getSlot` → `getBlock` → `sendTransaction`. pub fn submit_transaction(self, block_height: u64) -> Self { - self.get_current_block(block_height, SUBMITTED_BLOCKHASH) - .expect( - send_transaction_request(), - send_transaction_response(SUBMITTED_SIGNATURE), - ) + self.get_current_block( + block_height, + SUBMITTED_BLOCKHASH, + GetBlockCommitmentLevel::Confirmed, + ) + .expect( + send_transaction_request(), + send_transaction_response(SUBMITTED_SIGNATURE), + ) } /// Mock for `getAccountInfo` returning an initialized durable nonce account @@ -191,15 +195,23 @@ impl MockBuilder { /// signature expired at `block_height`: `getSlot` → `getBlock` → `getSignatureStatuses` /// reporting it as not found. pub fn mark_transaction_expired(self, signature: &Signature, block_height: u64) -> Self { - self.get_current_block(block_height, IGNORED_BLOCKHASH) - .check_signature_statuses(signature, get_signature_statuses_not_found_response()) + self.get_current_block( + block_height, + IGNORED_BLOCKHASH, + GetBlockCommitmentLevel::Finalized, + ) + .check_signature_statuses(signature, get_signature_statuses_not_found_response()) } /// Mocks for `finalize_transactions` reporting the pending transaction with the given /// signature as finalized at `block_height`. pub fn finalize_transaction(self, signature: &Signature, block_height: u64) -> Self { - self.get_current_block(block_height, IGNORED_BLOCKHASH) - .check_signature_statuses(signature, get_signature_statuses_finalized_response()) + self.get_current_block( + block_height, + IGNORED_BLOCKHASH, + GetBlockCommitmentLevel::Finalized, + ) + .check_signature_statuses(signature, get_signature_statuses_finalized_response()) } /// Mock for `getTransaction` returning the given signed sweep of @@ -216,9 +228,14 @@ impl MockBuilder { self.expect(get_signature_statuses_request(signature), response) } - fn get_current_block(self, block_height: u64, blockhash: &str) -> Self { + fn get_current_block( + self, + block_height: u64, + blockhash: &str, + commitment: GetBlockCommitmentLevel, + ) -> Self { self.expect(get_slot_request(), get_slot_response()).expect( - get_block_request(), + get_block_request(commitment), get_block_response(block_height, blockhash), ) } @@ -366,12 +383,13 @@ fn get_slot_response() -> JsonRpcResponse { })) } -fn get_block_request() -> JsonRpcRequestMatcher { +fn get_block_request(commitment: GetBlockCommitmentLevel) -> JsonRpcRequestMatcher { JsonRpcRequestMatcher::with_method("getBlock").with_params(json!([ MOCK_SLOT, { "transactionDetails": "none", "rewards": false, + "commitment": commitment, "maxSupportedTransactionVersion": 0 } ])) diff --git a/libs/types/src/lib.rs b/libs/types/src/lib.rs index 6c8b8180..9b7ab6d4 100644 --- a/libs/types/src/lib.rs +++ b/libs/types/src/lib.rs @@ -3,7 +3,7 @@ #![forbid(unsafe_code)] #![forbid(missing_docs)] -use candid::{CandidType, Nat, Principal}; +use candid::{CandidType, Principal}; use icrc_ledger_types::icrc1::account::{Account, Subaccount}; pub use memo::{BurnMemo, MAX_SERIALIZED_MEMO_BYTES, Memo, MintMemo}; use serde::{Deserialize, Serialize}; @@ -226,8 +226,6 @@ pub enum TxFinalizedStatus { Success { /// The unique identifier (signature) of the Solana transaction. transaction_id: Signature, - /// The fee that was paid by the user. - effective_transaction_fee: Option, }, /// The transaction failed. Failure { diff --git a/minter/cksol_minter.did b/minter/cksol_minter.did index 79d77efe..0da8b876 100644 --- a/minter/cksol_minter.did +++ b/minter/cksol_minter.did @@ -271,8 +271,6 @@ type TxFinalizedStatus = variant { // Transaction was successful. Success : record { transaction_id : Signature; - // The fee that was paid by the user. - effective_transaction_fee: opt nat; }; // Transaction failed. Failure : record { diff --git a/minter/src/address/tests.rs b/minter/src/address/tests.rs index e2711487..ef496762 100644 --- a/minter/src/address/tests.rs +++ b/minter/src/address/tests.rs @@ -1,7 +1,8 @@ use crate::{ address::{ - MinterPublicKeyNotYetAvailable, account_address, derive_public_key_from_account, - fetch_and_record_minter_public_key, get_deposit_address, minter_address, minter_public_key, + MINTER_DERIVATION_PATH, MinterPublicKeyNotYetAvailable, account_address, + derive_public_key_from_account, fetch_and_record_minter_public_key, get_deposit_address, + minter_address, minter_public_key, }, state::{SchnorrPublicKey, event::EventType, read_state}, test_fixtures::{ @@ -11,7 +12,7 @@ use crate::{ }; use futures::{FutureExt, join}; use ic_cdk_management_canister::SchnorrPublicKeyResult; -use ic_ed25519::{PocketIcMasterPublicKeyId, PublicKey}; +use ic_ed25519::{CanisterId, MasterPublicKeyId, PocketIcMasterPublicKeyId, PublicKey}; use icrc_ledger_types::icrc1::account::Account; use solana_address::Address; use std::panic::AssertUnwindSafe; @@ -71,6 +72,7 @@ fn test_derive_different_chain_code() { mod minter_address_tests { use super::*; + use solana_address::address; #[test] fn should_differ_from_deposit_address_of_minter_account() { @@ -88,6 +90,37 @@ mod minter_address_tests { Address::from(master_key.public_key.serialize_raw()) ); } + + #[test] + fn should_derive_mainnet_minter_addresses_offline() { + const CKSOL_MINTER_PRODUCTION_CANISTER_ID: &str = "lh22c-kyaaa-aaaar-qb5nq-cai"; + const CKSOL_MINTER_STAGING_CANISTER_ID: &str = "ljyxk-riaaa-aaaar-qb5mq-cai"; + + for (minter_id, expected_address) in [ + ( + CKSOL_MINTER_PRODUCTION_CANISTER_ID, + address!("GXVewvv6HehcLFYCmwpqh5CrMjqN9zJ8rqzGq3HEt9Ax"), + ), + ( + CKSOL_MINTER_STAGING_CANISTER_ID, + address!("Br8eRkeya8hy3sHCYqtWqGeNNZ349aPSUKGVYFWKKer1"), + ), + ] { + let (master_public_key, chain_code) = PublicKey::derive_mainnet_key( + MasterPublicKeyId::Key1, + &CanisterId::from_text(minter_id).unwrap(), + &MINTER_DERIVATION_PATH, + ); + let minter_address = minter_address(&SchnorrPublicKey { + public_key: master_public_key, + chain_code, + }); + assert_eq!( + minter_address, expected_address, + "unexpected main address for minter {minter_id}" + ); + } + } } mod fetch_and_record_minter_public_key_tests { diff --git a/minter/src/constants.rs b/minter/src/constants.rs index 2f7d69f7..b9292037 100644 --- a/minter/src/constants.rs +++ b/minter/src/constants.rs @@ -34,12 +34,14 @@ pub const MAX_PENDING_MINTS_PER_ROUND: usize = 10; /// pending mint older than that is quarantined instead of retried. pub const LEDGER_DEDUPLICATION_WINDOW: Duration = Duration::from_hours(24); -/// Matches the ICP HTTPS outcall response limit for variable-length RPC calls -/// such as `getTransaction` and `getSignatureStatuses`: -/// https://docs.internetcomputer.org/references/ic-interface-spec#ic-http_request -pub const MAX_HTTP_OUTCALL_RESPONSE_BYTES: u64 = 2_000_000; - /// Cycles to attach for `getTransaction` RPC calls. +/// +/// The SOL RPC canister charges about 3.2B cycles for a `getTransaction` +/// request with the default 3-out-of-4 provider consensus and its default +/// response size estimate of 10 KiB, which fits a full sweep or withdrawal +/// transaction. The attached amount leaves a wide margin for provider or price +/// changes and for the SOL RPC canister doubling the response size estimate +/// after an oversized response; the unused part is refunded. pub const GET_TRANSACTION_CYCLES: u128 = 50_000_000_000; /// Cycles to attach for `getBalance` RPC calls. @@ -52,7 +54,17 @@ pub const GET_TRANSACTION_CYCLES: u128 = 50_000_000_000; pub const GET_BALANCE_CYCLES: u128 = 10_000_000_000; /// Cycles to attach for `getSignatureStatuses` RPC calls. -pub const GET_SIGNATURE_STATUSES_CYCLES: u128 = 1_000_000_000_000; +/// +/// The SOL RPC canister charges about 2.3B cycles for a `getSignatureStatuses` +/// request for one signature and about 10.7B cycles for 256 signatures, the +/// largest batch, with the default 3-out-of-4 provider consensus and its +/// default response size estimate of 256 bytes per signature. The attached +/// amount leaves a wide margin for provider or price changes and for the SOL +/// RPC canister doubling the response size estimate after an oversized +/// response; the unused part is refunded. Up to `MAX_CONCURRENT_RPC_CALLS` +/// requests run at once, so the minter needs that many times this amount +/// available. +pub const GET_SIGNATURE_STATUSES_CYCLES: u128 = 50_000_000_000; /// Cycles to attach for `getAccountInfo` RPC calls. /// diff --git a/minter/src/deposit/sweep/timer.rs b/minter/src/deposit/sweep/timer.rs index 103ef025..8c75b122 100644 --- a/minter/src/deposit/sweep/timer.rs +++ b/minter/src/deposit/sweep/timer.rs @@ -16,6 +16,7 @@ use canlog::log; use cksol_types::DepositSolId; use cksol_types_internal::log::Priority; use itertools::Itertools; +use sol_rpc_types::CommitmentLevel; use solana_address::Address; use solana_signature::Signature; use std::time::Duration; @@ -26,6 +27,8 @@ mod tests; pub(crate) const MAX_DEPOSITS_PER_SWEEP: usize = MAX_SIGNATURES as usize; +const SWEEP_BLOCKHASH_COMMITMENT: CommitmentLevel = CommitmentLevel::Confirmed; + pub async fn sweep_queued_deposits(runtime: R) { let _guard = match TimerGuard::new(TaskType::SweepDeposits) { Ok(guard) => guard, @@ -46,7 +49,7 @@ pub async fn sweep_queued_deposits(runtime: R) { }; let sweep_destination = minter_address(&master_key); - let block = match get_recent_block(&runtime).await { + let block = match get_recent_block(&runtime, SWEEP_BLOCKHASH_COMMITMENT).await { Ok(block) => block, Err(e) => { log!( @@ -143,7 +146,7 @@ async fn submit_sweep_transaction( ) }); - submit_transaction(runtime, transaction).await?; + submit_transaction(runtime, transaction, SWEEP_BLOCKHASH_COMMITMENT).await?; Ok(signature) } diff --git a/minter/src/deposit/sweep/timer/tests.rs b/minter/src/deposit/sweep/timer/tests.rs index ba178a03..e637c106 100644 --- a/minter/src/deposit/sweep/timer/tests.rs +++ b/minter/src/deposit/sweep/timer/tests.rs @@ -18,7 +18,11 @@ use crate::{ }; use assert_matches::assert_matches; use cksol_types::{DepositSolId, DepositSolStatus}; -use sol_rpc_types::{Lamport, MultiRpcResult, RpcError, Signature, Slot}; +use sol_rpc_types::{ + CommitmentLevel, GetBlockCommitmentLevel, GetBlockParams, GetSlotParams, GetSlotRpcConfig, + Lamport, MultiRpcResult, RpcConfig, RpcError, RpcSources, SendTransactionParams, Signature, + Slot, +}; use solana_address::Address; use solana_system_interface::instruction::SystemInstruction; @@ -184,6 +188,48 @@ async fn should_record_event_even_if_transaction_submission_fails() { ); } +#[tokio::test] +async fn should_build_and_simulate_the_sweep_at_confirmed_commitment() { + setup(); + queue_deposit(0, account(1), MINIMUM_DEPOSIT_AMOUNT); + let runtime = TestCanisterRuntime::new() + .with_increasing_time() + .add_recent_block(Ok(SLOT)) + .add_stub_response(SendTransactionResult::Consistent(Ok(account_signature( + &account(1), + ) + .into()))) + .add_signer(sign_for(&account(1))); + + sweep_queued_deposits(runtime.clone()).await; + + let [get_slot, get_block, send_transaction] = runtime.sent_update_calls().try_into().unwrap(); + assert_eq!(get_slot.method, "getSlot"); + let (_sources, _config, slot_params): ( + RpcSources, + Option, + Option, + ) = get_slot.args(); + assert_eq!( + slot_params.and_then(|params| params.commitment), + Some(CommitmentLevel::Confirmed) + ); + assert_eq!(get_block.method, "getBlock"); + let (_sources, _config, block_params): (RpcSources, Option, GetBlockParams) = + get_block.args(); + assert_eq!( + block_params.commitment, + Some(GetBlockCommitmentLevel::Confirmed) + ); + assert_eq!(send_transaction.method, "sendTransaction"); + let (_sources, _config, send_params): (RpcSources, Option, SendTransactionParams) = + send_transaction.args(); + assert_eq!( + send_params.preflight_commitment, + Some(CommitmentLevel::Confirmed) + ); +} + #[tokio::test] async fn should_split_deposits_into_batches_of_max_size() { const NUM_DEPOSITS: usize = MAX_DEPOSITS_PER_SWEEP + 2; diff --git a/minter/src/monitor/mod.rs b/minter/src/monitor/mod.rs index c77d1a9d..4e8b4585 100644 --- a/minter/src/monitor/mod.rs +++ b/minter/src/monitor/mod.rs @@ -15,6 +15,7 @@ use crate::{ use canlog::log; use cksol_types_internal::log::Priority; use itertools::Itertools; +use sol_rpc_types::CommitmentLevel; use solana_signature::Signature; use solana_transaction_status_client_types::TransactionConfirmationStatus; use std::collections::{BTreeMap, BTreeSet}; @@ -140,7 +141,7 @@ async fn check_sweep_transactions(runtime: &R) -> bool { } async fn fetch_current_block_height(runtime: &R) -> Option { - match get_recent_block(runtime).await { + match get_recent_block(runtime, CommitmentLevel::Finalized).await { Ok(block) => Some(block.block_height), Err(e) => { log!( diff --git a/minter/src/monitor/tests.rs b/minter/src/monitor/tests.rs index c32a9db6..fbe2d3e2 100644 --- a/minter/src/monitor/tests.rs +++ b/minter/src/monitor/tests.rs @@ -21,7 +21,8 @@ use crate::{ }, }; use sol_rpc_types::{ - ConfirmedBlock, MultiRpcResult, RpcError, SendTransactionParams, Slot, + CommitmentLevel, ConfirmedBlock, GetBlockCommitmentLevel, GetBlockParams, GetSlotParams, + GetSlotRpcConfig, MultiRpcResult, RpcConfig, RpcError, RpcSources, SendTransactionParams, Slot, TransactionConfirmationStatus, TransactionError, TransactionStatus, }; use solana_transaction::Transaction; @@ -310,6 +311,38 @@ mod finalization { }); } + #[tokio::test] + async fn should_fetch_the_current_block_height_at_finalized_commitment() { + setup(); + submit_sweep_transaction(OLDEST_VALID_BLOCK_HEIGHT); + let runtime = TestCanisterRuntime::new() + .with_increasing_time() + .add_stub_response(SlotResult::Consistent(Ok(CURRENT_SLOT))) + .add_stub_response(BlockResult::Consistent(Ok(current_block()))) + .add_stub_response(SignatureStatusesResult::Consistent(Ok(vec![None]))); + + finalize_transactions(runtime.clone()).await; + + let calls = runtime.sent_update_calls(); + let get_slot = calls.iter().find(|call| call.method == "getSlot").unwrap(); + let (_sources, _config, slot_params): ( + RpcSources, + Option, + Option, + ) = get_slot.args(); + assert_eq!( + slot_params.and_then(|params| params.commitment), + Some(CommitmentLevel::Finalized) + ); + let get_block = calls.iter().find(|call| call.method == "getBlock").unwrap(); + let (_sources, _config, block_params): (RpcSources, Option, GetBlockParams) = + get_block.args(); + assert_eq!( + block_params.commitment, + Some(GetBlockCommitmentLevel::Finalized) + ); + } + struct ExpiryCase { name: &'static str, transaction_block_height: BlockHeight, diff --git a/minter/src/rpc/mod.rs b/minter/src/rpc/mod.rs index 09eab56f..7330c8aa 100644 --- a/minter/src/rpc/mod.rs +++ b/minter/src/rpc/mod.rs @@ -1,7 +1,7 @@ use crate::{ constants::{ GET_ACCOUNT_INFO_CYCLES, GET_BALANCE_CYCLES, GET_RECENT_BLOCK_MAX_TRIES, - GET_SIGNATURE_STATUSES_CYCLES, GET_TRANSACTION_CYCLES, MAX_HTTP_OUTCALL_RESPONSE_BYTES, + GET_SIGNATURE_STATUSES_CYCLES, GET_TRANSACTION_CYCLES, }, runtime::CanisterRuntime, state::read_state, @@ -47,7 +47,6 @@ pub async fn get_transaction( .with_encoding(GetTransactionEncoding::Base64) .with_commitment(CommitmentLevel::Finalized) .with_max_supported_transaction_version(0) - .with_response_size_estimate(MAX_HTTP_OUTCALL_RESPONSE_BYTES) .with_cycles(GET_TRANSACTION_CYCLES) .try_send() .await; @@ -161,8 +160,14 @@ impl From for DepositSolError { pub async fn submit_transaction( runtime: &R, transaction: Transaction, + preflight_commitment: CommitmentLevel, ) -> Result { - send_transaction(runtime, transaction, Preflight::Simulate).await + send_transaction( + runtime, + transaction, + Preflight::Simulate(preflight_commitment), + ) + .await } /// Submits a withdrawal transaction without the providers' preflight @@ -177,7 +182,7 @@ pub async fn submit_transaction_skipping_preflight( } enum Preflight { - Simulate, + Simulate(CommitmentLevel), Skip, } @@ -188,7 +193,9 @@ async fn send_transaction( ) -> Result { let client = read_state(|state| state.sol_rpc_client(runtime.inter_canister_call_runtime())); let request = match preflight { - Preflight::Simulate => client.send_transaction(transaction), + Preflight::Simulate(commitment) => client + .send_transaction(transaction) + .with_preflight_commitment(commitment), Preflight::Skip => client .send_transaction(transaction) .with_skip_preflight(true), @@ -292,8 +299,12 @@ pub enum GetNonceAccountError { pub async fn get_recent_block( runtime: &R, + commitment: CommitmentLevel, ) -> Result { - let client = read_state(|state| state.sol_rpc_client(runtime.inter_canister_call_runtime())); + let client = + read_state(|state| state.sol_rpc_client_builder(runtime.inter_canister_call_runtime())) + .with_default_commitment_level(commitment) + .build(); match client .get_recent_block() .with_num_tries(GET_RECENT_BLOCK_MAX_TRIES) @@ -374,7 +385,6 @@ pub async fn get_signature_statuses( .get_signature_statuses(signatures) .map_err(GetSignatureStatusesError::RpcError)? .with_search_transaction_history(true) - .with_response_size_estimate(MAX_HTTP_OUTCALL_RESPONSE_BYTES) .with_cycles(GET_SIGNATURE_STATUSES_CYCLES) .try_send() .await; diff --git a/minter/src/rpc/tests.rs b/minter/src/rpc/tests.rs index 8640eee8..3db8fb61 100644 --- a/minter/src/rpc/tests.rs +++ b/minter/src/rpc/tests.rs @@ -3,7 +3,7 @@ use crate::{ rpc::{ Block, BlockHeight, GetBalanceError, GetNonceAccountError, GetRecentBlockError, GetTransactionError, NonceAccount, SubmitTransactionError, get_balance, get_nonce_account, - get_recent_block, get_transaction, submit_transaction, + get_recent_block, get_signature_statuses, get_transaction, submit_transaction, submit_transaction_skipping_preflight, }, test_fixtures::{ @@ -20,8 +20,9 @@ use crate::{ use assert_matches::assert_matches; use ic_canister_runtime::IcError; use sol_rpc_types::{ - HttpOutcallError, RpcConfig, RpcError, RpcSource, RpcSources, SendTransactionParams, - SupportedRpcProviderId, + CommitmentLevel, GetBlockCommitmentLevel, GetBlockParams, GetSignatureStatusesParams, + GetSlotParams, GetSlotRpcConfig, GetTransactionParams, HttpOutcallError, RpcConfig, RpcError, + RpcSource, RpcSources, SendTransactionParams, SupportedRpcProviderId, }; use solana_transaction::{Message, Transaction}; use solana_transaction_status_client_types::{EncodedTransaction, TransactionBinaryEncoding}; @@ -243,6 +244,55 @@ mod get_transaction_tests { assert_eq!(result, Ok(Some(fetched(legacy_deposit_transaction())))) } + + #[tokio::test] + async fn should_leave_the_response_size_estimate_to_the_sol_rpc_canister() { + init_state(); + let runtime = + TestCanisterRuntime::new().add_stub_response(MultiRpcResult::Consistent(Ok(None))); + + let result = get_transaction(&runtime, legacy_deposit_transaction_signature()).await; + + assert_eq!(result, Ok(None)); + let [call] = runtime.sent_update_calls().try_into().unwrap(); + assert_eq!(call.method, "getTransaction"); + let (_sources, config, _params): (RpcSources, Option, GetTransactionParams) = + call.args(); + assert_eq!( + config.and_then(|config| config.response_size_estimate), + None + ); + } +} + +mod get_signature_statuses_tests { + use super::*; + + type MultiRpcResult = + sol_rpc_types::MultiRpcResult>>; + + #[tokio::test] + async fn should_leave_the_response_size_estimate_to_the_sol_rpc_canister() { + init_state(); + let runtime = TestCanisterRuntime::new() + .add_stub_response(MultiRpcResult::Consistent(Ok(vec![None]))); + + let result = + get_signature_statuses(&runtime, &[legacy_deposit_transaction_signature()]).await; + + assert_eq!(result, Ok(vec![None])); + let [call] = runtime.sent_update_calls().try_into().unwrap(); + assert_eq!(call.method, "getSignatureStatuses"); + let (_sources, config, _params): ( + RpcSources, + Option, + GetSignatureStatusesParams, + ) = call.args(); + assert_eq!( + config.and_then(|config| config.response_size_estimate), + None + ); + } } mod submit_transaction_tests { @@ -259,7 +309,7 @@ mod submit_transaction_tests { SendTransactionResult::Consistent(Ok(expected_signature.clone())), ); - let result = submit_transaction(&runtime, transaction()).await; + let result = submit_transaction(&runtime, transaction(), CommitmentLevel::Confirmed).await; assert_eq!(result, Ok(expected_signature.into())); } @@ -270,7 +320,7 @@ mod submit_transaction_tests { let runtime = TestCanisterRuntime::new().add_stub_error(IcError::CallPerformFailed); - let result = submit_transaction(&runtime, transaction()).await; + let result = submit_transaction(&runtime, transaction(), CommitmentLevel::Confirmed).await; assert_eq!( result, @@ -291,7 +341,7 @@ mod submit_transaction_tests { let runtime = TestCanisterRuntime::new() .add_stub_response(SendTransactionResult::Consistent(Err(rpc_error.clone()))); - let result = submit_transaction(&runtime, transaction()).await; + let result = submit_transaction(&runtime, transaction(), CommitmentLevel::Confirmed).await; assert_eq!(result, Err(SubmitTransactionError::RpcError(rpc_error))); } @@ -314,7 +364,7 @@ mod submit_transaction_tests { let runtime = TestCanisterRuntime::new() .add_stub_response(SendTransactionResult::Inconsistent(results)); - let result = submit_transaction(&runtime, transaction()).await; + let result = submit_transaction(&runtime, transaction(), CommitmentLevel::Confirmed).await; assert_eq!(result, Err(SubmitTransactionError::InconsistentRpcResults)); } @@ -325,12 +375,26 @@ mod submit_transaction_tests { let runtime = TestCanisterRuntime::new() .add_stub_response(SendTransactionResult::Consistent(Ok(signature()))); - let result = submit_transaction(&runtime, transaction()).await; + let result = submit_transaction(&runtime, transaction(), CommitmentLevel::Confirmed).await; assert_eq!(result, Ok(signature().into())); assert_eq!(sent_params(&runtime).skip_preflight, None); } + #[tokio::test] + async fn should_simulate_at_the_requested_commitment() { + init_state(); + for commitment in [CommitmentLevel::Confirmed, CommitmentLevel::Finalized] { + let runtime = TestCanisterRuntime::new() + .add_stub_response(SendTransactionResult::Consistent(Ok(signature()))); + + let result = submit_transaction(&runtime, transaction(), commitment.clone()).await; + + assert_eq!(result, Ok(signature().into())); + assert_eq!(sent_params(&runtime).preflight_commitment, Some(commitment)); + } + } + #[tokio::test] async fn should_skip_the_preflight_simulation_when_requested() { init_state(); @@ -518,7 +582,7 @@ mod get_recent_block_tests { confirmed_block_at_height(block_height), )))); - let result = get_recent_block(&runtime).await; + let result = get_recent_block(&runtime, CommitmentLevel::Finalized).await; assert_eq!( result, @@ -542,7 +606,7 @@ mod get_recent_block_tests { }, )))); - let result = get_recent_block(&runtime).await; + let result = get_recent_block(&runtime, CommitmentLevel::Finalized).await; assert_eq!( result, @@ -556,7 +620,7 @@ mod get_recent_block_tests { let runtime = TestCanisterRuntime::new() .add_recent_block(Err(RpcError::ValidationError("Error".to_string()))); - let result = get_recent_block(&runtime).await; + let result = get_recent_block(&runtime, CommitmentLevel::Finalized).await; assert_matches!( result, @@ -565,6 +629,42 @@ mod get_recent_block_tests { ); } + #[tokio::test] + async fn should_fetch_slot_and_block_at_the_requested_commitment() { + init_state(); + for (commitment, block_commitment) in [ + ( + CommitmentLevel::Confirmed, + GetBlockCommitmentLevel::Confirmed, + ), + ( + CommitmentLevel::Finalized, + GetBlockCommitmentLevel::Finalized, + ), + ] { + let runtime = TestCanisterRuntime::new().add_recent_block(Ok(SLOT)); + + let result = get_recent_block(&runtime, commitment.clone()).await; + + assert_matches!(result, Ok(Block { slot: SLOT, .. })); + let [get_slot, get_block] = runtime.sent_update_calls().try_into().unwrap(); + assert_eq!(get_slot.method, "getSlot"); + let (_sources, _config, slot_params): ( + RpcSources, + Option, + Option, + ) = get_slot.args(); + assert_eq!( + slot_params.and_then(|params| params.commitment), + Some(commitment) + ); + assert_eq!(get_block.method, "getBlock"); + let (_sources, _config, block_params): (RpcSources, Option, GetBlockParams) = + get_block.args(); + assert_eq!(block_params.commitment, Some(block_commitment)); + } + } + fn blockhash() -> sol_rpc_types::Hash { solana_hash::Hash::from([0x42; 32]).into() } diff --git a/minter/src/state/mod.rs b/minter/src/state/mod.rs index 5db66cd5..5c1bca70 100644 --- a/minter/src/state/mod.rs +++ b/minter/src/state/mod.rs @@ -20,7 +20,7 @@ use cksol_types_internal::{Ed25519KeyName, InitArgs, UpgradeArgs}; use ic_canister_runtime::Runtime; use ic_ed25519::PublicKey; use icrc_ledger_types::icrc1::account::Account; -use sol_rpc_client::SolRpcClient; +use sol_rpc_client::{ClientBuilder, SolRpcClient}; use sol_rpc_types::{ConsensusStrategy, Lamport, RpcSources, SolanaCluster}; use solana_address::Address; use solana_hash::Hash; @@ -254,6 +254,10 @@ impl State { } pub fn sol_rpc_client(&self, runtime: R) -> SolRpcClient { + self.sol_rpc_client_builder(runtime).build() + } + + pub fn sol_rpc_client_builder(&self, runtime: R) -> ClientBuilder { SolRpcClient::builder(runtime, self.sol_rpc_canister_id) .with_rpc_sources(RpcSources::Default(SolanaCluster::from( self.solana_network, @@ -262,7 +266,6 @@ impl State { min: 3, total: Some(4), }) - .build() } pub fn ledger_client(&self, runtime: R) -> LedgerClient { @@ -473,7 +476,6 @@ impl State { if let Some(sent) = self.successful_withdrawal_requests.get(&burn_index) { return WithdrawSolStatus::TxFinalized(TxFinalizedStatus::Success { transaction_id: sent.signature.into(), - effective_transaction_fee: None, }); } if let Some(sent) = self.failed_withdrawal_requests.get(&burn_index) { diff --git a/minter/src/state/tests.rs b/minter/src/state/tests.rs index 88b6b500..8c12ee94 100644 --- a/minter/src/state/tests.rs +++ b/minter/src/state/tests.rs @@ -1310,7 +1310,6 @@ mod withdrawal_transactions { s.withdrawal_status(0), WithdrawSolStatus::TxFinalized(TxFinalizedStatus::Success { transaction_id: signature(7).into(), - effective_transaction_fee: None, }) ); }); diff --git a/minter/src/withdraw/tests.rs b/minter/src/withdraw/tests.rs index f8667086..6281ff25 100644 --- a/minter/src/withdraw/tests.rs +++ b/minter/src/withdraw/tests.rs @@ -604,7 +604,6 @@ mod process_pending_withdrawals_tests { withdrawal_status(1), WithdrawSolStatus::TxFinalized(TxFinalizedStatus::Success { transaction_id: signature.into(), - effective_transaction_fee: None, }) ); } @@ -1040,7 +1039,6 @@ mod withdrawal_finalization_tests { withdrawal_status(1), WithdrawSolStatus::TxFinalized(TxFinalizedStatus::Success { transaction_id: tx_signature.into(), - effective_transaction_fee: None, }) ); }