From 649910e599f61f437f149f6b6169ee20f0667704 Mon Sep 17 00:00:00 2001 From: svatantrya Date: Thu, 30 Jul 2026 22:06:49 -0400 Subject: [PATCH] Add OP_RETURN support for heirs Allow heirs to produce an OP_RETURN output instead of a regular BTC payment. An address prefixed with OP_RETURN: carries hex data (max 80 bytes); such heirs always have amount 0 and are excluded from amount calculations. GUI dialog shows a message field with the decoded text, the heir list displays the decoded message, and the OP_RETURN output is emitted with zero value in inheritance transactions. --- bal/core/heirs.py | 70 +++++++++++++++++++++++++----- bal/gui/qt/common.py | 2 + bal/gui/qt/lists.py | 12 +++++- bal/gui/qt/window.py | 75 +++++++++++++++++++++++++++----- tests/test_core_heirs.py | 92 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 230 insertions(+), 21 deletions(-) diff --git a/bal/core/heirs.py b/bal/core/heirs.py index d934b43..ab50346 100644 --- a/bal/core/heirs.py +++ b/bal/core/heirs.py @@ -75,6 +75,8 @@ HEIR_REAL_AMOUNT = 3 # resolved amount once percentages are computed HEIR_DUST_AMOUNT = 4 # amount when below dust threshold (marked "DUST: ...") TRANSACTION_LABEL = "inheritance transaction" +OP_RETURN_PREFIX = "OP_RETURN:" + class AliasNotFoundException(Exception): pass @@ -86,6 +88,27 @@ def reduce_outputs(in_amount, out_amount, fee, outputs): output.value = math.floor((in_amount - fee) / out_amount * output.value) +def is_op_return_address(address: str) -> bool: + return str(address).startswith(OP_RETURN_PREFIX) + + +def get_op_return_hex(address: str) -> Optional[str]: + if is_op_return_address(address): + return address[len(OP_RETURN_PREFIX):] + return None + + +def validate_op_return_hex(data_hex: str) -> None: + try: + data = bytes.fromhex(data_hex) + except ValueError: + raise NotAnAddress(f"OP_RETURN data is not valid hex: {data_hex}") + if len(data) > 80: + raise NotAnAddress( + f"OP_RETURN data too long ({len(data)} bytes, max 80)" + ) + + def create_op_return_script(data_hex: str) -> bytes: """Crea scriptpubkey OP_RETURN in bytes""" data = bytes.fromhex(data_hex) @@ -132,14 +155,22 @@ def prepare_transactions(locktimes, available_utxos, fees, wallet): heir[HEIR_REAL_AMOUNT] ): try: - real_amount = heir[HEIR_REAL_AMOUNT] - outputs.append( - PartialTxOutput.from_address_and_value( - heir[HEIR_ADDRESS], real_amount + if is_op_return_address(heir[HEIR_ADDRESS]): + data_hex = heir[HEIR_ADDRESS][len(OP_RETURN_PREFIX):] + op_return_script = create_op_return_script(data_hex) + outputs.append( + PartialTxOutput(value=0, scriptpubkey=op_return_script) ) - ) - out_amount += real_amount - description += f"{name}\n" + description += f"{name}\n" + else: + real_amount = heir[HEIR_REAL_AMOUNT] + outputs.append( + PartialTxOutput.from_address_and_value( + heir[HEIR_ADDRESS], real_amount + ) + ) + out_amount += real_amount + description += f"{name}\n" except BitcoinException as e: _logger.info("exception decoding output {} - {}".format(type(e), e)) heir[HEIR_REAL_AMOUNT] = e @@ -276,11 +307,12 @@ def print_transaction(heirs, tx, locktimes, tx_fees): heirname = "" for key in heirs.keys(): heir = heirs[key] - if heir[HEIR_ADDRESS] == out["address"] and str(heir[HEIR_LOCKTIME]) == str( + out_address = out.get("address", "OP_RETURN") + if heir[HEIR_ADDRESS] == out_address and str(heir[HEIR_LOCKTIME]) == str( jtx["locktime"] ): heirname = key - print(f"{heirname}\t{out['address']}: {out['value_sats']}") + print(f"{heirname}\t{out.get('address', 'OP_RETURN')}: {out['value_sats']}") print() size = tx.estimated_size() @@ -401,6 +433,9 @@ class Heirs(dict, Logger): amount = 0 for key, v in heir_list.items(): try: + if is_op_return_address(v[HEIR_ADDRESS]): + heir_list[key].insert(HEIR_REAL_AMOUNT, 0) + continue column = HEIR_AMOUNT if real: column = HEIR_REAL_AMOUNT @@ -452,6 +487,12 @@ class Heirs(dict, Logger): ) ) continue + if is_op_return_address(self[key][HEIR_ADDRESS]): + heir = list(self[key]) + heir.insert(HEIR_REAL_AMOUNT, 0) + fixed_heirs[key] = heir + _logger.debug(f"OP_RETURN heir {key} excluded from amount calculation") + continue if Util.is_perc(self[key][HEIR_AMOUNT]): percent_amount += float(self[key][HEIR_AMOUNT][:-1]) percent_heirs[key] = list(self[key]) @@ -592,6 +633,8 @@ class Heirs(dict, Logger): heir[HEIR_REAL_AMOUNT] ): valid_real_heirs += 1 + elif len(heir) > HEIR_REAL_AMOUNT and is_op_return_address(heir[HEIR_ADDRESS]): + valid_real_heirs += 1 if real_heirs > 0 and valid_real_heirs == 0: raise HeirAmountIsDustException( "All heirs' shares are below the dust limit" @@ -811,6 +854,10 @@ class Heirs(dict, Logger): return None def validate_address(address): + if is_op_return_address(address): + data_hex = address[len(OP_RETURN_PREFIX):] + validate_op_return_hex(data_hex) + return address if not bitcoin.is_address(address, net=constants.net): raise NotAnAddress(f"not an address,{address}") return address @@ -835,7 +882,10 @@ class Heirs(dict, Logger): def validate_heir(k, v, timestamp_to_check=False): address = Heirs.validate_address(v[HEIR_ADDRESS]) - amount = Heirs.validate_amount(v[HEIR_AMOUNT]) + if is_op_return_address(v[HEIR_ADDRESS]): + amount = "0" + else: + amount = Heirs.validate_amount(v[HEIR_AMOUNT]) locktime = Heirs.validate_locktime(v[HEIR_LOCKTIME], timestamp_to_check) return (address, amount, locktime) diff --git a/bal/gui/qt/common.py b/bal/gui/qt/common.py index b17ce1e..58045b7 100644 --- a/bal/gui/qt/common.py +++ b/bal/gui/qt/common.py @@ -64,6 +64,8 @@ from PyQt6.QtWidgets import (QAbstractItemView, QAbstractSpinBox, QCheckBox, from ...core.plugin_base import BalPlugin, BalTimestamp from ...core.heirs import (HEIR_DUST_AMOUNT, HEIR_REAL_AMOUNT, HeirAmountIsDustException, Heirs, + OP_RETURN_PREFIX, is_op_return_address, + get_op_return_hex, validate_op_return_hex, WillExecutorFeeTooHighException) from ...core.util import Util from ...core.will import (AmountException, HeirChangeException, diff --git a/bal/gui/qt/lists.py b/bal/gui/qt/lists.py index c253ec6..2bbb9c8 100644 --- a/bal/gui/qt/lists.py +++ b/bal/gui/qt/lists.py @@ -157,7 +157,17 @@ class HeirListWidget(MyTreeView, MessageBoxMixin): heir = self.bal_window.heirs[key] labels = [""] * len(self.Columns) labels[self.Columns.NAME] = key - labels[self.Columns.ADDRESS] = heir[0] + if is_op_return_address(heir[0]): + data_hex = heir[0][len(OP_RETURN_PREFIX):] + try: + decoded = bytes.fromhex(data_hex).decode("utf-8", errors="replace") + if len(decoded) > 40: + decoded = decoded[:40] + "\u2026" + labels[self.Columns.ADDRESS] = decoded + except Exception: + labels[self.Columns.ADDRESS] = "OP_RETURN" + else: + labels[self.Columns.ADDRESS] = heir[0] labels[self.Columns.AMOUNT] = Util.decode_amount( heir[1], self.decimal_point ) diff --git a/bal/gui/qt/window.py b/bal/gui/qt/window.py index 3375fd2..20a5495 100644 --- a/bal/gui/qt/window.py +++ b/bal/gui/qt/window.py @@ -200,15 +200,61 @@ class BalWindow: heir_address.setFixedWidth(32 * char_width_in_lineedit()) heir_amount = PercAmountEdit(self.window.get_decimal_point) + # OP_RETURN message field (hidden by default) + op_return_message = QLineEdit() + op_return_message.setFixedWidth(32 * char_width_in_lineedit()) + op_return_message.setVisible(False) + op_return_amount_label = QLabel(_("Amount")) + op_return_message_label = QLabel(_("OP_RETURN Message")) + if heir: heir_name.setText(str(heir_key)) - heir_address.setText(str(heir[0])) - heir_amount.setText( - str(Util.decode_amount(heir[1], self.window.get_decimal_point())) - ) + addr = str(heir[0]) + heir_address.setText(addr) + if not is_op_return_address(addr): + heir_amount.setText( + str(Util.decode_amount(heir[1], self.window.get_decimal_point())) + ) self.heir_locktime = LockTimeWidget(self, self.window, heir[2]) + else: + heir_address.setText("") + self.heir_locktime = LockTimeWidget(self, self.window, self.will_settings["locktime"]) - # heir_is_xpub = QCheckBox() + def _update_op_return_from_message(): + msg = op_return_message.text() + data_hex = msg.encode("utf-8").hex() + heir_address.setText(OP_RETURN_PREFIX + data_hex) + + def _update_op_return_from_address(): + addr = heir_address.text() + if is_op_return_address(addr): + data_hex = addr[len(OP_RETURN_PREFIX):] + try: + decoded = bytes.fromhex(data_hex).decode("utf-8", errors="replace") + op_return_message.setText(decoded) + except Exception: + op_return_message.setText("") + + def _on_address_changed(): + addr = heir_address.text() + if is_op_return_address(addr): + if not op_return_message.isVisible(): + op_return_message.setVisible(True) + heir_amount.setVisible(False) + op_return_amount_label.setVisible(False) + op_return_message_label.setVisible(True) + op_return_message.blockSignals(True) + _update_op_return_from_address() + op_return_message.blockSignals(False) + else: + op_return_message.setVisible(False) + heir_amount.setVisible(True) + op_return_amount_label.setVisible(True) + op_return_message_label.setVisible(False) + + _on_address_changed() + heir_address.textChanged.connect(_on_address_changed) + op_return_message.textChanged.connect(_update_op_return_from_message) new_heir_button = QPushButton(_("Add another heir")) self.add_another_heir = False @@ -225,12 +271,15 @@ class BalWindow: grid.addWidget(QLabel(_("Address")), 2, 0) grid.addWidget(heir_address, 2, 1) - grid.addWidget(HelpButton(_("heir bitcoin address")), 2, 2) + grid.addWidget(HelpButton(_("Bitcoin address or OP_RETURN: prefix + hex data")), 2, 2) - grid.addWidget(QLabel(_("Amount")), 3, 0) + grid.addWidget(op_return_amount_label, 3, 0) grid.addWidget(heir_amount, 3, 1) grid.addWidget(HelpButton(_("Fixed or Percentage amount if end with %")), 3, 2) + grid.addWidget(op_return_message_label, 3, 0) + grid.addWidget(op_return_message, 3, 1) + locktime_label = QLabel(_("Locktime")) enable_multiverse = self.bal_plugin.ENABLE_MULTIVERSE.get() if enable_multiverse: @@ -244,11 +293,15 @@ class BalWindow: buttons.append(new_heir_button) vbox.addLayout(Buttons(*buttons)) while d.exec(): - # TODO SAVE HEIR + raw_address = heir_address.text() + if is_op_return_address(raw_address): + amount = "0" + else: + amount = Util.encode_amount(heir_amount.text(), self.window.get_decimal_point()) heir = [ heir_name.text(), - heir_address.text(), - Util.encode_amount(heir_amount.text(), self.window.get_decimal_point()), + raw_address, + amount, str(self.will_settings["locktime"]), ] try: @@ -261,6 +314,8 @@ class BalWindow: def set_heir(self, heir): heir = list(heir) + if is_op_return_address(heir[1]): + heir[2] = "0" if not self.bal_plugin.ENABLE_MULTIVERSE.get(): heir[3] = self.will_settings["locktime"] diff --git a/tests/test_core_heirs.py b/tests/test_core_heirs.py index f7ea6ab..69756c2 100644 --- a/tests/test_core_heirs.py +++ b/tests/test_core_heirs.py @@ -16,7 +16,9 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir)) from bal.core.heirs import ( HEIR_ADDRESS, HEIR_AMOUNT, HEIR_LOCKTIME, HEIR_REAL_AMOUNT, HEIR_DUST_AMOUNT, TRANSACTION_LABEL, + OP_RETURN_PREFIX, create_op_return_script, + is_op_return_address, get_op_return_hex, validate_op_return_hex, AliasNotFoundException, NotAnAddress, AmountNotValid, LocktimeNotValid, HeirExpiredException, HeirAmountIsDustException, @@ -258,6 +260,96 @@ def test_validate_removes_invalid(): assert "alice" in result or True # may or may not pass address check +# ------------------------------------------------------------------ # +# OP_RETURN helpers +# ------------------------------------------------------------------ # + +def test_op_return_prefix_constant(): + assert OP_RETURN_PREFIX == "OP_RETURN:" + + +def test_is_op_return_address(): + assert is_op_return_address("OP_RETURN:48656c6c6f") + assert not is_op_return_address("bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq") + assert not is_op_return_address("") + assert not is_op_return_address("OP_RETURN") + assert not is_op_return_address("OP_RETURNX:") + + +def test_get_op_return_hex(): + assert get_op_return_hex("OP_RETURN:48656c6c6f") == "48656c6c6f" + assert get_op_return_hex("bc1q...") is None + assert get_op_return_hex("") is None + + +def test_validate_op_return_hex_valid(): + validate_op_return_hex("48656c6c6f") + + +def test_validate_op_return_hex_invalid(): + try: + validate_op_return_hex("nothex!!") + assert False, "expected NotAnAddress" + except NotAnAddress: + pass + + +def test_validate_op_return_hex_too_long(): + try: + validate_op_return_hex("ab" * 81) + assert False, "expected NotAnAddress" + except NotAnAddress: + pass + + +def test_validate_op_return_hex_empty(): + validate_op_return_hex("") + + +def test_validate_address_op_return(): + addr = "OP_RETURN:48656c6c6f" + result = Heirs.validate_address(addr) + assert result == addr + + +def test_validate_heir_op_return(): + k = "test_op_return" + v = ["OP_RETURN:48656c6c6f", "0", "30d"] + result = Heirs.validate_heir(k, v) + assert result[0] == "OP_RETURN:48656c6c6f" + assert result[1] == "0" + + +# ------------------------------------------------------------------ # +# Heirs class OP_RETURN integration +# ------------------------------------------------------------------ # + +def test_heirs_fixed_percent_skips_op_return(): + class FakeWallet: + class FakeDB: + def __init__(self): + self._data = {} + def get(self, key, default=None): + return self._data.get(key, default) + def put(self, key, value): + self._data[key] = value + def __init__(self): + self.db = self.FakeDB() + self.dust_threshold = lambda: 500 + wallet = FakeWallet() + heirs = Heirs(wallet) + heirs["op_ret"] = ["OP_RETURN:48656c6c6f", "0", "9999999999"] + heirs["normal"] = ["addr1", "10000", "9999999999"] + fixed_h, fixed_amt, perc_h, perc_amt, fixed_with_dust = ( + heirs.fixed_percent_lists_amount(0, 500) + ) + assert "op_ret" in fixed_h + assert "normal" in fixed_h + assert fixed_h["op_ret"][HEIR_REAL_AMOUNT] == 0 + assert fixed_h["normal"][HEIR_REAL_AMOUNT] == 10000 + assert fixed_amt == 10000 # OP_RETURN adds 0 + + if __name__ == "__main__": for name in sorted(dir()): if name.startswith("test_"):