Skip to content

feat!: introduces multisignature abstractions in utils #218

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 2 additions & 1 deletion crates/algokit_utils/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
pub mod clients;
mod multisig;
pub mod testing;
pub mod transactions;

Expand All @@ -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,
};
47 changes: 47 additions & 0 deletions crates/algokit_utils/src/multisig.rs
Original file line number Diff line number Diff line change
@@ -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 {
Copy link
Collaborator

Choose a reason for hiding this comment

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

I'm not completely clear how we intend this to be used.
The MultisigAccount from utils-ts for example requires either accounts with private keys or signer accounts on construction, giving the account the required info to be able to also sign the transaction. https://github.com/algorandfoundation/algokit-utils-ts/blob/main/src/types/account.ts#L20

pub version: u8,
pub threshold: u8,
pub participants: Vec<Address>,
}

impl From<algokit_transact::MultisigSignature> for MultisigAccount {
fn from(multisig: algokit_transact::MultisigSignature) -> Self {
Self {
version: multisig.version,
threshold: multisig.threshold,
participants: multisig.participants(),
}
}
}

impl TryFrom<MultisigAccount> for algokit_transact::MultisigSignature {
type Error = MultisigError;
fn try_from(account: MultisigAccount) -> Result<Self, Self::Error> {
Ok(algokit_transact::MultisigSignature::from_participants(
account.version,
account.threshold,
account.participants,
)?)
}
}

impl TryFrom<MultisigAccount> for Address {
type Error = MultisigError;
fn try_from(account: MultisigAccount) -> Result<Self, Self::Error> {
let msig_signature: algokit_transact::MultisigSignature = account.try_into()?;
Ok(msig_signature.into())
}
}

Copy link
Preview

Copilot AI Jul 29, 2025

Choose a reason for hiding this comment

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

The MultisigSignature struct lacks documentation explaining its purpose and how it differs from algokit_transact::MultisigSignature. Add a docstring explaining when and how this struct should be used.

Suggested change
/// Represents a multisignature with optional subsignatures for each participant.
///
/// This struct is distinct from `algokit_transact::MultisigSignature` in that it
/// focuses on the subsignature details, allowing for partial or incomplete
/// multisignatures. It is used when working with multisignature accounts where
/// some participants may not have signed yet.
///
/// Use this struct when you need to manage or inspect the state of individual
/// subsignatures in a multisignature account.

Copilot uses AI. Check for mistakes.

pub struct MultisigSignature {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Is this needed or can we leverage the existing algokit_transact MultisigSignature?

pub subsignatures: Vec<Option<MultisigSubsignature>>,
}
31 changes: 16 additions & 15 deletions crates/algokit_utils/src/testing/account_helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Arc<dyn TransactionSigner>> {
let test_account_address = self.account().expect("Failed to get test account address");
if address == test_account_address.address() {
Expand All @@ -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],
Expand Down Expand Up @@ -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<Self, Box<dyn std::error::Error + Send + Sync>> {
// Generate a random signing key
Expand Down Expand Up @@ -151,7 +151,7 @@ impl TestAccount {
/// LocalNet dispenser for funding test accounts using AlgoKit CLI
pub struct LocalNetDispenser {
client: AlgodClient,
dispenser_account: Option<TestAccount>,
dispenser_account: Option<TestKeyPairAccount>,
}

impl LocalNetDispenser {
Expand All @@ -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<dyn std::error::Error + Send + Sync>> {
) -> Result<&TestKeyPairAccount, Box<dyn std::error::Error + Send + Sync>> {
if self.dispenser_account.is_none() {
self.dispenser_account = Some(self.fetch_dispenser_from_algokit().await?);
}
Expand All @@ -177,7 +177,7 @@ impl LocalNetDispenser {
/// Fetch the dispenser account using AlgoKit CLI
async fn fetch_dispenser_from_algokit(
&self,
) -> Result<TestAccount, Box<dyn std::error::Error + Send + Sync>> {
) -> Result<TestKeyPairAccount, Box<dyn std::error::Error + Send + Sync>> {
// Get list of accounts to find the one with highest balance
let output = Command::new("algokit")
.args(["goal", "account", "list"])
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -324,11 +324,11 @@ impl TestAccountManager {
pub async fn get_test_account(
&mut self,
config: Option<TestAccountConfig>,
) -> Result<TestAccount, Box<dyn std::error::Error + Send + Sync>> {
) -> Result<TestKeyPairAccount, Box<dyn std::error::Error + Send + Sync>> {
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
Expand Down Expand Up @@ -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<dyn std::error::Error + Send + Sync>> {
) -> Result<(TestKeyPairAccount, TestKeyPairAccount), Box<dyn std::error::Error + Send + Sync>>
{
let sender_config = TestAccountConfig {
initial_funds: 10_000_000, // 10 ALGO
network_type: NetworkType::LocalNet,
Expand All @@ -382,7 +383,7 @@ impl TestAccountManager {
&mut self,
count: usize,
config: Option<TestAccountConfig>,
) -> Result<Vec<TestAccount>, Box<dyn std::error::Error + Send + Sync>> {
) -> Result<Vec<TestKeyPairAccount>, Box<dyn std::error::Error + Send + Sync>> {
let mut accounts = Vec::with_capacity(count);

for _i in 0..count {
Expand All @@ -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());
Expand All @@ -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
Expand Down
8 changes: 5 additions & 3 deletions crates/algokit_utils/src/testing/fixture.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -15,7 +17,7 @@ pub struct AlgorandTestContext {

pub composer: Composer,

pub test_account: TestAccount,
pub test_account: TestKeyPairAccount,

pub account_manager: TestAccountManager,
}
Expand Down Expand Up @@ -75,7 +77,7 @@ impl AlgorandFixture {
pub async fn generate_account(
&mut self,
config: Option<TestAccountConfig>,
) -> Result<TestAccount, Box<dyn std::error::Error + Send + Sync>> {
) -> Result<TestKeyPairAccount, Box<dyn std::error::Error + Send + Sync>> {
let context = self
.context
.as_mut()
Expand Down
2 changes: 1 addition & 1 deletion crates/algokit_utils/src/testing/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
55 changes: 48 additions & 7 deletions crates/algokit_utils/src/transactions/common.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -15,8 +16,8 @@ pub trait TransactionSigner: Send + Sync {
&self,
transaction: &Transaction,
) -> Result<SignedTransaction, String> {
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))
}
}

Expand All @@ -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],
Expand All @@ -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,
})
Expand All @@ -52,7 +53,47 @@ impl TransactionSigner for EmptySigner {
}
}

impl TransactionSignerGetter for EmptySigner {
Copy link
Preview

Copilot AI Jul 29, 2025

Choose a reason for hiding this comment

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

The EmptyMultisigSigner struct lacks documentation explaining its purpose and intended use case. Add a docstring describing when this signer should be used and how it behaves.

Suggested change
/// A signer implementation for multisignature accounts that produces placeholder signatures.
///
/// # Purpose
/// This struct is intended for use in testing or scenarios where real signatures are not required.
/// It creates a multisignature with empty subsignatures (`[0; ALGORAND_SIGNATURE_BYTE_LENGTH]`).
///
/// # Behavior
/// When signing transactions, it populates the multisignature with placeholder subsignatures
/// and does not produce a valid cryptographic signature. This makes it unsuitable for
/// production use but useful for testing or as a placeholder.

Copilot uses AI. Check for mistakes.

#[derive(Clone)]
pub struct EmptyMultisigSigner {
multisig: MultisigAccount,
}

#[async_trait]
impl TransactionSigner for EmptyMultisigSigner {
async fn sign_transactions(
&self,
txns: &[Transaction],
indices: &[usize],
) -> Result<Vec<SignedTransaction>, 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]));

Comment on lines +73 to +76
Copy link
Preview

Copilot AI Jul 29, 2025

Choose a reason for hiding this comment

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

Setting all subsignatures to zero bytes creates an invalid multisig signature. This could be misleading for testing purposes. Consider adding a comment explaining that this creates dummy signatures for testing, or provide a way to create valid empty signatures.

Suggested change
multisig
.subsignatures
.iter_mut()
.for_each(|subsig| subsig.signature = Some([0; ALGORAND_SIGNATURE_BYTE_LENGTH]));
// Set dummy signatures for testing purposes. These are not valid signatures
// and should only be used in non-production scenarios.
multisig
.subsignatures
.iter_mut()
.for_each(|subsig| subsig.signature = Some(Self::generate_dummy_signature()));

Copilot uses AI. Check for mistakes.

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<Arc<dyn TransactionSigner>> {
Some(Arc::new(self.clone()))
}
Expand Down
4 changes: 2 additions & 2 deletions crates/algokit_utils/src/transactions/composer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
Expand Down
2 changes: 1 addition & 1 deletion crates/algokit_utils/src/transactions/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading