-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathcomposer_dialog.py
More file actions
1019 lines (848 loc) · 41.7 KB
/
composer_dialog.py
File metadata and controls
1019 lines (848 loc) · 41.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""
Stacks Transaction Composer Dialog
Provides the main dialog interface for composing Stacks transactions
via Bitcoin OP_RETURN encoding.
"""
from typing import TYPE_CHECKING
import threading
import urllib.request
import json
import ssl
from PyQt6.QtCore import Qt
from PyQt6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout,
QLabel, QLineEdit, QComboBox, QPushButton, QGroupBox,
QSpinBox, QTextEdit, QMessageBox, QTableWidget, QTableWidgetItem,
QHeaderView, QAbstractItemView, QCheckBox, QWidget, QStackedWidget,
QFormLayout, QDoubleSpinBox
)
from electrum.i18n import _
from electrum.transaction import PartialTxOutput
from electrum.util import NotEnoughFunds, NoDynamicFeeEstimates
from electrum.fee_policy import FeePolicy
from .stacks_wire import (
StacksNetwork,
LeaderBlockCommit, LeaderKeyRegister, UserBurnSupport,
PreStx, StackStx, TransferStx, DelegateStx,
create_op_return_script, get_burn_address,
bitcoin_address_to_stx_address,
CONSENSUS_HASH_LENGTH, VRF_PUBLIC_KEY_LENGTH, BLOCK_HASH_LENGTH, SEED_LENGTH
)
from .stacks_preferences import get_stacks_network, get_stacks_api_url
if TYPE_CHECKING:
from electrum.gui.qt.main_window import ElectrumWindow
from .qt import Plugin
def address_to_scriptpubkey(address: str) -> bytes:
"""Convert a Bitcoin address to its scriptPubKey."""
from electrum.bitcoin import address_to_script
return address_to_script(address)
def is_legacy_bitcoin_address(address: str) -> bool:
"""
Check if a Bitcoin address is a legacy P2PKH or P2SH address.
Legacy addresses are required for TransferStxOp and DelegateStxOp recipients
because the Stacks blockchain's burnchain operation parsing only accepts
these address types (not SegWit P2WPKH, P2WSH, or P2TR).
See SIP-007: "The second Bitcoin output is either a p2pkh or p2sh output..."
Returns:
True if the address is legacy (P2PKH or P2SH), False otherwise.
"""
address = address.strip()
# SegWit addresses start with bc1, tb1, or bcrt1 (bech32/bech32m)
if address.lower().startswith(('bc1', 'tb1', 'bcrt1')):
return False
# Legacy mainnet: P2PKH starts with '1', P2SH starts with '3'
# Legacy testnet: P2PKH starts with 'm' or 'n', P2SH starts with '2'
if address.startswith(('1', '3', 'm', 'n', '2')):
return True
return False
def validate_legacy_recipient_address(address: str) -> None:
"""
Validate that a recipient address is a legacy Bitcoin address.
Raises:
ValueError: If the address is not a valid legacy address.
"""
address = address.strip()
if not address:
raise ValueError("Recipient address is required")
if not is_legacy_bitcoin_address(address):
if address.lower().startswith(('bc1', 'tb1', 'bcrt1')):
raise ValueError(
"SegWit addresses (bc1.../tb1...) are not supported for STX transfers.\n\n"
"The Stacks blockchain only accepts legacy P2PKH (starting with '1') or "
"P2SH (starting with '3') addresses for the recipient output.\n\n"
"Please use a legacy Bitcoin address for the recipient."
)
else:
raise ValueError(
f"Invalid Bitcoin address format: {address}\n\n"
"Please use a legacy P2PKH (starting with '1') or P2SH (starting with '3') address."
)
class StacksComposerDialog(QDialog):
"""
Dialog for composing Stacks transactions via OP_RETURN.
Allows users to:
1. Select a Stacks transaction type
2. Fill in the required parameters
3. Preview the encoded OP_RETURN data
4. Create and broadcast the transaction
Two-Transaction Flows:
- VRF Key Registration -> Block Commit: The block commit must use a UTXO
from the same address that sent the VRF key registration.
- Pre-STX -> Stack/Transfer/Delegate: The STX operation must use a UTXO
from the Pre-STX transaction to authorize the operation.
"""
TX_TYPES = [
("Leader VRF Key Registration", "leader_key_register",
"Register a VRF proving key for mining. Required before block commits."),
("Leader Block Commit", "leader_block_commit",
"Commit a Stacks block to the burnchain. Requires prior VRF key registration."),
("User Burn Support", "user_burn_support",
"Support a miner by contributing burns to their block commitment."),
("Pre-STX Authorization", "pre_stx",
"Authorize an address for STX operations. Required before Stack/Transfer/Delegate."),
("Stack STX", "stack_stx",
"Lock STX tokens for Proof-of-Transfer participation. Requires Pre-STX."),
("Transfer STX", "transfer_stx",
"Transfer STX to another address via Bitcoin. Requires Pre-STX."),
("Delegate STX", "delegate_stx",
"Delegate STX to a stacking pool operator. Requires Pre-STX."),
]
def __init__(self, window: 'ElectrumWindow', plugin: 'Plugin', preselected_address: str = None):
super().__init__(window)
self.window = window
self.wallet = window.wallet
self.plugin = plugin
self.config = window.config
self.preselected_address = preselected_address
self.setWindowTitle(_("New Stacks Transaction"))
self.setMinimumWidth(750)
self.setMinimumHeight(650)
self.setup_ui()
self.on_tx_type_changed(0)
self.update_stx_address_display()
def setup_ui(self):
"""Set up the dialog UI."""
layout = QVBoxLayout(self)
# Network display (read from global preferences)
network_layout = QHBoxLayout()
network = get_stacks_network(self.config)
network_name = _("Mainnet") if network == StacksNetwork.MAINNET else _("Testnet")
self.network_label = QLabel(_("Network: <b>{}</b>").format(network_name))
network_layout.addWidget(self.network_label)
network_layout.addStretch()
network_hint = QLabel(_("(change in Tools > Stacks preferences)"))
network_hint.setStyleSheet("color: #666; font-style: italic;")
network_layout.addWidget(network_hint)
layout.addLayout(network_layout)
# Stacks Identity section
self.stx_address_group = QGroupBox()
stx_layout = QFormLayout()
stx_layout.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.ExpandingFieldsGrow)
self.btc_address_label = QLineEdit()
self.btc_address_label.setReadOnly(True)
self.btc_address_label.setPlaceholderText(_("Select a UTXO below or auto-selected"))
stx_layout.addRow(_("BTC Address:"), self.btc_address_label)
self.stx_address_label = QLineEdit()
self.stx_address_label.setReadOnly(True)
self.stx_address_label.setPlaceholderText(_("Derived from BTC address"))
self.stx_address_label.setStyleSheet("font-weight: bold;")
stx_layout.addRow(_("STX Address:"), self.stx_address_label)
self.stx_balance_label = QLineEdit()
self.stx_balance_label.setReadOnly(True)
self.stx_balance_label.setPlaceholderText(_("Fetching..."))
stx_layout.addRow(_("STX Balance:"), self.stx_balance_label)
stx_info = QLabel(_("This STX address will be the sender/actor for the Stacks transaction."))
stx_info.setWordWrap(True)
stx_info.setStyleSheet("color: #666; font-style: italic;")
stx_layout.addRow(stx_info)
self.stx_address_group.setLayout(stx_layout)
layout.addWidget(self.stx_address_group)
# Transaction type selection
type_group = QGroupBox(_("Transaction Type"))
type_layout = QVBoxLayout()
type_row = QHBoxLayout()
self.tx_type_combo = QComboBox()
for name, tx_id, desc in self.TX_TYPES:
self.tx_type_combo.addItem(name)
self.tx_type_combo.currentIndexChanged.connect(self.on_tx_type_changed)
type_row.addWidget(QLabel(_("Type:")))
type_row.addWidget(self.tx_type_combo)
type_row.addStretch()
type_layout.addLayout(type_row)
self.type_description = QLabel()
self.type_description.setWordWrap(True)
self.type_description.setStyleSheet("color: gray; font-style: italic; margin-top: 5px;")
type_layout.addWidget(self.type_description)
type_group.setLayout(type_layout)
layout.addWidget(type_group)
# Parameter input area (stacked widget for different tx types)
self.params_group = QGroupBox(_("Parameters"))
self.params_stack = QStackedWidget()
self.create_parameter_forms()
params_layout = QVBoxLayout()
params_layout.addWidget(self.params_stack)
self.params_group.setLayout(params_layout)
layout.addWidget(self.params_group)
# Coin control section
self.coin_group = QGroupBox(_("Coin Control (for two-transaction flows)"))
coin_layout = QVBoxLayout()
self.use_specific_utxo = QCheckBox(_("Use specific UTXO as input (required for linked transactions)"))
self.use_specific_utxo.toggled.connect(self.on_coin_control_toggled)
coin_layout.addWidget(self.use_specific_utxo)
self.utxo_table = QTableWidget()
self.utxo_table.setColumnCount(4)
self.utxo_table.setHorizontalHeaderLabels([
_("Address"), _("Amount (BTC)"), _("Confirmations"), _("TXID:Vout")
])
self.utxo_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self.utxo_table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
self.utxo_table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch)
self.utxo_table.setVisible(False)
self.utxo_table.setMaximumHeight(150)
coin_layout.addWidget(self.utxo_table)
self.refresh_utxos_btn = QPushButton(_("Refresh UTXOs"))
self.refresh_utxos_btn.clicked.connect(self.refresh_utxos)
self.refresh_utxos_btn.setVisible(False)
coin_layout.addWidget(self.refresh_utxos_btn)
self.coin_group.setLayout(coin_layout)
layout.addWidget(self.coin_group)
# Connect UTXO selection to update STX address
self.utxo_table.itemSelectionChanged.connect(self.update_stx_address_display)
# Burn amount input
self.burn_group = QGroupBox(_("Burn Amount"))
burn_layout = QHBoxLayout()
self.burn_amount_input = QDoubleSpinBox()
self.burn_amount_input.setDecimals(8)
self.burn_amount_input.setMinimum(0.00000546) # Dust limit
self.burn_amount_input.setMaximum(21000000)
self.burn_amount_input.setValue(0.0001)
self.burn_amount_input.setSuffix(" BTC")
burn_layout.addWidget(QLabel(_("Amount to burn:")))
burn_layout.addWidget(self.burn_amount_input)
burn_layout.addStretch()
self.burn_group.setLayout(burn_layout)
layout.addWidget(self.burn_group)
# Preview area
preview_group = QGroupBox(_("OP_RETURN Preview"))
preview_layout = QVBoxLayout()
self.preview_text = QTextEdit()
self.preview_text.setReadOnly(True)
self.preview_text.setMaximumHeight(100)
self.preview_text.setStyleSheet("font-family: monospace;")
preview_layout.addWidget(self.preview_text)
self.preview_btn = QPushButton(_("Preview Encoded Data"))
self.preview_btn.clicked.connect(self.preview_transaction)
preview_layout.addWidget(self.preview_btn)
preview_group.setLayout(preview_layout)
layout.addWidget(preview_group)
# Action buttons
btn_layout = QHBoxLayout()
self.create_btn = QPushButton(_("Create Transaction"))
self.create_btn.clicked.connect(self.create_transaction)
self.create_btn.setDefault(True)
btn_layout.addWidget(self.create_btn)
self.close_btn = QPushButton(_("Close"))
self.close_btn.clicked.connect(self.close)
btn_layout.addWidget(self.close_btn)
layout.addLayout(btn_layout)
def create_parameter_forms(self):
"""Create parameter input forms for each transaction type."""
# Leader VRF Key Registration
vrf_form = self.create_vrf_key_form()
self.params_stack.addWidget(vrf_form)
# Leader Block Commit
commit_form = self.create_block_commit_form()
self.params_stack.addWidget(commit_form)
# User Burn Support
support_form = self.create_user_burn_support_form()
self.params_stack.addWidget(support_form)
# Pre-STX
pre_stx_form = self.create_pre_stx_form()
self.params_stack.addWidget(pre_stx_form)
# Stack STX
stack_stx_form = self.create_stack_stx_form()
self.params_stack.addWidget(stack_stx_form)
# Transfer STX
transfer_stx_form = self.create_transfer_stx_form()
self.params_stack.addWidget(transfer_stx_form)
# Delegate STX
delegate_stx_form = self.create_delegate_stx_form()
self.params_stack.addWidget(delegate_stx_form)
def create_vrf_key_form(self) -> QWidget:
"""Create form for Leader VRF Key Registration."""
widget = QWidget()
layout = QFormLayout(widget)
layout.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.ExpandingFieldsGrow)
self.vrf_consensus_hash = QLineEdit()
self.vrf_consensus_hash.setPlaceholderText(_("40 hex characters (20 bytes)"))
self.vrf_consensus_hash.setMaxLength(40)
layout.addRow(_("Consensus Hash:"), self.vrf_consensus_hash)
self.vrf_public_key = QLineEdit()
self.vrf_public_key.setPlaceholderText(_("64 hex characters (32 bytes)"))
self.vrf_public_key.setMaxLength(64)
layout.addRow(_("VRF Public Key:"), self.vrf_public_key)
self.vrf_memo = QLineEdit()
self.vrf_memo.setPlaceholderText(_("Optional - up to 50 hex characters (25 bytes)"))
self.vrf_memo.setMaxLength(50)
layout.addRow(_("Memo (hex):"), self.vrf_memo)
info = QLabel(_(
"The address of the first input will be your mining identity.\n"
"Use coin control to select the UTXO from your desired mining address."
))
info.setWordWrap(True)
info.setStyleSheet("color: #666; margin-top: 10px;")
layout.addRow(info)
return widget
def create_block_commit_form(self) -> QWidget:
"""Create form for Leader Block Commit."""
widget = QWidget()
layout = QFormLayout(widget)
layout.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.ExpandingFieldsGrow)
self.commit_block_hash = QLineEdit()
self.commit_block_hash.setPlaceholderText(_("64 hex characters (32 bytes)"))
self.commit_block_hash.setMaxLength(64)
layout.addRow(_("Block Hash:"), self.commit_block_hash)
self.commit_new_seed = QLineEdit()
self.commit_new_seed.setPlaceholderText(_("64 hex characters (32 bytes)"))
self.commit_new_seed.setMaxLength(64)
layout.addRow(_("New VRF Seed:"), self.commit_new_seed)
self.commit_parent_block = QSpinBox()
self.commit_parent_block.setRange(0, 2**31 - 1)
layout.addRow(_("Parent Block Height:"), self.commit_parent_block)
self.commit_parent_txoff = QSpinBox()
self.commit_parent_txoff.setRange(0, 65535)
layout.addRow(_("Parent Tx Offset:"), self.commit_parent_txoff)
self.commit_key_block = QSpinBox()
self.commit_key_block.setRange(0, 2**31 - 1)
layout.addRow(_("Key Reg Block Height:"), self.commit_key_block)
self.commit_key_txoff = QSpinBox()
self.commit_key_txoff.setRange(0, 65535)
layout.addRow(_("Key Reg Tx Index:"), self.commit_key_txoff)
self.commit_burn_modulus = QSpinBox()
self.commit_burn_modulus.setRange(0, 5)
layout.addRow(_("Burn Parent Modulus:"), self.commit_burn_modulus)
info = QLabel(_(
"IMPORTANT: Use coin control to select a UTXO from the same address\n"
"that was used in your VRF Key Registration transaction."
))
info.setWordWrap(True)
info.setStyleSheet("color: #c60; font-weight: bold; margin-top: 10px;")
layout.addRow(info)
return widget
def create_user_burn_support_form(self) -> QWidget:
"""Create form for User Burn Support."""
widget = QWidget()
layout = QFormLayout(widget)
layout.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.ExpandingFieldsGrow)
self.support_consensus_hash = QLineEdit()
self.support_consensus_hash.setPlaceholderText(_("40 hex characters (20 bytes)"))
self.support_consensus_hash.setMaxLength(40)
layout.addRow(_("Consensus Hash:"), self.support_consensus_hash)
self.support_vrf_key = QLineEdit()
self.support_vrf_key.setPlaceholderText(_("64 hex characters (32 bytes)"))
self.support_vrf_key.setMaxLength(64)
layout.addRow(_("Miner's VRF Key:"), self.support_vrf_key)
self.support_block_hash_160 = QLineEdit()
self.support_block_hash_160.setPlaceholderText(_("40 hex characters (20 bytes) - HASH160"))
self.support_block_hash_160.setMaxLength(40)
layout.addRow(_("Block Hash (HASH160):"), self.support_block_hash_160)
self.support_key_block = QSpinBox()
self.support_key_block.setRange(0, 2**24 - 1)
layout.addRow(_("Miner Key Reg Block:"), self.support_key_block)
self.support_key_vtxindex = QSpinBox()
self.support_key_vtxindex.setRange(0, 65535)
layout.addRow(_("Miner Key Reg Tx Index:"), self.support_key_vtxindex)
return widget
def create_pre_stx_form(self) -> QWidget:
"""Create form for Pre-STX Authorization."""
widget = QWidget()
layout = QVBoxLayout(widget)
info = QLabel(_(
"Pre-STX Authorization creates a minimal OP_RETURN transaction that\n"
"authorizes an address for STX operations.\n\n"
"The address of the first input becomes authorized.\n"
"Use coin control to select which address you want to authorize.\n\n"
"After this transaction confirms, use the UTXO from this transaction\n"
"as input for Stack STX, Transfer STX, or Delegate STX operations."
))
info.setWordWrap(True)
layout.addWidget(info)
layout.addStretch()
return widget
def create_stack_stx_form(self) -> QWidget:
"""Create form for Stack STX."""
widget = QWidget()
layout = QFormLayout(widget)
layout.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.ExpandingFieldsGrow)
self.stack_amount = QLineEdit()
self.stack_amount.setPlaceholderText(_("Amount in micro-STX (1 STX = 1,000,000 uSTX)"))
layout.addRow(_("Amount (uSTX):"), self.stack_amount)
self.stack_cycles = QSpinBox()
self.stack_cycles.setRange(1, 12)
self.stack_cycles.setValue(1)
layout.addRow(_("Lock Cycles (1-12):"), self.stack_cycles)
info = QLabel(_(
"IMPORTANT: Use coin control to select a UTXO from your\n"
"Pre-STX Authorization transaction."
))
info.setWordWrap(True)
info.setStyleSheet("color: #c60; font-weight: bold; margin-top: 10px;")
layout.addRow(info)
return widget
def create_transfer_stx_form(self) -> QWidget:
"""Create form for Transfer STX."""
widget = QWidget()
layout = QFormLayout(widget)
layout.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.ExpandingFieldsGrow)
self.transfer_amount = QLineEdit()
self.transfer_amount.setPlaceholderText(_("Amount in micro-STX (1 STX = 1,000,000 uSTX)"))
layout.addRow(_("Amount (uSTX):"), self.transfer_amount)
self.transfer_recipient = QLineEdit()
self.transfer_recipient.setPlaceholderText(_("Legacy address only (starts with 1 or 3)"))
self.transfer_recipient.textChanged.connect(self.validate_transfer_recipient_realtime)
layout.addRow(_("Recipient Address:"), self.transfer_recipient)
self.transfer_recipient_status = QLabel()
self.transfer_recipient_status.setWordWrap(True)
layout.addRow("", self.transfer_recipient_status)
address_warning = QLabel(_(
"⚠️ REQUIRED: Legacy P2PKH (1...) or P2SH (3...) address only.\n"
"SegWit addresses (bc1...) are NOT supported by the Stacks blockchain."
))
address_warning.setWordWrap(True)
address_warning.setStyleSheet("color: #c60; margin-top: 5px;")
layout.addRow(address_warning)
info = QLabel(_(
"Use coin control to select a UTXO from your Pre-STX Authorization transaction.\n"
"The recipient address is encoded in the second output."
))
info.setWordWrap(True)
info.setStyleSheet("color: #666; font-style: italic; margin-top: 10px;")
layout.addRow(info)
return widget
def validate_transfer_recipient_realtime(self, text: str):
"""Validate transfer recipient address in real-time and show status."""
text = text.strip()
if not text:
self.transfer_recipient_status.setText("")
self.transfer_recipient_status.setStyleSheet("")
return
if is_legacy_bitcoin_address(text):
try:
network = self.get_selected_network()
stx_addr = bitcoin_address_to_stx_address(text, network)
self.transfer_recipient_status.setText(f"✓ Valid legacy address → {stx_addr}")
self.transfer_recipient_status.setStyleSheet("color: green;")
except Exception as e:
self.transfer_recipient_status.setText(f"⚠ Address error: {e}")
self.transfer_recipient_status.setStyleSheet("color: orange;")
elif text.lower().startswith(('bc1', 'tb1', 'bcrt1')):
self.transfer_recipient_status.setText(
"✗ SegWit addresses not supported. Use legacy address (1... or 3...)"
)
self.transfer_recipient_status.setStyleSheet("color: red; font-weight: bold;")
else:
self.transfer_recipient_status.setText("⚠ Unrecognized address format")
self.transfer_recipient_status.setStyleSheet("color: orange;")
def create_delegate_stx_form(self) -> QWidget:
"""Create form for Delegate STX."""
widget = QWidget()
layout = QFormLayout(widget)
layout.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.ExpandingFieldsGrow)
self.delegate_amount = QLineEdit()
self.delegate_amount.setPlaceholderText(_("Amount in micro-STX (1 STX = 1,000,000 uSTX)"))
layout.addRow(_("Amount (uSTX):"), self.delegate_amount)
self.delegate_address = QLineEdit()
self.delegate_address.setPlaceholderText(_("Legacy address only (starts with 1 or 3)"))
self.delegate_address.textChanged.connect(self.validate_delegate_address_realtime)
layout.addRow(_("Delegate Address:"), self.delegate_address)
self.delegate_address_status = QLabel()
self.delegate_address_status.setWordWrap(True)
layout.addRow("", self.delegate_address_status)
address_warning = QLabel(_(
"⚠️ REQUIRED: Legacy P2PKH (1...) or P2SH (3...) address only.\n"
"SegWit addresses (bc1...) are NOT supported by the Stacks blockchain."
))
address_warning.setWordWrap(True)
address_warning.setStyleSheet("color: #c60; margin-top: 5px;")
layout.addRow(address_warning)
info = QLabel(_(
"Use coin control to select a UTXO from your Pre-STX Authorization transaction.\n"
"The delegate address is encoded in the second output."
))
info.setWordWrap(True)
info.setStyleSheet("color: #666; font-style: italic; margin-top: 10px;")
layout.addRow(info)
return widget
def validate_delegate_address_realtime(self, text: str):
"""Validate delegate address in real-time and show status."""
text = text.strip()
if not text:
self.delegate_address_status.setText("")
self.delegate_address_status.setStyleSheet("")
return
if is_legacy_bitcoin_address(text):
try:
network = self.get_selected_network()
stx_addr = bitcoin_address_to_stx_address(text, network)
self.delegate_address_status.setText(f"✓ Valid legacy address → {stx_addr}")
self.delegate_address_status.setStyleSheet("color: green;")
except Exception as e:
self.delegate_address_status.setText(f"⚠ Address error: {e}")
self.delegate_address_status.setStyleSheet("color: orange;")
elif text.lower().startswith(('bc1', 'tb1', 'bcrt1')):
self.delegate_address_status.setText(
"✗ SegWit addresses not supported. Use legacy address (1... or 3...)"
)
self.delegate_address_status.setStyleSheet("color: red; font-weight: bold;")
else:
self.delegate_address_status.setText("⚠ Unrecognized address format")
self.delegate_address_status.setStyleSheet("color: orange;")
def on_tx_type_changed(self, index: int):
"""Handle transaction type selection change."""
self.params_stack.setCurrentIndex(index)
_, tx_type, description = self.TX_TYPES[index]
# Update description
self.type_description.setText(description)
# Show/hide burn amount for appropriate types
needs_burn = tx_type in ['leader_block_commit', 'user_burn_support']
self.burn_group.setVisible(needs_burn)
# Update preview
self.preview_text.clear()
def on_coin_control_toggled(self, checked: bool):
"""Handle coin control checkbox toggle."""
self.utxo_table.setVisible(checked)
self.refresh_utxos_btn.setVisible(checked)
if checked:
self.refresh_utxos()
def refresh_utxos(self):
"""Refresh the UTXO table with current wallet UTXOs."""
self.utxo_table.setRowCount(0)
try:
utxos = self.wallet.get_utxos()
except Exception as e:
QMessageBox.warning(self, _("Error"), f"Failed to get UTXOs: {e}")
return
# Get current blockchain height for confirmations
current_height = 0
try:
if self.wallet.network and self.wallet.network.blockchain():
current_height = self.wallet.network.blockchain().height()
except Exception:
pass
for utxo in utxos:
row = self.utxo_table.rowCount()
self.utxo_table.insertRow(row)
address = utxo.address or _("Unknown")
amount = utxo.value_sats() / 1e8
height = utxo.block_height or 0
confirmations = max(0, current_height - height + 1) if height > 0 and current_height > 0 else 0
outpoint = f"{utxo.prevout.txid.hex()}:{utxo.prevout.out_idx}"
self.utxo_table.setItem(row, 0, QTableWidgetItem(address))
self.utxo_table.setItem(row, 1, QTableWidgetItem(f"{amount:.8f}"))
self.utxo_table.setItem(row, 2, QTableWidgetItem(str(confirmations)))
self.utxo_table.setItem(row, 3, QTableWidgetItem(outpoint))
# Store the UTXO object in the row for later retrieval
self.utxo_table.item(row, 0).setData(Qt.ItemDataRole.UserRole, utxo)
def update_stx_address_display(self):
"""Update the STX address display based on selected UTXO, preselected address, or wallet default."""
btc_address = None
stx_address = None
# Try to get address from selected UTXO
selected_items = self.utxo_table.selectedItems()
if selected_items:
row = selected_items[0].row()
btc_address = self.utxo_table.item(row, 0).text()
# If no UTXO selected, use preselected address if available
if not btc_address and self.preselected_address:
btc_address = self.preselected_address
# If still no address, try to get first receiving address from wallet
if not btc_address:
try:
btc_address = self.wallet.get_receiving_address()
except Exception:
pass
# Convert to STX address
if btc_address:
self.btc_address_label.setText(btc_address)
try:
network = self.get_selected_network()
stx_address = bitcoin_address_to_stx_address(btc_address, network)
self.stx_address_label.setText(stx_address)
# Fetch balance asynchronously
self.fetch_stx_balance(stx_address)
except ValueError as e:
self.stx_address_label.setText(f"Cannot convert: {e}")
self.stx_balance_label.clear()
except Exception as e:
self.stx_address_label.setText(f"Error: {e}")
self.stx_balance_label.clear()
else:
self.btc_address_label.clear()
self.stx_address_label.clear()
self.stx_balance_label.clear()
def fetch_stx_balance(self, stx_address: str):
"""Fetch STX balance from Stacks API in a background thread."""
self.stx_balance_label.setText(_("Fetching..."))
api_url = get_stacks_api_url(self.config, f"/v2/accounts/{stx_address}")
def fetch():
try:
req = urllib.request.Request(api_url, headers={'Accept': 'application/json'})
# Create SSL context - try to use certifi if available (common on macOS),
# otherwise fall back to default context
try:
import certifi
ssl_context = ssl.create_default_context(cafile=certifi.where())
except ImportError:
ssl_context = ssl.create_default_context()
with urllib.request.urlopen(req, timeout=10, context=ssl_context) as response:
data = json.loads(response.read().decode())
# Balance is hex encoded without 0x prefix
balance_hex = data.get('balance', '0')
if balance_hex.startswith('0x'):
balance_hex = balance_hex[2:]
balance_micro = int(balance_hex, 16)
# STX has 6 decimal places
balance_stx = balance_micro / 1_000_000
return f"{balance_stx:,.6f} STX"
except urllib.error.HTTPError as e:
if e.code == 404:
return "0.000000 STX (new address)"
return f"API error: {e.code}"
except Exception as e:
return f"Error: {str(e)[:30]}"
def on_result(result):
# Update UI on main thread
if self.stx_address_label.text() == stx_address: # Still showing same address
self.stx_balance_label.setText(result)
# Run in background thread
def run_fetch():
result = fetch()
# Schedule UI update on main thread
from PyQt6.QtCore import QMetaObject, Q_ARG
QMetaObject.invokeMethod(self.stx_balance_label, "setText", Qt.ConnectionType.QueuedConnection, Q_ARG(str, result))
thread = threading.Thread(target=run_fetch, daemon=True)
thread.start()
def get_selected_network(self) -> StacksNetwork:
"""Get the selected Stacks network from global preferences."""
return get_stacks_network(self.config)
def get_selected_utxo(self):
"""Get the selected UTXO if coin control is enabled."""
if not self.use_specific_utxo.isChecked():
return None
selected_items = self.utxo_table.selectedItems()
if not selected_items:
return None
row = selected_items[0].row()
return self.utxo_table.item(row, 0).data(Qt.ItemDataRole.UserRole)
def validate_hex_field(self, value: str, expected_bytes: int, field_name: str) -> bytes:
"""Validate and convert a hex string field."""
value = value.strip().lower()
if value.startswith('0x'):
value = value[2:]
if len(value) != expected_bytes * 2:
raise ValueError(
f"{field_name} must be {expected_bytes * 2} hex characters ({expected_bytes} bytes), "
f"got {len(value)}"
)
try:
return bytes.fromhex(value)
except ValueError:
raise ValueError(f"{field_name} contains invalid hex characters")
def get_encoded_payload(self) -> bytes:
"""Get the encoded OP_RETURN payload based on current form values."""
name, tx_type, desc = self.TX_TYPES[self.tx_type_combo.currentIndex()]
network = self.get_selected_network()
if tx_type == "leader_key_register":
consensus_hash = self.validate_hex_field(
self.vrf_consensus_hash.text(), CONSENSUS_HASH_LENGTH, "Consensus Hash")
vrf_key = self.validate_hex_field(
self.vrf_public_key.text(), VRF_PUBLIC_KEY_LENGTH, "VRF Public Key")
memo_text = self.vrf_memo.text().strip()
memo = bytes.fromhex(memo_text) if memo_text else b''
op = LeaderKeyRegister(
consensus_hash=consensus_hash,
proving_public_key=vrf_key,
memo=memo
)
return op.encode(network)
elif tx_type == "leader_block_commit":
block_hash = self.validate_hex_field(
self.commit_block_hash.text(), BLOCK_HASH_LENGTH, "Block Hash")
new_seed = self.validate_hex_field(
self.commit_new_seed.text(), SEED_LENGTH, "New Seed")
op = LeaderBlockCommit(
block_hash=block_hash,
new_seed=new_seed,
parent_block=self.commit_parent_block.value(),
parent_txoff=self.commit_parent_txoff.value(),
key_block=self.commit_key_block.value(),
key_txoff=self.commit_key_txoff.value(),
burn_parent_modulus=self.commit_burn_modulus.value()
)
return op.encode(network)
elif tx_type == "user_burn_support":
consensus_hash = self.validate_hex_field(
self.support_consensus_hash.text(), CONSENSUS_HASH_LENGTH, "Consensus Hash")
vrf_key = self.validate_hex_field(
self.support_vrf_key.text(), VRF_PUBLIC_KEY_LENGTH, "VRF Key")
block_hash_160 = self.validate_hex_field(
self.support_block_hash_160.text(), 20, "Block Hash 160")
op = UserBurnSupport(
consensus_hash=consensus_hash,
proving_public_key=vrf_key,
block_hash_160=block_hash_160,
key_block=self.support_key_block.value(),
key_vtxindex=self.support_key_vtxindex.value()
)
return op.encode(network)
elif tx_type == "pre_stx":
op = PreStx()
return op.encode(network)
elif tx_type == "stack_stx":
amount_text = self.stack_amount.text().strip()
if not amount_text:
raise ValueError("Amount is required")
amount = int(amount_text)
op = StackStx(
stacked_ustx=amount,
num_cycles=self.stack_cycles.value()
)
return op.encode(network)
elif tx_type == "transfer_stx":
amount_text = self.transfer_amount.text().strip()
if not amount_text:
raise ValueError("Amount is required")
amount = int(amount_text)
op = TransferStx(amount_ustx=amount)
return op.encode(network)
elif tx_type == "delegate_stx":
amount_text = self.delegate_amount.text().strip()
if not amount_text:
raise ValueError("Amount is required")
amount = int(amount_text)
op = DelegateStx(amount_ustx=amount)
return op.encode(network)
raise ValueError(f"Unknown transaction type: {tx_type}")
def preview_transaction(self):
"""Preview the encoded OP_RETURN data."""
try:
payload = self.get_encoded_payload()
script = create_op_return_script(payload)
# Decode magic and opcode for display
magic = payload[:2].decode('ascii', errors='replace')
opcode = chr(payload[2]) if len(payload) > 2 else '?'
preview = (
f"Magic: {magic} | Opcode: '{opcode}' (0x{payload[2]:02x})\n\n"
f"Payload ({len(payload)} bytes):\n"
f" {payload.hex()}\n\n"
f"Script ({len(script)} bytes):\n"
f" {script.hex()}"
)
self.preview_text.setText(preview)
except Exception as e:
self.preview_text.setText(f"Error: {str(e)}")
def create_transaction(self):
"""Create the Bitcoin transaction with OP_RETURN output."""
try:
payload = self.get_encoded_payload()
op_return_script = create_op_return_script(payload)
name, tx_type, desc = self.TX_TYPES[self.tx_type_combo.currentIndex()]
network = self.get_selected_network()
# Build outputs
outputs = []
# First output: OP_RETURN (value must be 0)
op_return_output = PartialTxOutput(
scriptpubkey=op_return_script,
value=0
)
outputs.append(op_return_output)
# Second output depends on transaction type
if tx_type in ['leader_block_commit', 'user_burn_support']:
# Burn output to burn address
burn_address = get_burn_address(network)
burn_amount = int(self.burn_amount_input.value() * 1e8)
burn_scriptpubkey = address_to_scriptpubkey(burn_address)
burn_output = PartialTxOutput(
scriptpubkey=burn_scriptpubkey,
value=burn_amount
)
outputs.append(burn_output)
elif tx_type == 'transfer_stx':
# Recipient output (dust amount to mark recipient)
# Must be a legacy address (P2PKH or P2SH) per SIP-007
recipient = self.transfer_recipient.text().strip()
validate_legacy_recipient_address(recipient)
recipient_scriptpubkey = address_to_scriptpubkey(recipient)
recipient_output = PartialTxOutput(
scriptpubkey=recipient_scriptpubkey,
value=546 # Dust limit
)
outputs.append(recipient_output)
elif tx_type == 'delegate_stx':
# Delegate output (dust amount to mark delegate)
# Must be a legacy address (P2PKH or P2SH) per SIP-007
delegate = self.delegate_address.text().strip()
validate_legacy_recipient_address(delegate)
delegate_scriptpubkey = address_to_scriptpubkey(delegate)
delegate_output = PartialTxOutput(
scriptpubkey=delegate_scriptpubkey,
value=546 # Dust limit
)
outputs.append(delegate_output)
# Get coins to spend
selected_utxo = self.get_selected_utxo()
if selected_utxo:
coins = [selected_utxo]
else:
coins = self.wallet.get_utxos()
if not coins:
raise ValueError("No UTXOs available to spend")
# Determine sender address for change output
# For Pre-STX and similar transactions, change must go back to sender
sender_address = coins[0].address
if not sender_address:
raise ValueError("Could not determine sender address from selected UTXO")
# Warn about coin control for linked transactions
needs_coin_control = tx_type in [
'leader_block_commit', 'stack_stx', 'transfer_stx', 'delegate_stx'
]
if needs_coin_control and not selected_utxo:
reply = QMessageBox.question(
self,
_("Coin Control Recommended"),
_(
"This transaction type typically requires spending from a specific UTXO "
"(from a prior VRF registration or Pre-STX transaction).\n\n"
"Continue without specifying a UTXO?"
),
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No
)
if reply != QMessageBox.StandardButton.Yes:
return
# Create unsigned transaction
try:
tx = self.wallet.make_unsigned_transaction(
coins=coins,
outputs=outputs,
fee_policy=FeePolicy('eta:2'), # Target 2-block confirmation
change_addr=sender_address, # Send change back to sender address
)
except NotEnoughFunds: