diff --git a/integration-test/test/base.py b/integration-test/test/base.py index 7c174529..36ff879f 100644 --- a/integration-test/test/base.py +++ b/integration-test/test/base.py @@ -57,15 +57,21 @@ class TestBase: payment_key_pair = PaymentKeyPair.generate() stake_key_pair = StakeKeyPair.generate() - @retry(tries=TEST_RETRIES, delay=3) - def assert_output(self, target_address, target_output): + @retry(tries=10, delay=3) + def assert_output(self, target_address, target): utxos = self.chain_context.utxos(target_address) found = False for utxo in utxos: - output = utxo.output - if output == target_output: - found = True + if isinstance(target, UTxO): + if utxo == target: + found = True + if isinstance(target, TransactionOutput): + if utxo.output == target: + found = True + if isinstance(target, TransactionId): + if utxo.input.transaction_id == target: + found = True assert found, f"Cannot find target UTxO in address: {target_address}" @@ -84,4 +90,5 @@ def fund(self, source_address, source_key, target_address, amount=5000000): print(signed_tx.to_cbor_hex()) print("############### Submitting transaction ###############") self.chain_context.submit_tx(signed_tx) - self.assert_output(target_address, target_output=output) + target_utxo = UTxO(TransactionInput(signed_tx.id, 0), output) + self.assert_output(target_address, target_utxo) diff --git a/integration-test/test/test_mint.py b/integration-test/test/test_mint.py index 35ced77b..e439e894 100644 --- a/integration-test/test/test_mint.py +++ b/integration-test/test/test_mint.py @@ -169,6 +169,8 @@ def load_or_create_key_pair(base_dir, base_name): @retry(tries=TEST_RETRIES, backoff=1.3, delay=2, jitter=(0, 10)) def test_mint_nft_with_script(self): address = Address(self.payment_vkey.hash(), network=self.NETWORK) + # Create a collateral + self.fund(address, self.payment_skey, address) with open("./plutus_scripts/fortytwoV2.plutus", "r") as f: script_hex = f.read() @@ -229,13 +231,13 @@ def test_mint_nft_with_script(self): nft_output = TransactionOutput(address, Value(min_val, my_nft)) builder.add_output(nft_output) - # Create a collateral - self.fund(address, self.payment_skey, address) - non_nft_utxo = None for utxo in self.chain_context.utxos(address): # multi_asset should be empty for collateral utxo - if not utxo.output.amount.multi_asset: + if ( + not utxo.output.amount.multi_asset + and utxo.output.amount.coin >= 5000000 + ): non_nft_utxo = utxo break diff --git a/integration-test/test/test_plutus.py b/integration-test/test/test_plutus.py index 2b60dd40..6fdbe27b 100644 --- a/integration-test/test/test_plutus.py +++ b/integration-test/test/test_plutus.py @@ -463,7 +463,8 @@ def test_plutus_v3_unroll(self): builder = TransactionBuilder(self.chain_context) builder.add_input_address(giver_address) - builder.add_output(TransactionOutput(script_address, 50000000, datum=Unit())) + output = TransactionOutput(script_address, 50000000, datum=Unit()) + builder.add_output(output) signed_tx = builder.build_and_sign([self.payment_skey], giver_address) @@ -472,7 +473,7 @@ def test_plutus_v3_unroll(self): print(signed_tx.to_cbor_hex()) print("############### Submitting transaction ###############") self.chain_context.submit_tx(signed_tx) - time.sleep(3) + time.sleep(6) # ----------- Taker take --------------- diff --git a/pycardano/coinselection.py b/pycardano/coinselection.py index ce8dcb5c..e88a740d 100644 --- a/pycardano/coinselection.py +++ b/pycardano/coinselection.py @@ -3,6 +3,7 @@ """ import random +from copy import deepcopy from typing import Iterable, List, Optional, Tuple from pycardano.address import Address @@ -36,6 +37,7 @@ def select( max_input_count: Optional[int] = None, include_max_fee: Optional[bool] = True, respect_min_utxo: Optional[bool] = True, + existing_amount: Optional[Value] = None, ) -> Tuple[List[UTxO], Value]: """From an input list of UTxOs, select a subset of UTxOs whose sum (including ADA and multi-assets) is equal to or larger than the sum of a set of outputs. @@ -50,6 +52,7 @@ def select( respect_min_utxo (bool): Respect minimum amount of ADA required to hold a multi-asset bundle in the change. Defaults to True. If disabled, the selection will not add addition amount of ADA to change even when the amount is too small to hold a multi-asset bundle. + existing_amount (Value): A starting amount already existed before selection. Defaults to 0. Returns: Tuple[List[UTxO], Value]: A tuple containing: @@ -83,6 +86,7 @@ def select( max_input_count: Optional[int] = None, include_max_fee: Optional[bool] = True, respect_min_utxo: Optional[bool] = True, + existing_amount: Optional[Value] = None, ) -> Tuple[List[UTxO], Value]: available: List[UTxO] = sorted(utxos, key=lambda utxo: utxo.output.lovelace) max_fee = max_tx_fee(context) if include_max_fee else 0 @@ -91,7 +95,7 @@ def select( total_requested += o.amount selected = [] - selected_amount = Value() + selected_amount = existing_amount if existing_amount is not None else Value() while not total_requested <= selected_amount: if not available: @@ -99,7 +103,6 @@ def select( to_add = available.pop() selected.append(to_add) selected_amount += to_add.output.amount - if max_input_count and len(selected) > max_input_count: raise MaxInputCountExceededException( f"Max input count: {max_input_count} exceeded!" @@ -108,9 +111,8 @@ def select( if respect_min_utxo: change = selected_amount - total_requested min_change_amount = min_lovelace_post_alonzo( - TransactionOutput(_FAKE_ADDR, change), context + TransactionOutput(_FAKE_ADDR, deepcopy(change)), context ) - if change.coin < min_change_amount: additional, _ = self.select( available, @@ -127,7 +129,6 @@ def select( for u in additional: selected.append(u) selected_amount += u.output.amount - return selected, selected_amount - total_requested @@ -218,10 +219,9 @@ def _find_diff_by_former(a: Value, b: Value) -> int: else: policy_id = list(a.multi_asset.keys())[0] asset_name = list(a.multi_asset[policy_id].keys())[0] - return ( - a.multi_asset[policy_id][asset_name] - - b.multi_asset[policy_id][asset_name] - ) + return a.multi_asset[policy_id].get(asset_name, 0) - b.multi_asset[ + policy_id + ].get(asset_name, 0) def _improve( self, @@ -272,6 +272,7 @@ def select( max_input_count: Optional[int] = None, include_max_fee: Optional[bool] = True, respect_min_utxo: Optional[bool] = True, + existing_amount: Optional[Value] = None, ) -> Tuple[List[UTxO], Value]: # Shallow copy the list remaining = list(utxos) @@ -281,11 +282,13 @@ def select( request_sum += o.amount assets = self._split_by_asset(request_sum) + request_sorted = sorted(assets, key=self._get_single_asset_val, reverse=True) # Phase 1 - random select selected: List[UTxO] = [] - selected_amount = Value() + selected_amount = existing_amount if existing_amount is not None else Value() + for r in request_sorted: self._random_select_subset(r, remaining, selected, selected_amount) if max_input_count and len(selected) > max_input_count: @@ -315,7 +318,7 @@ def select( if respect_min_utxo: change = selected_amount - request_sum min_change_amount = min_lovelace_post_alonzo( - TransactionOutput(_FAKE_ADDR, change), context + TransactionOutput(_FAKE_ADDR, deepcopy(change)), context ) if change.coin < min_change_amount: diff --git a/pycardano/transaction.py b/pycardano/transaction.py index f4a30eff..0f926853 100644 --- a/pycardano/transaction.py +++ b/pycardano/transaction.py @@ -131,6 +131,18 @@ def __le__(self, other: Asset) -> bool: return False return True + def __lt__(self, other: Asset): + return self <= other and self != other + + def __ge__(self, other: Asset) -> bool: + for n in other: + if n not in self or self[n] < other[n]: + return False + return True + + def __gt__(self, other: Asset) -> bool: + return self >= other and self != other + @classmethod @limit_primitive_type(dict) def from_primitive(cls: Type[DictBase], value: dict) -> DictBase: @@ -191,12 +203,28 @@ def __eq__(self, other): return False return True + def __ge__(self, other: MultiAsset) -> bool: + for n in other: + if n not in self: + return False + if not self[n] >= other[n]: + return False + return True + + def __gt__(self, other: MultiAsset) -> bool: + return self >= other and self != other + def __le__(self, other: MultiAsset): for p in self: - if p not in other or not self[p] <= other[p]: + if p not in other: + return False + if not self[p] <= other[p]: return False return True + def __lt__(self, other: MultiAsset): + return self <= other and self != other + def filter( self, criteria=Callable[[ScriptHash, AssetName, int], bool] ) -> MultiAsset: @@ -297,6 +325,14 @@ def __le__(self, other: Union[Value, int]): def __lt__(self, other: Union[Value, int]): return self <= other and self != other + def __ge__(self, other: Union[Value, int]): + if isinstance(other, int): + other = Value(other) + return self.coin >= other.coin and self.multi_asset >= other.multi_asset + + def __gt__(self, other: Union[Value, int]): + return self >= other and self != other + def to_shallow_primitive(self): if self.multi_asset: return super().to_shallow_primitive() diff --git a/pycardano/txbuilder.py b/pycardano/txbuilder.py index f767ba50..1da8d8c6 100644 --- a/pycardano/txbuilder.py +++ b/pycardano/txbuilder.py @@ -105,7 +105,7 @@ class TransactionBuilder: context: ChainContext utxo_selectors: List[UTxOSelector] = field( - default_factory=lambda: [RandomImproveMultiAsset(), LargestFirstSelector()] + default_factory=lambda: [LargestFirstSelector(), RandomImproveMultiAsset()] ) execution_memory_buffer: float = 0.2 @@ -641,8 +641,13 @@ def _calc_change( provided.coin -= self._get_total_key_deposit() provided.coin -= self._get_total_proposal_deposit() - - if not requested < provided: + provided.multi_asset.filter( + lambda p, n, v: p in requested.multi_asset and n in requested.multi_asset[p] + ) + if ( + provided.coin < requested.coin + or requested.multi_asset > provided.multi_asset + ): raise InvalidTransactionException( f"The input UTxOs cannot cover the transaction outputs and tx fee. \n" f"Inputs: {inputs} \n" @@ -733,6 +738,7 @@ def _merge_changes(changes): # Set fee to max self.fee = self._estimate_fee() + changes = self._calc_change( self.fee, self.inputs, @@ -1344,10 +1350,15 @@ def build( unfulfilled_amount = requested_amount - trimmed_selected_amount + remaining = trimmed_selected_amount - requested_amount + remaining.multi_asset = remaining.multi_asset.filter(lambda p, n, v: v > 0) + remaining.coin = max(0, remaining.coin) + if change_address is not None and not can_merge_change: # If change address is provided and remainder is smaller than minimum ADA required in change, # we need to select additional UTxOs available from the address if unfulfilled_amount.coin < 0: + unfulfilled_amount.coin = max( 0, unfulfilled_amount.coin @@ -1401,11 +1412,12 @@ def build( self.context, include_max_fee=False, respect_min_utxo=not can_merge_change, + existing_amount=remaining, ) + for s in selected: selected_amount += s.output.amount selected_utxos.append(s) - break except UTxOSelectionException as e: diff --git a/test/pycardano/test_coinselection.py b/test/pycardano/test_coinselection.py index 19e67d39..35bac3af 100644 --- a/test/pycardano/test_coinselection.py +++ b/test/pycardano/test_coinselection.py @@ -49,7 +49,6 @@ class TestLargestFirst: def test_ada_only(self, chain_context): request = [TransactionOutput.from_primitive([address, [15000000]])] - selected, change = self.selector.select(UTXOS, request, chain_context) assert selected == [UTXOS[-1], UTXOS[-2]] @@ -60,7 +59,6 @@ def test_multiple_request_outputs(self, chain_context): TransactionOutput.from_primitive([address, [9000000]]), TransactionOutput.from_primitive([address, [6000000]]), ] - selected, change = self.selector.select(UTXOS, request, chain_context) assert selected == [UTXOS[-1], UTXOS[-2]] @@ -77,26 +75,33 @@ def test_fee_effect(self, chain_context): def test_no_fee_effect(self, chain_context): request = [TransactionOutput.from_primitive([address, [10000000]])] selected, change = self.selector.select( - UTXOS, request, chain_context, include_max_fee=False, respect_min_utxo=False + UTXOS, + request, + chain_context, + include_max_fee=False, + respect_min_utxo=False, ) assert selected == [UTXOS[-1]] def test_no_fee_but_respect_min_utxo(self, chain_context): request = [TransactionOutput.from_primitive([address, [10000000]])] selected, change = self.selector.select( - UTXOS, request, chain_context, include_max_fee=False, respect_min_utxo=True + UTXOS, + request, + chain_context, + include_max_fee=False, + respect_min_utxo=True, ) + assert selected == [UTXOS[-1], UTXOS[-2]] def test_insufficient_balance(self, chain_context): request = [TransactionOutput.from_primitive([address, [1000000000]])] - with pytest.raises(InsufficientUTxOBalanceException): self.selector.select(UTXOS, request, chain_context) def test_max_input_count(self, chain_context): request = [TransactionOutput.from_primitive([address, [15000000]])] - with pytest.raises(MaxInputCountExceededException): self.selector.select(UTXOS, request, chain_context, max_input_count=1) @@ -117,7 +122,6 @@ def test_multi_asset(self, chain_context): ] ) ] - selected, change = self.selector.select(UTXOS, request, chain_context) # token0 is attached to the smallest utxo, which will be the last utxo during selection, @@ -133,7 +137,6 @@ def selector(self): def test_ada_only(self, chain_context): request = [TransactionOutput.from_primitive([address, [15000000]])] - selected, change = self.selector.select(UTXOS, request, chain_context) assert selected == list(reversed(UTXOS[-4:])) @@ -168,7 +171,11 @@ def test_no_fee_but_respect_min_utxo(self, chain_context): # Only the first two UTxOs should be selected in this test case. # The first one is for the request amount, the second one is to respect min UTxO size. selected, change = RandomImproveMultiAsset(random_generator=[0, 0]).select( - UTXOS, request, chain_context, include_max_fee=False, respect_min_utxo=True + UTXOS, + request, + chain_context, + include_max_fee=False, + respect_min_utxo=True, ) assert selected == [ UTXOS[0], @@ -178,13 +185,11 @@ def test_no_fee_but_respect_min_utxo(self, chain_context): def test_utxo_depleted(self, chain_context): request = [TransactionOutput.from_primitive([address, [1000000000]])] - with pytest.raises(InputUTxODepletedException): self.selector.select(UTXOS, request, chain_context) def test_max_input_count(self, chain_context): request = [TransactionOutput.from_primitive([address, [15000000]])] - with pytest.raises(MaxInputCountExceededException): self.selector.select(UTXOS, request, chain_context, max_input_count=1) diff --git a/test/pycardano/test_transaction.py b/test/pycardano/test_transaction.py index 62796ae2..30925d6d 100644 --- a/test/pycardano/test_transaction.py +++ b/test/pycardano/test_transaction.py @@ -358,13 +358,18 @@ def test_asset_comparison(): assert result == Asset.from_primitive({b"Token1": 2, b"Token2": 5}) assert a == a - + assert a < b assert a <= b assert not b <= a + assert b > a + assert b >= a assert a != b + assert a < c assert a <= c assert not c <= a + assert c > a + assert c >= a assert a != c assert not any([a == d, a <= d, d <= a]) @@ -401,10 +406,15 @@ def test_multi_asset_comparison(): assert a != b assert a <= b + assert a < c + assert b > a + assert b >= a assert not b <= a assert a != c assert a <= c + assert c > a + assert c >= a assert not c <= a assert a != d @@ -452,12 +462,20 @@ def test_values(): }, ] ) + e = Value.from_primitive([1000]) + d = 1000 + assert e >= d assert a != b assert a <= b + assert a < b + assert b > a + assert b >= a assert not b <= a assert a <= c + assert c > a + assert c >= a assert not c <= a assert b <= c diff --git a/test/pycardano/test_txbuilder.py b/test/pycardano/test_txbuilder.py index b30ee791..31794480 100644 --- a/test/pycardano/test_txbuilder.py +++ b/test/pycardano/test_txbuilder.py @@ -34,6 +34,7 @@ POOL_KEY_HASH_SIZE, VERIFICATION_KEY_HASH_SIZE, PoolKeyHash, + ScriptHash, TransactionId, VerificationKeyHash, ) @@ -277,7 +278,7 @@ def test_tx_too_big_exception(chain_context): tx_builder.build(change_address=sender_address) -def test_tx_small_utxo_precise_fee(chain_context): +def test_tx_builder_with_potential_inputs(chain_context): tx_builder = TransactionBuilder(chain_context, [RandomImproveMultiAsset([0, 0])]) sender = "addr_test1vrm9x2zsux7va6w892g38tvchnzahvcd9tykqf3ygnmwtaqyfg52x" sender_address = Address.from_primitive(sender) @@ -454,12 +455,12 @@ def test_tx_builder_burn_multi_asset(chain_context): ), ) ) + tx_builder.add_input_address(sender).add_output( TransactionOutput.from_primitive([sender, 3000000]) ).add_output(TransactionOutput.from_primitive([sender, 2000000])) tx_builder.mint = to_burn - tx_body = tx_builder.build(change_address=sender_address) assert tx_input in tx_body.inputs @@ -2326,3 +2327,97 @@ def test_collateral_no_duplicates(chain_context): total_collateral_input == Value(tx_body.total_collateral) + tx_body.collateral_return.amount ), "The total collateral input amount should match the sum of the selected UTxOs" + + +def test_token_transfer_with_change(chain_context): + """Test token transfer with change address handling. + + Replicates issue where transaction fails with 'Input UTxOs depleted' when: + - Input 1: 4 ADA + - Input 2: ~1.03 ADA + 1,876,083 tokens + - Output: ~1.32 ADA + 382 tokens + - Expected change should handle remaining ADA and tokens + """ + # Create the vault address that holds tokens + vault_address = Address.from_primitive( + "addr_test1vrs324jltsc0ssuptpa5ngpfk89cps92xa99a2t6vlg6kdqtm5qnv" + ) + + # Create receiver address + receiver_address = Address.from_primitive( + "addr_test1vrm9x2zsux7va6w892g38tvchnzahvcd9tykqf3ygnmwtaqyfg52x" + ) + + # Create token details + token_policy_id = ScriptHash( + bytes.fromhex("1f847bb9ac60e869780037c0510dbd89f745316db7ec4fee81ff1e97") + ) + token_name = AssetName(b"dux_1") + + # Create the two input UTXOs and patch chain_context.utxos + with patch.object(chain_context, "utxos") as mock_utxos: + mock_utxos.return_value = [ + UTxO( + TransactionInput.from_primitive( + [ + "e11efc26f94a3cbf724dc052c43abf36f7a631a831acc6d783f1c9c8c52725c5", + 0, + ] + ), + TransactionOutput( + vault_address, + Value( + 1038710, # ~1.03 ADA + MultiAsset.from_primitive( + { + token_policy_id.payload: { + b"dux_1": 1876083 # 1,876,083 tokens + } + } + ), + ), + ), + ) + ] + + # Create transaction builder + tx_builder = TransactionBuilder(chain_context) + + # Add inputs - using add_input_address for the vault input + tx_builder.add_input_address(vault_address) + tx_builder.add_input( + UTxO( + TransactionInput.from_primitive([b"1" * 32, 0]), + TransactionOutput(receiver_address, Value(40000000)), # 4 ADA input + ) + ) + + # Add output for receiver + output_value = Value( + 1326255, # ~1.32 ADA + MultiAsset.from_primitive( + {token_policy_id.payload: {b"dux_1": 382}} # 382 tokens + ), + ) + tx_builder.add_output(TransactionOutput(receiver_address, output_value)) + + # Build transaction with change going back to vault + tx = tx_builder.build(change_address=vault_address, merge_change=True) + + # Verify the transaction outputs + assert len(tx.outputs) == 2 # One for receiver, one for change + + # Verify receiver output + receiver_output = tx.outputs[0] + assert receiver_output.address == receiver_address + assert receiver_output.amount.coin == 1326255 + assert receiver_output.amount.multi_asset[token_policy_id][token_name] == 382 + + # Verify change output + change_output = tx.outputs[1] + assert change_output.address == vault_address + assert change_output.amount.coin == 40000000 + 1038710 - 1326255 - tx.fee + assert ( + change_output.amount.multi_asset[token_policy_id][token_name] + == 1876083 - 382 + )