Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .pyre_configuration
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"source_directories": [
{
"root": "."
}
],
"search_path": [
".venv/Lib/site-packages"
],
"python_version": "3.12"
}
1 change: 1 addition & 0 deletions pycardano/certificate.py
Original file line number Diff line number Diff line change
Expand Up @@ -644,6 +644,7 @@ class UpdateDRepCertificate(CodedSerializable):
StakeRegistrationAndDelegationAndVoteDelegation,
AuthCommitteeHotCertificate,
ResignCommitteeColdCertificate,
RegDRepCert,
UnregDRepCertificate,
UpdateDRepCertificate,
]
44 changes: 43 additions & 1 deletion pycardano/txbuilder.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions pyrightconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"include": [
"pycardano",
"test"
],
"venvPath": ".",
"venv": ".venv",
"pythonVersion": "3.12",
"typeCheckingMode": "basic",
"reportMissingImports": false,
"reportMissingModuleSource": false
}
144 changes: 144 additions & 0 deletions test/pycardano/test_txbuilder.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@
script_hash,
)
from pycardano.transaction import (
Asset,
AssetName,
MultiAsset,
TransactionInput,
TransactionOutput,
Expand Down Expand Up @@ -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"
Expand Down
3 changes: 3 additions & 0 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.