Skip to content

Commit 2e7c300

Browse files
authored
feat: further MultisigAccount alignment with ts variant (#258)
Align Python implementation with TypeScript PR #492 by adding: - Implement from_signature() static method to create MultisigAccount from MultisigSignature - Add create_multisig_transaction() to create empty multisig transaction structure - Add create_multisig_signature() to create empty multisig signature structure - Add apply_signature_to_txn() to apply signatures to signed transactions (immutable) - Remove empty sub_signers validation to support address-only MultisigAccounts These changes enable better multisig workflow support, particularly for delegated logic signatures and signature collection scenarios. Related to algorandfoundation/algokit-utils-ts#492
1 parent 0cabc1e commit 2e7c300

File tree

10 files changed

+437
-73
lines changed

10 files changed

+437
-73
lines changed

src/algokit_transact/multisig.py

Lines changed: 84 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -46,13 +46,26 @@ class MultisigAccount:
4646
sub_signers: Sequence[AddressWithSigners]
4747
"""The list of signing accounts."""
4848

49-
def __post_init__(self) -> None:
50-
if not self.sub_signers:
51-
raise ValueError("sub_signers cannot be empty")
52-
5349
@staticmethod
5450
def from_signature(msig: MultisigSignature) -> "MultisigAccount":
55-
raise NotImplementedError
51+
"""
52+
Create a MultisigAccount from a MultisigSignature.
53+
54+
This is primarily used to extract the multisig address from a signature,
55+
such as when dealing with delegated logic signatures.
56+
57+
Args:
58+
msig: The multisig signature to create the account from
59+
60+
Returns:
61+
A MultisigAccount with no sub-signers
62+
"""
63+
params = MultisigMetadata(
64+
version=msig.version,
65+
threshold=msig.threshold,
66+
addrs=[address_from_public_key(subsig.public_key) for subsig in msig.subsigs],
67+
)
68+
return MultisigAccount(params=params, sub_signers=[])
5669

5770
@cached_property
5871
def _multisig_signature(self) -> MultisigSignature:
@@ -139,3 +152,69 @@ def apply_signature(self, msig: MultisigSignature, address: str, sig: bytes) ->
139152
f"Multisig signature parameters do not match expected multisig parameters. {expected=!r}, {given=!r}"
140153
)
141154
return apply_multisig_subsignature(msig, address, sig)
155+
156+
def create_multisig_transaction(self, txn: AlgokitTransaction) -> SignedTransaction:
157+
"""
158+
Create a multisig transaction without any signatures.
159+
160+
Args:
161+
txn: The transaction to create a multisig transaction for
162+
163+
Returns:
164+
A SignedTransaction with empty multisig structure
165+
"""
166+
msig = self.create_multisig_signature()
167+
168+
auth_address = self.address if txn.sender != self.address else None
169+
170+
return SignedTransaction(
171+
txn=txn,
172+
sig=None,
173+
msig=msig,
174+
lsig=None,
175+
auth_address=auth_address,
176+
)
177+
178+
def create_multisig_signature(self) -> MultisigSignature:
179+
"""
180+
Create an empty multisig signature structure.
181+
182+
Returns:
183+
A MultisigSignature with empty signatures
184+
"""
185+
return new_multisig_signature(
186+
self.params.version,
187+
self.params.threshold,
188+
self.params.addrs,
189+
)
190+
191+
def apply_signature_to_txn(self, txn: SignedTransaction, pubkey: bytes, signature: bytes) -> SignedTransaction:
192+
"""
193+
Apply a signature to a signed transaction, returning a new SignedTransaction.
194+
195+
Note: Unlike TypeScript which mutates in place, this returns a new SignedTransaction
196+
since Python's SignedTransaction is a frozen dataclass.
197+
198+
Args:
199+
txn: The signed transaction to apply the signature to
200+
pubkey: The public key of the signer
201+
signature: The signature to apply
202+
203+
Returns:
204+
A new SignedTransaction with the signature applied
205+
"""
206+
from dataclasses import replace
207+
208+
msig = txn.msig
209+
if not msig:
210+
created_txn = self.create_multisig_transaction(txn.txn)
211+
msig = created_txn.msig
212+
213+
if not msig:
214+
raise ValueError("Failed to create multisig signature")
215+
216+
# Convert to address for validation via apply_signature
217+
address = address_from_public_key(pubkey)
218+
updated_msig = self.apply_signature(msig, address, signature)
219+
220+
return replace(txn, msig=updated_msig)

tests/modules/kmd_client/test_delete_v1_key.py

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,7 @@ def test_basic_request_and_response_validation(
2323
address = generate_test_key(localnet_kmd_client, wallet_handle_token)
2424

2525
# Verify key exists
26-
list_before = localnet_kmd_client.list_keys_in_wallet(
27-
ListKeysRequest(wallet_handle_token=wallet_handle_token)
28-
)
26+
list_before = localnet_kmd_client.list_keys_in_wallet(ListKeysRequest(wallet_handle_token=wallet_handle_token))
2927
assert address in (list_before.addresses or [])
3028

3129
# Delete the key (returns empty response)
@@ -38,7 +36,5 @@ def test_basic_request_and_response_validation(
3836
)
3937

4038
# Verify key was deleted
41-
list_after = localnet_kmd_client.list_keys_in_wallet(
42-
ListKeysRequest(wallet_handle_token=wallet_handle_token)
43-
)
39+
list_after = localnet_kmd_client.list_keys_in_wallet(ListKeysRequest(wallet_handle_token=wallet_handle_token))
4440
assert address not in (list_after.addresses or [])

tests/modules/kmd_client/test_delete_v1_multisig.py

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,7 @@ def test_basic_request_and_response_validation(
2323
multisig_address, _, _, _ = create_test_multisig(localnet_kmd_client, wallet_handle_token)
2424

2525
# Verify multisig exists
26-
list_before = localnet_kmd_client.list_multisig(
27-
ListMultisigRequest(wallet_handle_token=wallet_handle_token)
28-
)
26+
list_before = localnet_kmd_client.list_multisig(ListMultisigRequest(wallet_handle_token=wallet_handle_token))
2927
assert multisig_address in (list_before.addresses or [])
3028

3129
# Delete the multisig (returns empty response)
@@ -38,7 +36,5 @@ def test_basic_request_and_response_validation(
3836
)
3937

4038
# Verify multisig was deleted
41-
list_after = localnet_kmd_client.list_multisig(
42-
ListMultisigRequest(wallet_handle_token=wallet_handle_token)
43-
)
39+
list_after = localnet_kmd_client.list_multisig(ListMultisigRequest(wallet_handle_token=wallet_handle_token))
4440
assert multisig_address not in (list_after.addresses or [])

tests/modules/kmd_client/test_post_v1_key.py

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,15 +17,11 @@ def test_basic_request_and_response_validation(
1717
"""Given a known request validate that the same request can be made using our models. Then, validate that our response model aligns with the known response"""
1818
wallet_handle_token, _, _ = wallet_handle
1919

20-
result = localnet_kmd_client.generate_key(
21-
GenerateKeyRequest(wallet_handle_token=wallet_handle_token)
22-
)
20+
result = localnet_kmd_client.generate_key(GenerateKeyRequest(wallet_handle_token=wallet_handle_token))
2321

2422
assert result.address is not None
2523

2624
# Verify the key exists in the wallet
27-
list_result = localnet_kmd_client.list_keys_in_wallet(
28-
ListKeysRequest(wallet_handle_token=wallet_handle_token)
29-
)
25+
list_result = localnet_kmd_client.list_keys_in_wallet(ListKeysRequest(wallet_handle_token=wallet_handle_token))
3026
addresses = list_result.addresses or []
3127
assert result.address in addresses

tests/modules/kmd_client/test_post_v1_key_list.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,7 @@ def test_basic_request_and_response_validation(
2222
# Generate at least one key
2323
generate_test_key(localnet_kmd_client, wallet_handle_token)
2424

25-
result = localnet_kmd_client.list_keys_in_wallet(
26-
ListKeysRequest(wallet_handle_token=wallet_handle_token)
27-
)
25+
result = localnet_kmd_client.list_keys_in_wallet(ListKeysRequest(wallet_handle_token=wallet_handle_token))
2826

2927
assert result.addresses is not None
3028
assert len(result.addresses) > 0

tests/modules/kmd_client/test_post_v1_multisig_list.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,7 @@ def test_basic_request_and_response_validation(
2222
# Create a multisig first
2323
multisig_address, _, _, _ = create_test_multisig(localnet_kmd_client, wallet_handle_token)
2424

25-
result = localnet_kmd_client.list_multisig(
26-
ListMultisigRequest(wallet_handle_token=wallet_handle_token)
27-
)
25+
result = localnet_kmd_client.list_multisig(ListMultisigRequest(wallet_handle_token=wallet_handle_token))
2826

2927
assert result.addresses is not None
3028
# Verify the multisig is in the list

tests/modules/kmd_client/test_post_v1_wallet_info.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,6 @@ def test_basic_request_and_response_validation(
1717
"""Given a known request validate that the same request can be made using our models. Then, validate that our response model aligns with the known response"""
1818
wallet_handle_token, _, _ = wallet_handle
1919

20-
result = localnet_kmd_client.wallet_info(
21-
WalletInfoRequest(wallet_handle_token=wallet_handle_token)
22-
)
20+
result = localnet_kmd_client.wallet_info(WalletInfoRequest(wallet_handle_token=wallet_handle_token))
2321

2422
assert result.wallet_handle is not None

tests/modules/kmd_client/test_post_v1_wallet_rename.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,7 @@ def test_basic_request_and_response_validation(
3131
assert result.wallet is not None
3232

3333
# Verify the wallet was renamed
34-
wallet_info = localnet_kmd_client.wallet_info(
35-
WalletInfoRequest(wallet_handle_token=wallet_handle_token)
36-
)
34+
wallet_info = localnet_kmd_client.wallet_info(WalletInfoRequest(wallet_handle_token=wallet_handle_token))
3735
assert wallet_info.wallet_handle is not None
3836
assert wallet_info.wallet_handle.wallet is not None
3937
assert wallet_info.wallet_handle.wallet.name == new_wallet_name

0 commit comments

Comments
 (0)