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.
This commit is contained in:
2026-07-30 22:06:49 -04:00
parent fb797f31e3
commit 649910e599
5 changed files with 230 additions and 21 deletions

View File

@@ -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)

View File

@@ -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,

View File

@@ -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
)

View File

@@ -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"]