Skip to content

Commit 3103f7d

Browse files
Fee Estimation Improvement (#27)
Replaced max fee estimation with the more accurate fee estimation function. Created additional unit tests to check: - When input utxo value is small but greater than requested and respect min ada required for change - When input utxo value is small and remainder value is smaller than min ada required for change - When input utxo value is small and remainder value is smaller than min ada required for change, but there are additional utxos in the address that can be selected
1 parent 523e8e8 commit 3103f7d

File tree

2 files changed

+137
-31
lines changed

2 files changed

+137
-31
lines changed

pycardano/txbuilder.py

Lines changed: 42 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@
4141
UTxO,
4242
Value,
4343
)
44-
from pycardano.utils import fee, max_tx_fee, min_lovelace, script_data_hash
44+
from pycardano.utils import fee, min_lovelace, script_data_hash
4545
from pycardano.witness import TransactionWitnessSet, VerificationKeyWitness
4646

4747
__all__ = ["TransactionBuilder"]
@@ -294,6 +294,8 @@ def _calc_change(
294294

295295
# when there is only ADA left, simply use remaining coin value as change
296296
if not change.multi_asset:
297+
if change.coin < min_lovelace(change, self.context):
298+
raise InsufficientUTxOBalanceException("Not enough ADA left for change")
297299
lovelace_change = change.coin
298300
change_output_arr.append(TransactionOutput(address, lovelace_change))
299301

@@ -309,11 +311,7 @@ def _calc_change(
309311
# Combine remainder of provided ADA with last MultiAsset for output
310312
# There may be rare cases where adding ADA causes size exceeds limit
311313
# We will revisit if it becomes an issue
312-
if (
313-
precise_fee
314-
and change.coin - min_lovelace(Value(0, multi_asset), self.context)
315-
< 0
316-
):
314+
if change.coin < min_lovelace(Value(0, multi_asset), self.context):
317315
raise InsufficientUTxOBalanceException(
318316
"Not enough ADA left to cover non-ADA assets in a change address"
319317
)
@@ -335,24 +333,17 @@ def _add_change_and_fee(
335333
self, change_address: Optional[Address]
336334
) -> TransactionBuilder:
337335
original_outputs = self.outputs[:]
336+
338337
if change_address:
339338
# Set fee to max
340-
self.fee = max_tx_fee(self.context)
339+
self.fee = self._estimate_fee()
341340
changes = self._calc_change(
342-
self.fee, self.inputs, self.outputs, change_address, precise_fee=False
341+
self.fee, self.inputs, self.outputs, change_address, precise_fee=True
343342
)
344343
self._outputs += changes
345344

346-
plutus_execution_units = ExecutionUnits(0, 0)
347-
for redeemer in self.redeemers:
348-
plutus_execution_units += redeemer.ex_units
349-
350-
self.fee = fee(
351-
self.context,
352-
len(self._build_full_fake_tx().to_cbor("bytes")),
353-
plutus_execution_units.steps,
354-
plutus_execution_units.mem,
355-
)
345+
# With changes included, we can estimate the fee more precisely
346+
self.fee = self._estimate_fee()
356347

357348
if change_address:
358349
self._outputs = original_outputs
@@ -375,11 +366,15 @@ def _adding_asset_make_output_overflow(
375366
"""Check if adding the asset will make output exceed maximum size limit
376367
377368
Args:
378-
output (TransactionOutput): current output
379-
current_assets (Asset): current Assets to be included in output
380-
policy_id (ScriptHash): policy id containing the MultiAsset
381-
asset_to_add (Asset): Asset to add to current MultiAsset to check size limit
369+
output (TransactionOutput): Current output
370+
current_assets (Asset): Current Assets to be included in output
371+
policy_id (ScriptHash): Policy id containing the MultiAsset
372+
add_asset_name (AssetName): Name of asset to add to current MultiAsset
373+
add_asset_val (int): Value of asset to add to current MultiAsset
374+
max_val_size (int): maximum size limit for output
382375
376+
Returns:
377+
bool: whether adding asset will make output greater than maximum size limit
383378
"""
384379
attempt_assets = deepcopy(current_assets)
385380
attempt_assets += Asset({add_asset_name: add_asset_val})
@@ -577,6 +572,20 @@ def _ensure_no_input_exclusion_conflict(self):
577572
f"{intersection}."
578573
)
579574

575+
def _estimate_fee(self):
576+
plutus_execution_units = ExecutionUnits(0, 0)
577+
for redeemer in self.redeemers:
578+
plutus_execution_units += redeemer.ex_units
579+
580+
estimated_fee = fee(
581+
self.context,
582+
len(self._build_full_fake_tx().to_cbor("bytes")),
583+
plutus_execution_units.steps,
584+
plutus_execution_units.mem,
585+
)
586+
587+
return estimated_fee
588+
580589
def build(self, change_address: Optional[Address] = None) -> TransactionBody:
581590
"""Build a transaction body from all constraints set through the builder.
582591
@@ -601,6 +610,9 @@ def build(self, change_address: Optional[Address] = None) -> TransactionBody:
601610
for o in self.outputs:
602611
requested_amount += o.amount
603612

613+
# Include min fees associated as part of requested amount
614+
requested_amount += self._estimate_fee()
615+
604616
# Trim off assets that are not requested because they will be returned as changes eventually.
605617
trimmed_selected_amount = Value(
606618
selected_amount.coin,
@@ -611,7 +623,13 @@ def build(self, change_address: Optional[Address] = None) -> TransactionBody:
611623
)
612624

613625
unfulfilled_amount = requested_amount - trimmed_selected_amount
614-
unfulfilled_amount.coin = max(0, unfulfilled_amount.coin)
626+
# if remainder is smaller than minimum ADA required in change,
627+
# we need to select additional UTxOs available from the address
628+
unfulfilled_amount.coin = max(
629+
0,
630+
unfulfilled_amount.coin
631+
+ min_lovelace(selected_amount - trimmed_selected_amount, self.context),
632+
)
615633
# Clean up all non-positive assets
616634
unfulfilled_amount.multi_asset = unfulfilled_amount.multi_asset.filter(
617635
lambda p, n, v: v > 0
@@ -637,6 +655,7 @@ def build(self, change_address: Optional[Address] = None) -> TransactionBody:
637655
additional_utxo_pool,
638656
[TransactionOutput(None, unfulfilled_amount)],
639657
self.context,
658+
include_max_fee=False,
640659
)
641660
for s in selected:
642661
selected_amount += s.output.amount

test/pycardano/test_txbuilder.py

Lines changed: 95 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,7 @@ def test_tx_builder_multi_asset(chain_context):
120120

121121
# Add sender address as input
122122
tx_builder.add_input_address(sender).add_output(
123-
TransactionOutput.from_primitive([sender, 1000000])
123+
TransactionOutput.from_primitive([sender, 3000000])
124124
).add_output(
125125
TransactionOutput.from_primitive(
126126
[sender, [2000000, {b"1" * 28: {b"Token1": 1}}]]
@@ -136,7 +136,7 @@ def test_tx_builder_multi_asset(chain_context):
136136
],
137137
1: [
138138
# First output
139-
[sender_address.to_primitive(), 1000000],
139+
[sender_address.to_primitive(), 3000000],
140140
# Second output
141141
[
142142
sender_address.to_primitive(),
@@ -145,7 +145,7 @@ def test_tx_builder_multi_asset(chain_context):
145145
# Third output as change
146146
[
147147
sender_address.to_primitive(),
148-
[7827767, {b"1111111111111111111111111111": {b"Token2": 2}}],
148+
[5827767, {b"1111111111111111111111111111": {b"Token2": 2}}],
149149
],
150150
],
151151
2: 172233,
@@ -171,7 +171,8 @@ def test_tx_builder_raises_utxo_selection(chain_context):
171171
with pytest.raises(UTxOSelectionException) as e:
172172
tx_body = tx_builder.build(change_address=sender_address)
173173
assert "Unfulfilled amount:" in e.value.args[0]
174-
assert "'coin': 991000000" in e.value.args[0]
174+
# The unfulfilled amount includes requested (991000000) and estimated fees (161101)
175+
assert "'coin': 991161101" in e.value.args[0]
175176
assert "{AssetName(b'NewToken'): 1}" in e.value.args[0]
176177

177178

@@ -189,6 +190,92 @@ def test_tx_too_big_exception(chain_context):
189190
tx_body = tx_builder.build(change_address=sender_address)
190191

191192

193+
def test_tx_small_utxo_precise_fee(chain_context):
194+
tx_builder = TransactionBuilder(chain_context)
195+
sender = "addr_test1vrm9x2zsux7va6w892g38tvchnzahvcd9tykqf3ygnmwtaqyfg52x"
196+
sender_address = Address.from_primitive(sender)
197+
198+
tx_in1 = TransactionInput.from_primitive([b"1" * 32, 3])
199+
tx_out1 = TransactionOutput.from_primitive([sender, 4000000])
200+
utxo1 = UTxO(tx_in1, tx_out1)
201+
202+
tx_builder.add_input(utxo1).add_output(
203+
TransactionOutput.from_primitive([sender, 2500000])
204+
)
205+
206+
# This will not fail as we replace max fee constraint with more precise fee calculation
207+
# And remainder is greater than minimum ada required for change
208+
tx_body = tx_builder.build(change_address=sender_address)
209+
210+
expect = {
211+
0: [
212+
[b"11111111111111111111111111111111", 3],
213+
],
214+
1: [
215+
# First output
216+
[sender_address.to_primitive(), 2500000],
217+
# Second output as change
218+
[sender_address.to_primitive(), 1334587],
219+
],
220+
2: 165413,
221+
}
222+
223+
expect == tx_body.to_primitive()
224+
225+
226+
def test_tx_small_utxo_balance_fail(chain_context):
227+
tx_builder = TransactionBuilder(chain_context)
228+
sender = "addr_test1vrm9x2zsux7va6w892g38tvchnzahvcd9tykqf3ygnmwtaqyfg52x"
229+
sender_address = Address.from_primitive(sender)
230+
231+
tx_in1 = TransactionInput.from_primitive([b"1" * 32, 3])
232+
tx_out1 = TransactionOutput.from_primitive([sender, 4000000])
233+
utxo1 = UTxO(tx_in1, tx_out1)
234+
235+
tx_builder.add_input(utxo1).add_output(
236+
TransactionOutput.from_primitive([sender, 3000000])
237+
)
238+
239+
# Balance is smaller than minimum ada required in change
240+
# No more UTxO is available, throwing UTxO selection exception
241+
with pytest.raises(UTxOSelectionException):
242+
tx_body = tx_builder.build(change_address=sender_address)
243+
244+
245+
def test_tx_small_utxo_balance_pass(chain_context):
246+
tx_builder = TransactionBuilder(chain_context)
247+
sender = "addr_test1vrm9x2zsux7va6w892g38tvchnzahvcd9tykqf3ygnmwtaqyfg52x"
248+
sender_address = Address.from_primitive(sender)
249+
250+
tx_in1 = TransactionInput.from_primitive([b"1" * 32, 3])
251+
tx_out1 = TransactionOutput.from_primitive([sender, 4000000])
252+
utxo1 = UTxO(tx_in1, tx_out1)
253+
254+
tx_builder.add_input(utxo1).add_input_address(sender_address).add_output(
255+
TransactionOutput.from_primitive([sender, 3000000])
256+
)
257+
258+
# Balance is smaller than minimum ada required in change
259+
# Additional UTxOs are selected from the input address
260+
tx_body = tx_builder.build(change_address=sender_address)
261+
262+
expected = {
263+
0: [
264+
[b"11111111111111111111111111111111", 3],
265+
[b"11111111111111111111111111111111", 1],
266+
],
267+
1: [
268+
# First output
269+
[sender_address.to_primitive(), 3000000],
270+
# Second output as change
271+
[sender_address.to_primitive(), 5833003],
272+
],
273+
2: 166997,
274+
}
275+
276+
expected == tx_body.to_primitive()
277+
278+
192279
def test_tx_builder_mint_multi_asset(chain_context):
193280
vk1 = VerificationKey.from_cbor(
194281
"58206443a101bdb948366fc87369336224595d36d8b0eee5602cba8b81a024e58473"
@@ -210,7 +297,7 @@ def test_tx_builder_mint_multi_asset(chain_context):
210297
# Add sender address as input
211298
mint = {policy_id.payload: {b"Token1": 1}}
212299
tx_builder.add_input_address(sender).add_output(
213-
TransactionOutput.from_primitive([sender, 1000000])
300+
TransactionOutput.from_primitive([sender, 3000000])
214301
).add_output(TransactionOutput.from_primitive([sender, [2000000, mint]]))
215302
tx_builder.mint = MultiAsset.from_primitive(mint)
216303
tx_builder.native_scripts = [script]
@@ -225,14 +312,14 @@ def test_tx_builder_mint_multi_asset(chain_context):
225312
],
226313
1: [
227314
# First output
228-
[sender_address.to_primitive(), 1000000],
315+
[sender_address.to_primitive(), 3000000],
229316
# Second output
230317
[sender_address.to_primitive(), [2000000, mint]],
231318
# Third output as change
232319
[
233320
sender_address.to_primitive(),
234321
[
235-
7811267,
322+
5811267,
236323
{b"1111111111111111111111111111": {b"Token1": 1, b"Token2": 2}},
237324
],
238325
],
@@ -332,7 +419,7 @@ def test_not_enough_input_amount(chain_context):
332419
TransactionOutput.from_primitive([sender, input_utxo.output.amount])
333420
)
334421

335-
with pytest.raises(InvalidTransactionException):
422+
with pytest.raises(UTxOSelectionException):
336423
# Tx builder must fail here because there is not enough amount of input ADA to pay tx fee
337424
tx_body = tx_builder.build(change_address=sender_address)
338425

0 commit comments

Comments
 (0)