20 Commits
main ... main

Author SHA1 Message Date
kaibot
9ce42d6f12 docs(will): add module header 2026-04-10 15:28:06 +00:00
kaibot
014dd230c1 docs(will): prepend module header 2026-04-10 15:26:13 +00:00
kaibot
2054e4f4b2 docs(util): prepend module header with description 2026-04-10 15:24:00 +00:00
kaibot
ae7ea24bdf docs(util): add module level description 2026-04-10 15:22:03 +00:00
kaibot
06c32268a7 docs(willexecutors.py): Add comprehensive documentation for all classes and methods 2026-04-10 01:32:20 +00:00
kaibot
6fb82dc8d0 docs(README): Remove version references, remove PyQt mentions, update wallet_util steps, simplify contributing section 2026-04-09 11:47:10 +00:00
kaibot
9c91cc8019 docs(README): Remove PyQt5 references, remove configurations section, add onchain invalidation transaction detail 2026-04-09 11:40:29 +00:00
kaibot
5dc1ca36ab docs(README): Update requirements - PyQt6 only, Electrum 4.7.2+, remove pytest, correct wallet_utils usage 2026-04-09 11:35:25 +00:00
kaibot
694e14d851 docs(README): Update executor fee to be fixed amount only, remove percentage option 2026-04-09 11:29:35 +00:00
kaibot
60fc08ad5a docs(README): Update with correct executor fee details and wallet_utils purpose 2026-04-09 11:26:03 +00:00
kaibot
22fa6cd708 docs(README): Update with correct file structure and module descriptions 2026-04-09 11:16:38 +00:00
kaibot
b63dc5ba4f docs(README): Update with accurate plugin functionality description 2026-04-09 11:06:08 +00:00
kaibot
a5f6b9f925 Merge branch 'main' of https://bitcoin-after.life/gitea/kaibot/bal-electrum-plugin
# Conflicts:
#	qt.py
2026-04-09 10:46:08 +00:00
kaibot
c739d110d6 docs(README): Add comprehensive installation and usage guide for Bal Electrum Plugin 2026-04-09 10:45:46 +00:00
kaibot
45d8173cf7 docs(qt.py): Add comprehensive documentation for BalWizardDialog class 2026-04-09 02:43:57 +00:00
kaibot
b739bdab40 docs(qt.py): Add comprehensive documentation for BalWizardDialog class 2026-04-09 02:43:06 +00:00
kaibot
d613438800 docs(qt.py): Add comprehensive documentation for BalWizardDialog class 2026-04-09 02:39:04 +00:00
kaibot
a27df11dfa docs(qt.py): Add detailed interval documentation for HeirsLockTimeEdit class behavior 2026-04-09 02:10:49 +00:00
kaibot
686c11080f Merge branch 'doc' into main with documentation for HeirsLockTimeEdit and BalBuildWillDialog 2026-04-09 01:36:34 +00:00
kaibot
be38c9b589 docs(qt.py): Add comprehensive documentation for HeirsLockTimeEdit class 2026-04-09 01:28:47 +00:00
10 changed files with 1717 additions and 879 deletions

359
README.md
View File

@@ -1,2 +1,357 @@
# BalPlugin # Bal Electrum Plugin
Bitcoin After Life Electrum Plugin
**Bitcoin Electrum plugin for managing heir inheritance with locktime-based will execution**
---
## 📥 Installation
### Method 1: Install from Release (Recommended)
1. **Download the plugin**
- Go to: [https://bitcoin-after.life/gitea/bitcoinafterlife/bal-electrum-plugin/releases](https://bitcoin-after.life/gitea/bitcoinafterlife/bal-electrum-plugin/releases)
- Download the latest `bal-electrum-plugin-vX.X.X.zip` file
2. **Install in Electrum**
- Open Electrum Bitcoin wallet
- Go to **Tools → Plugins**
- Click **Add**
- Select the downloaded `.zip` file
- Click **Open** or **Install**
- Restart Electrum if required
### Method 2: Install from Source
```bash
git clone https://bitcoin-after.life/gitea/bitcoinafterlife/bal-electrum-plugin.git
cd bal-electrum-plugin
zip -r bal-electrum-plugin.zip bal_electrum_plugin/
```
Then install using Method 1, step 2.
---
## 🚀 How It Works
### Core Functionality
The plugin allows users to create a **Bitcoin will** that automatically executes under specific conditions:
1. **Define Heirs** - Create a list of beneficiaries with Bitcoin addresses and inheritance amounts
2. **Set Locktime** - Configure when transactions can be spent (timestamp or interval)
3. **Set CheckAlive** - Set health verification mechanism (interval or fixed date)
4. **Select Executor** - Choose which will-executor will handle the transaction
5. **Create Transaction** - Plugin generates a timelocked transaction sent to the executor
### Transaction Flow
```
User Setup → [Plugin Creates Transaction] → Executor Receives →
Executor Broadcasts at Locktime → Transaction Confirmed → Heirs Receive Funds
```
### Key Incentive
**Executor Fee is Included in Transaction**:
- The will-executor receives their fee directly in the transaction output
- Fee is paid only when transaction is confirmed on the Bitcoin network
- Executor is **financially incentivized** to broadcast and confirm the transaction
- Fee is a **fixed amount only** (no percentage option)
---
## 📁 Plugin Structure
```
bal-electrum-plugin/
├── bal_electrum_plugin/ # Main plugin directory (what to zip)
│ ├── __init__.py # Plugin initialization and entry point
│ ├── bal.py # Main plugin logic and core functionality
│ ├── bal_resources.py # Resource management and asset handling
│ ├── heirs.py # Heir management logic and validation
│ ├── qt.py # Main Qt interface and GUI components
│ ├── util.py # General utility functions
│ ├── wallet_util/
│ │ ├── bal_wallet_utils.py # ⚠️ Advanced wallet utilities for emergency fixes
│ │ └── bal_wallet_utils_qt.py # Qt-specific wallet utilities
│ ├── will.py # Will creation, locktime, and checkalive logic
│ └── willexecutors.py # Will executor management and fee handling
├── README.md # This file
└── LICENSE # MIT License
```
**Important**: Only the `bal_electrum_plugin/` directory needs to be zipped for installation.
---
## 🎯 Key Features
### Heir Management (heirs.py)
- **Multiple heirs**: Add unlimited beneficiaries
- **Flexible distribution**: Percentage-based or fixed amount
- **Address validation**: Verify Bitcoin addresses before saving
- **Distribution summary**: View total inheritance breakdown
- **Percentage validation**: Ensures percentages sum to 100%
### Locktime Configuration (will.py)
- **Timestamp format**: Unix timestamp (e.g., `1735689600` for Jan 1, 2025)
- **Interval format**: Days (`180d`) or years (`1y`)
- **Automatic conversion**: Intervals converted to timestamps internally
- **Validation**: Ensures locktime is in the future
- **Flexible timing**: Set exact date or relative intervals
### CheckAlive Mechanism (will.py)
- **Two verification modes**:
**Interval Mode**:
- Format: Same as locktime (e.g., `180d`, `2y`)
- Logic: `locktime - interval > current_time`
- Example: Locktime=1y, CheckAlive=180d → Valid if 365-180 > current days
**Fixed Date Mode**:
- Format: Timestamp
- Logic: `fixed_date > current_time`
- Example: CheckAlive=2025-12-31 → Valid if date is in the future
- **Effect**: If CheckAlive expires, the old will is invalidated
- **Safety mechanism**: Prevents stale wills from executing
### Will Executor System (willexecutors.py)
- **Multiple executors**: Choose from available executors
- **Fixed fee only**: Executor receives a fixed BTC amount (no percentage option)
- **Fee included in transaction**: Fee is part of the timelocked transaction output
- **Financial motivation**: Executor must broadcast to get paid
- **Automatic transmission**: Transaction sent to executor after creation
- **Blockchain monitoring**: Executor broadcasts at locktime expiration
**Executor Fee Details**:
- Fee is specified as fixed BTC amount only
- Fee is added to transaction outputs
- Executor receives fee only when transaction confirms
- No separate payment channel needed
### Advanced Wallet Utilities (wallet_util/)
- **⚠️ Emergency tools only**: For fixing wallet database issues
- **Purpose**: Handle compatibility across different Electrum walletdb versions
- **Usage**: Advanced users only, requires manual installation
- **Installation Steps**:
1. Copy `wallet_util/` files to Electrum's plugin directory
2. Load Electrum's virtualenv
3. Run `./bal_wallet_utils_qt.py`
- **Risk**: Advanced operations that can affect wallet data
- **Documentation**: Limited to inline code comments
**Normal operation does NOT require wallet_util/ files.**
### Main Interface (qt.py)
- **User-friendly wizards**: Step-by-step setup interface
- **Real-time validation**: Immediate feedback on inputs
- **Transaction preview**: Review before finalizing
- **Status monitoring**: Track will execution progress
- **Error handling**: Clear messages for invalid inputs
---
## 📖 Usage Examples
### Example 1: Simple Inheritance Will
**Scenario**: Leave 1 BTC to three heirs after 1 year, with 180-day CheckAlive
**Configuration**:
```
Heirs:
- Heir 1: bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq - 40% (0.4 BTC)
- Heir 2: bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4 - 35% (0.35 BTC)
- Heir 3: bc1ql4wk3ym38m8p3kns5w5q4tech38x58s3yy263q - 25% (0.25 BTC)
Locktime: 1y (1 year from now)
CheckAlive: 180d (180 days before locktime)
Executor: "Alice Executor" (fee: 0.01 BTC fixed)
```
**Transaction Created**:
- Input: All UTXOs in wallet (1.0 BTC total)
- Outputs:
- Heir 1: 0.4 BTC
- Heir 2: 0.35 BTC
- Heir 3: 0.25 BTC
- Executor fee: 0.01 BTC (fixed amount)
- Network fee: 0.001 BTC
- Locktime: 1 year from creation
- Sent to: Alice Executor's address
**Executor Incentive**:
- Alice receives 0.01 BTC **only if** she broadcasts and transaction confirms
- Alice has financial motivation to ensure transaction is confirmed
- Transaction cannot be spent until locktime expires
### Example 2: Fixed Date CheckAlive
**Scenario**: Leave funds with CheckAlive on specific date
**Configuration**:
```
Locktime: 2026-06-01 (timestamp: 1748832000)
CheckAlive: 2025-12-31 (timestamp: 1767187200)
Heirs: 50% to heir1, 50% to heir2
Executor: "Bob Executor" (fee: 0.005 BTC fixed)
```
**Logic**:
- If today > 2025-12-31 → Old will is invalid
- If old will is invalid, a non-timelocked transaction is sent onchain to invalidate the old will
- Transaction executes on 2026-06-01
- Bob receives 0.005 BTC only when transaction confirms
- Bob is incentivized to broadcast at correct time
### Example 3: Complex Distribution with Fixed Amounts
**Scenario**: Leave different fixed amounts to heirs
**Configuration**:
```
Heirs:
- Heir 1: bc1... - 0.3 BTC (fixed amount)
- Heir 2: bc1... - 0.5 BTC (fixed amount)
- Heir 3: bc1... - 0.2 BTC (fixed amount)
Total: 1.0 BTC
Locktime: 365d (1 year from now)
CheckAlive: 180d (6 months before locktime)
Executor: "Charlie Executor" (fee: 0.02 BTC fixed)
```
---
## 🛠️ Development
### Prerequisites
- Electrum Bitcoin wallet
### Setup Development Environment
```bash
# Clone repository
git clone https://bitcoin-after.life/gitea/bitcoinafterlife/bal-electrum-plugin.git
cd bal-electrum-plugin
# Create development zip
zip -r bal-electrum-plugin-dev.zip bal_electrum_plugin/
```
### Important Notes on wallet_util/
**These utilities require manual installation**:
- **Purpose**: Fix wallet database compatibility issues across Electrum versions
- **Usage**: Only when experiencing wallet corruption or version mismatches
- **Installation Steps**:
1. Copy `wallet_util/` files to Electrum's plugin directory
2. Load Electrum's virtualenv
3. Run `./bal_wallet_utils_qt.py`
- **Risk**: Advanced operations that can affect wallet data
- **Documentation**: Limited to inline code comments
**Normal operation does NOT require wallet_util/ files.**
### Install for Development
1. Copy zip to Electrum plugins directory:
```bash
cp bal-electrum-plugin-dev.zip ~/.electrum/plugins/
```
2. Install in Electrum (Tools → Plugins → Add)
3. Make changes to files in `bal_electrum_plugin/` directory
4. Re-zip and reinstall to test
### Running the Plugin
The plugin uses Electrum's existing virtualenv.
---
## 🐛 Troubleshooting
### Common Issues
#### Plugin Not Showing in Electrum
- ✅ Check zip contains `bal_electrum_plugin/` directory at root
- ✅ Verify directory structure inside zip is correct
- ✅ Restart Electrum completely after installation
- ✅ Check Electrum logs: Help → Debug → Console
#### Locktime Format Errors
- ✅ Use valid formats: `180d`, `1y`, or Unix timestamp (e.g., `1735689600`)
- ✅ Ensure locktime is in the future (after current time)
- ✅ Check for typos: lowercase `d` and `y` only
- ✅ Verify interval calculations: `1y` = 365 days
#### CheckAlive Not Working
- ✅ Verify CheckAlive mode setting (interval or fixed_date)
- ✅ Ensure CheckAlive value is before Locktime value
- ✅ For interval mode: Check logic `locktime - interval > now`
- ✅ For fixed date: Verify timestamp is in the future
- ✅ If CheckAlive expires, old will is invalidated by sending a non-timelocked transaction onchain
#### Heir Distribution Errors
- ✅ Verify percentages sum to exactly 100%
- ✅ For fixed amounts: Ensure total doesn't exceed wallet balance
- ✅ Check Bitcoin addresses are valid (use testnet for testing)
- ✅ Ensure no duplicate addresses in heir list
- ✅ Verify address format: bc1... for native segwit, 1... for legacy
#### Transaction Creation Fails
- ✅ Check wallet has sufficient funds (including fees)
- ✅ Verify all heirs have valid, unique addresses
- ✅ Ensure locktime format is correct
- ✅ Check executor is selected and available
- ✅ Verify executor fee is properly configured (fixed amount only)
#### Executor Fee Issues
- ✅ Fee is a fixed BTC amount only (no percentage option)
- ✅ Fee is included in transaction output
- ✅ Executor receives fee only when transaction confirms
- ✅ Transaction must be broadcast and confirmed for executor to get paid
#### wallet_util/ Not Working
- ✅ Copy files to Electrum's plugin directory
- ✅ Load Electrum's virtualenv before using
- ✅ Run `./bal_wallet_utils_qt.py`
- ✅ Only use for emergency wallet fixes
- ✅ Backup wallet before using wallet utilities
---
## 🤝 Contributing
Thanks for considering contributing!
---
## 📜 License
MIT License - see [LICENSE](LICENSE) for details.
---
## 🔗 Links
- **Repository**: [https://bitcoin-after.life/gitea/bitcoinafterlife/bal-electrum-plugin](https://bitcoin-after.life/gitea/bitcoinafterlife/bal-electrum-plugin)
- **Releases**: [https://bitcoin-after.life/gitea/bitcoinafterlife/bal-electrum-plugin/releases](https://bitcoin-after.life/gitea/bitcoinafterlife/bal-electrum-plugin/releases)
- **Issues**: [https://bitcoin-after.life/gitea/bitcoinafterlife/bal-electrum-plugin/issues](https://bitcoin-after.like/bitcoinafterlife/bal-electrum-plugin/issues)
- **Discussions**: [https://bitcoin-after.life/gitea/bitcoinafterlife/bal-electrum-plugin/discussions](https://bitcoin-after.life/gitea/bitcoinafterlife/bal-electrum-plugin/discussions)
---
**⚠️ Important**: Always test with small amounts first. This plugin deals with Bitcoin transactions that may be irreversible.
**🔒 Security**: Never share your seed phrase or private keys. This plugin only creates transactions, it doesn't store your keys.
**💡 Note**: The executor fee is a fixed BTC amount included in the transaction output. The executor is financially incentivized to broadcast the transaction and ensure it confirms on the Bitcoin network.

65
bal.py
View File

@@ -1,14 +1,14 @@
import os import os
from datetime import date, datetime, timedelta
import platform
# import random # import random
# import zipfile as zipfile_lib # import zipfile as zipfile_lib
from electrum import constants, json_db
from electrum import json_db
from electrum.logging import get_logger from electrum.logging import get_logger
from electrum.plugin import BasePlugin from electrum.plugin import BasePlugin
from electrum.transaction import tx_from_any from electrum.transaction import tx_from_any
_logger = get_logger(__name__)
def get_will_settings(x): def get_will_settings(x):
# print(x) # print(x)
pass pass
@@ -49,12 +49,6 @@ class BalConfig:
class BalPlugin(BasePlugin): class BalPlugin(BasePlugin):
_version=None _version=None
__version__ = "0.2.8" #AUTOMATICALLY GENERATED DO NOT EDIT __version__ = "0.2.8" #AUTOMATICALLY GENERATED DO NOT EDIT
default_app={
"Linux":"xdg-open",
"Window":"start",
"Darwin":"open"
}
chainname = constants.net.NET_NAME if constants.net.NET_NAME != "mainnet" else "bitcoin"
def version(self): def version(self):
if not self._version: if not self._version:
try: try:
@@ -65,7 +59,7 @@ class BalPlugin(BasePlugin):
except Exception as e: except Exception as e:
_logger.error(f"failed to get version: {e}") _logger.error(f"failed to get version: {e}")
self._version="unknown" self._version="unknown"
return self._version return self._version
SIZE = (159, 97) SIZE = (159, 97)
@@ -106,7 +100,6 @@ class BalPlugin(BasePlugin):
self.HIDE_INVALIDATED = BalConfig(config, "bal_hide_invalidated", True) self.HIDE_INVALIDATED = BalConfig(config, "bal_hide_invalidated", True)
self.ALLOW_REPUSH = BalConfig(config, "bal_allow_repush", True) self.ALLOW_REPUSH = BalConfig(config, "bal_allow_repush", True)
self.FIRST_EXECUTION = BalConfig(config, "bal_first_execution", True) self.FIRST_EXECUTION = BalConfig(config, "bal_first_execution", True)
self.WELIST_SERVER = BalConfig(config,"bal_welist_server","https://welist.bitcoin-after.life/")
self.WILLEXECUTORS = BalConfig( self.WILLEXECUTORS = BalConfig(
config, config,
"bal_willexecutors", "bal_willexecutors",
@@ -129,33 +122,18 @@ class BalPlugin(BasePlugin):
"selected": True, "selected": True,
} }
}, },
"testnet4": {
"https://we.bitcoin-after.life": {
"base_fee": 100000,
"status": "New",
"info": "Bitcoin After Life Will Executor",
"address": "bcrt1qa5cntu4hgadw8zd3n6sq2nzjy34sxdtd9u0gp7",
"selected": True,
}
},
"regtest": {
"https://we.bitcoin-after.life": {
"base_fee": 100000,
"status": "New",
"info": "Bitcoin After Life Will Executor",
"address": "bcrt1qa5cntu4hgadw8zd3n6sq2nzjy34sxdtd9u0gp7",
"selected": True,
}
},
}, },
) )
self.WILL_SETTINGS = BalConfig( self.WILL_SETTINGS = BalConfig(
config, config,
"bal_will_settings", "bal_will_settings",
BalPlugin.default_will_settings(), {
"baltx_fees": 100,
"threshold": "180d",
"locktime": "1y",
},
) )
self.system = platform.system()
self.CALENDAR_APP = BalConfig(config,"bal_open_app",self.default_app[self.system])
self._hide_invalidated = self.HIDE_INVALIDATED.get() self._hide_invalidated = self.HIDE_INVALIDATED.get()
self._hide_replaced = self.HIDE_REPLACED.get() self._hide_replaced = self.HIDE_REPLACED.get()
@@ -171,22 +149,13 @@ class BalPlugin(BasePlugin):
self.HIDE_REPLACED.set(self._hide_replaced) self.HIDE_REPLACED.set(self._hide_replaced)
def validate_will_settings(self, will_settings): def validate_will_settings(self, will_settings):
defaults=BalPlugin.default_will_settings() if int(will_settings.get("baltx_fees", 1)) < 1:
if not will_settings: will_settings["baltx_fees"] = 1
will_settings=[]
if int(will_settings.get("baltx_fees", 0)) < 1:
will_settings["baltx_fees"] = defaults['baltx_fees']
if not will_settings.get("threshold"): if not will_settings.get("threshold"):
will_settings["threshold"] = defaults['threshold'] will_settings["threshold"] = "180d"
if not will_settings.get("locktime"): if not will_settings.get("locktime"):
will_settings["locktime"] = defaults['locktime'] will_settings["locktime"] = "1y"
return will_settings return will_settings
@staticmethod def default_will_settings(self):
def default_will_settings(): return {"baltx_fees": 100, "threshold": "180d", "locktime": "1y"}
today = date.today()
dt = datetime(today.year, today.month, today.day, 0, 0, 0)
threshold =(dt + timedelta(days=180)).timestamp()
locktime =(dt + timedelta(days=365)).timestamp()
return {"baltx_fees": 100, "threshold": threshold, "locktime": locktime}

View File

@@ -30,11 +30,12 @@ from electrum.transaction import (
PartialTransaction, PartialTransaction,
PartialTxInput, PartialTxInput,
PartialTxOutput, PartialTxOutput,
TxOutput,
TxOutpoint, TxOutpoint,
# TxOutput, # TxOutput,
) )
from electrum.payment_identifier import PaymentIdentifier
from electrum.util import ( from electrum.util import (
BitcoinException,
bfh, bfh,
read_json_file, read_json_file,
to_string, to_string,
@@ -44,7 +45,7 @@ from electrum.util import (
from .util import Util from .util import Util
from .willexecutors import Willexecutors from .willexecutors import Willexecutors
from electrum.util import BitcoinException
if TYPE_CHECKING: if TYPE_CHECKING:
from .simple_config import SimpleConfig from .simple_config import SimpleConfig
@@ -74,10 +75,10 @@ def reduce_outputs(in_amount, out_amount, fee, outputs):
def create_op_return_script(data_hex: str) -> bytes: def create_op_return_script(data_hex: str) -> bytes:
"""Crea scriptpubkey OP_RETURN in bytes""" """Crea scriptpubkey OP_RETURN in bytes"""
data = bytes.fromhex(data_hex) data = bytes.fromhex(data_hex)
if len(data) > 80: if len(data) > 80:
raise ValueError("OP_RETURN data too big (max 80 bytes)") raise ValueError("OP_RETURN data too big (max 80 bytes)")
# Costruzione manuale: OP_RETURN + push data # Costruzione manuale: OP_RETURN + push data
if len(data) <= 75: if len(data) <= 75:
# Formato più comune: OP_RETURN + 1-byte length + data # Formato più comune: OP_RETURN + 1-byte length + data
@@ -85,7 +86,7 @@ def create_op_return_script(data_hex: str) -> bytes:
else: else:
# Per dati più grandi (fino a 80) si usa OP_PUSHDATA1 # Per dati più grandi (fino a 80) si usa OP_PUSHDATA1
script = b'\x6a\x4c' + bytes([len(data)]) + data script = b'\x6a\x4c' + bytes([len(data)]) + data
return script return script
def prepare_transactions(locktimes, available_utxos, fees, wallet): def prepare_transactions(locktimes, available_utxos, fees, wallet):
@@ -163,7 +164,7 @@ def prepare_transactions(locktimes, available_utxos, fees, wallet):
random.shuffle(outputs) random.shuffle(outputs)
#op_return_text = "Hello Bal!" #op_return_text = "Hello Bal!"
## Convert text to hex ## Convert text to hex
#op_return_hex = op_return_text.encode('utf-8').hex() #op_return_hex = op_return_text.encode('utf-8').hex()
#op_return_script = create_op_return_script(op_return_hex) #op_return_script = create_op_return_script(op_return_hex)
@@ -183,7 +184,7 @@ def prepare_transactions(locktimes, available_utxos, fees, wallet):
tx.remove_signatures() tx.remove_signatures()
txid = tx.txid() txid = tx.txid()
if txid is None: if txid is None:
raise Exception(f"txid is none: {tx}") raise Exception("txid is none", tx)
tx.heirs = paid_heirs tx.heirs = paid_heirs
tx.my_locktime = locktime tx.my_locktime = locktime

1071
qt.py

File diff suppressed because it is too large Load Diff

614
util.py
View File

@@ -1,14 +1,39 @@
# -*- coding: utf-8 -*-
# Utility module for the BAL Electrum plugin.
import bisect import bisect
from datetime import datetime, timedelta from datetime import datetime, timedelta
from electrum.gui.qt.util import getSaveFileName
from electrum.i18n import _
from electrum.transaction import PartialTxOutput from electrum.transaction import PartialTxOutput
from electrum.util import FileExportFailed
LOCKTIME_THRESHOLD = 500000000 LOCKTIME_THRESHOLD = 500000000
class Util: class Util:
@staticmethod """Utility class providing static methods for the BAL Electrum Plugin.
Contains helper functions for locktime handling, amount encoding/decoding,
heir and will-executor comparison, UTXO and transaction comparison,
debug printing, and data migration helpers.
"""
def locktime_to_str(locktime): def locktime_to_str(locktime):
"""Convert a locktime value to a human-readable string.
If the locktime is a Unix timestamp (greater than LOCKTIME_THRESHOLD),
it is converted to an ISO 8601 date string. Otherwise, it is returned
as a plain string (typically a block height).
Args:
locktime: A locktime value, either a block height or a Unix timestamp.
Returns:
str: An ISO 8601 date string for timestamps, or the numeric value
as a string for block heights.
"""
try: try:
locktime = int(locktime) locktime = int(locktime)
if locktime > LOCKTIME_THRESHOLD: if locktime > LOCKTIME_THRESHOLD:
@@ -19,8 +44,21 @@ class Util:
pass pass
return str(locktime) return str(locktime)
@staticmethod
def str_to_locktime(locktime): def str_to_locktime(locktime):
"""Convert a string representation to a locktime value.
If the string ends with a suffix ('y', 'd', 'b'), it is preserved as-is
for later parsing. Numeric strings are converted to integers. ISO 8601
date strings are converted to Unix timestamps.
Args:
locktime (str): A locktime string, number, ISO date, or
suffixed interval ('y' for years, 'd' for days, 'b' for blocks).
Returns:
int or str: An integer locktime value, or the original string if it
carries a suffix for deferred parsing.
"""
try: try:
if locktime[-1] in ("y", "d", "b"): if locktime[-1] in ("y", "d", "b"):
return locktime return locktime
@@ -32,8 +70,24 @@ class Util:
timestamp = dt_object.timestamp() timestamp = dt_object.timestamp()
return int(timestamp) return int(timestamp)
@staticmethod
def parse_locktime_string(locktime, w=None): def parse_locktime_string(locktime, w=None):
"""Parse a locktime string into an integer locktime value.
Supports multiple formats:
- Plain integer: returned directly.
- Suffix 'y': converted to days (N * 365) then to a timestamp at midnight.
- Suffix 'd': converted to a timestamp N days from now at midnight.
- Suffix 'b': added to the current block height (requires wallet network).
Args:
locktime (str): The locktime string to parse.
w (optional): The Electrum wallet, used to resolve block-height-based
locktimes. Defaults to None.
Returns:
int: The resolved locktime as an integer (timestamp or block height),
or 0 if parsing fails.
"""
try: try:
return int(locktime) return int(locktime)
@@ -60,8 +114,22 @@ class Util:
pass pass
return 0 return 0
@staticmethod
def int_locktime(seconds=0, minutes=0, hours=0, days=0, blocks=0): def int_locktime(seconds=0, minutes=0, hours=0, days=0, blocks=0):
"""Convert time intervals into a locktime integer value in seconds.
Each unit is converted to seconds and summed. Block intervals are
estimated at 600 seconds (10 minutes) per block.
Args:
seconds (int): Number of seconds. Defaults to 0.
minutes (int): Number of minutes. Defaults to 0.
hours (int): Number of hours. Defaults to 0.
days (int): Number of days. Defaults to 0.
blocks (int): Number of blocks (600 seconds each). Defaults to 0.
Returns:
int: Total time in seconds.
"""
return int( return int(
seconds seconds
+ minutes * 60 + minutes * 60
@@ -70,8 +138,21 @@ class Util:
+ blocks * 600 + blocks * 600
) )
@staticmethod
def encode_amount(amount, decimal_point): def encode_amount(amount, decimal_point):
"""Encode a human-readable amount string to an integer value.
If the amount is a percentage (ends with '%'), it is returned as-is.
Otherwise, the float value is multiplied by 10^decimal_point to convert
to the smallest unit (e.g., satoshis for BTC).
Args:
amount (str): The amount string, either a number or a percentage.
decimal_point (int): The number of decimal places for encoding.
Returns:
str or int: The encoded integer amount, the original percentage string,
or 0 if encoding fails.
"""
if Util.is_perc(amount): if Util.is_perc(amount):
return amount return amount
else: else:
@@ -80,8 +161,21 @@ class Util:
except Exception: except Exception:
return 0 return 0
@staticmethod
def decode_amount(amount, decimal_point): def decode_amount(amount, decimal_point):
"""Decode an integer amount to a human-readable string.
If the amount is a percentage (ends with '%'), it is returned as-is.
Otherwise, the integer value is divided by 10^decimal_point and formatted
to the specified number of decimal places.
Args:
amount: The amount to decode, either an integer or a percentage string.
decimal_point (int): The number of decimal places for formatting.
Returns:
str: The decoded amount string formatted to decimal_point places,
the original percentage string, or a string representation on error.
"""
if Util.is_perc(amount): if Util.is_perc(amount):
return amount return amount
else: else:
@@ -91,15 +185,31 @@ class Util:
except Exception: except Exception:
return str(amount) return str(amount)
@staticmethod
def is_perc(value): def is_perc(value):
"""Check whether a value represents a percentage.
Args:
value: The value to check.
Returns:
bool: True if the value ends with '%', False otherwise or on error.
"""
try: try:
return value[-1] == "%" return value[-1] == "%"
except Exception: except Exception:
return False return False
@staticmethod
def cmp_array(heira, heirb): def cmp_array(heira, heirb):
"""Compare two heir arrays element by element.
Args:
heira (list): First heir array.
heirb (list): Second heir array.
Returns:
bool: True if both arrays have the same length and identical elements,
False otherwise.
"""
try: try:
if len(heira) != len(heirb): if len(heira) != len(heirb):
return False return False
@@ -110,14 +220,33 @@ class Util:
except Exception: except Exception:
return False return False
@staticmethod
def cmp_heir(heira, heirb): def cmp_heir(heira, heirb):
"""Compare two heirs by their address and amount.
Args:
heira: First heir (index 0=address, index 1=amount).
heirb: Second heir (same format).
Returns:
bool: True if both heirs have the same address and amount.
"""
if heira[0] == heirb[0] and heira[1] == heirb[1]: if heira[0] == heirb[0] and heira[1] == heirb[1]:
return True return True
return False return False
@staticmethod
def cmp_willexecutor(willexecutora, willexecutorb): def cmp_willexecutor(willexecutora, willexecutorb):
"""Compare two will executors for equality.
Two executors are considered equal if they are the same object, or if they
share the same url, address, and base_fee.
Args:
willexecutora (dict): First will executor dictionary.
willexecutorb (dict): Second will executor dictionary.
Returns:
bool: True if the executors are equal, False otherwise.
"""
if willexecutora == willexecutorb: if willexecutora == willexecutorb:
return True return True
try: try:
@@ -131,8 +260,20 @@ class Util:
return False return False
return False return False
@staticmethod
def search_heir_by_values(heirs, heir, values): def search_heir_by_values(heirs, heir, values):
"""Search for an heir in a dict by matching specific field values.
Iterates over all heirs and returns the key of the first heir whose
values for all specified fields match those of the given heir.
Args:
heirs (dict): Dictionary of heirs keyed by ID.
heir (dict): The reference heir to match against.
values (list): List of field keys to compare.
Returns:
str or bool: The key of the first matching heir, or False if no match.
"""
for h, v in heirs.items(): for h, v in heirs.items():
found = False found = False
for val in values: for val in values:
@@ -143,17 +284,43 @@ class Util:
return h return h
return False return False
@staticmethod
def cmp_heir_by_values(heira, heirb, values): def cmp_heir_by_values(heira, heirb, values):
"""Compare two heirs by a set of specific field values.
Args:
heira (dict): First heir data dictionary.
heirb (dict): Second heir data dictionary.
values (list): List of field keys to compare.
Returns:
bool: True if all specified fields match between both heirs.
"""
for v in values: for v in values:
if heira[v] != heirb[v]: if heira[v] != heirb[v]:
return False return False
return True return True
@staticmethod
def cmp_heirs_by_values( def cmp_heirs_by_values(
heirsa, heirsb, values, exclude_willexecutors=False, reverse=True heirsa, heirsb, values, exclude_willexecutors=False, reverse=True
): ):
"""Compare two heir dictionaries by specific field values.
Performs a bidirectional comparison: every heir in heirsa must have a
matching heir in heirsb (and vice versa when reverse=True) for the
specified field values.
Args:
heirsa (dict): First dictionary of heirs.
heirsb (dict): Second dictionary of heirs.
values (list): Field keys to compare.
exclude_willexecutors (bool): If True, exclude will-executor entries
(containing 'w!ll3x3c"') from comparison. Defaults to False.
reverse (bool): If True, also verify the reverse comparison. Defaults
to True.
Returns:
bool: True if all matching heirs are found in both directions.
"""
for heira in heirsa: for heira in heirsa:
if ( if (
exclude_willexecutors and 'w!ll3x3c"' not in heira exclude_willexecutors and 'w!ll3x3c"' not in heira
@@ -175,13 +342,32 @@ class Util:
else: else:
return True return True
@staticmethod
def cmp_heirs( def cmp_heirs(
heirsa, heirsa,
heirsb, heirsb,
cmp_function=lambda x, y: x[0] == y[0] and x[3] == y[3], cmp_function=lambda x, y: x[0] == y[0] and x[3] == y[3],
reverse=True, reverse=True,
): ):
"""Compare two heir dictionaries using a custom comparison function.
Performs a bidirectional comparison. Will-executor entries are excluded
from the comparison. If an exact key match fails, a fallback search by
values (indices 0 and 3) is attempted.
Args:
heirsa (dict): First dictionary of heirs.
heirsb (dict): Second dictionary of heirs.
cmp_function (callable): A function taking two heir values and returning
a bool. Defaults to comparing index 0 (address) and index 3.
reverse (bool): If True, also verify the reverse comparison. Defaults
to True.
Returns:
bool: True if all heirs match in both directions.
Raises:
Exception: Re-raises any exception encountered during comparison.
"""
try: try:
for heir in heirsa: for heir in heirsa:
if 'w!ll3x3c"' not in heir: if 'w!ll3x3c"' not in heir:
@@ -198,8 +384,17 @@ class Util:
raise e raise e
return False return False
@staticmethod
def cmp_inputs(inputsa, inputsb): def cmp_inputs(inputsa, inputsb):
"""Compare two lists of transaction inputs for equality.
Args:
inputsa (list): First list of transaction inputs.
inputsb (list): Second list of transaction inputs.
Returns:
bool: True if both lists have the same length and every input in
inputsa is found in inputsb.
"""
if len(inputsa) != len(inputsb): if len(inputsa) != len(inputsb):
return False return False
for inputa in inputsa: for inputa in inputsa:
@@ -207,8 +402,22 @@ class Util:
return False return False
return True return True
@staticmethod
def cmp_outputs(outputsa, outputsb, willexecutor_output=None): def cmp_outputs(outputsa, outputsb, willexecutor_output=None):
"""Compare two lists of transaction outputs for equality.
Optionally excludes a specific will-executor output from the comparison,
as it may differ between transactions.
Args:
outputsa (list): First list of transaction outputs.
outputsb (list): Second list of transaction outputs.
willexecutor_output: An output to exclude from comparison. Defaults
to None.
Returns:
bool: True if both lists have the same length and all outputs match,
excluding the willexecutor_output if provided.
"""
if len(outputsa) != len(outputsb): if len(outputsa) != len(outputsb):
return False return False
for outputa in outputsa: for outputa in outputsa:
@@ -217,16 +426,37 @@ class Util:
return False return False
return True return True
@staticmethod
def cmp_txs(txa, txb): def cmp_txs(txa, txb):
"""Compare two transactions for equality by inputs and outputs.
Args:
txa: First transaction object.
txb: Second transaction object.
Returns:
bool: True if both transactions have matching inputs and outputs.
"""
if not Util.cmp_inputs(txa.inputs(), txb.inputs()): if not Util.cmp_inputs(txa.inputs(), txb.inputs()):
return False return False
if not Util.cmp_outputs(txa.outputs(), txb.outputs()): if not Util.cmp_outputs(txa.outputs(), txb.outputs()):
return False return False
return True return True
@staticmethod
def get_value_amount(txa, txb): def get_value_amount(txa, txb):
"""Calculate the total value of outputs that appear in both transactions.
For each output in txa, checks if a matching output (same amount and/or
same address) exists in txb. Returns the sum of matched output values,
or False if any output has neither a matching amount nor address.
Args:
txa: Reference transaction (whose outputs are evaluated).
txb: Comparison transaction.
Returns:
int or False: Total satoshi value of matched outputs, or False if
any output has no correspondence at all.
"""
outputsa = txa.outputs() outputsa = txa.outputs()
# outputsb = txb.outputs() # outputsb = txb.outputs()
value_amount = 0 value_amount = 0
@@ -244,8 +474,25 @@ class Util:
return value_amount return value_amount
@staticmethod
def chk_locktime(timestamp_to_check, block_height_to_check, locktime): def chk_locktime(timestamp_to_check, block_height_to_check, locktime):
"""Check whether a locktime has been reached.
If the locktime is a Unix timestamp (above LOCKTIME_THRESHOLD), it is
compared against timestamp_to_check. If it is a block height (below the
threshold), it is compared against block_height_to_check.
Note:
There is a known issue at the threshold boundary (LOCKTIME_THRESHOLD)
where the behavior is ambiguous.
Args:
timestamp_to_check (int): Current Unix timestamp to compare against.
block_height_to_check (int): Current block height to compare against.
locktime (int): The locktime value to check.
Returns:
bool: True if the locktime has been reached or exceeded.
"""
# TODO BUG: WHAT HAPPEN AT THRESHOLD? # TODO BUG: WHAT HAPPEN AT THRESHOLD?
locktime = int(locktime) locktime = int(locktime)
if locktime > LOCKTIME_THRESHOLD and locktime > timestamp_to_check: if locktime > LOCKTIME_THRESHOLD and locktime > timestamp_to_check:
@@ -255,8 +502,26 @@ class Util:
else: else:
return False return False
@staticmethod
def anticipate_locktime(locktime, blocks=0, hours=0, days=0): def anticipate_locktime(locktime, blocks=0, hours=0, days=0):
"""Anticipate a locktime by subtracting a time interval from it.
Used to compute the checkalive time: the point at which the plugin
should verify whether the user is still alive before the locktime expires.
For timestamp-based locktimes, the interval is subtracted as seconds.
For block-height-based locktimes, the interval is converted to block
equivalents (hours * 6 + days * 144) and subtracted.
Args:
locktime (int): The original locktime value (timestamp or block height).
blocks (int): Number of blocks to subtract (each ~10 min). Defaults to 0.
hours (int): Number of hours to subtract. Defaults to 0.
days (int): Number of days to subtract. Defaults to 0.
Returns:
int: The anticipated locktime, adjusted by the specified interval.
Returns at least 1 to avoid invalid values.
"""
locktime = int(locktime) locktime = int(locktime)
out = 0 out = 0
if locktime > LOCKTIME_THRESHOLD: if locktime > LOCKTIME_THRESHOLD:
@@ -272,8 +537,19 @@ class Util:
out = 1 out = 1
return out return out
@staticmethod
def cmp_locktime(locktimea, locktimeb): def cmp_locktime(locktimea, locktimeb):
"""Compare two locktime values.
If the locktimes are equal, returns 0. If both are suffixed strings
with the same unit suffix, compares their numeric parts.
Args:
locktimea: First locktime value.
locktimeb: Second locktime value.
Returns:
int: Negative if locktimea < locktimeb, 0 if equal, positive if greater.
"""
if locktimea == locktimeb: if locktimea == locktimeb:
return 0 return 0
strlocktimea = str(locktimea) strlocktimea = str(locktimea)
@@ -286,21 +562,49 @@ class Util:
else: else:
return int(locktimea) - (locktimeb) return int(locktimea) - (locktimeb)
@staticmethod
def get_lowest_valid_tx(available_utxos, will): def get_lowest_valid_tx(available_utxos, will):
"""Find the lowest valid transaction in a will by locktime.
Iterates through will items sorted by locktime to find the first
transaction whose inputs are still available.
Args:
available_utxos: List of available UTXOs.
will (dict): Dictionary of will items keyed by transaction ID.
Note:
This method is incomplete and currently does not return a value.
"""
will = sorted(will.items(), key=lambda x: x[1]["tx"].locktime) will = sorted(will.items(), key=lambda x: x[1]["tx"].locktime)
for txid, willitem in will.items(): for txid, willitem in will.items():
pass pass
@staticmethod
def get_locktimes(will): def get_locktimes(will):
"""Extract all unique locktime values from a will.
Args:
will (dict): Dictionary of will items keyed by transaction ID.
Returns:
dict_keys: Collection of unique locktime values from the will.
"""
locktimes = {} locktimes = {}
for txid, willitem in will.items(): for txid, willitem in will.items():
locktimes[willitem["tx"].locktime] = True locktimes[willitem["tx"].locktime] = True
return locktimes.keys() return locktimes.keys()
@staticmethod
def get_lowest_locktimes(locktimes): def get_lowest_locktimes(locktimes):
"""Separate and sort locktimes into timestamp and block-height groups.
Locktimes below LOCKTIME_THRESHOLD are sorted as block heights;
those above are sorted as timestamps.
Args:
locktimes: Iterable of locktime values.
Returns:
tuple: (sorted_timestamp_list, sorted_block_list)
"""
sorted_timestamp = [] sorted_timestamp = []
sorted_block = [] sorted_block = []
for locktime in locktimes: for locktime in locktimes:
@@ -312,31 +616,76 @@ class Util:
return sorted(sorted_timestamp), sorted(sorted_block) return sorted(sorted_timestamp), sorted(sorted_block)
@staticmethod
def get_lowest_locktimes_from_will(will): def get_lowest_locktimes_from_will(will):
"""Get the lowest locktimes from a will, separated by type.
Convenience method that combines get_locktimes and get_lowest_locktimes.
Args:
will (dict): Dictionary of will items keyed by transaction ID.
Returns:
tuple: (sorted_timestamp_list, sorted_block_list)
"""
return Util.get_lowest_locktimes(Util.get_locktimes(will)) return Util.get_lowest_locktimes(Util.get_locktimes(will))
@staticmethod
def search_willtx_per_io(will, tx): def search_willtx_per_io(will, tx):
"""Search for a will transaction matching a given transaction by inputs and outputs.
Args:
will (dict): Dictionary of will items keyed by transaction ID.
tx (dict): Transaction dictionary with a 'tx' key containing the
transaction to match.
Returns:
tuple: (will_id, will_item) if found, (None, None) otherwise.
"""
for wid, w in will.items(): for wid, w in will.items():
if Util.cmp_txs(w["tx"], tx["tx"]): if Util.cmp_txs(w["tx"], tx["tx"]):
return wid, w return wid, w
return None, None return None, None
@staticmethod
def invalidate_will(will): def invalidate_will(will):
"""Invalidate a will.
Note:
Not yet implemented.
Args:
will (dict): Dictionary of will items to invalidate.
Raises:
Exception: Always raises "not implemented".
"""
raise Exception("not implemented") raise Exception("not implemented")
@staticmethod
def get_will_spent_utxos(will): def get_will_spent_utxos(will):
"""Collect all UTXOs spent by the transactions in a will.
Args:
will (dict): Dictionary of will items keyed by transaction ID.
Returns:
list: All input UTXOs from all transactions in the will.
"""
utxos = [] utxos = []
for txid, willitem in will.items(): for txid, willitem in will.items():
utxos += willitem["tx"].inputs() utxos += willitem["tx"].inputs()
return utxos return utxos
@staticmethod
def utxo_to_str(utxo): def utxo_to_str(utxo):
"""Convert a UTXO to its string representation.
Tries multiple methods: utxo.to_str(), utxo.prevout.to_str(),
or str() as a fallback.
Args:
utxo: A UTXO object.
Returns:
str: String representation of the UTXO.
"""
try: try:
return utxo.to_str() return utxo.to_str()
except Exception: except Exception:
@@ -347,8 +696,16 @@ class Util:
pass pass
return str(utxo) return str(utxo)
@staticmethod
def cmp_utxo(utxoa, utxob): def cmp_utxo(utxoa, utxob):
"""Compare two UTXOs for equality based on their string representation.
Args:
utxoa: First UTXO object.
utxob: Second UTXO object.
Returns:
bool: True if both UTXOs have the same string representation.
"""
utxoa = Util.utxo_to_str(utxoa) utxoa = Util.utxo_to_str(utxoa)
utxob = Util.utxo_to_str(utxob) utxob = Util.utxo_to_str(utxob)
if utxoa == utxob: if utxoa == utxob:
@@ -356,26 +713,58 @@ class Util:
else: else:
return False return False
@staticmethod
def in_utxo(utxo, utxos): def in_utxo(utxo, utxos):
"""Check if a UTXO exists in a list of UTXOs.
Args:
utxo: The UTXO to search for.
utxos (list): List of UTXOs to search in.
Returns:
bool: True if the UTXO is found in the list.
"""
for s_u in utxos: for s_u in utxos:
if Util.cmp_utxo(s_u, utxo): if Util.cmp_utxo(s_u, utxo):
return True return True
return False return False
@staticmethod
def txid_in_utxo(txid, utxos): def txid_in_utxo(txid, utxos):
"""Check if a transaction ID exists in any UTXO's prevout.
Args:
txid (str): The transaction ID to search for.
utxos (list): List of UTXOs to search in.
Returns:
bool: True if any UTXO's prevout matches the given txid.
"""
for s_u in utxos: for s_u in utxos:
if s_u.prevout.txid == txid: if s_u.prevout.txid == txid:
return True return True
return False return False
@staticmethod
def cmp_output(outputa, outputb): def cmp_output(outputa, outputb):
"""Compare two transaction outputs for equality by address and value.
Args:
outputa: First transaction output.
outputb: Second transaction output.
Returns:
bool: True if both outputs have the same address and value.
"""
return outputa.address == outputb.address and outputa.value == outputb.value return outputa.address == outputb.address and outputa.value == outputb.value
@staticmethod
def in_output(output, outputs): def in_output(output, outputs):
"""Check if an output exists in a list of outputs.
Args:
output: The transaction output to search for.
outputs (list): List of transaction outputs to search in.
Returns:
bool: True if a matching output is found in the list.
"""
for s_o in outputs: for s_o in outputs:
if Util.cmp_output(s_o, output): if Util.cmp_output(s_o, output):
return True return True
@@ -386,8 +775,23 @@ class Util:
# return true false same amount different address # return true false same amount different address
# return false false different amount, different address not found # return false false different amount, different address not found
@staticmethod
def din_output(out, outputs): def din_output(out, outputs):
"""Differentiate an output against a list of outputs by amount and address.
Checks whether the output's amount appears in the list, and whether
any output with the same amount also shares the same address. Used to
detect change outputs.
Args:
out: The transaction output to check.
outputs (list): List of transaction outputs to compare against.
Returns:
tuple: (same_amount, same_address) where:
- (True, True): same address and same amount found.
- (True, False): same amount but different address (potential change).
- (False, False): no matching amount found.
"""
same_amount = [] same_amount = []
for s_o in outputs: for s_o in outputs:
if int(out.value) == int(s_o.value): if int(out.value) == int(s_o.value):
@@ -402,8 +806,23 @@ class Util:
else: else:
return False, False return False, False
@staticmethod
def get_change_output(wallet, in_amount, out_amount, fee): def get_change_output(wallet, in_amount, out_amount, fee):
"""Create a change output for a transaction if the change exceeds the dust threshold.
Calculates the change amount as inputs minus outputs minus fees, and
creates a PartialTxOutput directed to a new change address if the amount
is above the wallet's dust threshold.
Args:
wallet: The Electrum wallet object.
in_amount (int): Total input amount in satoshis.
out_amount (int): Total output amount in satoshis.
fee (int): Transaction fee in satoshis.
Returns:
PartialTxOutput or None: A change output if change exceeds dust threshold,
otherwise None.
"""
change_amount = int(in_amount - out_amount - fee) change_amount = int(in_amount - out_amount - fee)
if change_amount > wallet.dust_threshold(): if change_amount > wallet.dust_threshold():
change_addresses = wallet.get_change_addresses_for_new_transaction() change_addresses = wallet.get_change_addresses_for_new_transaction()
@@ -413,8 +832,19 @@ class Util:
out.is_change = True out.is_change = True
return out return out
@staticmethod
def get_current_height(network): def get_current_height(network):
"""Get the current best block height from the Electrum network.
Uses the minimum of the SPV-verified chain height and the server-reported
height to discourage fee sniping. Returns 0 if the network is unavailable,
stale, or suspiciously divergent.
Args:
network: The Electrum network object.
Returns:
int: The current block height, or 0 if unavailable/unreliable.
"""
# if no network or not up to date, just set locktime to zero # if no network or not up to date, just set locktime to zero
if not network: if not network:
return 0 return 0
@@ -435,8 +865,17 @@ class Util:
height = min(chain_height, server_height) height = min(chain_height, server_height)
return height return height
@staticmethod
def print_var(var, name="", veryverbose=False): def print_var(var, name="", veryverbose=False):
"""Print detailed debug information about a variable.
Attempts to print various representations: str, repr, dict, dir, type,
to_json, and __slotnames__.
Args:
var: The variable to inspect.
name (str): A label for the output header. Defaults to "".
veryverbose (bool): Unused parameter. Defaults to False.
"""
print(f"---{name}---") print(f"---{name}---")
if var is not None: if var is not None:
try: try:
@@ -470,8 +909,16 @@ class Util:
print(f"---end {name}---") print(f"---end {name}---")
@staticmethod
def print_utxo(utxo, name=""): def print_utxo(utxo, name=""):
"""Print detailed debug information about a UTXO.
Prints the UTXO itself, its prevout, script_sig, witness, and private
attributes (address, scriptpubkey, value_sats).
Args:
utxo: The UTXO object to inspect.
name (str): A label for the output header. Defaults to "".
"""
print(f"---utxo-{name}---") print(f"---utxo-{name}---")
Util.print_var(utxo, name) Util.print_var(utxo, name)
Util.print_prevout(utxo.prevout, name) Util.print_prevout(utxo.prevout, name)
@@ -482,21 +929,71 @@ class Util:
print("_TxInput__value_sats:", utxo._TxInput__value_sats) print("_TxInput__value_sats:", utxo._TxInput__value_sats)
print(f"---utxo-end {name}---") print(f"---utxo-end {name}---")
@staticmethod
def print_prevout(prevout, name=""): def print_prevout(prevout, name=""):
"""Print detailed debug information about a prevout object.
Args:
prevout: The prevout object to inspect.
name (str): A label for the output header. Defaults to "".
"""
print(f"---prevout-{name}---") print(f"---prevout-{name}---")
Util.print_var(prevout, f"{name}-prevout") Util.print_var(prevout, f"{name}-prevout")
Util.print_var(prevout._asdict()) Util.print_var(prevout._asdict())
print(f"---prevout-end {name}---") print(f"---prevout-end {name}---")
def export_meta_gui(electrum_window, title, exporter):
"""Export data to a file via the Electrum GUI save dialog.
Opens a file save dialog, calls the exporter function with the chosen
filename, and shows a success or error message.
Args:
electrum_window: The Electrum main window object.
title (str): Description of the data being exported.
exporter (callable): A function that takes a filename and writes the
data to it. Raises FileExportFailed on error.
"""
filter_ = "All files (*)"
filename = getSaveFileName(
parent=electrum_window,
title=_("Select file to save your {}".format(title)),
filename="BALplugin_{}".format(title),
filter=filter_,
config=electrum_window.config,
)
if not filename:
return
try:
exporter(filename)
except FileExportFailed as e:
electrum_window.show_critical(str(e))
else:
electrum_window.show_message(
_("Your {0} were exported to '{1}'".format(title, str(filename)))
)
@staticmethod
def copy(dicto, dictfrom): def copy(dicto, dictfrom):
"""Copy all key-value pairs from one dictionary to another.
Args:
dicto (dict): The destination dictionary.
dictfrom (dict): The source dictionary.
"""
for k, v in dictfrom.items(): for k, v in dictfrom.items():
dicto[k] = v dicto[k] = v
@staticmethod
def fix_will_settings_tx_fees(will_settings): def fix_will_settings_tx_fees(will_settings):
"""Migrate will settings from old 'tx_fees' key to 'baltx_fees'.
Renames the 'tx_fees' key to 'baltx_fees' in the will settings dictionary
for backward compatibility.
Args:
will_settings (dict): The will settings dictionary to update.
Returns:
bool: True if the key was renamed, False if no migration was needed.
"""
tx_fees = will_settings.get("tx_fees", False) tx_fees = will_settings.get("tx_fees", False)
have_to_update = False have_to_update = False
if tx_fees: if tx_fees:
@@ -505,8 +1002,18 @@ class Util:
have_to_update = True have_to_update = True
return have_to_update return have_to_update
@staticmethod
def fix_will_tx_fees(will): def fix_will_tx_fees(will):
"""Migrate will transactions from old 'tx_fees' key to 'baltx_fees'.
Iterates through all will items and renames the 'tx_fees' key to
'baltx_fees' in each transaction for backward compatibility.
Args:
will (dict): Dictionary of will items keyed by transaction ID.
Returns:
bool: True if any key was renamed, False if no migration was needed.
"""
have_to_update = False have_to_update = False
for txid, willitem in will.items(): for txid, willitem in will.items():
tx_fees = willitem.get("tx_fees", False) tx_fees = willitem.get("tx_fees", False)
@@ -516,18 +1023,29 @@ class Util:
have_to_update = True have_to_update = True
return have_to_update return have_to_update
@staticmethod
def text_to_hex(text: str) -> str: def text_to_hex(text: str) -> str:
"""Convert text to hexadecimal string""" """Convert text to a hexadecimal string.
Args:
text (str): The text to convert.
Returns:
str: The hexadecimal representation of the text.
"""
hex_string = text.encode('utf-8').hex() hex_string = text.encode('utf-8').hex()
return hex_string return hex_string
@staticmethod
def hex_to_text(hex_string: str) -> str: def hex_to_text(hex_string: str) -> str:
"""Convert hexadecimal string back to text (for verification)""" """Convert a hexadecimal string back to text.
Args:
hex_string (str): The hexadecimal string to convert.
Returns:
str: The decoded text, or an error message if the hex string is invalid.
"""
try: try:
return bytes.fromhex(hex_string).decode('utf-8') return bytes.fromhex(hex_string).decode('utf-8')
except Exception: except Exception:
return "Error: Invalid hex string" return "Error: Invalid hex string"

View File

@@ -1,50 +0,0 @@
## README
### Overview
This tool provides two entry points: a CLI script (bal_wallet_utils.py) and a Qt GUI script (bal_wallet_utils_qt.py) that operate against an Electrum source tree.
### Installation / Preparation
1. Copy both files into the Electrum project root (the folder that contains the Electrum source package):
- bal_wallet_utils.py
- bal_wallet_utils_qt.py
2. Activate the Electrum Python environment (the virtualenv used to run Electrum). Example (PowerShell, adjust path to your venv):
```
.\env\Scripts\Activate.ps1
```
or (cmd):
```
env\Scripts\activate.bat
```
### Running
- CLI version:
```
python bal_wallet_utils.py
```
- Qt GUI version:
```
python bal_wallet_utils_qt.py
```
### Building a Windows executable with PyInstaller
From the project root (with the Electrum environment active), you can build the Qt executable using PyInstaller. Example command (adjust the paths if your environment path differs):
```
pyinstaller.exe --onefile --noconsole --add-data "electrum\currencies.json;electrum" --add-data "electrum\bip39_wallet_formats.json;electrum" --add-data "electrum\lnwire\peer_wire.csv;electrum\lnwire" --add-data "electrum\lnwire\onion_wire.csv;electrum\lnwire" --add-binary "env/Lib/site-packages\electrum_ecc\libsecp256k1-6.dll;electrum_ecc" bal_wallet_utils_qt.py
```
Notes:
- Run the command from the project root so relative paths resolve correctly.
- On Windows the --add-data and --add-binary arguments use ";" to separate source and destination.
- If electrum expects additional data files or native DLLs, include them with additional --add-data / --add-binary flags.
- For debugging include --onedir first to inspect the created folder before using --onefile.
### Troubleshooting
- If PyInstaller is not found, run it via Python:
```
python -m PyInstaller <same arguments>
```
- If the frozen exe fails because DLLs or JSON files are missing, add those files explicitly with --add-data or --add-binary.
- Test the build on a clean Windows VM to ensure all runtime dependencies are included.
License and attribution: include your preferred license or attribution details here.

View File

@@ -1,11 +1,10 @@
#!env/bin/python3 #!env/bin/python3
import getpass
import json
import os
import sys
from electrum.storage import WalletStorage from electrum.storage import WalletStorage
import json
from electrum.util import MyEncoder from electrum.util import MyEncoder
import sys
import getpass
import os
default_fees = 100 default_fees = 100
@@ -27,12 +26,9 @@ def fix_will_settings_tx_fees(json_wallet):
def uninstall_bal(json_wallet): def uninstall_bal(json_wallet):
if "will_settings" in json_wallet: del json_wallet["will_settings"]
del json_wallet["will_settings"] del json_wallet["will"]
if "will" in json_wallet: del json_wallet["heirs"]
del json_wallet["will"]
if "heirs" in json_wallet:
del json_wallet["heirs"]
return True return True

View File

@@ -1,31 +1,30 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import json
import os
import sys import sys
import os
from bal_wallet_utils import fix_will_settings_tx_fees, save, uninstall_bal import json
from electrum.storage import WalletStorage
from PyQt6.QtWidgets import ( from PyQt6.QtWidgets import (
QApplication, QApplication,
QFileDialog, QMainWindow,
QGroupBox, QVBoxLayout,
QHBoxLayout, QHBoxLayout,
QLabel, QLabel,
QLineEdit, QLineEdit,
QMainWindow,
QPushButton, QPushButton,
QTextEdit,
QVBoxLayout,
QWidget, QWidget,
QFileDialog,
QGroupBox,
QTextEdit,
) )
from electrum.storage import WalletStorage
from bal_wallet_utils import fix_will_settings_tx_fees, uninstall_bal, save
class WalletUtilityGUI(QMainWindow): class WalletUtilityGUI(QMainWindow):
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self.init_ui() self.initUI()
def init_ui(self): def initUI(self):
self.setWindowTitle("BAL Wallet Utility") self.setWindowTitle("BAL Wallet Utility")
self.setFixedSize(500, 400) self.setFixedSize(500, 400)

70
will.py
View File

@@ -1,3 +1,11 @@
# -*- coding: utf-8 -*-
# Will module for the BAL Electrum plugin.
# -*- coding: utf-8 -*-
# Will module for the BAL Electrum plugin.
import copy import copy
from electrum.bitcoin import NLOCKTIME_BLOCKHEIGHT_MAX from electrum.bitcoin import NLOCKTIME_BLOCKHEIGHT_MAX
@@ -24,7 +32,6 @@ _logger = get_logger(__name__)
class Will: class Will:
@staticmethod
def get_children(will, willid): def get_children(will, willid):
out = [] out = []
for _id in will: for _id in will:
@@ -36,7 +43,6 @@ class Will:
return out return out
# build a tree with parent transactions # build a tree with parent transactions
@staticmethod
def add_willtree(will): def add_willtree(will):
for willid in will: for willid in will:
will[willid].children = Will.get_children(will, willid) will[willid].children = Will.get_children(will, willid)
@@ -45,17 +51,14 @@ class Will:
will[child[0]].father = willid will[child[0]].father = willid
# return a list of will sorted by locktime # return a list of will sorted by locktime
@staticmethod
def get_sorted_will(will): def get_sorted_will(will):
return sorted(will.items(), key=lambda x: x[1]["tx"].locktime) return sorted(will.items(), key=lambda x: x[1]["tx"].locktime)
@staticmethod
def only_valid(will): def only_valid(will):
for k, v in will.items(): for k, v in will.items():
if v.get_status("VALID"): if v.get_status("VALID"):
yield k yield k
@staticmethod
def search_equal_tx(will, tx, wid): def search_equal_tx(will, tx, wid):
for w in will: for w in will:
if w != wid and not tx.to_json() != will[w]["tx"].to_json(): if w != wid and not tx.to_json() != will[w]["tx"].to_json():
@@ -64,7 +67,6 @@ class Will:
return will[w]["tx"] return will[w]["tx"]
return False return False
@staticmethod
def get_tx_from_any(x): def get_tx_from_any(x):
try: try:
a = str(x) a = str(x)
@@ -75,7 +77,6 @@ class Will:
return x return x
@staticmethod
def add_info_from_will(will, wid, wallet): def add_info_from_will(will, wid, wallet):
if isinstance(will[wid].tx, str): if isinstance(will[wid].tx, str):
will[wid].tx = Will.get_tx_from_any(will[wid].tx) will[wid].tx = Will.get_tx_from_any(will[wid].tx)
@@ -96,9 +97,7 @@ class Will:
txin._TxInput__value_sats = change.value txin._TxInput__value_sats = change.value
txin._trusted_value_sats = change.value txin._trusted_value_sats = change.value
@staticmethod def normalize_will(will, wallet=None, others_inputs={}):
def normalize_will(will, wallet=None, others_inputs=None):
others_input = others_inputs if others_inputs is not None else {}
to_delete = [] to_delete = []
to_add = {} to_add = {}
# add info from wallet # add info from wallet
@@ -147,7 +146,6 @@ class Will:
if wid in will: if wid in will:
del will[wid] del will[wid]
@staticmethod
def new_input(txid, idx, change): def new_input(txid, idx, change):
prevout = TxOutpoint(txid=bfh(txid), out_idx=idx) prevout = TxOutpoint(txid=bfh(txid), out_idx=idx)
inp = PartialTxInput(prevout=prevout) inp = PartialTxInput(prevout=prevout)
@@ -158,7 +156,16 @@ class Will:
inp._TxInput__value_sats = change.value inp._TxInput__value_sats = change.value
return inp return inp
@staticmethod """
in questa situazione sono presenti due transazioni con id differente(quindi transazioni differenti)
per prima cosa controllo il locktime
se il locktime della nuova transazione e' maggiore del locktime della vecchia transazione, allora
confronto gli eredi, per locktime se corrispondono controllo i willexecutor
se hanno la stessa url ma le fee vecchie sono superiori alle fee nuove, allora anticipare.
"""
def check_anticipate(ow: "WillItem", nw: "WillItem"): def check_anticipate(ow: "WillItem", nw: "WillItem"):
anticipate = Util.anticipate_locktime(ow.tx.locktime, days=1) anticipate = Util.anticipate_locktime(ow.tx.locktime, days=1)
if int(nw.tx.locktime) >= int(anticipate): if int(nw.tx.locktime) >= int(anticipate):
@@ -188,7 +195,6 @@ class Will:
return anticipate return anticipate
return 4294967295 + 1 return 4294967295 + 1
@staticmethod
def change_input(will, otxid, idx, change, others_inputs, to_delete, to_append): def change_input(will, otxid, idx, change, others_inputs, to_delete, to_append):
ow = will[otxid] ow = will[otxid]
ntxid = ow.tx.txid() ntxid = ow.tx.txid()
@@ -229,7 +235,6 @@ class Will:
to_append, to_append,
) )
@staticmethod
def get_all_inputs(will, only_valid=False): def get_all_inputs(will, only_valid=False):
all_inputs = {} all_inputs = {}
for w, wi in will.items(): for w, wi in will.items():
@@ -244,7 +249,6 @@ class Will:
all_inputs[prevout_str].append(inp) all_inputs[prevout_str].append(inp)
return all_inputs return all_inputs
@staticmethod
def get_all_inputs_min_locktime(all_inputs): def get_all_inputs_min_locktime(all_inputs):
all_inputs_min_locktime = {} all_inputs_min_locktime = {}
@@ -259,7 +263,6 @@ class Will:
return all_inputs_min_locktime return all_inputs_min_locktime
@staticmethod
def search_anticipate_rec(will, old_inputs): def search_anticipate_rec(will, old_inputs):
redo = False redo = False
to_delete = [] to_delete = []
@@ -299,7 +302,6 @@ class Will:
Will.search_anticipate_rec(will, old_inputs) Will.search_anticipate_rec(will, old_inputs)
@staticmethod
def update_will(old_will, new_will): def update_will(old_will, new_will):
all_old_inputs = Will.get_all_inputs(old_will, only_valid=True) all_old_inputs = Will.get_all_inputs(old_will, only_valid=True)
# all_inputs_min_locktime = Will.get_all_inputs_min_locktime(all_old_inputs) # all_inputs_min_locktime = Will.get_all_inputs_min_locktime(all_old_inputs)
@@ -326,7 +328,6 @@ class Will:
else: else:
continue continue
@staticmethod
def get_higher_input_for_tx(will): def get_higher_input_for_tx(will):
out = {} out = {}
for wid in will: for wid in will:
@@ -340,7 +341,6 @@ class Will:
out[inp.prevout.to_str()] = inp out[inp.prevout.to_str()] = inp
return out return out
@staticmethod
def invalidate_will(will, wallet, fees_per_byte): def invalidate_will(will, wallet, fees_per_byte):
will_only_valid = Will.only_valid_list(will) will_only_valid = Will.only_valid_list(will)
inputs = Will.get_all_inputs(will_only_valid) inputs = Will.get_all_inputs(will_only_valid)
@@ -392,13 +392,11 @@ class Will:
_logger.debug("len utxo_to_spend <=0") _logger.debug("len utxo_to_spend <=0")
pass pass
@staticmethod
def is_new(will): def is_new(will):
for wid, w in will.items(): for wid, w in will.items():
if w.get_status("VALID") and not w.get_status("COMPLETE"): if w.get_status("VALID") and not w.get_status("COMPLETE"):
return True return True
@staticmethod
def search_rai(all_inputs, all_utxos, will, wallet): def search_rai(all_inputs, all_utxos, will, wallet):
# will_only_valid = Will.only_valid_or_replaced_list(will) # will_only_valid = Will.only_valid_or_replaced_list(will)
for inp, ws in all_inputs.items(): for inp, ws in all_inputs.items():
@@ -432,25 +430,20 @@ class Will:
else: else:
pass pass
@staticmethod
def utxos_strs(utxos): def utxos_strs(utxos):
return [Util.utxo_to_str(u) for u in utxos] return [Util.utxo_to_str(u) for u in utxos]
@staticmethod def set_invalidate(wid, will=[]):
def set_invalidate(wid, will=None):
will = will if will is not None else {}
will[wid].set_status("INVALIDATED", True) will[wid].set_status("INVALIDATED", True)
if will[wid].children: if will[wid].children:
for c in will[wid].children.items(): for c in will[wid].children.items():
Will.set_invalidate(c[0], will) Will.set_invalidate(c[0], will)
@staticmethod
def check_tx_height(tx, wallet): def check_tx_height(tx, wallet):
info = wallet.get_tx_info(tx) info = wallet.get_tx_info(tx)
return info.tx_mined_status.height() return info.tx_mined_status.height()
# check if transactions are stil valid tecnically valid # check if transactions are stil valid tecnically valid
@staticmethod
def check_invalidated(willtree, utxos_list, wallet): def check_invalidated(willtree, utxos_list, wallet):
for wid, w in willtree.items(): for wid, w in willtree.items():
if ( if (
@@ -483,7 +476,6 @@ class Will:
# if wc.children: # if wc.children:
# Will.reflect_to_children(wc) # Will.reflect_to_children(wc)
@staticmethod
def check_amounts(heirs, willexecutors, all_utxos, timestamp_to_check, dust): def check_amounts(heirs, willexecutors, all_utxos, timestamp_to_check, dust):
fixed_heirs, fixed_amount, perc_heirs, perc_amount, fixed_amount_with_dust = ( fixed_heirs, fixed_amount, perc_heirs, perc_amount, fixed_amount_with_dust = (
heirs.fixed_percent_lists_amount(timestamp_to_check, dust, reverse=True) heirs.fixed_percent_lists_amount(timestamp_to_check, dust, reverse=True)
@@ -507,7 +499,6 @@ class Will:
f"Willexecutor{url} excess base fee({wex['base_fee']}), {fixed_amount} >={temp_balance}" f"Willexecutor{url} excess base fee({wex['base_fee']}), {fixed_amount} >={temp_balance}"
) )
@staticmethod
def check_will(will, all_utxos, wallet, block_to_check, timestamp_to_check): def check_will(will, all_utxos, wallet, block_to_check, timestamp_to_check):
Will.add_willtree(will) Will.add_willtree(will)
utxos_list = Will.utxos_strs(all_utxos) utxos_list = Will.utxos_strs(all_utxos)
@@ -524,28 +515,18 @@ class Will:
Will.search_rai(all_inputs, all_utxos, will, wallet) Will.search_rai(all_inputs, all_utxos, will, wallet)
@staticmethod
def get_min_locktime(will,default_value=None):
return min((v.tx.locktime for v in will.values() if v.get_status('VALID')), default=default_value)
@staticmethod
def is_will_valid( def is_will_valid(
will, will,
block_to_check, block_to_check,
timestamp_to_check, timestamp_to_check,
tx_fees, tx_fees,
all_utxos, all_utxos,
heirs=None, heirs={},
willexecutors=None, willexecutors={},
self_willexecutor=False, self_willexecutor=False,
wallet=False, wallet=False,
callback_not_valid_tx=None, callback_not_valid_tx=None,
): ):
heirs = heirs if heirs is not None else {}
willexecutors= willexecutors if willexecutors is not None else {}
Will.check_will(will, all_utxos, wallet, block_to_check, timestamp_to_check) Will.check_will(will, all_utxos, wallet, block_to_check, timestamp_to_check)
if heirs: if heirs:
if not Will.check_willexecutors_and_heirs( if not Will.check_willexecutors_and_heirs(
@@ -574,7 +555,6 @@ class Will:
_logger.info("will ok") _logger.info("will ok")
return True return True
@staticmethod
def check_will_expired(all_inputs_min_locktime, block_to_check, timestamp_to_check): def check_will_expired(all_inputs_min_locktime, block_to_check, timestamp_to_check):
_logger.info("check if some transaction is expired") _logger.info("check if some transaction is expired")
for prevout_str, wid in all_inputs_min_locktime.items(): for prevout_str, wid in all_inputs_min_locktime.items():
@@ -606,7 +586,6 @@ class Will:
# if not parentwill or not parentwill.get_status("VALID"): # if not parentwill or not parentwill.get_status("VALID"):
# w[1].set_status("INVALIDATED", True) # w[1].set_status("INVALIDATED", True)
@staticmethod
def only_valid_list(will): def only_valid_list(will):
out = {} out = {}
for wid, w in will.items(): for wid, w in will.items():
@@ -614,7 +593,6 @@ class Will:
out[wid] = w out[wid] = w
return out return out
@staticmethod
def only_valid_or_replaced_list(will): def only_valid_or_replaced_list(will):
out = [] out = []
for wid, w in will.items(): for wid, w in will.items():
@@ -623,7 +601,6 @@ class Will:
out.append(wid) out.append(wid)
return out return out
@staticmethod
def check_willexecutors_and_heirs( def check_willexecutors_and_heirs(
will, heirs, willexecutors, self_willexecutor, check_date, tx_fees will, heirs, willexecutors, self_willexecutor, check_date, tx_fees
): ):
@@ -637,7 +614,7 @@ class Will:
for wid in Will.only_valid_list(will): for wid in Will.only_valid_list(will):
w = will[wid] w = will[wid]
if w.tx_fees != tx_fees: if w.tx_fees != tx_fees:
raise TxFeesChangedException(f"{tx_fees}: {w.tx_fees}") raise TxFeesChangedException(f"{tx_fees}:", w.tx_fees)
for wheir in w.heirs: for wheir in w.heirs:
if not 'w!ll3x3c"' == wheir[:9]: if not 'w!ll3x3c"' == wheir[:9]:
their = will[wid].heirs[wheir] their = will[wid].heirs[wheir]
@@ -686,7 +663,6 @@ class Will:
return True return True
class WillItem(Logger): class WillItem(Logger):
STATUS_DEFAULT = { STATUS_DEFAULT = {
"ANTICIPATED": ["Anticipated", False], "ANTICIPATED": ["Anticipated", False],

View File

@@ -1,8 +1,19 @@
"""Willexecutors module for BAL Electrum Plugin.
This module provides functionality to manage will executor servers that
broadcast timelocked transactions at the appropriate locktime.
Classes:
Willexecutors: Static class for managing executor list and communication
WillExecutor: Data class representing a single will executor
"""
import json import json
import time
from datetime import datetime from datetime import datetime
import time
from aiohttp import ClientResponse from aiohttp import ClientResponse
from electrum import constants
from electrum.i18n import _ from electrum.i18n import _
from electrum.logging import get_logger from electrum.logging import get_logger
from electrum.network import Network from electrum.network import Network
@@ -13,13 +24,23 @@ DEFAULT_TIMEOUT = 5
_logger = get_logger(__name__) _logger = get_logger(__name__)
chainname = BalPlugin.chainname chainname = constants.net.NET_NAME if constants.net.NET_NAME != "mainnet" else "bitcoin"
class Willexecutors: class Willexecutors:
"""Static class managing will executor servers.
Provides methods to save/load configurations, communicate via HTTP,
push transactions, and download executor lists from remote sources.
"""
@staticmethod
def save(bal_plugin, willexecutors): def save(bal_plugin, willexecutors):
"""Save will executor configuration to plugin settings.
Args:
bal_plugin: BAL plugin instance
willexecutors: Dictionary of executor configs keyed by URL
"""
_logger.debug(f"save {willexecutors},{chainname}") _logger.debug(f"save {willexecutors},{chainname}")
aw = bal_plugin.WILLEXECUTORS.get() aw = bal_plugin.WILLEXECUTORS.get()
aw[chainname] = willexecutors aw[chainname] = willexecutors
@@ -27,10 +48,21 @@ class Willexecutors:
_logger.debug(f"saved: {aw}") _logger.debug(f"saved: {aw}")
# bal_plugin.WILLEXECUTORS.set(willexecutors) # bal_plugin.WILLEXECUTORS.set(willexecutors)
@staticmethod
def get_willexecutors( def get_willexecutors(
bal_plugin, update=False, bal_window=False, force=False, task=True bal_plugin, update=False, bal_window=False, force=False, task=True
): ):
"""Retrieve and update the list of available will executors.
Args:
bal_plugin: BAL plugin instance
update: If True, ping servers to refresh data
bal_window: GUI window for user prompts
force: Force update regardless of cached data age
task: Run as background task if True
Returns:
dict: Sorted dictionary of executor configurations
"""
willexecutors = bal_plugin.WILLEXECUTORS.get() willexecutors = bal_plugin.WILLEXECUTORS.get()
willexecutors = willexecutors.get(chainname, {}) willexecutors = willexecutors.get(chainname, {})
to_del = [] to_del = []
@@ -79,8 +111,16 @@ class Willexecutors:
) )
return w_sorted return w_sorted
@staticmethod
def is_selected(willexecutor, value=None): def is_selected(willexecutor, value=None):
"""Check or set the selection status of an executor.
Args:
willexecutor: Executor configuration dictionary
value: Optional boolean to set selection status
Returns:
bool: Current selection status
"""
if not willexecutor: if not willexecutor:
return False return False
if value is not None: if value is not None:
@@ -91,8 +131,16 @@ class Willexecutors:
willexecutor["selected"] = False willexecutor["selected"] = False
return False return False
@staticmethod
def get_willexecutor_transactions(will, force=False): def get_willexecutor_transactions(will, force=False):
"""Collect transactions grouped by executor for valid, complete wills.
Args:
will: Dictionary of will items keyed by ID
force: Include already-pushed transactions
Returns:
dict: Executors with their aggregated transactions
"""
willexecutors = {} willexecutors = {}
for wid, willitem in will.items(): for wid, willitem in will.items():
if willitem.get_status("VALID"): if willitem.get_status("VALID"):
@@ -127,10 +175,25 @@ class Willexecutors:
# willexecutors[url]["txs"], url # willexecutors[url]["txs"], url
# ) # )
@staticmethod
def send_request( def send_request(
method, url, data=None, *, timeout=10, handle_response=None, count_reply=0 method, url, data=None, *, timeout=10, handle_response=None, count_reply=0
): ):
"""Send HTTP request to a will executor server.
Args:
method: HTTP method (get/post)
url: Target server URL
data: Request payload
timeout: Timeout in seconds
handle_response: Response processing function
count_reply: Retry counter for timeouts
Returns:
Server response object
Raises:
Exception: On connection errors or invalid method
"""
network = Network.get_instance() network = Network.get_instance()
if not network: if not network:
raise Exception("You are offline.") raise Exception("You are offline.")
@@ -181,15 +244,29 @@ class Willexecutors:
_logger.debug(f"--> {response}") _logger.debug(f"--> {response}")
return response return response
@staticmethod
def get_we_url_from_response(resp): def get_we_url_from_response(resp):
"""Extract base URL from response object.
Args:
resp: Response object with url attribute
Returns:
str: Base URL without trailing paths
"""
url_slices = str(resp.url).split("/") url_slices = str(resp.url).split("/")
if len(url_slices) > 2: if len(url_slices) > 2:
url_slices = url_slices[:-2] url_slices = url_slices[:-2]
return "/".join(url_slices) return "/".join(url_slices)
@staticmethod
async def handle_response(resp: ClientResponse): async def handle_response(resp: ClientResponse):
"""Parse JSON response from executor server.
Args:
resp: aiohttp ClientResponse object
Returns:
Parsed JSON data or raw text on parse failure
"""
r = await resp.text() r = await resp.text()
try: try:
@@ -202,12 +279,22 @@ class Willexecutors:
pass pass
return r return r
@staticmethod
class AlreadyPresentException(Exception): class AlreadyPresentException(Exception):
"""Raised when transactions already exist on executor server."""
pass pass
@staticmethod
def push_transactions_to_willexecutor(willexecutor): def push_transactions_to_willexecutor(willexecutor):
"""Push timelocked transactions to an executor server for broadcast.
Args:
willexecutor: Dict containing url and txs keys
Returns:
bool: True on success, False on failure
Raises:
AlreadyPresentException: If transactions already exist
"""
out = True out = True
try: try:
_logger.debug(f"{willexecutor['url']}: {willexecutor['txs']}") _logger.debug(f"{willexecutor['url']}: {willexecutor['txs']}")
@@ -232,13 +319,25 @@ class Willexecutors:
return out return out
@staticmethod
def ping_servers(willexecutors): def ping_servers(willexecutors):
"""Ping all executor servers to update their information.
Args:
willexecutors: Dictionary of executor configurations
"""
for url, we in willexecutors.items(): for url, we in willexecutors.items():
Willexecutors.get_info_task(url, we) Willexecutors.get_info_task(url, we)
@staticmethod
def get_info_task(url, willexecutor): def get_info_task(url, willexecutor):
"""Fetch current information from a single executor server.
Args:
url: Executor server URL
willexecutor: Configuration dict to update
Returns:
Updated willexecutor dict with status, base_fee, address
"""
w = None w = None
try: try:
_logger.info("GETINFO_WILLEXECUTOR") _logger.info("GETINFO_WILLEXECUTOR")
@@ -258,9 +357,15 @@ class Willexecutors:
willexecutor["last_update"] = datetime.now().timestamp() willexecutor["last_update"] = datetime.now().timestamp()
return willexecutor return willexecutor
@staticmethod def initialize_willexecutor(willexecutor, url, status=None, old_willexecutor={}):
def initialize_willexecutor(willexecutor, url, status=None, old_willexecutor=None): """Initialize or merge executor configuration preserving user settings.
old_willexecutor=old_willexecutor if old_willexecutor is not None else {}
Args:
willexecutor: New executor configuration dict
url: Executor server URL
status: Optional status override
old_willexecutor: Previous config to preserve user preferences
"""
willexecutor["url"] = url willexecutor["url"] = url
if status is not None: if status is not None:
willexecutor["status"] = status willexecutor["status"] = status
@@ -272,13 +377,19 @@ class Willexecutors:
@staticmethod def download_list(old_willexecutors):
def download_list(old_willexecutors,welist_server): """Download latest executor list from remote source.
Args:
old_willexecutors: Existing configs to merge with new list
Returns:
dict: Merged executor configurations
"""
try: try:
welist_server = welist_server if welist_server[-1] == '/' else welist_server+'/'
willexecutors = Willexecutors.send_request( willexecutors = Willexecutors.send_request(
"get", "get",
f"{welist_server}data/{chainname}?page=0&limit=100", f"https://welist.bitcoin-after.life/data/{chainname}?page=0&limit=100",
) )
# del willexecutors["status"] # del willexecutors["status"]
for w in willexecutors: for w in willexecutors:
@@ -294,8 +405,12 @@ class Willexecutors:
_logger.error(f"Failed to download willexecutors list: {e}") _logger.error(f"Failed to download willexecutors list: {e}")
return {} return {}
@staticmethod
def get_willexecutors_list_from_json(): def get_willexecutors_list_from_json():
"""Load executor list from local willexecutors.json file.
Returns:
dict: Executor configurations from JSON file
"""
try: try:
with open("willexecutors.json") as f: with open("willexecutors.json") as f:
willexecutors = json.load(f) willexecutors = json.load(f)
@@ -309,8 +424,16 @@ class Willexecutors:
return {} return {}
@staticmethod
def check_transaction(txid, url): def check_transaction(txid, url):
"""Check if a transaction exists on executor server.
Args:
txid: Transaction ID string
url: Executor server URL
Returns:
Server response about transaction existence
"""
_logger.debug(f"{url}:{txid}") _logger.debug(f"{url}:{txid}")
try: try:
w = Willexecutors.send_request( w = Willexecutors.send_request(
@@ -321,54 +444,104 @@ class Willexecutors:
_logger.error(f"error contacting {url} for checking txs {e}") _logger.error(f"error contacting {url} for checking txs {e}")
raise e raise e
@staticmethod
def compute_id(willexecutor): def compute_id(willexecutor):
"""Compute unique identifier for an executor.
Args:
willexecutor: Executor configuration dict
Returns:
str: Unique ID combining URL and chain name
"""
return "{}-{}".format(willexecutor.get("url"), willexecutor.get("chain")) return "{}-{}".format(willexecutor.get("url"), willexecutor.get("chain"))
#class WillExecutor: class WillExecutor:
# def __init__( """Data class representing a single will executor server.
# self,
# url, Attributes:
# base_fee, url: Executor server URL
# chain, base_fee: Fixed fee in satoshis
# info, chain: Bitcoin chain name
# version, info: Additional executor information
# status, version: Plugin version compatibility
# is_selected=False, status: Connection status
# promo_code="", is_selected: User selection flag
# ): promo_code: Promotional discount code
# self.url = url """
# self.base_fee = base_fee
# self.chain = chain def __init__(
# self.info = info self,
# self.version = version url,
# self.status = status base_fee,
# self.promo_code = promo_code chain,
# self.is_selected = is_selected info,
# self.id = self.compute_id() version,
# status,
# def from_dict(d): is_selected=False,
# return WillExecutor( promo_code="",
# url=d.get("url", "http://localhost:8000"), ):
# base_fee=d.get("base_fee", 1000), """Initialize a new WillExecutor instance.
# chain=d.get("chain", chainname),
# info=d.get("info", ""), Args:
# version=d.get("version", 0), url: Executor server URL
# status=d.get("status", "Ko"), base_fee: Fixed fee in satoshis
# is_selected=d.get("is_selected", "False"), chain: Bitcoin chain name
# promo_code=d.get("promo_code", ""), info: Additional executor information
# ) version: Plugin version compatibility
# status: Connection status (OK/Ko)
# def to_dict(self): is_selected: Whether user has selected this executor
# return { promo_code: Promotional discount code
# "url": self.url, """
# "base_fee": self.base_fee, self.url = url
# "chain": self.chain, self.base_fee = base_fee
# "info": self.info, self.chain = chain
# "version": self.version, self.info = info
# "promo_code": self.promo_code, self.version = version
# } self.status = status
# self.promo_code = promo_code
# def compute_id(self): self.is_selected = is_selected
# return f"{self.url}-{self.chain}" self.id = self.compute_id()
def from_dict(d):
"""Create WillExecutor instance from a dictionary.
Args:
d: Dictionary containing executor data fields
Returns:
WillExecutor: New instance with defaults for missing fields
"""
return WillExecutor(
url=d.get("url", "http://localhost:8000"),
base_fee=d.get("base_fee", 1000),
chain=d.get("chain", chainname),
info=d.get("info", ""),
version=d.get("version", 0),
status=d.get("status", "Ko"),
is_selected=d.get("is_selected", "False"),
promo_code=d.get("promo_code", ""),
)
def to_dict(self):
"""Convert WillExecutor to dictionary representation.
Returns:
dict: Serializable representation excluding computed fields
"""
return {
"url": self.url,
"base_fee": self.base_fee,
"chain": self.chain,
"info": self.info,
"version": self.version,
"promo_code": self.promo_code,
}
def compute_id(self):
"""Generate unique identifier for this executor.
Returns:
str: Unique ID from URL and chain name
"""
return f"{self.url}-{self.chain}"