diff --git a/.pyre_configuration b/.pyre_configuration new file mode 100644 index 00000000..82ff41a4 --- /dev/null +++ b/.pyre_configuration @@ -0,0 +1,11 @@ +{ + "source_directories": [ + { + "root": "." + } + ], + "search_path": [ + ".venv/Lib/site-packages" + ], + "python_version": "3.12" +} diff --git a/pycardano/certificate.py b/pycardano/certificate.py index b4717224..c8f18a0f 100644 --- a/pycardano/certificate.py +++ b/pycardano/certificate.py @@ -644,6 +644,7 @@ class UpdateDRepCertificate(CodedSerializable): StakeRegistrationAndDelegationAndVoteDelegation, AuthCommitteeHotCertificate, ResignCommitteeColdCertificate, + RegDRepCert, UnregDRepCertificate, UpdateDRepCertificate, ] diff --git a/pycardano/txbuilder.py b/pycardano/txbuilder.py index e296a510..a6ec0115 100644 --- a/pycardano/txbuilder.py +++ b/pycardano/txbuilder.py @@ -2,7 +2,7 @@ from copy import deepcopy from dataclasses import dataclass, field, fields -from typing import Any, Dict, List, Optional, Set, Tuple, Union +from typing import Any, Callable, Dict, List, Optional, Set, Tuple, Union from pycardano import RedeemerMap from pycardano.address import Address, AddressType @@ -206,6 +206,32 @@ class TransactionBuilder: init=False, default_factory=lambda: [] ) + change_output_fn: Optional[ + Callable[[Value, Address], List[TransactionOutput]] + ] = field(default=None) + """Optional function to customise how change is distributed across output UTxOs. + + When set, this function is called instead of the default change-packing + logic. It receives the **net change** :class:`Value` (already accounting + for fees, deposits, and withdrawals) and the change :class:`Address`, and + must return a list of :class:`TransactionOutput` objects. + + Example — split change into two equal ADA-only outputs:: + + def split_change(change: Value, addr: Address) -> List[TransactionOutput]: + half = change.coin // 2 + return [ + TransactionOutput(addr, Value(half)), + TransactionOutput(addr, Value(change.coin - half)), + ] + + builder.change_output_fn = split_change + + The builder will raise :class:`InsufficientUTxOBalanceException` for any + returned output that does not satisfy the minimum-lovelace requirement + (unless ``respect_min_utxo`` is *False* in the underlying call). + """ + _should_estimate_execution_units: Optional[bool] = field(init=False, default=None) def add_input(self, utxo: UTxO) -> TransactionBuilder: @@ -665,6 +691,22 @@ def _calc_change( if change.multi_asset: change.multi_asset = change.multi_asset.filter(lambda p, n, v: v > 0) + # --- custom change output function hook --- + change_output_fn = self.change_output_fn + if change_output_fn is not None: + custom_outputs = change_output_fn(change, address) + if respect_min_utxo: + for out in custom_outputs: + min_ada = min_lovelace_post_alonzo(out, self.context) + if out.lovelace < min_ada: + raise InsufficientUTxOBalanceException( + f"Custom change output {out} does not meet minimum lovelace " + f"requirement: {out.lovelace} lovelace provided but " + f"{min_ada} lovelace required." + ) + return custom_outputs + # --- end custom hook, fall through to default packing logic --- + change_output_arr = [] # when there is only ADA left, simply use remaining coin value as change diff --git a/pyrightconfig.json b/pyrightconfig.json new file mode 100644 index 00000000..b6b765f4 --- /dev/null +++ b/pyrightconfig.json @@ -0,0 +1,12 @@ +{ + "include": [ + "pycardano", + "test" + ], + "venvPath": ".", + "venv": ".venv", + "pythonVersion": "3.12", + "typeCheckingMode": "basic", + "reportMissingImports": false, + "reportMissingModuleSource": false +} \ No newline at end of file diff --git a/test/pycardano/test_txbuilder.py b/test/pycardano/test_txbuilder.py index 9b924405..898dc147 100644 --- a/test/pycardano/test_txbuilder.py +++ b/test/pycardano/test_txbuilder.py @@ -60,6 +60,8 @@ script_hash, ) from pycardano.transaction import ( + Asset, + AssetName, MultiAsset, TransactionInput, TransactionOutput, @@ -522,6 +524,148 @@ def test_tx_add_change_split_nfts(chain_context): assert expected == tx_body.to_primitive() +def test_tx_builder_custom_change_fn_single_output(chain_context): + """Custom fn that returns a single lovelace-only change output (defrag scenario).""" + sender = "addr_test1vrm9x2zsux7va6w892g38tvchnzahvcd9tykqf3ygnmwtaqyfg52x" + sender_address = Address.from_primitive(sender) + + tx_in1 = TransactionInput.from_primitive([b"1" * 32, 3]) + tx_out1 = TransactionOutput.from_primitive([sender, 6000000]) + utxo1 = UTxO(tx_in1, tx_out1) + + def defrag_change(change: Value, addr: Address): + # Always return a single ADA-only output with all the change + return [TransactionOutput(addr, Value(change.coin))] + + tx_builder = TransactionBuilder(chain_context) + tx_builder.change_output_fn = defrag_change + tx_builder.add_input(utxo1).add_output( + TransactionOutput.from_primitive([sender, 2000000]) + ) + + tx_body = tx_builder.build(change_address=sender_address) + + # There should be exactly 2 outputs: the explicit one and one custom change + assert len(tx_body.outputs) == 2 + change_output = tx_body.outputs[1] + assert change_output.address == sender_address + # custom fn chose to return ADA only — multi_asset must be empty/None + assert not change_output.amount.multi_asset + + +def test_tx_builder_custom_change_fn_split_cnts(chain_context): + """Custom fn that distributes CNT change across separate UTxOs (one per policy).""" + sender = "addr_test1vrm9x2zsux7va6w892g38tvchnzahvcd9tykqf3ygnmwtaqyfg52x" + sender_address = Address.from_primitive(sender) + + policy_id_a = b"1" * 28 + policy_id_b = b"2" * 28 + + # Build a UTxO that holds tokens under two distinct policies + tx_in1 = TransactionInput.from_primitive([b"3" * 32, 5]) + tx_out1 = TransactionOutput( + sender_address, + Value( + 8_000_000, + MultiAsset( + { + ScriptHash(policy_id_a): Asset({AssetName(b"TokenA"): 3}), + ScriptHash(policy_id_b): Asset({AssetName(b"TokenB"): 5}), + } + ), + ), + ) + utxo1 = UTxO(tx_in1, tx_out1) + + def split_by_policy(change: Value, addr: Address): + """One output per policy in the change, ADA split evenly.""" + outputs = [] + remaining_coin = change.coin + if change.multi_asset: + policies = list(change.multi_asset.items()) + n = len(policies) + for i, (pid, asset) in enumerate(policies): + ada = remaining_coin // n if i < n - 1 else remaining_coin + remaining_coin -= ada + outputs.append( + TransactionOutput(addr, Value(ada, MultiAsset({pid: asset}))) + ) + else: + outputs.append(TransactionOutput(addr, Value(remaining_coin))) + return outputs + + tx_builder = TransactionBuilder(chain_context) + tx_builder.change_output_fn = split_by_policy + tx_builder.add_input(utxo1).add_output( + TransactionOutput.from_primitive([sender, 2_000_000]) + ) + + tx_body = tx_builder.build(change_address=sender_address) + + # Explicit output + 2 custom change outputs (one per policy) + assert len(tx_body.outputs) == 3 + change_outputs = tx_body.outputs[1:] + # Each custom output must carry tokens from a distinct policy + assert all(out.amount.multi_asset for out in change_outputs) + # Together they must cover both policies + seen_policies = set() + for out in change_outputs: + seen_policies.update(out.amount.multi_asset.keys()) + assert ScriptHash(policy_id_a) in seen_policies + assert ScriptHash(policy_id_b) in seen_policies + + +def test_tx_builder_custom_change_fn_empty(chain_context): + """Custom fn returning [] causes no extra change output to be appended.""" + sender = "addr_test1vrm9x2zsux7va6w892g38tvchnzahvcd9tykqf3ygnmwtaqyfg52x" + sender_address = Address.from_primitive(sender) + + tx_in1 = TransactionInput.from_primitive([b"1" * 32, 3]) + # Intentionally large input so change would normally be produced + tx_out1 = TransactionOutput.from_primitive([sender, 6000000]) + utxo1 = UTxO(tx_in1, tx_out1) + + def absorb_change(change: Value, addr: Address): + return [] # silently absorb all change + + tx_builder = TransactionBuilder(chain_context) + tx_builder.change_output_fn = absorb_change + tx_builder.add_input(utxo1).add_output( + TransactionOutput.from_primitive([sender, 2000000]) + ) + + tx_body = tx_builder.build(change_address=sender_address) + + # Only the single explicit output should be present + assert len(tx_body.outputs) == 1 + assert tx_body.outputs[0].amount == Value(2000000) + + +def test_tx_builder_custom_change_fn_below_min_utxo(chain_context): + """Custom fn returning an output below min-lovelace raises InsufficientUTxOBalanceException.""" + from pycardano.exception import InsufficientUTxOBalanceException + + sender = "addr_test1vrm9x2zsux7va6w892g38tvchnzahvcd9tykqf3ygnmwtaqyfg52x" + sender_address = Address.from_primitive(sender) + + tx_in1 = TransactionInput.from_primitive([b"1" * 32, 3]) + tx_out1 = TransactionOutput.from_primitive([sender, 6000000]) + utxo1 = UTxO(tx_in1, tx_out1) + + def tiny_change(change: Value, addr: Address): + # Deliberately return only 1 lovelace — well below the minimum + return [TransactionOutput(addr, Value(1))] + + tx_builder = TransactionBuilder(chain_context) + tx_builder.change_output_fn = tiny_change + tx_builder.add_input(utxo1).add_output( + TransactionOutput.from_primitive([sender, 2000000]) + ) + + with pytest.raises(InsufficientUTxOBalanceException, match="minimum lovelace"): + tx_builder.build(change_address=sender_address) + + def test_tx_add_change_split_nfts_not_enough_add(chain_context): vk1 = VerificationKey.from_cbor( "58206443a101bdb948366fc87369336224595d36d8b0eee5602cba8b81a024e58473" diff --git a/uv.lock b/uv.lock new file mode 100644 index 00000000..a5bc5147 --- /dev/null +++ b/uv.lock @@ -0,0 +1,3 @@ +version = 1 +revision = 3 +requires-python = ">=3.14"