diff --git a/crates/algokit_utils/src/lib.rs b/crates/algokit_utils/src/lib.rs index 3e064bc5..62c725cf 100644 --- a/crates/algokit_utils/src/lib.rs +++ b/crates/algokit_utils/src/lib.rs @@ -13,6 +13,7 @@ pub use testing::{ pub use transactions::{ ApplicationCallParams, ApplicationCreateParams, ApplicationDeleteParams, ApplicationUpdateParams, AssetCreateParams, AssetDestroyParams, AssetReconfigureParams, - CommonParams, Composer, ComposerError, ComposerTxn, EmptySigner, PaymentParams, TxnSigner, - TxnSignerGetter, + CommonParams, Composer, ComposerError, ComposerTxn, EmptySigner, + NonParticipationKeyRegistrationParams, OfflineKeyRegistrationParams, + OnlineKeyRegistrationParams, PaymentParams, TxnSigner, TxnSignerGetter, }; diff --git a/crates/algokit_utils/src/transactions/composer.rs b/crates/algokit_utils/src/transactions/composer.rs index 39668033..590fda53 100644 --- a/crates/algokit_utils/src/transactions/composer.rs +++ b/crates/algokit_utils/src/transactions/composer.rs @@ -4,19 +4,25 @@ use algod_client::{ models::{PendingTransactionResponse, TransactionParams}, }; use algokit_transact::{ - Address, AlgorandMsgpack, AssetConfigTransactionFields, FeeParams, OnApplicationComplete, - PaymentTransactionFields, SignedTransaction, Transaction, TransactionHeader, Transactions, + Address, AlgorandMsgpack, AssetConfigTransactionFields, FeeParams, + KeyRegistrationTransactionFields, OnApplicationComplete, PaymentTransactionFields, + SignedTransaction, Transaction, TransactionHeader, Transactions, }; use derive_more::Debug; use std::sync::Arc; +use crate::genesis_id_is_localnet; + use super::application_call::{ ApplicationCallParams, ApplicationCreateParams, ApplicationDeleteParams, ApplicationUpdateParams, }; use super::asset_config::{AssetCreateParams, AssetDestroyParams, AssetReconfigureParams}; use super::common::{CommonParams, DefaultSignerGetter, TxnSigner, TxnSignerGetter}; -use crate::clients::network_client::genesis_id_is_localnet; +use super::key_registration::{ + NonParticipationKeyRegistrationParams, OfflineKeyRegistrationParams, + OnlineKeyRegistrationParams, +}; #[derive(Debug, thiserror::Error)] pub enum ComposerError { @@ -102,6 +108,9 @@ pub enum ComposerTxn { ApplicationCreate(ApplicationCreateParams), ApplicationUpdate(ApplicationUpdateParams), ApplicationDelete(ApplicationDeleteParams), + OnlineKeyRegistration(OnlineKeyRegistrationParams), + OfflineKeyRegistration(OfflineKeyRegistrationParams), + NonParticipationKeyRegistration(NonParticipationKeyRegistrationParams), } impl ComposerTxn { @@ -139,6 +148,15 @@ impl ComposerTxn { ComposerTxn::ApplicationDelete(app_delete_params) => { app_delete_params.common_params.clone() } + ComposerTxn::OnlineKeyRegistration(online_key_reg_params) => { + online_key_reg_params.common_params.clone() + } + ComposerTxn::OfflineKeyRegistration(offline_key_reg_params) => { + offline_key_reg_params.common_params.clone() + } + ComposerTxn::NonParticipationKeyRegistration(non_participation_params) => { + non_participation_params.common_params.clone() + } _ => CommonParams::default(), } } @@ -168,6 +186,10 @@ impl Composer { self.built_group.as_ref() } + pub fn signed_group(&self) -> Option<&Vec> { + self.signed_group.as_ref() + } + #[cfg(feature = "default_http_client")] pub fn testnet() -> Self { Composer { @@ -240,6 +262,29 @@ impl Composer { self.push(ComposerTxn::AssetDestroy(asset_destroy_params)) } + pub fn add_online_key_registration( + &mut self, + online_key_reg_params: OnlineKeyRegistrationParams, + ) -> Result<(), String> { + self.push(ComposerTxn::OnlineKeyRegistration(online_key_reg_params)) + } + + pub fn add_offline_key_registration( + &mut self, + offline_key_reg_params: OfflineKeyRegistrationParams, + ) -> Result<(), String> { + self.push(ComposerTxn::OfflineKeyRegistration(offline_key_reg_params)) + } + + pub fn add_non_participation_key_registration( + &mut self, + non_participation_params: NonParticipationKeyRegistrationParams, + ) -> Result<(), String> { + self.push(ComposerTxn::NonParticipationKeyRegistration( + non_participation_params, + )) + } + pub fn add_application_call( &mut self, app_call_params: ApplicationCallParams, @@ -519,6 +564,42 @@ impl Composer { }, ) } + ComposerTxn::OnlineKeyRegistration(online_key_reg_params) => { + Transaction::KeyRegistration(KeyRegistrationTransactionFields { + header, + vote_key: Some(online_key_reg_params.vote_key), + selection_key: Some(online_key_reg_params.selection_key), + vote_first: Some(online_key_reg_params.vote_first), + vote_last: Some(online_key_reg_params.vote_last), + vote_key_dilution: Some(online_key_reg_params.vote_key_dilution), + state_proof_key: online_key_reg_params.state_proof_key, + non_participation: None, + }) + } + ComposerTxn::OfflineKeyRegistration(offline_key_reg_params) => { + Transaction::KeyRegistration(KeyRegistrationTransactionFields { + header, + vote_key: None, + selection_key: None, + vote_first: None, + vote_last: None, + vote_key_dilution: None, + state_proof_key: None, + non_participation: offline_key_reg_params.non_participation, + }) + } + ComposerTxn::NonParticipationKeyRegistration(_) => { + Transaction::KeyRegistration(KeyRegistrationTransactionFields { + header, + vote_key: None, + selection_key: None, + vote_first: None, + vote_last: None, + vote_key_dilution: None, + state_proof_key: None, + non_participation: Some(true), + }) + } }; if calculate_fee { @@ -744,138 +825,6 @@ mod tests { assert!(composer.add_payment(payment_params).is_ok()); } - #[tokio::test] - async fn test_build_payment() { - let mut composer = Composer::testnet(); - let payment_params = PaymentParams { - common_params: CommonParams { - sender: AddressMother::address(), - signer: None, - rekey_to: None, - note: None, - lease: None, - static_fee: None, - extra_fee: None, - max_fee: None, - validity_window: None, - first_valid_round: None, - last_valid_round: None, - }, - receiver: AddressMother::address(), - amount: 1000, - close_remainder_to: None, - }; - composer.add_payment(payment_params).unwrap(); - - let result = composer.build().await; - assert!(result.is_ok()); - - let built_group = composer.built_group().unwrap(); - assert_eq!(built_group.len(), 1); - } - - #[tokio::test] - async fn test_build_asset_transfer() { - let mut composer = Composer::testnet(); - let asset_transfer_params = AssetTransferParams { - common_params: CommonParams { - sender: AddressMother::address(), - signer: None, - rekey_to: None, - note: None, - lease: None, - static_fee: None, - extra_fee: None, - max_fee: None, - validity_window: None, - first_valid_round: None, - last_valid_round: None, - }, - asset_id: 12345, - amount: 1000, - receiver: AddressMother::address(), - }; - assert!(composer.add_asset_transfer(asset_transfer_params).is_ok()); - assert!(composer.build().await.is_ok()); - assert!(composer.built_group().is_some()); - } - - #[tokio::test] - async fn test_build_asset_opt_in() { - let mut composer = Composer::testnet(); - let asset_opt_in_params = AssetOptInParams { - common_params: CommonParams { - sender: AddressMother::address(), - signer: None, - rekey_to: None, - note: None, - lease: None, - static_fee: None, - extra_fee: None, - max_fee: None, - validity_window: None, - first_valid_round: None, - last_valid_round: None, - }, - asset_id: 12345, - }; - assert!(composer.add_asset_opt_in(asset_opt_in_params).is_ok()); - assert!(composer.build().await.is_ok()); - assert!(composer.built_group().is_some()); - } - - #[tokio::test] - async fn test_build_asset_opt_out() { - let mut composer = Composer::testnet(); - let asset_opt_out_params = AssetOptOutParams { - common_params: CommonParams { - sender: AddressMother::address(), - signer: None, - rekey_to: None, - note: None, - lease: None, - static_fee: None, - extra_fee: None, - max_fee: None, - validity_window: None, - first_valid_round: None, - last_valid_round: None, - }, - asset_id: 12345, - close_remainder_to: Some(AddressMother::neil()), - }; - assert!(composer.add_asset_opt_out(asset_opt_out_params).is_ok()); - assert!(composer.build().await.is_ok()); - assert!(composer.built_group().is_some()); - } - - #[tokio::test] - async fn test_build_asset_clawback() { - let mut composer = Composer::testnet(); - let asset_clawback_params = AssetClawbackParams { - common_params: CommonParams { - sender: AddressMother::address(), - signer: None, - rekey_to: None, - note: None, - lease: None, - static_fee: None, - extra_fee: None, - max_fee: None, - validity_window: None, - first_valid_round: None, - last_valid_round: None, - }, - asset_id: 12345, - amount: 1000, - receiver: AddressMother::address(), - clawback_target: AddressMother::neil(), - }; - assert!(composer.add_asset_clawback(asset_clawback_params).is_ok()); - assert!(composer.build().await.is_ok()); - assert!(composer.built_group().is_some()); - } - #[tokio::test] async fn test_gather_signatures() { let mut composer = Composer::new(AlgodClient::testnet(), Some(Arc::new(EmptySigner {}))); diff --git a/crates/algokit_utils/src/transactions/key_registration.rs b/crates/algokit_utils/src/transactions/key_registration.rs new file mode 100644 index 00000000..701d4cbf --- /dev/null +++ b/crates/algokit_utils/src/transactions/key_registration.rs @@ -0,0 +1,23 @@ +use super::common::CommonParams; + +#[derive(Debug, Clone)] +pub struct OnlineKeyRegistrationParams { + pub common_params: CommonParams, + pub vote_key: [u8; 32], + pub selection_key: [u8; 32], + pub vote_first: u64, + pub vote_last: u64, + pub vote_key_dilution: u64, + pub state_proof_key: Option<[u8; 64]>, +} + +#[derive(Debug, Clone)] +pub struct OfflineKeyRegistrationParams { + pub common_params: CommonParams, + pub non_participation: Option, +} + +#[derive(Debug, Clone)] +pub struct NonParticipationKeyRegistrationParams { + pub common_params: CommonParams, +} diff --git a/crates/algokit_utils/src/transactions/mod.rs b/crates/algokit_utils/src/transactions/mod.rs index addae9ed..a34f9a6a 100644 --- a/crates/algokit_utils/src/transactions/mod.rs +++ b/crates/algokit_utils/src/transactions/mod.rs @@ -2,6 +2,7 @@ pub mod application_call; pub mod asset_config; pub mod common; pub mod composer; +pub mod key_registration; // Re-export commonly used transaction types pub use application_call::{ @@ -11,3 +12,7 @@ pub use application_call::{ pub use asset_config::{AssetCreateParams, AssetDestroyParams, AssetReconfigureParams}; pub use common::{CommonParams, DefaultSignerGetter, EmptySigner, TxnSigner, TxnSignerGetter}; pub use composer::{Composer, ComposerError, ComposerTxn, PaymentParams}; +pub use key_registration::{ + NonParticipationKeyRegistrationParams, OfflineKeyRegistrationParams, + OnlineKeyRegistrationParams, +}; diff --git a/crates/algokit_utils/tests/transactions/composer/key_registration.rs b/crates/algokit_utils/tests/transactions/composer/key_registration.rs new file mode 100644 index 00000000..f649cab5 --- /dev/null +++ b/crates/algokit_utils/tests/transactions/composer/key_registration.rs @@ -0,0 +1,442 @@ +use algokit_utils::testing::*; +use algokit_utils::{ + CommonParams, NonParticipationKeyRegistrationParams, OfflineKeyRegistrationParams, + OnlineKeyRegistrationParams, +}; +use base64::{Engine, engine::general_purpose}; + +use crate::common::init_test_logging; + +#[tokio::test] +async fn test_offline_key_registration_transaction() { + init_test_logging(); + + let mut fixture = algorand_fixture().await.expect("Failed to create fixture"); + + fixture + .new_scope() + .await + .expect("Failed to create new scope"); + + let context = fixture.context().expect("Failed to get context"); + let sender_addr = context + .test_account + .address() + .expect("Failed to get sender address"); + + let offline_key_reg_params = OfflineKeyRegistrationParams { + common_params: CommonParams { + sender: sender_addr.clone(), + ..Default::default() + }, + non_participation: Some(false), + }; + + let mut composer = context.composer.clone(); + composer + .add_offline_key_registration(offline_key_reg_params) + .expect("Failed to add offline key registration"); + + let result = composer + .send() + .await + .expect("Failed to send offline key registration"); + + // Assert transaction was confirmed + assert!( + result.confirmed_round.is_some(), + "Transaction should be confirmed" + ); + assert!( + result.confirmed_round.unwrap() > 0, + "Confirmed round should be greater than 0" + ); + + let transaction = result.txn.transaction; + + match transaction { + algokit_transact::Transaction::KeyRegistration(key_reg_fields) => { + assert!( + key_reg_fields.vote_key.is_none(), + "Vote key should be None for offline registration" + ); + assert!( + key_reg_fields.selection_key.is_none(), + "Selection key should be None for offline registration" + ); + assert!( + key_reg_fields.non_participation.is_none(), + "Non participation should be None for offline registration" + ); + } + _ => panic!("Transaction should be a key registration transaction"), + } + + // Verify account participation status + let account_info = context + .algod + .account_information(None, &sender_addr.to_string(), None) + .await + .expect("Failed to get account information"); + + // For offline registration, participation should be empty/none + assert!( + account_info.participation.is_none() + || account_info + .participation + .as_ref() + .is_none_or(|p| p.vote_participation_key.is_empty()), + "Account should not have participation keys after going offline" + ); +} + +#[tokio::test] +async fn test_non_participation_key_registration_transaction() { + init_test_logging(); + + let mut fixture = algorand_fixture().await.expect("Failed to create fixture"); + + fixture + .new_scope() + .await + .expect("Failed to create new scope"); + + let context = fixture.context().expect("Failed to get context"); + let sender_addr = context + .test_account + .address() + .expect("Failed to get sender address"); + + // Use real participation keys for initial online registration + let vote_key = general_purpose::STANDARD + .decode("G/lqTV6MKspW6J8wH2d8ZliZ5XZVZsruqSBJMwLwlmo=") + .expect("Failed to decode vote key") + .try_into() + .expect("Vote key should be 32 bytes"); + + let selection_key = general_purpose::STANDARD + .decode("LrpLhvzr+QpN/bivh6IPpOaKGbGzTTB5lJtVfixmmgk=") + .expect("Failed to decode selection key") + .try_into() + .expect("Selection key should be 32 bytes"); + + let state_proof_key = general_purpose::STANDARD.decode( + "RpUpNWfZMjZ1zOOjv3MF2tjO714jsBt0GKnNsw0ihJ4HSZwci+d9zvUi3i67LwFUJgjQ5Dz4zZgHgGduElnmSA==", + ) + .expect("Failed to decode state proof key") + .try_into() + .expect("State proof key should be 64 bytes"); + + // Step 1: First make the account online to demonstrate the permanent nature of non-participation + let params1 = context + .algod + .transaction_params() + .await + .expect("Failed to get transaction params"); + + let vote_first = params1.last_round; + let vote_last = vote_first + 10_000_000; + + let online_key_reg_params = OnlineKeyRegistrationParams { + common_params: CommonParams { + sender: sender_addr.clone(), + ..Default::default() + }, + vote_key, + selection_key, + vote_first, + vote_last, + vote_key_dilution: 100, + state_proof_key: Some(state_proof_key), + }; + + let mut composer = context.composer.clone(); + composer + .add_online_key_registration(online_key_reg_params) + .expect("Failed to add online key registration"); + + let online_result = composer + .send() + .await + .expect("Failed to send online key registration"); + + assert!( + online_result.confirmed_round.is_some(), + "Online transaction should be confirmed" + ); + + // Verify account is now online + let account_info = context + .algod + .account_information(None, &sender_addr.to_string(), None) + .await + .expect("Failed to get account information"); + + assert!( + account_info.participation.is_some(), + "Account should have participation information after going online" + ); + + // Step 2: Mark account as permanently non-participating + let non_participation_params = NonParticipationKeyRegistrationParams { + common_params: CommonParams { + sender: sender_addr.clone(), + ..Default::default() + }, + }; + + let mut composer2 = context.composer.clone(); + composer2 + .add_non_participation_key_registration(non_participation_params) + .expect("Failed to add non participation key registration"); + + let result = composer2 + .send() + .await + .expect("Failed to send non participation key registration"); + + // Assert transaction was confirmed + assert!( + result.confirmed_round.is_some(), + "Transaction should be confirmed" + ); + assert!( + result.confirmed_round.unwrap() > 0, + "Confirmed round should be greater than 0" + ); + + let transaction = result.txn.transaction; + + match transaction { + algokit_transact::Transaction::KeyRegistration(key_reg_fields) => { + assert!( + key_reg_fields.vote_key.is_none(), + "Vote key should be None for non participation" + ); + assert!( + key_reg_fields.selection_key.is_none(), + "Selection key should be None for non participation" + ); + assert_eq!( + key_reg_fields.non_participation, + Some(true), + "Non participation should be true" + ); + } + _ => panic!("Transaction should be a key registration transaction"), + } + + // Verify account participation status + let account_info = context + .algod + .account_information(None, &sender_addr.to_string(), None) + .await + .expect("Failed to get account information"); + + // For non-participation, participation should be empty/none + assert!( + account_info.participation.is_none() + || account_info + .participation + .as_ref() + .is_none_or(|p| p.vote_participation_key.is_empty()), + "Account should not have participation keys after non-participation registration" + ); + + // Step 3: Verify that once marked as non-participating, account cannot be brought back online + let params3 = context + .algod + .transaction_params() + .await + .expect("Failed to get transaction params"); + + let vote_first_3 = params3.last_round; + let vote_last_3 = vote_first_3 + 10_000_000; + + let try_online_again_params = OnlineKeyRegistrationParams { + common_params: CommonParams { + sender: sender_addr.clone(), + ..Default::default() + }, + vote_key, + selection_key, + vote_first: vote_first_3, + vote_last: vote_last_3, + vote_key_dilution: 100, + state_proof_key: Some(state_proof_key), + }; + + let mut composer3 = context.composer.clone(); + composer3 + .add_online_key_registration(try_online_again_params) + .expect("Failed to add second online key registration"); + + // This should fail because the account is permanently marked as non-participating + let online_again_result = composer3.send().await; + + assert!( + online_again_result.is_err(), + "Attempting to bring a non-participating account back online should fail" + ); + + // Verify the error is related to the account being marked as non-participating + let error_message = online_again_result.unwrap_err().to_string(); + assert!( + error_message.contains("nonparticipatory") + || error_message.contains("non-participating") + || error_message.contains("nonpart") + || error_message.contains("pool error") + || error_message.contains("rejected"), + "Error should indicate the account cannot participate: {}", + error_message + ); +} + +#[tokio::test] +async fn test_online_key_registration_transaction() { + init_test_logging(); + + let mut fixture = algorand_fixture().await.expect("Failed to create fixture"); + + fixture + .new_scope() + .await + .expect("Failed to create new scope"); + + let context = fixture.context().expect("Failed to get context"); + let sender_addr = context + .test_account + .address() + .expect("Failed to get sender address"); + + // Use real participation keys from the Python test + let vote_key = general_purpose::STANDARD + .decode("G/lqTV6MKspW6J8wH2d8ZliZ5XZVZsruqSBJMwLwlmo=") + .expect("Failed to decode vote key") + .try_into() + .expect("Vote key should be 32 bytes"); + + let selection_key = general_purpose::STANDARD + .decode("LrpLhvzr+QpN/bivh6IPpOaKGbGzTTB5lJtVfixmmgk=") + .expect("Failed to decode selection key") + .try_into() + .expect("Selection key should be 32 bytes"); + + let state_proof_key = general_purpose::STANDARD.decode( + "RpUpNWfZMjZ1zOOjv3MF2tjO714jsBt0GKnNsw0ihJ4HSZwci+d9zvUi3i67LwFUJgjQ5Dz4zZgHgGduElnmSA==", + ) + .expect("Failed to decode state proof key") + .try_into() + .expect("State proof key should be 64 bytes"); + + // Get fresh suggested params to use proper voting rounds + let params = context + .algod + .transaction_params() + .await + .expect("Failed to get transaction params"); + + // Use voting rounds from suggested params like in Python test + let vote_first = params.last_round; + let vote_last = vote_first + 10_000_000; // 10 million rounds like Python test + + let online_key_reg_params = OnlineKeyRegistrationParams { + common_params: CommonParams { + sender: sender_addr.clone(), + ..Default::default() + }, + vote_key, + selection_key, + vote_first, + vote_last, + vote_key_dilution: 100, // Same as Python test + state_proof_key: Some(state_proof_key), + }; + + let mut composer = context.composer.clone(); + composer + .add_online_key_registration(online_key_reg_params) + .expect("Failed to add online key registration"); + + // Submit the transaction - should succeed with proper keys and voting rounds + let result = composer + .send() + .await + .expect("Failed to send online key registration"); + + // Assert transaction was confirmed + assert!( + result.confirmed_round.is_some(), + "Transaction should be confirmed" + ); + assert!( + result.confirmed_round.unwrap() > 0, + "Confirmed round should be greater than 0" + ); + + let transaction = result.txn.transaction; + + match transaction { + algokit_transact::Transaction::KeyRegistration(key_reg_fields) => { + assert_eq!( + key_reg_fields.vote_key, + Some(vote_key), + "Vote key should match" + ); + assert_eq!( + key_reg_fields.selection_key, + Some(selection_key), + "Selection key should match" + ); + assert_eq!( + key_reg_fields.vote_first, + Some(vote_first), + "Vote first should match" + ); + assert_eq!( + key_reg_fields.vote_last, + Some(vote_last), + "Vote last should match" + ); + assert_eq!( + key_reg_fields.vote_key_dilution, + Some(100), + "Vote key dilution should match" + ); + assert_eq!( + key_reg_fields.state_proof_key, + Some(state_proof_key), + "State proof key should match" + ); + assert!( + key_reg_fields.non_participation.is_none(), + "Non participation should be None for online registration" + ); + } + _ => panic!("Transaction should be a key registration transaction"), + } + + // Verify account participation status + let account_info = context + .algod + .account_information(None, &sender_addr.to_string(), None) + .await + .expect("Failed to get account information"); + + // For online registration, participation should contain the keys + if let Some(participation) = account_info.participation { + assert!( + !participation.vote_participation_key.is_empty(), + "Account should have participation keys after going online" + ); + + // Verify the participation keys match what we submitted + assert_eq!( + participation.vote_participation_key, + vote_key.to_vec(), + "Vote participation key should match submitted key" + ); + } else { + panic!("Account should have participation information after online key registration"); + } +} diff --git a/crates/algokit_utils/tests/transactions/composer/mod.rs b/crates/algokit_utils/tests/transactions/composer/mod.rs index d3ee60de..36bf22e4 100644 --- a/crates/algokit_utils/tests/transactions/composer/mod.rs +++ b/crates/algokit_utils/tests/transactions/composer/mod.rs @@ -1,3 +1,4 @@ pub mod application_call; pub mod asset_config; +pub mod key_registration; pub mod payment;