Skip to content

Commit 9925ae1

Browse files
authored
Merge pull request #256 from algorandfoundation/fix/handle-no-signer
fix: handle no signer
2 parents 35d6aac + b1df394 commit 9925ae1

File tree

2 files changed

+229
-10
lines changed

2 files changed

+229
-10
lines changed

src/algokit_utils/transactions/transaction_composer.py

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1348,7 +1348,7 @@ def _resolve_param_signer(
13481348
return resolved
13491349
return signer
13501350

1351-
def _sign_transactions(self, txns_with_signers: Sequence[TransactionWithSigner]) -> list[SignedTransaction]:
1351+
def _sign_transactions(self, txns_with_signers: Sequence[TransactionWithSigner]) -> list[SignedTransaction]: # noqa: C901
13521352
if not txns_with_signers:
13531353
raise ValueError("No transactions available to sign")
13541354

@@ -1364,21 +1364,34 @@ def _sign_transactions(self, txns_with_signers: Sequence[TransactionWithSigner])
13641364
for key, (signer, indexes) in signer_groups.items():
13651365
blobs = signer(transactions, indexes)
13661366
signed_blobs[key] = list(blobs)
1367-
if len(blobs) != len(indexes):
1368-
raise ValueError("Signer returned unexpected number of transactions")
13691367

1370-
ordered: list[SignedTransaction | None] = [None] * len(transactions)
1368+
raw_signed_transactions: list[bytes | None] = [None] * len(transactions)
1369+
13711370
for key, (_, indexes) in signer_groups.items():
13721371
blobs = signed_blobs[key]
13731372
for blob_index, txn_index in enumerate(indexes):
1374-
signed_txn = decode_signed_transaction(blobs[blob_index])
1375-
validate_signed_transaction(signed_txn)
1376-
ordered[txn_index] = signed_txn
1373+
if blob_index < len(blobs):
1374+
raw_signed_transactions[txn_index] = blobs[blob_index]
1375+
1376+
unsigned_indexes = [i for i, item in enumerate(raw_signed_transactions) if item is None]
1377+
if unsigned_indexes:
1378+
raise ValueError(f"Transactions at indexes [{', '.join(map(str, unsigned_indexes))}] were not signed")
13771379

1378-
if any(item is None for item in ordered):
1379-
raise ValueError("One or more transactions were not signed")
1380+
# Decode and validate all signed transactions
1381+
signed_transactions: list[SignedTransaction] = []
1382+
for index, stxn in enumerate(raw_signed_transactions):
1383+
if stxn is None:
1384+
# This shouldn't happen due to the check above, but ensures type safety
1385+
raise ValueError(f"Transaction at index {index} was not signed")
1386+
1387+
try:
1388+
signed_transaction = decode_signed_transaction(stxn)
1389+
validate_signed_transaction(signed_transaction)
1390+
signed_transactions.append(signed_transaction)
1391+
except Exception as err:
1392+
raise ValueError(f"Invalid signed transaction at index {index}. {err}") from err
13801393

1381-
return [item for item in ordered if item is not None]
1394+
return signed_transactions
13821395

13831396
def _group_id(self) -> str | None:
13841397
txns = self._transactions_with_signers or []

tests/transactions/test_transaction_composer.py

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -574,3 +574,209 @@ def test_send_without_params_respects_composer_config(
574574
f"simulate_transactions was called {patched.call_count} times, "
575575
"but should not be called when populate_app_call_resources=False"
576576
)
577+
578+
579+
class TestGatherSignatures:
580+
"""Tests for the gather_signatures method."""
581+
582+
def test_should_successfully_sign_a_single_transaction(
583+
self, algorand: AlgorandClient, funded_account: AddressWithSigners
584+
) -> None:
585+
"""Test that a single transaction is signed successfully."""
586+
composer = algorand.new_group()
587+
composer.add_payment(
588+
PaymentParams(
589+
sender=funded_account.addr,
590+
receiver=funded_account.addr,
591+
amount=AlgoAmount.from_micro_algo(1000),
592+
)
593+
)
594+
595+
signed_txns = composer.gather_signatures()
596+
597+
assert len(signed_txns) == 1
598+
assert signed_txns[0] is not None
599+
assert signed_txns[0].sig is not None
600+
601+
def test_should_successfully_sign_multiple_transactions_with_same_signer(
602+
self, algorand: AlgorandClient, funded_account: AddressWithSigners
603+
) -> None:
604+
"""Test that multiple transactions from the same sender are signed correctly."""
605+
composer = algorand.new_group()
606+
composer.add_payment(
607+
PaymentParams(
608+
sender=funded_account.addr,
609+
receiver=funded_account.addr,
610+
amount=AlgoAmount.from_micro_algo(1000),
611+
)
612+
)
613+
composer.add_payment(
614+
PaymentParams(
615+
sender=funded_account.addr,
616+
receiver=funded_account.addr,
617+
amount=AlgoAmount.from_micro_algo(2000),
618+
)
619+
)
620+
621+
signed_txns = composer.gather_signatures()
622+
623+
assert len(signed_txns) == 2
624+
assert signed_txns[0].sig is not None
625+
assert signed_txns[1].sig is not None
626+
627+
def test_should_successfully_sign_transactions_with_multiple_different_signers(
628+
self, algorand: AlgorandClient, funded_account: AddressWithSigners
629+
) -> None:
630+
"""Test that transactions from different senders are each signed correctly."""
631+
# Create and fund a second account
632+
sender2 = algorand.account.random()
633+
algorand.send.payment(
634+
PaymentParams(
635+
sender=funded_account.addr,
636+
receiver=sender2.addr,
637+
amount=AlgoAmount.from_algo(10),
638+
)
639+
)
640+
641+
composer = algorand.new_group()
642+
composer.add_payment(
643+
PaymentParams(
644+
sender=funded_account.addr,
645+
receiver=sender2.addr,
646+
amount=AlgoAmount.from_micro_algo(1000),
647+
)
648+
)
649+
composer.add_payment(
650+
PaymentParams(
651+
sender=sender2.addr,
652+
receiver=funded_account.addr,
653+
amount=AlgoAmount.from_micro_algo(1000),
654+
signer=sender2.signer,
655+
)
656+
)
657+
658+
signed_txns = composer.gather_signatures()
659+
660+
assert len(signed_txns) == 2
661+
assert signed_txns[0].sig is not None
662+
assert signed_txns[1].sig is not None
663+
664+
def test_should_throw_error_when_no_transactions_to_sign(self, algorand: AlgorandClient) -> None:
665+
"""Test that an error is thrown when there are no transactions to sign."""
666+
composer = algorand.new_group()
667+
668+
with pytest.raises(ValueError, match="Cannot build an empty transaction group"):
669+
composer.gather_signatures()
670+
671+
def test_should_throw_error_when_signer_returns_fewer_signed_transactions_than_expected(
672+
self, algorand: AlgorandClient, funded_account: AddressWithSigners
673+
) -> None:
674+
"""Test error handling when a signer returns fewer signed transactions than requested."""
675+
from collections.abc import Sequence
676+
677+
from algokit_transact.models.transaction import Transaction
678+
679+
real_signer = algorand.account.get_signer(funded_account.addr)
680+
681+
# Create a faulty signer that returns fewer signed transactions than requested
682+
def faulty_signer(txns: Sequence[Transaction], indexes: Sequence[int]) -> list[bytes]:
683+
# Only return one signed transaction even if multiple are requested
684+
return real_signer(txns, [indexes[0]])
685+
686+
composer = algorand.new_group()
687+
composer.add_payment(
688+
PaymentParams(
689+
sender=funded_account.addr,
690+
receiver=funded_account.addr,
691+
amount=AlgoAmount.from_micro_algo(1000),
692+
signer=faulty_signer,
693+
)
694+
)
695+
composer.add_payment(
696+
PaymentParams(
697+
sender=funded_account.addr,
698+
receiver=funded_account.addr,
699+
amount=AlgoAmount.from_micro_algo(2000),
700+
signer=faulty_signer,
701+
)
702+
)
703+
704+
with pytest.raises(ValueError, match=r"Transactions at indexes \[1\] were not signed"):
705+
composer.gather_signatures()
706+
707+
def test_should_throw_error_when_signer_returns_none_signed_transaction(
708+
self, algorand: AlgorandClient, funded_account: AddressWithSigners
709+
) -> None:
710+
"""Test error handling when a signer returns None values."""
711+
from collections.abc import Sequence
712+
713+
from algokit_transact.models.transaction import Transaction
714+
715+
# Create a faulty signer that returns array of Nones
716+
def faulty_signer(_txns: Sequence[Transaction], indexes: Sequence[int]) -> list[bytes]:
717+
return [None] * len(indexes) # type: ignore[list-item]
718+
719+
composer = algorand.new_group()
720+
composer.add_payment(
721+
PaymentParams(
722+
sender=funded_account.addr,
723+
receiver=funded_account.addr,
724+
amount=AlgoAmount.from_micro_algo(1000),
725+
signer=faulty_signer,
726+
)
727+
)
728+
729+
# Should provide a clear error message indicating which transaction was not signed
730+
with pytest.raises(ValueError, match=r"Transactions at indexes \[0\] were not signed"):
731+
composer.gather_signatures()
732+
733+
def test_should_throw_error_when_signer_returns_empty_array(
734+
self, algorand: AlgorandClient, funded_account: AddressWithSigners
735+
) -> None:
736+
"""Test error handling when a signer returns an empty array."""
737+
from collections.abc import Sequence
738+
739+
from algokit_transact.models.transaction import Transaction
740+
741+
# Create a faulty signer that returns empty array
742+
def faulty_signer(_txns: Sequence[Transaction], _indexes: Sequence[int]) -> list[bytes]:
743+
return []
744+
745+
composer = algorand.new_group()
746+
composer.add_payment(
747+
PaymentParams(
748+
sender=funded_account.addr,
749+
receiver=funded_account.addr,
750+
amount=AlgoAmount.from_micro_algo(1000),
751+
signer=faulty_signer,
752+
)
753+
)
754+
755+
with pytest.raises(ValueError, match=r"Transactions at indexes \[0\] were not signed"):
756+
composer.gather_signatures()
757+
758+
def test_should_throw_error_when_signer_returns_invalid_signed_transaction_data(
759+
self, algorand: AlgorandClient, funded_account: AddressWithSigners
760+
) -> None:
761+
"""Test error handling when a signer returns malformed signed transaction data."""
762+
from collections.abc import Sequence
763+
764+
from algokit_transact.models.transaction import Transaction
765+
766+
# Create a faulty signer that returns invalid data
767+
def faulty_signer(_txns: Sequence[Transaction], indexes: Sequence[int]) -> list[bytes]:
768+
return [bytes([1, 2, 3]) for _ in indexes]
769+
770+
composer = algorand.new_group()
771+
composer.add_payment(
772+
PaymentParams(
773+
sender=funded_account.addr,
774+
receiver=funded_account.addr,
775+
amount=AlgoAmount.from_micro_algo(1000),
776+
signer=faulty_signer,
777+
)
778+
)
779+
780+
# Should provide a clear error message indicating which transaction had invalid data
781+
with pytest.raises(ValueError, match="Invalid signed transaction at index 0"):
782+
composer.gather_signatures()

0 commit comments

Comments
 (0)