Skip to content

Commit 2da92db

Browse files
committed
fix: algosdk configs respecting port field; Edge case for auto fee handling for readonly calls
1 parent 1ad1507 commit 2da92db

File tree

10 files changed

+370
-29
lines changed

10 files changed

+370
-29
lines changed

docs/markdown/autoapi/algokit_utils/models/network/index.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,11 @@ Connection details for connecting to an {py:class}\`algosdk.v2client.algod.Algod
1515

1616
#### server *: str*
1717

18-
URL for the service e.g. http://localhost:4001 or https://testnet-api.algonode.cloud
18+
URL for the service e.g. http://localhost or https://testnet-api.algonode.cloud
1919

2020
#### token *: str | None* *= None*
2121

22-
API Token to authenticate with the service
22+
API Token to authenticate with the service e.g ‘4001’ or ‘8980’
2323

2424
#### port *: str | int | None* *= None*
2525

docs/markdown/autoapi/algokit_utils/transactions/transaction_composer/index.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -819,7 +819,7 @@ Simulate transaction group execution with configurable validation rules.
819819
* **Parameters:**
820820
* **allow_more_logs** – Whether to allow more logs than the standard limit
821821
* **allow_empty_signatures** – Whether to allow transactions with empty signatures
822-
* **allow_unnamed_resources** – Whether to allow unnamed resources
822+
* **allow_unnamed_resources** – Whether to allow unnamed resources.
823823
* **extra_opcode_budget** – Additional opcode budget to allocate
824824
* **exec_trace_config** – Configuration for execution tracing
825825
* **simulation_round** – Round number to simulate at

docs/markdown/capabilities/transaction-composer.md

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,3 +226,120 @@ This feature is particularly useful when:
226226
- Developing applications where resource requirements may change dynamically
227227

228228
Note: Resource population uses simulation under the hood to detect required resources, so it may add a small overhead to transaction preparation time.
229+
230+
### Covering App Call Inner Transaction Fees
231+
232+
`cover_app_call_inner_transaction_fees` automatically calculate the required fee for a parent app call transaction that sends inner transactions. It leverages the simulate endpoint to discover the inner transactions sent and calculates a fee delta to resolve the optimal fee. This feature also takes care of accounting for any surplus transaction fee at the various levels, so as to effectively minimise the fees needed to successfully handle complex scenarios. This setting only applies when you have constucted at least one app call transaction.
233+
234+
For example:
235+
236+
```python
237+
myMethod = algosdk.ABIMethod.fromSignature('my_method()void')
238+
result = algorand
239+
.new_group()
240+
.add_app_call_method_call(AppCallMethodCallParams(
241+
sender: 'SENDER',
242+
app_id=123,
243+
method=myMethod,
244+
args=[1, 2, 3],
245+
max_fee=AlgoAmount.from_micro_algo(5000), # NOTE: a maxFee value is required when enabling coverAppCallInnerTransactionFees
246+
))
247+
.send(send_params={"cover_app_call_inner_transaction_fees": True})
248+
```
249+
250+
Assuming the app account is not covering any of the inner transaction fees, if `my_method` in the above example sends 2 inner transactions, then the fee calculated for the parent transaction will be 3000 µALGO when the transaction is sent to the network.
251+
252+
The above example also has a `max_fee` of 5000 µALGO specified. An exception will be thrown if the transaction fee execeeds that value, which allows you to set fee limits. The `max_fee` field is required when enabling `cover_app_call_inner_transaction_fees`.
253+
254+
Because `max_fee` is required and an `algosdk.Transaction` does not hold any max fee information, you cannot use the generic `add_transaction()` method on the composer with `cover_app_call_inner_transaction_fees` enabled. Instead use the below, which provides a better overall experience:
255+
256+
```python
257+
my_method = algosdk.abi.Method.from_signature('my_method()void')
258+
259+
# Does not work
260+
result = algorand
261+
.new_group()
262+
.add_transaction(localnet.algorand.create_transaction.app_call_method_call(
263+
AppCallMethodCallParams(
264+
sender='SENDER',
265+
app_id=123,
266+
method=my_method,
267+
args=[1, 2, 3],
268+
max_fee=AlgoAmount.from_micro_algos(5000), # This is only used to create the algosdk.Transaction object and isn't made available to the composer.
269+
)
270+
).transactions[0]
271+
)
272+
.send(send_params={"cover_app_call_inner_transaction_fees": True})
273+
274+
# Works as expected
275+
result = algorand
276+
.new_group()
277+
.add_app_call_method_call(AppCallMethodCallParams(
278+
sender='SENDER',
279+
app_id=123,
280+
method=my_method,
281+
args=[1, 2, 3],
282+
max_fee=AlgoAmount.from_micro_algos(5000),
283+
))
284+
.send(send_params={"cover_app_call_inner_transaction_fees": True})
285+
```
286+
287+
A more complex valid scenario which leverages an app client to send an ABI method call with ABI method call transactions argument is below:
288+
289+
```python
290+
app_factory = algorand.client.get_app_factory(
291+
app_spec='APP_SPEC',
292+
default_sender=sender.addr,
293+
)
294+
295+
app_client_1, _ = app_factory.send.bare.create()
296+
app_client_2, _ = app_factory.send.bare.create()
297+
298+
payment_arg = algorand.create_transaction.payment(
299+
PaymentParams(
300+
sender=sender.addr,
301+
receiver=receiver.addr,
302+
amount=AlgoAmount.from_micro_algos(1),
303+
)
304+
)
305+
306+
# Note the use of .params. here, this ensure that maxFee is still available to the composer
307+
app_call_arg = app_client_2.params.call(
308+
AppCallMethodCallParams(
309+
method='my_other_method',
310+
args=[],
311+
max_fee=AlgoAmount.from_micro_algos(2000),
312+
)
313+
)
314+
315+
result = app_client_1.algorand
316+
.new_group()
317+
.add_app_call_method_call(
318+
app_client_1.params.call(
319+
AppClientMethodCallParams(
320+
method='my_method',
321+
args=[payment_arg, app_call_arg],
322+
max_fee=AlgoAmount.from_micro_algos(5000),
323+
)
324+
),
325+
)
326+
.send({"cover_app_call_inner_transaction_fees": True})
327+
```
328+
329+
This feature should efficiently calculate the minimum fee needed to execute an app call transaction with inners, however we always recommend testing your specific scenario behaves as expected before releasing.
330+
331+
#### Read-only calls
332+
333+
When interacting with read-only calls, the transactions are not sent to the network, instead a simulation is performed to evaluate the transaction.
334+
However, a read-only call will still consume the op budget, so to prevent this, by the default, the following logic is applied:
335+
336+
1. If `max_fee` is not specified, `algokit-utils` will automatically set `max_fee` to `10` Algo.
337+
2. If `max_fee` is specified, provided `max_fee` value will be respected when calculating required fee per read-only call.
338+
339+
In either cases, resource population and app call inner transaction fees will still be automatically calculated and applied as per the rules above however there is no need to explicitly specify `populate_app_call_resources=True` or `cover_app_call_inner_transaction_fees=True` when sending read-only calls.
340+
341+
### Covering App Call Op Budget
342+
343+
The high level Algorand contract authoring languages all have support for ensuring appropriate app op budget is available via `ensure_budget` in Algorand Python, `ensureBudget` in Algorand TypeScript and `increaseOpcodeBudget` in TEALScript. This is great, as it allows contract authors to ensure appropriate budget is available by automatically sending op-up inner transactions to increase the budget available. These op-up inner transactions require the fees to be covered by an account, which is generally the responsibility of the application consumer.
344+
345+
Application consumers may not be immediately aware of the number of op-up inner transactions sent, so it can be difficult for them to determine the exact fees required to successfully execute an application call. Fortunately the `cover_app_call_inner_transaction_fees` setting above can be leveraged to automatically cover the fees for any op-up inner transaction that an application sends. Additionally if a contract author decides to cover the fee for an op-up inner transaction, then the application consumer will not be charged a fee for that transaction.

docs/source/capabilities/transaction-composer.md

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,3 +226,120 @@ This feature is particularly useful when:
226226
- Developing applications where resource requirements may change dynamically
227227

228228
Note: Resource population uses simulation under the hood to detect required resources, so it may add a small overhead to transaction preparation time.
229+
230+
### Covering App Call Inner Transaction Fees
231+
232+
`cover_app_call_inner_transaction_fees` automatically calculate the required fee for a parent app call transaction that sends inner transactions. It leverages the simulate endpoint to discover the inner transactions sent and calculates a fee delta to resolve the optimal fee. This feature also takes care of accounting for any surplus transaction fee at the various levels, so as to effectively minimise the fees needed to successfully handle complex scenarios. This setting only applies when you have constucted at least one app call transaction.
233+
234+
For example:
235+
236+
```python
237+
myMethod = algosdk.ABIMethod.fromSignature('my_method()void')
238+
result = algorand
239+
.new_group()
240+
.add_app_call_method_call(AppCallMethodCallParams(
241+
sender: 'SENDER',
242+
app_id=123,
243+
method=myMethod,
244+
args=[1, 2, 3],
245+
max_fee=AlgoAmount.from_micro_algo(5000), # NOTE: a maxFee value is required when enabling coverAppCallInnerTransactionFees
246+
))
247+
.send(send_params={"cover_app_call_inner_transaction_fees": True})
248+
```
249+
250+
Assuming the app account is not covering any of the inner transaction fees, if `my_method` in the above example sends 2 inner transactions, then the fee calculated for the parent transaction will be 3000 µALGO when the transaction is sent to the network.
251+
252+
The above example also has a `max_fee` of 5000 µALGO specified. An exception will be thrown if the transaction fee execeeds that value, which allows you to set fee limits. The `max_fee` field is required when enabling `cover_app_call_inner_transaction_fees`.
253+
254+
Because `max_fee` is required and an `algosdk.Transaction` does not hold any max fee information, you cannot use the generic `add_transaction()` method on the composer with `cover_app_call_inner_transaction_fees` enabled. Instead use the below, which provides a better overall experience:
255+
256+
```python
257+
my_method = algosdk.abi.Method.from_signature('my_method()void')
258+
259+
# Does not work
260+
result = algorand
261+
.new_group()
262+
.add_transaction(localnet.algorand.create_transaction.app_call_method_call(
263+
AppCallMethodCallParams(
264+
sender='SENDER',
265+
app_id=123,
266+
method=my_method,
267+
args=[1, 2, 3],
268+
max_fee=AlgoAmount.from_micro_algos(5000), # This is only used to create the algosdk.Transaction object and isn't made available to the composer.
269+
)
270+
).transactions[0]
271+
)
272+
.send(send_params={"cover_app_call_inner_transaction_fees": True})
273+
274+
# Works as expected
275+
result = algorand
276+
.new_group()
277+
.add_app_call_method_call(AppCallMethodCallParams(
278+
sender='SENDER',
279+
app_id=123,
280+
method=my_method,
281+
args=[1, 2, 3],
282+
max_fee=AlgoAmount.from_micro_algos(5000),
283+
))
284+
.send(send_params={"cover_app_call_inner_transaction_fees": True})
285+
```
286+
287+
A more complex valid scenario which leverages an app client to send an ABI method call with ABI method call transactions argument is below:
288+
289+
```python
290+
app_factory = algorand.client.get_app_factory(
291+
app_spec='APP_SPEC',
292+
default_sender=sender.addr,
293+
)
294+
295+
app_client_1, _ = app_factory.send.bare.create()
296+
app_client_2, _ = app_factory.send.bare.create()
297+
298+
payment_arg = algorand.create_transaction.payment(
299+
PaymentParams(
300+
sender=sender.addr,
301+
receiver=receiver.addr,
302+
amount=AlgoAmount.from_micro_algos(1),
303+
)
304+
)
305+
306+
# Note the use of .params. here, this ensure that maxFee is still available to the composer
307+
app_call_arg = app_client_2.params.call(
308+
AppCallMethodCallParams(
309+
method='my_other_method',
310+
args=[],
311+
max_fee=AlgoAmount.from_micro_algos(2000),
312+
)
313+
)
314+
315+
result = app_client_1.algorand
316+
.new_group()
317+
.add_app_call_method_call(
318+
app_client_1.params.call(
319+
AppClientMethodCallParams(
320+
method='my_method',
321+
args=[payment_arg, app_call_arg],
322+
max_fee=AlgoAmount.from_micro_algos(5000),
323+
)
324+
),
325+
)
326+
.send({"cover_app_call_inner_transaction_fees": True})
327+
```
328+
329+
This feature should efficiently calculate the minimum fee needed to execute an app call transaction with inners, however we always recommend testing your specific scenario behaves as expected before releasing.
330+
331+
#### Read-only calls
332+
333+
When interacting with read-only calls, the transactions are not sent to the network, instead a simulation is performed to evaluate the transaction.
334+
However, a read-only call will still consume the op budget, so to prevent this, by the default, the following logic is applied:
335+
336+
1. If `max_fee` is not specified, `algokit-utils` will automatically set `max_fee` to `10` Algo.
337+
2. If `max_fee` is specified, provided `max_fee` value will be respected when calculating required fee per read-only call.
338+
339+
In either cases, resource population and app call inner transaction fees will still be automatically calculated and applied as per the rules above however there is no need to explicitly specify `populate_app_call_resources=True` or `cover_app_call_inner_transaction_fees=True` when sending read-only calls.
340+
341+
### Covering App Call Op Budget
342+
343+
The high level Algorand contract authoring languages all have support for ensuring appropriate app op budget is available via `ensure_budget` in Algorand Python, `ensureBudget` in Algorand TypeScript and `increaseOpcodeBudget` in TEALScript. This is great, as it allows contract authors to ensure appropriate budget is available by automatically sending op-up inner transactions to increase the budget available. These op-up inner transactions require the fees to be covered by an account, which is generally the responsibility of the application consumer.
344+
345+
Application consumers may not be immediately aware of the number of op-up inner transactions sent, so it can be difficult for them to determine the exact fees required to successfully execute an application call. Fortunately the `cover_app_call_inner_transaction_fees` setting above can be leveraged to automatically cover the fees for any op-up inner transaction that an application sends. Additionally if a contract author decides to cover the fee for an op-up inner transaction, then the application consumer will not be charged a fee for that transaction.

src/algokit_utils/clients/client_manager.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -361,7 +361,7 @@ def get_algod_client(config: AlgoClientNetworkConfig | None = None) -> AlgodClie
361361
headers = {"X-Algo-API-Token": config.token or ""}
362362
return AlgodClient(
363363
algod_token=config.token or "",
364-
algod_address=f'{config.server}{f':{config.port}' if config.port else ''}',
364+
algod_address=config.full_url(),
365365
headers=headers,
366366
)
367367

@@ -381,7 +381,7 @@ def get_kmd_client(config: AlgoClientNetworkConfig | None = None) -> KMDClient:
381381
:return: KMD client instance
382382
"""
383383
config = config or _get_config_from_environment("KMD")
384-
return KMDClient(config.token, f'{config.server}{f':{config.port}' if config.port else ''}')
384+
return KMDClient(config.token, config.full_url())
385385

386386
@staticmethod
387387
def get_kmd_client_from_environment() -> KMDClient:
@@ -402,7 +402,7 @@ def get_indexer_client(config: AlgoClientNetworkConfig | None = None) -> Indexer
402402
headers = {"X-Indexer-API-Token": config.token}
403403
return IndexerClient(
404404
indexer_token=config.token,
405-
indexer_address=f'{config.server}{f":{config.port}" if config.port else ''}',
405+
indexer_address=config.full_url(),
406406
headers=headers,
407407
)
408408

src/algokit_utils/models/network.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@ class AlgoClientNetworkConfig:
1717
"""API Token to authenticate with the service e.g '4001' or '8980'"""
1818
port: str | int | None = None
1919

20+
def full_url(self) -> str:
21+
"""Returns the full URL for the service"""
22+
return f"{self.server}{f':{self.port}' if self.port else ''}"
23+
2024

2125
@dataclasses.dataclass
2226
class AlgoClientConfigs:

src/algokit_utils/transactions/transaction_composer.py

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -614,6 +614,12 @@ class _TransactionWithPriority:
614614
NULL_SIGNER: TransactionSigner = algosdk.atomic_transaction_composer.EmptySigner()
615615

616616

617+
def _get_dummy_max_fees_for_simulated_opups(group_len: int) -> dict[int, AlgoAmount]:
618+
from algokit_utils.models.amount import AlgoAmount
619+
620+
return {i: AlgoAmount(algo=10) for i in range(group_len)}
621+
622+
617623
def _encode_lease(lease: str | bytes | None) -> bytes | None:
618624
if lease is None:
619625
return None
@@ -801,16 +807,6 @@ def _num_extra_program_pages(approval: bytes | None, clear: bytes | None) -> int
801807
return max(0, (total - 1) // algosdk.constants.APP_PAGE_MAX_SIZE)
802808

803809

804-
def populate_app_call_resources(atc: AtomicTransactionComposer, algod: AlgodClient) -> AtomicTransactionComposer:
805-
"""Populate application call resources based on simulation results.
806-
807-
:param atc: The AtomicTransactionComposer containing transactions
808-
:param algod: Algod client for simulation
809-
:return: Modified AtomicTransactionComposer with populated resources
810-
"""
811-
return prepare_group_for_sending(atc, algod, populate_app_call_resources=True)
812-
813-
814810
def prepare_group_for_sending( # noqa: C901, PLR0912, PLR0915
815811
atc: AtomicTransactionComposer,
816812
algod: AlgodClient,
@@ -1600,6 +1596,8 @@ def build_transactions(self) -> BuiltTransactions:
16001596
signers[idx] = ts.signer
16011597
if isinstance(ts, TransactionWithSignerAndContext) and ts.context.abi_method:
16021598
method_calls[idx] = ts.context.abi_method
1599+
if ts.context.max_fee:
1600+
self._txn_max_fees[idx] = ts.context.max_fee
16031601
idx += 1
16041602

16051603
return BuiltTransactions(transactions=transactions, method_calls=method_calls, signers=signers)
@@ -1681,7 +1679,7 @@ def simulate(
16811679
16821680
:param allow_more_logs: Whether to allow more logs than the standard limit
16831681
:param allow_empty_signatures: Whether to allow transactions with empty signatures
1684-
:param allow_unnamed_resources: Whether to allow unnamed resources
1682+
:param allow_unnamed_resources: Whether to allow unnamed resources.
16851683
:param extra_opcode_budget: Additional opcode budget to allocate
16861684
:param exec_trace_config: Configuration for execution tracing
16871685
:param simulation_round: Round number to simulate at
@@ -1701,6 +1699,17 @@ def simulate(
17011699
else:
17021700
self.build()
17031701

1702+
atc = prepare_group_for_sending(
1703+
atc,
1704+
self._algod,
1705+
populate_app_call_resources=allow_unnamed_resources,
1706+
cover_app_call_inner_transaction_fees=allow_unnamed_resources,
1707+
additional_atc_context=AdditionalAtcContext(
1708+
suggested_params=self._get_suggested_params(),
1709+
max_fees=self._txn_max_fees or _get_dummy_max_fees_for_simulated_opups(atc.get_tx_count()),
1710+
),
1711+
)
1712+
17041713
if config.debug and config.project_root and config.trace_all:
17051714
response = simulate_and_persist_response(
17061715
atc,

0 commit comments

Comments
 (0)