Skip to content

Commit 07edf0b

Browse files
squash
1 parent 4deec76 commit 07edf0b

File tree

107 files changed

+10304
-1181
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

107 files changed

+10304
-1181
lines changed

.github/workflows/python-tests.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ on:
77
branches: [ "main" ]
88
pull_request:
99
branches: [ "main" ]
10+
workflow_dispatch:
11+
1012

1113
jobs:
1214
test:

.pre-commit-config.yaml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,6 @@ repos:
6868
- types-Pillow
6969
- types-reportlab
7070
- pyqt6
71-
- "bdkpython==1.2.0"
7271

7372

7473
- repo: local

.vscode/launch.json

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
"args": [
1111
// "--profile",
1212
],
13+
"preLaunchTask": "Poetry Install",
1314
// "justMyCode": false
1415
},
1516
{
@@ -105,7 +106,17 @@
105106
"console": "integratedTerminal",
106107
"cwd": "${workspaceFolder}",
107108
"preLaunchTask": "Poetry Install",
108-
}, {
109+
},
110+
{
111+
"name": "cbf",
112+
"type": "python",
113+
"request": "launch",
114+
"module": "bitcoin_safe.cbf",
115+
"console": "integratedTerminal",
116+
"cwd": "${workspaceFolder}",
117+
"preLaunchTask": "Poetry Install",
118+
},
119+
{
109120
"name": "Translation update",
110121
"type": "python",
111122
"request": "launch",

bitcoin_safe/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
11
# this is the source of the version information
2-
__version__ = "1.5.0"
2+
__version__ = "1.6.0dev5"

bitcoin_safe/cbf/__init__.py

Whitespace-only changes.

bitcoin_safe/cbf/__main__.py

Lines changed: 300 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,300 @@
1+
#
2+
# Bitcoin Safe
3+
# Copyright (C) 2024 Andreas Griffin
4+
#
5+
# This program is free software: you can redistribute it and/or modify
6+
# it under the terms of version 3 of the GNU General Public License as
7+
# published by the Free Software Foundation.
8+
#
9+
# This program is distributed in the hope that it will be useful,
10+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
# GNU General Public License for more details.
13+
#
14+
# You should have received a copy of the GNU General Public License
15+
# along with this program. If not, see https://www.gnu.org/licenses/gpl-3.0.html
16+
#
17+
# The above copyright notice and this permission notice shall be
18+
# included in all copies or substantial portions of the Software.
19+
#
20+
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
21+
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
22+
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
23+
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
24+
# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
25+
# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
26+
# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
27+
# SOFTWARE.
28+
29+
30+
import asyncio
31+
import sys
32+
from concurrent.futures import Future
33+
from datetime import datetime, timedelta
34+
from pathlib import Path
35+
from typing import Any, Callable, Coroutine, Optional
36+
37+
import bdkpython as bdk
38+
from bdkpython import Descriptor, IpAddress, Peer, Persister, Wallet
39+
from PyQt6.QtCore import QTimer
40+
from PyQt6.QtGui import QCloseEvent
41+
from PyQt6.QtWidgets import (
42+
QApplication,
43+
QComboBox,
44+
QFormLayout,
45+
QHBoxLayout,
46+
QLineEdit,
47+
QMessageBox,
48+
QProgressBar,
49+
QPushButton,
50+
QTextEdit,
51+
QVBoxLayout,
52+
QWidget,
53+
)
54+
55+
from bitcoin_safe.client import UpdateInfo
56+
57+
from .cbf_sync import CbfSync
58+
59+
60+
# --- MainWindow ---
61+
class MainWindow(QWidget):
62+
def __init__(self):
63+
super().__init__()
64+
self.syncer: Optional[CbfSync] = None
65+
self._bridge_tasks: list[Future[Any]] = []
66+
67+
self.setWindowTitle("CBF Demo - PyQt6 Interface")
68+
self.resize(600, 550)
69+
70+
# # bacon descriptor
71+
DEFAULT_DESCRIPTOR = "wpkh([9a6a2580/84h/0h/0h]xpub6DEzNop46vmxR49zYWFnMwmEfawSNmAMf6dLH5YKDY463twtvw1XD7ihwJRLPRGZJz799VPFzXHpZu6WdhT29WnaeuChS6aZHZPFmqczR5K/0/*)"
72+
DEFAULT_CHANGE = "wpkh([9a6a2580/84h/0h/0h]xpub6DEzNop46vmxR49zYWFnMwmEfawSNmAMf6dLH5YKDY463twtvw1XD7ihwJRLPRGZJz799VPFzXHpZu6WdhT29WnaeuChS6aZHZPFmqczR5K/1/*)"
73+
74+
form = QFormLayout()
75+
self.network_input = QComboBox()
76+
for network in bdk.Network:
77+
self.network_input.addItem(network.name, network)
78+
self.network_input.setCurrentIndex(0)
79+
self.ip_input = QLineEdit("127.0.0.1")
80+
self.port_input = QLineEdit("8333")
81+
self.start_height = QLineEdit("0")
82+
self.desc_input = QLineEdit(DEFAULT_DESCRIPTOR)
83+
self.change_input = QLineEdit(DEFAULT_CHANGE)
84+
form.addRow("Network:", self.network_input)
85+
form.addRow("Start block height:", self.start_height)
86+
form.addRow("Peer IP:", self.ip_input)
87+
form.addRow("Peer Port:", self.port_input)
88+
form.addRow("Descriptor:", self.desc_input)
89+
form.addRow("Change Descriptor:", self.change_input)
90+
self.peers = []
91+
92+
# Buttons for node control and sync control
93+
self.build_button = QPushButton("Build Node")
94+
self.build_button.clicked.connect(self.on_build_node)
95+
self.delete_button = QPushButton("Delete Node")
96+
self.delete_button.clicked.connect(self.on_delete_node)
97+
98+
button_layout = QHBoxLayout()
99+
button_layout.addWidget(self.build_button)
100+
button_layout.addWidget(self.delete_button)
101+
button_layout.addStretch()
102+
103+
self.start_time = datetime.now()
104+
self.progress_bar = QProgressBar()
105+
self.progress_bar.setRange(0, 100)
106+
107+
self.log_view = QTextEdit()
108+
self.log_view.setReadOnly(True)
109+
110+
layout = QVBoxLayout(self)
111+
layout.addLayout(form)
112+
layout.addLayout(button_layout)
113+
layout.addWidget(self.progress_bar)
114+
layout.addWidget(self.log_view)
115+
116+
@classmethod
117+
def format_timedelta(cls, td: timedelta) -> str:
118+
total_seconds = int(td.total_seconds())
119+
hours, remainder = divmod(total_seconds, 3600)
120+
minutes, seconds = divmod(remainder, 60)
121+
return f"{hours:02d}:{minutes:02d}:{seconds:02d}"
122+
123+
@classmethod
124+
def weighted_past_time(
125+
cls,
126+
current_height: int,
127+
past_time: timedelta,
128+
bip141_activation: int = 481_824,
129+
pre_factor: int = 4,
130+
post_factor: int = 1,
131+
) -> timedelta:
132+
"""
133+
Return `past_time` after weighting the portion that corresponds to
134+
blocks *before* SegWit activation (`≤ bip141_activation`) by
135+
`pre_factor`, and the portion after activation by `post_factor`.
136+
137+
─ If current_height ≤ 0 → just return `past_time`.
138+
─ If current_height ≤ activation
139+
every block seen so far is pre-SegWit → weight × `pre_factor`.
140+
─ If current_height > activation
141+
pre-ratio = activation / current_height
142+
post-ratio = 1 − pre-ratio
143+
"""
144+
if past_time <= timedelta(0):
145+
return past_time # nothing to weight
146+
if current_height <= 0:
147+
return past_time # avoid div-by-zero / negative heights
148+
149+
# --- fraction of blocks processed so far that are pre-SegWit -------------
150+
pre_blocks = min(current_height, bip141_activation)
151+
pre_ratio = pre_blocks / max(1, current_height)
152+
post_ratio = 1.0 - pre_ratio
153+
154+
weighted_secs = past_time.total_seconds() * (pre_ratio * pre_factor + post_ratio * post_factor)
155+
return timedelta(seconds=weighted_secs)
156+
157+
def on_log_info(self, log: bdk.Info):
158+
if isinstance(log, bdk.Info.PROGRESS):
159+
self.progress_bar.setValue(int(log.progress * 100))
160+
161+
passed_time = datetime.now() - self.start_time
162+
163+
estimated_time = timedelta(
164+
seconds=passed_time.total_seconds() / max(0.001, log.progress) * (1 - log.progress)
165+
)
166+
self.progress_bar.setFormat(
167+
f"{int(log.progress * 100)}% ETA: {self.format_timedelta(estimated_time)}. Past: {self.format_timedelta(passed_time)}"
168+
)
169+
else:
170+
self.log_view.append(str(log))
171+
172+
def on_log_warning(self, log: bdk.Warning):
173+
self.log_view.append(str(log))
174+
175+
def log_message(self, message: str):
176+
self.log_view.append(message)
177+
print(message)
178+
179+
def _cancel_bridge_tasks(self) -> None:
180+
for task in self._bridge_tasks:
181+
if task and not task.done():
182+
task.cancel()
183+
self._bridge_tasks.clear()
184+
185+
async def _bridge(
186+
self,
187+
coro: Callable[[], Coroutine[Any, Any, Any]],
188+
callback: Callable[[Any], None],
189+
) -> None:
190+
try:
191+
while True:
192+
result = await coro()
193+
if result is None:
194+
continue
195+
196+
QTimer.singleShot(0, lambda res=result: callback(res))
197+
except asyncio.CancelledError:
198+
pass
199+
except Exception as exc:
200+
self.log_message(f"Bridge error: {exc}")
201+
202+
def _start_bridge_tasks(self) -> None:
203+
if not self.syncer:
204+
return
205+
206+
self._cancel_bridge_tasks()
207+
bridges = [
208+
self.syncer.loop_in_thread.run_background(self._bridge(self.syncer.next_log, self.log_message)),
209+
self.syncer.loop_in_thread.run_background(self._bridge(self.syncer.next_info, self.on_log_info)),
210+
self.syncer.loop_in_thread.run_background(
211+
self._bridge(self.syncer.next_warning, self.on_log_warning)
212+
),
213+
self.syncer.loop_in_thread.run_background(
214+
self._bridge(self.syncer.next_update_info, self.on_update)
215+
),
216+
]
217+
218+
for task in bridges:
219+
self.syncer.register_task(task)
220+
self._bridge_tasks.append(task)
221+
222+
def create_syncer(self) -> bool:
223+
"""
224+
Initialize the CbfSync instance with current inputs.
225+
Returns True on success, False on failure.
226+
"""
227+
try:
228+
network = self.network_input.currentData()
229+
ip_parts = list(map(int, self.ip_input.text().split(".")))
230+
ip = IpAddress.from_ipv4(*ip_parts)
231+
port = int(self.port_input.text())
232+
peer = Peer(address=ip, port=port, v2_transport=False)
233+
self.peers.append(peer)
234+
desc = Descriptor(self.desc_input.text(), network=network)
235+
change = Descriptor(self.change_input.text(), network=network)
236+
except Exception as e:
237+
QMessageBox.critical(self, "Input Error", str(e))
238+
return False
239+
240+
persister = Persister.new_in_memory()
241+
self.wallet = Wallet(desc, change, network, persister)
242+
addresses = self.wallet.reveal_addresses_to(keychain=bdk.KeychainKind.EXTERNAL, index=0)
243+
self.log_message(f"Receive address: {addresses[0].address}")
244+
self.syncer = CbfSync(wallet_id="demo")
245+
self._start_bridge_tasks()
246+
return True
247+
248+
def on_update(self, update: UpdateInfo):
249+
self.wallet.apply_update(update=update.update)
250+
self.log_message(f"Wallet balance {self.wallet.balance().total.to_sat()}")
251+
252+
def on_build_node(self):
253+
"""Build the node without starting synchronization."""
254+
if self.syncer is None:
255+
self.create_syncer()
256+
if not self.syncer:
257+
return
258+
if self.syncer.node_running():
259+
self.log_message("Delete the node first")
260+
return
261+
self.syncer.build_node(
262+
wallet=self.wallet,
263+
peers=self.peers,
264+
recovery_height=int(self.start_height.text()),
265+
proxy_info=None,
266+
data_dir=Path("."),
267+
cbf_connections=2,
268+
is_new_wallet=True,
269+
)
270+
self.log_message("Node built successfully.")
271+
272+
def on_delete_node(self):
273+
"""Delete the current built node."""
274+
if self.syncer is None:
275+
self.log_message("No node to delete. Build a node first.")
276+
return
277+
278+
if not self.syncer.node_running():
279+
self.log_message("No node running")
280+
return
281+
282+
try:
283+
self._cancel_bridge_tasks()
284+
self.syncer.shutdown_node()
285+
self.log_message("Node deleted successfully.")
286+
except Exception as e:
287+
self.log_message(f"Failed to delete node: {e}")
288+
289+
def closeEvent(self, a0: Optional[QCloseEvent]) -> None:
290+
if self.syncer is not None:
291+
self._cancel_bridge_tasks()
292+
self.close()
293+
super().closeEvent(a0)
294+
295+
296+
if __name__ == "__main__":
297+
app = QApplication(sys.argv)
298+
window = MainWindow()
299+
window.show()
300+
app.exec()

0 commit comments

Comments
 (0)