Skip to content

Commit c6aef93

Browse files
fix: readonly simulate extra opcode budget handling (#180)
* fix: readonly simulate extra opcode budget handling The simulate for readonly calls now uses a fixed maximum opcode budget. Updates tests to reflect the new behavior. * chore: remove max fee enforcement for readonly calls * fix: adjust test and clarify some docs * chore: bump urllib (pip audit) --------- Co-authored-by: Neil Campbell <[email protected]>
1 parent dcc4676 commit c6aef93

File tree

3 files changed

+62
-59
lines changed

3 files changed

+62
-59
lines changed

poetry.lock

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/algokit_utils/applications/app_client.py

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,9 @@
9595
# TEAL opcodes for constant blocks
9696
BYTE_CBLOCK = 38 # bytecblock opcode
9797
INT_CBLOCK = 32 # intcblock opcode
98+
MAX_SIMULATE_OPCODE_BUDGET = (
99+
20_000 * 16
100+
) # https://github.com/algorand/go-algorand/blob/807b29a91c371d225e12b9287c5d56e9b33c4e4c/ledger/simulation/trace.go#L104
98101

99102
T = TypeVar("T") # For generic return type in _handle_call_errors
100103

@@ -1183,7 +1186,6 @@ def call(
11831186
:param params: Parameters for the application call including method and transaction options
11841187
:param send_params: Send parameters
11851188
:return: The result of sending or simulating the transaction, including ABI return value if applicable
1186-
:raises ValueError: If the transaction is read-only and `max_fee` is not provided
11871189
"""
11881190
is_read_only_call = (
11891191
params.on_complete == algosdk.transaction.OnComplete.NoOpOC or params.on_complete is None
@@ -1194,14 +1196,10 @@ def call(
11941196
readonly_send_params = send_params or SendParams()
11951197

11961198
# Read-only calls do not require fees to be paid, as they are only simulated on the network.
1197-
# Therefore there is no value in calculating the minimum fee needed for a successful app call with inners.
1198-
# As a a result we only need to send a single simulate call,
1199-
# however to do this successfully we need to ensure fees for the transaction are fully covered using maxFee.
1200-
if readonly_send_params.get("cover_app_call_inner_transaction_fees"):
1201-
if params.max_fee is None:
1202-
raise ValueError(
1203-
"Please provide a `max_fee` for the transaction when `cover_app_call_inner_transaction_fees` is enabled." # noqa: E501
1204-
)
1199+
# With maximum opcode budget provided, ensure_budget won't create inner transactions,
1200+
# so fee coverage is no longer a concern for read-only calls.
1201+
# If max_fee is provided, use it as static_fee for potential benefits.
1202+
if readonly_send_params.get("cover_app_call_inner_transaction_fees") and params.max_fee is not None:
12051203
readonly_params = replace(readonly_params, static_fee=params.max_fee, extra_fee=None)
12061204

12071205
method_call_to_simulate = self._algorand.new_group().add_app_call_method_call(
@@ -1215,11 +1213,13 @@ def run_simulate() -> SendAtomicTransactionComposerResults:
12151213
skip_signatures=True,
12161214
allow_more_logs=True,
12171215
allow_empty_signatures=True,
1218-
extra_opcode_budget=None,
1216+
extra_opcode_budget=MAX_SIMULATE_OPCODE_BUDGET,
12191217
exec_trace_config=None,
12201218
simulation_round=None,
12211219
)
12221220
except Exception as e:
1221+
# For read-only calls with max opcode budget, fee issues should be rare
1222+
# but we can still provide helpful error message if they occur
12231223
if readonly_send_params.get("cover_app_call_inner_transaction_fees") and "fee too small" in str(e):
12241224
raise ValueError(
12251225
"Fees were too small. You may need to increase the transaction `maxFee`."

tests/transactions/test_fee_coverage.py

Lines changed: 49 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -623,64 +623,67 @@ def test_handles_expensive_abi_calls_with_ensure_budget(self) -> None:
623623
assert len(result.confirmation.get("inner-txns", [])) == 9 # type: ignore[union-attr]
624624
self._assert_min_fee(self.app_client1, params, expected_fee)
625625

626-
def test_readonly_handles_expensive_abi_calls_with_ensure_budget(self) -> None:
627-
"""Test fee handling with expensive readonly ABI method calls that use ensure_budget to op-up"""
626+
@pytest.mark.parametrize("cover_inner_fees", [True, False])
627+
def test_readonly_uses_fixed_opcode_budget_without_op_up_inner_transactions(self, cover_inner_fees: bool) -> None: # noqa: FBT001
628+
"""Test that readonly calls use fixed opcode budget and don't require inner transactions for op-ups
629+
regardless of fee coverage setting"""
628630

629-
expected_fee = 12_000
630631
params = AppClientMethodCallParams(
631632
method="burn_ops_readonly",
632-
args=[6200],
633-
max_fee=AlgoAmount.from_micro_algo(expected_fee),
633+
args=[6200], # This would normally require op-ups via inner transactions
634634
)
635-
result = self.app_client1.send.call(params, send_params={"cover_app_call_inner_transaction_fees": True})
636-
637-
assert result.transaction.raw.fee == expected_fee
638-
assert len(result.confirmation.get("inner-txns", [])) == 9 # type: ignore[union-attr]
639-
640-
def test_readonly_throws_when_no_max_fee(self) -> None:
641-
"""Test that error is thrown when no max fee is supplied for a readonly method call"""
642-
with pytest.raises(
643-
ValueError,
644-
match="Please provide a `max_fee` for the transaction when `cover_app_call_inner_transaction_fees` is enabled", # noqa: E501
645-
):
646-
self.app_client1.send.call(
647-
AppClientMethodCallParams(
648-
method="burn_ops_readonly",
649-
args=[6200],
650-
),
651-
send_params={
652-
"cover_app_call_inner_transaction_fees": True,
653-
},
654-
)
655-
656-
def test_readonly_throws_when_inner_fees_not_covered(self) -> None:
657-
"""Test that error is thrown when a readonly method call inner transaction fees are not covered"""
658-
659-
expected_fee = 7000
660-
params = AppClientMethodCallParams(
661-
method="burn_ops_readonly",
662-
args=[6200],
663-
max_fee=AlgoAmount.from_micro_algo(expected_fee),
635+
result = self.app_client1.send.call(
636+
params, send_params={"cover_app_call_inner_transaction_fees": cover_inner_fees}
637+
)
638+
639+
# No op-up inner transactions needed regardless of fee coverage setting
640+
assert len(result.confirmation.get("inner-txns", [])) == 0 # type: ignore[union-attr]
641+
assert result.transaction.raw.fee == 1_000
642+
assert len(result.tx_ids) == 1
643+
644+
def test_readonly_alters_fee_handling_inner_transactions(self) -> None:
645+
"""Test that inner transaction can be covered using the max_fee"""
646+
# Force `send_inners_with_fees` to be marked as readonly
647+
for method in self.app_client1._app_spec.methods: # noqa: SLF001
648+
if method.name == "send_inners_with_fees":
649+
method.readonly = True
650+
break
651+
652+
# The expected_fee differs to non readonly method call,as we don't want to
653+
# run simulate twice (once for resolving the minimum fee and once for the actual transaction result).
654+
# Because no fees are actually paid with readonly calls,
655+
# we simply use the maxFee value (if set) and skip any minimum fee calculations.
656+
# If this method is running in a non readonly context, the minimum fee would be calculated as 5300n.
657+
expected_fee = 12_000
658+
result = self.app_client1.send.call(
659+
AppClientMethodCallParams(
660+
method="send_inners_with_fees",
661+
args=[self.app_client2.app_id, self.app_client3.app_id, [1000, 0, 200, 0, [500, 0]]],
662+
max_fee=AlgoAmount.from_micro_algo(expected_fee),
663+
),
664+
send_params={
665+
"cover_app_call_inner_transaction_fees": True,
666+
},
664667
)
665668

666-
with pytest.raises(Exception, match="fee too small"):
667-
self.app_client1.send.call(
668-
params,
669-
send_params={
670-
"cover_app_call_inner_transaction_fees": False,
671-
},
672-
)
669+
assert result.transaction.raw.fee == expected_fee
670+
assert len(result.confirmation.get("inner-txns", [])) == 4 # type: ignore[union-attr]
671+
assert len(result.tx_ids) == 1
673672

674673
def test_readonly_throws_when_max_fee_too_small(self) -> None:
675674
"""Test that error is thrown when readonly method call max fee is too small to cover inner transaction fees"""
676675

677-
expected_fee = 7000
676+
# Force `send_inners_with_fees` to be marked as readonly
677+
for method in self.app_client1._app_spec.methods: # noqa: SLF001
678+
if method.name == "send_inners_with_fees":
679+
method.readonly = True
680+
break
681+
678682
params = AppClientMethodCallParams(
679-
method="burn_ops_readonly",
680-
args=[6200],
681-
max_fee=AlgoAmount.from_micro_algo(expected_fee),
683+
method="send_inners_with_fees",
684+
args=[self.app_client2.app_id, self.app_client3.app_id, [1000, 0, 200, 0, [500, 0]]],
685+
max_fee=AlgoAmount.from_micro_algo(2000),
682686
)
683-
684687
with pytest.raises(ValueError, match="Fees were too small. You may need to increase the transaction `maxFee`."):
685688
self.app_client1.send.call(
686689
params,

0 commit comments

Comments
 (0)