diff --git a/crates/algokit_utils/src/lib.rs b/crates/algokit_utils/src/lib.rs index 7b6f6894..d5b68d7b 100644 --- a/crates/algokit_utils/src/lib.rs +++ b/crates/algokit_utils/src/lib.rs @@ -1,4 +1,5 @@ pub mod clients; +mod multisig; pub mod testing; pub mod transactions; @@ -13,7 +14,7 @@ pub use testing::{ pub use transactions::{ AccountCloseParams, ApplicationCallParams, ApplicationCreateParams, ApplicationDeleteParams, ApplicationUpdateParams, AssetCreateParams, AssetDestroyParams, AssetReconfigureParams, - CommonParams, Composer, ComposerError, ComposerTransaction, EmptySigner, + CommonParams, Composer, ComposerError, ComposerTransaction, EmptyKeyPairSigner, NonParticipationKeyRegistrationParams, OfflineKeyRegistrationParams, OnlineKeyRegistrationParams, PaymentParams, TransactionSigner, TransactionSignerGetter, }; diff --git a/crates/algokit_utils/src/multisig.rs b/crates/algokit_utils/src/multisig.rs new file mode 100644 index 00000000..bdf75329 --- /dev/null +++ b/crates/algokit_utils/src/multisig.rs @@ -0,0 +1,47 @@ +use algokit_transact::{Address, MultisigSubsignature}; + +#[derive(Debug, thiserror::Error)] +pub enum MultisigError { + #[error("Invalid multisig account: {0}")] + InvalidMultisigSignature(#[from] algokit_transact::AlgoKitTransactError), +} + +#[derive(Clone)] +pub struct MultisigAccount { + pub version: u8, + pub threshold: u8, + pub participants: Vec
, +} + +impl From for MultisigAccount { + fn from(multisig: algokit_transact::MultisigSignature) -> Self { + Self { + version: multisig.version, + threshold: multisig.threshold, + participants: multisig.participants(), + } + } +} + +impl TryFrom for algokit_transact::MultisigSignature { + type Error = MultisigError; + fn try_from(account: MultisigAccount) -> Result { + Ok(algokit_transact::MultisigSignature::from_participants( + account.version, + account.threshold, + account.participants, + )?) + } +} + +impl TryFrom for Address { + type Error = MultisigError; + fn try_from(account: MultisigAccount) -> Result { + let msig_signature: algokit_transact::MultisigSignature = account.try_into()?; + Ok(msig_signature.into()) + } +} + +pub struct MultisigSignature { + pub subsignatures: Vec>, +} diff --git a/crates/algokit_utils/src/testing/account_helpers.rs b/crates/algokit_utils/src/testing/account_helpers.rs index 244bca44..fdfe82cd 100644 --- a/crates/algokit_utils/src/testing/account_helpers.rs +++ b/crates/algokit_utils/src/testing/account_helpers.rs @@ -49,13 +49,13 @@ pub enum NetworkType { /// A test account using algokit_transact and ed25519_dalek with proper Algorand mnemonics #[derive(Debug, Clone)] -pub struct TestAccount { +pub struct TestKeyPairAccount { /// The ed25519 secret key used for signing transactions secret_key: [u8; ALGORAND_SECRET_KEY_BYTE_LENGTH], } // Implement TransactionSignerGetter for TestAccount as well -impl TransactionSignerGetter for TestAccount { +impl TransactionSignerGetter for TestKeyPairAccount { fn get_signer(&self, address: Address) -> Option> { let test_account_address = self.account().expect("Failed to get test account address"); if address == test_account_address.address() { @@ -67,7 +67,7 @@ impl TransactionSignerGetter for TestAccount { } #[async_trait] -impl TransactionSigner for TestAccount { +impl TransactionSigner for TestKeyPairAccount { async fn sign_transactions( &self, txns: &[Transaction], @@ -109,7 +109,7 @@ impl TransactionSigner for TestAccount { } } -impl TestAccount { +impl TestKeyPairAccount { /// Generate a new random test account using ed25519_dalek pub fn generate() -> Result> { // Generate a random signing key @@ -151,7 +151,7 @@ impl TestAccount { /// LocalNet dispenser for funding test accounts using AlgoKit CLI pub struct LocalNetDispenser { client: AlgodClient, - dispenser_account: Option, + dispenser_account: Option, } impl LocalNetDispenser { @@ -166,7 +166,7 @@ impl LocalNetDispenser { /// Get the LocalNet dispenser account from AlgoKit CLI pub async fn get_dispenser_account( &mut self, - ) -> Result<&TestAccount, Box> { + ) -> Result<&TestKeyPairAccount, Box> { if self.dispenser_account.is_none() { self.dispenser_account = Some(self.fetch_dispenser_from_algokit().await?); } @@ -177,7 +177,7 @@ impl LocalNetDispenser { /// Fetch the dispenser account using AlgoKit CLI async fn fetch_dispenser_from_algokit( &self, - ) -> Result> { + ) -> Result> { // Get list of accounts to find the one with highest balance let output = Command::new("algokit") .args(["goal", "account", "list"]) @@ -242,7 +242,7 @@ impl LocalNetDispenser { .ok_or("Could not extract mnemonic from algokit output")?; // Create account from mnemonic using proper Algorand mnemonic parsing - TestAccount::from_mnemonic(mnemonic) + TestKeyPairAccount::from_mnemonic(mnemonic) } /// Fund an account with ALGOs using the dispenser @@ -324,11 +324,11 @@ impl TestAccountManager { pub async fn get_test_account( &mut self, config: Option, - ) -> Result> { + ) -> Result> { let config = config.unwrap_or_default(); // Generate new account using ed25519_dalek - let test_account = TestAccount::generate()?; + let test_account = TestKeyPairAccount::generate()?; let address_str = test_account.account()?.to_string(); // Fund the account based on network type @@ -358,7 +358,8 @@ impl TestAccountManager { /// Create a funded account pair (sender, receiver) for testing pub async fn create_account_pair( &mut self, - ) -> Result<(TestAccount, TestAccount), Box> { + ) -> Result<(TestKeyPairAccount, TestKeyPairAccount), Box> + { let sender_config = TestAccountConfig { initial_funds: 10_000_000, // 10 ALGO network_type: NetworkType::LocalNet, @@ -382,7 +383,7 @@ impl TestAccountManager { &mut self, count: usize, config: Option, - ) -> Result, Box> { + ) -> Result, Box> { let mut accounts = Vec::with_capacity(count); for _i in 0..count { @@ -404,7 +405,7 @@ mod tests { #[tokio::test] async fn test_account_generation_with_algokit_transact() { // Test basic account generation using algokit_transact and ed25519_dalek with proper mnemonics - let account = TestAccount::generate().expect("Failed to generate test account"); + let account = TestKeyPairAccount::generate().expect("Failed to generate test account"); let address = account.account().expect("Failed to get address"); assert!(!address.to_string().is_empty()); @@ -420,13 +421,13 @@ mod tests { #[tokio::test] async fn test_account_from_mnemonic_with_algokit_transact() { - let original = TestAccount::generate().expect("Failed to generate test account"); + let original = TestKeyPairAccount::generate().expect("Failed to generate test account"); let mnemonic = original.mnemonic(); // Only test round-trip if we have a proper mnemonic (not hex fallback) if mnemonic.split_whitespace().count() == 25 { // Recover account from mnemonic using proper Algorand mnemonic parsing - let recovered = TestAccount::from_mnemonic(&mnemonic) + let recovered = TestKeyPairAccount::from_mnemonic(&mnemonic) .expect("Failed to recover account from mnemonic"); // Both should have the same address diff --git a/crates/algokit_utils/src/testing/fixture.rs b/crates/algokit_utils/src/testing/fixture.rs index e96ed0f2..b35dde1e 100644 --- a/crates/algokit_utils/src/testing/fixture.rs +++ b/crates/algokit_utils/src/testing/fixture.rs @@ -1,6 +1,8 @@ use std::sync::Arc; -use super::account_helpers::{NetworkType, TestAccount, TestAccountConfig, TestAccountManager}; +use super::account_helpers::{ + NetworkType, TestAccountConfig, TestAccountManager, TestKeyPairAccount, +}; use crate::{AlgoConfig, ClientManager, Composer}; use algod_client::AlgodClient; use algokit_transact::Transaction; @@ -15,7 +17,7 @@ pub struct AlgorandTestContext { pub composer: Composer, - pub test_account: TestAccount, + pub test_account: TestKeyPairAccount, pub account_manager: TestAccountManager, } @@ -75,7 +77,7 @@ impl AlgorandFixture { pub async fn generate_account( &mut self, config: Option, - ) -> Result> { + ) -> Result> { let context = self .context .as_mut() diff --git a/crates/algokit_utils/src/testing/mod.rs b/crates/algokit_utils/src/testing/mod.rs index 85a6e5fb..bb1081e9 100644 --- a/crates/algokit_utils/src/testing/mod.rs +++ b/crates/algokit_utils/src/testing/mod.rs @@ -8,5 +8,5 @@ pub use fixture::{ // Re-export commonly used items from account_helpers for convenience pub use account_helpers::{ - LocalNetDispenser, NetworkType, TestAccount, TestAccountConfig, TestAccountManager, + LocalNetDispenser, NetworkType, TestAccountConfig, TestAccountManager, TestKeyPairAccount, }; diff --git a/crates/algokit_utils/src/transactions/common.rs b/crates/algokit_utils/src/transactions/common.rs index 2629fcf4..f4339526 100644 --- a/crates/algokit_utils/src/transactions/common.rs +++ b/crates/algokit_utils/src/transactions/common.rs @@ -1,4 +1,5 @@ -use algokit_transact::{Address, SignedTransaction, Transaction}; +use crate::multisig::MultisigAccount; +use algokit_transact::{ALGORAND_SIGNATURE_BYTE_LENGTH, Address, SignedTransaction, Transaction}; use async_trait::async_trait; use derive_more::Debug; use std::sync::Arc; @@ -15,8 +16,8 @@ pub trait TransactionSigner: Send + Sync { &self, transaction: &Transaction, ) -> Result { - let result = self.sign_transactions(&[transaction.clone()], &[0]).await?; - Ok(result[0].clone()) + let mut result = self.sign_transactions(&[transaction.clone()], &[0]).await?; + Ok(result.remove(0)) } } @@ -25,10 +26,10 @@ pub trait TransactionSignerGetter { } #[derive(Clone)] -pub struct EmptySigner {} +pub struct EmptyKeyPairSigner {} #[async_trait] -impl TransactionSigner for EmptySigner { +impl TransactionSigner for EmptyKeyPairSigner { async fn sign_transactions( &self, txns: &[Transaction], @@ -40,7 +41,7 @@ impl TransactionSigner for EmptySigner { if idx < txns.len() { Ok(SignedTransaction { transaction: txns[idx].clone(), - signature: Some([0; 64]), + signature: Some([0; ALGORAND_SIGNATURE_BYTE_LENGTH]), auth_address: None, multisignature: None, }) @@ -52,7 +53,47 @@ impl TransactionSigner for EmptySigner { } } -impl TransactionSignerGetter for EmptySigner { +#[derive(Clone)] +pub struct EmptyMultisigSigner { + multisig: MultisigAccount, +} + +#[async_trait] +impl TransactionSigner for EmptyMultisigSigner { + async fn sign_transactions( + &self, + txns: &[Transaction], + indices: &[usize], + ) -> Result, String> { + let mut multisig: algokit_transact::MultisigSignature = self + .multisig + .clone() + .try_into() + .map_err(|e| format!("Failed to convert multisig: {}", e))?; + multisig + .subsignatures + .iter_mut() + .for_each(|subsig| subsig.signature = Some([0; ALGORAND_SIGNATURE_BYTE_LENGTH])); + + indices + .iter() + .map(|&idx| { + if idx < txns.len() { + Ok(SignedTransaction { + transaction: txns[idx].clone(), + signature: None, + auth_address: None, + multisignature: Some(multisig.clone()), + }) + } else { + Err(format!("Index {} out of bounds for transactions", idx)) + } + }) + .collect() + } +} + +impl TransactionSignerGetter for EmptyKeyPairSigner { fn get_signer(&self, _address: Address) -> Option> { Some(Arc::new(self.clone())) } diff --git a/crates/algokit_utils/src/transactions/composer.rs b/crates/algokit_utils/src/transactions/composer.rs index 7c5562bb..c96d7e28 100644 --- a/crates/algokit_utils/src/transactions/composer.rs +++ b/crates/algokit_utils/src/transactions/composer.rs @@ -213,12 +213,12 @@ impl Composer { #[cfg(feature = "default_http_client")] pub fn testnet() -> Self { - use crate::EmptySigner; + use crate::EmptyKeyPairSigner; Composer { transactions: Vec::new(), algod_client: AlgodClient::testnet(), - signer_getter: Arc::new(EmptySigner {}), + signer_getter: Arc::new(EmptyKeyPairSigner {}), built_group: None, signed_group: None, } diff --git a/crates/algokit_utils/src/transactions/mod.rs b/crates/algokit_utils/src/transactions/mod.rs index 1eeb1b67..06972a61 100644 --- a/crates/algokit_utils/src/transactions/mod.rs +++ b/crates/algokit_utils/src/transactions/mod.rs @@ -13,7 +13,7 @@ pub use application_call::{ }; pub use asset_config::{AssetCreateParams, AssetDestroyParams, AssetReconfigureParams}; pub use asset_freeze::{AssetFreezeParams, AssetUnfreezeParams}; -pub use common::{CommonParams, EmptySigner, TransactionSigner, TransactionSignerGetter}; +pub use common::{CommonParams, EmptyKeyPairSigner, TransactionSigner, TransactionSignerGetter}; pub use composer::{ AssetClawbackParams, AssetOptInParams, AssetOptOutParams, AssetTransferParams, Composer, ComposerError, ComposerTransaction,