Skip to content

Commit 0eb4362

Browse files
authored
Merge pull request #57 from algorandfoundation/feat/dynamic-itxn-group
feat: add `stage` and `submit_staged` functions to support submitting dynamic number of inner transactions in a group
2 parents 388c3e8 + 84ad031 commit 0eb4362

File tree

15 files changed

+846
-4
lines changed

15 files changed

+846
-4
lines changed

docs/coverage.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ See which `algorand-python` stubs are implemented by the `algorand-python-testin
9696
| algopy.itxn.KeyRegistrationInnerTransaction | Emulated |
9797
| algopy.itxn.Payment | Emulated |
9898
| algopy.itxn.PaymentInnerTransaction | Emulated |
99+
| algopy.itxn.submit_staged | Emulated |
99100
| algopy.itxn.submit_txns | Emulated |
100101
| algopy.op.Base64 | Native |
101102
| algopy.op.EC | Native |

docs/testing-guide/transactions.md

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,75 @@ To access the submitted inner transactions:
190190

191191
These methods provide type validation and will raise an error if the requested transaction type doesn't match the actual type of the inner transaction.
192192

193+
### Submitting a group with dynamic number of inner transactions
194+
195+
`algorand-python` supports composing inner transaction groups with a dynamic number of transactions. To use this feature, call the `.stage()` method on inner transaction classes to queue transactions, then call `algopy.itxn.submit_staged()` to submit all staged transactions as a group.
196+
197+
The following example demonstrates how to test this functionality using the `algorand-python-testing` package.
198+
199+
```{testcode}
200+
from algopy import Application, ARC4Contract, Array, Global, arc4, gtxn, itxn, TransactionType, Txn, UInt64, urange
201+
202+
class DynamicItxnGroup(ARC4Contract):
203+
@arc4.abimethod
204+
def distribute(
205+
self, addresses: Array[arc4.Address], funds: gtxn.PaymentTransaction, verifier: Application
206+
) -> None:
207+
assert funds.receiver == Global.current_application_address, "Funds must be sent to app"
208+
209+
assert addresses.length, "must provide some accounts"
210+
211+
share = funds.amount // addresses.length
212+
213+
itxn.Payment(amount=share, receiver=addresses[0].native).stage(begin_group=True)
214+
215+
for i in urange(1, addresses.length):
216+
addr = addresses[i]
217+
itxn.Payment(amount=share, receiver=addr.native).stage()
218+
219+
itxn.ApplicationCall(
220+
app_id=verifier.id, app_args=(arc4.arc4_signature("verify()void"),)
221+
).stage()
222+
223+
itxn.AssetConfig(asset_name="abc").stage()
224+
225+
itxn.submit_staged()
226+
227+
class VerifierContract(ARC4Contract):
228+
@arc4.abimethod
229+
def verify(self) -> None:
230+
for i in urange(Txn.group_index):
231+
txn = gtxn.Transaction(i)
232+
assert txn.type == TransactionType.Payment, "Txn must be pay"
233+
234+
235+
# create contract instaces
236+
verifier = VerifierContract()
237+
dynamic_itxn_group = DynamicItxnGroup()
238+
239+
# get application id for contract instances
240+
verifier_app = context.ledger.get_app(verifier)
241+
dynamic_itxn_group_app = context.ledger.get_app(dynamic_itxn_group)
242+
243+
# create test accounts to distribute funds to and initial fund
244+
addresses = Array([arc4.Address(context.any.account()) for _ in range(3)])
245+
payment = context.any.txn.payment(
246+
amount=UInt64(9),
247+
receiver=dynamic_itxn_group_app.address,
248+
)
249+
250+
# call contract method which creates inner transactions according to number of addresses passed in
251+
dynamic_itxn_group.distribute(addresses, payment, verifier_app)
252+
253+
# get inner transaction group to assert the details
254+
itxns = context.txn.last_group.get_itxn_group(-1)
255+
assert len(itxns) == 5
256+
for i in range(3):
257+
assert itxns.payment(i).amount == 3
258+
assert itxns.application_call(3).app_id == verifier_app
259+
assert itxns.asset_config(4).asset_name == b"abc"
260+
```
261+
193262
## References
194263

195264
- [API](../api.md) for more details on the test context manager and inner transactions related methods that perform implicit inner transaction type validation.

src/_algopy_testing/context_helpers/txn_context.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -326,19 +326,19 @@ def _get_index(self, txn: algopy.gtxn.TransactionBase) -> int:
326326
except ValueError:
327327
raise ValueError("Transaction is not part of this group") from None
328328

329-
def _begin_itxn_group(self) -> None:
329+
def _begin_itxn_group(self, itxn: InnerTransaction | None = None) -> None:
330330
if self._constructing_itxn_group:
331331
raise RuntimeError("itxn begin without itxn submit")
332332

333333
if self.active_txn.on_completion == OnCompleteAction.ClearState:
334334
raise RuntimeError("Cannot begin inner transaction group in a clear state call")
335335

336-
self._constructing_itxn_group.append(InnerTransaction())
336+
self._constructing_itxn_group.append(itxn or InnerTransaction())
337337

338-
def _append_itxn_group(self) -> None:
338+
def _append_itxn_group(self, itxn: InnerTransaction | None = None) -> None:
339339
if not self._constructing_itxn_group:
340340
raise RuntimeError("itxn next without itxn begin")
341-
self._constructing_itxn_group.append(InnerTransaction())
341+
self._constructing_itxn_group.append(itxn or InnerTransaction())
342342

343343
def _submit_itxn_group(self) -> None:
344344
if not self._constructing_itxn_group:

src/_algopy_testing/itxn.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
"KeyRegistrationInnerTransaction",
3434
"Payment",
3535
"PaymentInnerTransaction",
36+
"submit_staged",
3637
"submit_txns",
3738
]
3839

@@ -113,6 +114,12 @@ def set(self, **fields: typing.Any) -> None:
113114
_narrow_covariant_types(fields)
114115
self.fields.update(fields)
115116

117+
def stage(self, *, begin_group: bool = False) -> None:
118+
if begin_group:
119+
lazy_context.active_group._begin_itxn_group(self) # type: ignore[arg-type]
120+
else:
121+
lazy_context.active_group._append_itxn_group(self) # type: ignore[arg-type]
122+
116123
def submit(self) -> _TResult_co:
117124
result = _get_itxn_result(self)
118125
lazy_context.active_group._add_itxn_group([result]) # type: ignore[list-item]
@@ -170,6 +177,10 @@ def submit_txns(
170177
return results
171178

172179

180+
def submit_staged() -> None:
181+
lazy_context.active_group._submit_itxn_group()
182+
183+
173184
def _get_itxn_result(
174185
itxn: _BaseInnerTransactionFields[_TResult_co],
175186
) -> _BaseInnerTransactionResult:

tests/artifacts/DynamicITxnGroup/__init__.py

Whitespace-only changes.
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
from algopy import (
2+
Application,
3+
ARC4Contract,
4+
Array,
5+
Global,
6+
arc4,
7+
gtxn,
8+
itxn,
9+
urange,
10+
)
11+
12+
13+
class DynamicItxnGroup(ARC4Contract):
14+
@arc4.abimethod
15+
def test_firstly(
16+
self, addresses: Array[arc4.Address], funds: gtxn.PaymentTransaction, verifier: Application
17+
) -> None:
18+
assert funds.receiver == Global.current_application_address, "Funds must be sent to app"
19+
20+
assert addresses.length, "must provide some accounts"
21+
22+
share = funds.amount // addresses.length
23+
24+
itxn.Payment(amount=share, receiver=addresses[0].native).stage(begin_group=True)
25+
26+
for i in urange(1, addresses.length):
27+
addr = addresses[i]
28+
itxn.Payment(amount=share, receiver=addr.native).stage()
29+
30+
itxn.ApplicationCall(
31+
app_id=verifier.id, app_args=(arc4.arc4_signature("verify()void"),)
32+
).stage()
33+
34+
itxn.AssetConfig(asset_name="abc").stage()
35+
36+
itxn.submit_staged()
37+
38+
@arc4.abimethod
39+
def test_looply(
40+
self,
41+
addresses: Array[arc4.Address],
42+
funds: gtxn.PaymentTransaction,
43+
verifier: Application,
44+
) -> None:
45+
assert funds.receiver == Global.current_application_address, "Funds must be sent to app"
46+
47+
assert addresses.length, "must provide some accounts"
48+
49+
share = funds.amount // addresses.length
50+
51+
is_first = True
52+
for addr in addresses:
53+
my_txn = itxn.Payment(amount=share, receiver=addr.native)
54+
my_txn.stage(begin_group=is_first)
55+
is_first = False
56+
57+
itxn.ApplicationCall(
58+
app_id=verifier.id, app_args=(arc4.arc4_signature("verify()void"),)
59+
).stage()
60+
61+
itxn.AssetConfig(asset_name="abc").stage()
62+
63+
itxn.submit_staged()

0 commit comments

Comments
 (0)