Skip to content

Commit 268a384

Browse files
committed
feat: sp address support
1 parent 2fe92ce commit 268a384

File tree

9 files changed

+654
-6
lines changed

9 files changed

+654
-6
lines changed

src/seedsigner/gui/components.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -936,6 +936,7 @@ class FormattedAddress(BaseComponent):
936936
screen_y: int = 0
937937
address: str = None
938938
max_lines: int = None
939+
line_spacing: int = GUIConstants.BODY_LINE_SPACING
939940
font_name: str = GUIConstants.FIXED_WIDTH_FONT_NAME
940941
font_size: int = 24
941942
font_accent_color: str = GUIConstants.ACCENT_COLOR
@@ -1074,7 +1075,7 @@ def __post_init__(self):
10741075
))
10751076

10761077
remaining_display_str = remaining_display_str[max_chars_per_line:]
1077-
cur_y += char_height + GUIConstants.BODY_LINE_SPACING
1078+
cur_y += char_height + self.line_spacing
10781079

10791080
self.height = cur_y
10801081

src/seedsigner/gui/screens/seed_screens.py

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -571,6 +571,155 @@ def __post_init__(self):
571571

572572

573573

574+
@dataclass
575+
class SeedBIP352GeneratePaymentAddressScreen(ButtonListScreen):
576+
payment_address: str = None
577+
578+
def __post_init__(self):
579+
self.title = "Payment Address"
580+
self.is_bottom_list = True
581+
super().__post_init__()
582+
583+
self.components.append(FormattedAddress(
584+
address=self.payment_address,
585+
font_size=GUIConstants.get_body_font_size() + 4,
586+
line_spacing=GUIConstants.BODY_LINE_SPACING - 2,
587+
screen_y=self.top_nav.height
588+
))
589+
590+
591+
592+
@dataclass
593+
class SeedBIP352ExportScanningPrivkeyDetailsScreen(WarningEdgesMixin, ButtonListScreen):
594+
is_bottom_list: bool = True
595+
fingerprint: str = None
596+
has_passphrase: bool = False
597+
derivation_path: str = "m/352'/0'/0'/1'/0"
598+
scanning_privkey: str = "xprv..."
599+
status_color: str = GUIConstants.DIRE_WARNING_COLOR
600+
601+
def __post_init__(self):
602+
self.button_data = [ButtonOption(_("Export Privkey via QR"))]
603+
self.title = _("Scan Private Key Details")
604+
605+
super().__post_init__()
606+
607+
self.fingerprint_line = IconTextLine(
608+
icon_name=SeedSignerIconConstants.FINGERPRINT,
609+
icon_color=GUIConstants.INFO_COLOR,
610+
label_text=_("Fingerprint"),
611+
value_text=self.fingerprint,
612+
screen_x=GUIConstants.COMPONENT_PADDING,
613+
screen_y=self.top_nav.height + GUIConstants.COMPONENT_PADDING,
614+
)
615+
self.components.append(self.fingerprint_line)
616+
617+
self.derivation_line = IconTextLine(
618+
icon_name=SeedSignerIconConstants.DERIVATION,
619+
icon_color=GUIConstants.INFO_COLOR,
620+
label_text=_("Derivation"),
621+
value_text=self.derivation_path,
622+
screen_x=GUIConstants.COMPONENT_PADDING,
623+
screen_y=self.components[-1].screen_y + self.components[-1].height + int(1.5*GUIConstants.COMPONENT_PADDING),
624+
)
625+
self.components.append(self.derivation_line)
626+
627+
font_name = GUIConstants.FIXED_WIDTH_FONT_NAME
628+
font_size = GUIConstants.get_body_font_size() + 2
629+
left, top, right, bottom = Fonts.get_font(font_name, font_size).getbbox("X")
630+
char_width = right - left
631+
num_chars = int((self.canvas_width - 2*GUIConstants.COMPONENT_PADDING) / char_width) - 3
632+
633+
self.privkey_line = IconTextLine(
634+
icon_name=FontAwesomeIconConstants.X,
635+
icon_color=GUIConstants.INFO_COLOR,
636+
label_text=_("Scan Private Key"),
637+
value_text=f"{self.scanning_privkey[:num_chars]}...",
638+
font_name=GUIConstants.FIXED_WIDTH_FONT_NAME,
639+
font_size=GUIConstants.get_body_font_size() + 2,
640+
screen_x=GUIConstants.COMPONENT_PADDING,
641+
screen_y=self.components[-1].screen_y + self.components[-1].height + int(1.5*GUIConstants.COMPONENT_PADDING),
642+
)
643+
self.components.append(self.privkey_line)
644+
645+
646+
647+
@dataclass
648+
class SeedBIP352ExportSigningPubkeyDetailsScreen(WarningEdgesMixin, ButtonListScreen):
649+
# Customize defaults
650+
is_bottom_list: bool = True
651+
fingerprint: str = None
652+
has_passphrase: bool = False
653+
derivation_path: str = "m/352'/0'/0'/0'/0"
654+
signing_pubkey: str = "xpub..."
655+
656+
def __post_init__(self):
657+
self.button_data = [ButtonOption(_("Export Pubkey via QR"))]
658+
self.title = _("Spend Pubkey Details")
659+
660+
super().__post_init__()
661+
662+
# Set up the fingerprint and passphrase displays
663+
self.fingerprint_line = IconTextLine(
664+
icon_name=SeedSignerIconConstants.FINGERPRINT,
665+
icon_color=GUIConstants.INFO_COLOR,
666+
label_text=_("Fingerprint"),
667+
value_text=self.fingerprint,
668+
screen_x=GUIConstants.COMPONENT_PADDING,
669+
screen_y=self.top_nav.height + GUIConstants.COMPONENT_PADDING,
670+
)
671+
self.components.append(self.fingerprint_line)
672+
673+
self.derivation_line = IconTextLine(
674+
icon_name=SeedSignerIconConstants.DERIVATION,
675+
icon_color=GUIConstants.INFO_COLOR,
676+
label_text=_("Derivation"),
677+
value_text=self.derivation_path,
678+
screen_x=GUIConstants.COMPONENT_PADDING,
679+
screen_y=self.components[-1].screen_y + self.components[-1].height + int(1.5*GUIConstants.COMPONENT_PADDING),
680+
)
681+
self.components.append(self.derivation_line)
682+
683+
font_name = GUIConstants.FIXED_WIDTH_FONT_NAME
684+
font_size = GUIConstants.get_body_font_size() + 2
685+
left, top, right, bottom = Fonts.get_font(font_name, font_size).getbbox("X")
686+
char_width = right - left
687+
num_chars = int((self.canvas_width - 2*GUIConstants.COMPONENT_PADDING) / char_width) - 3
688+
689+
self.pubkey_line = IconTextLine(
690+
icon_name=FontAwesomeIconConstants.X,
691+
icon_color=GUIConstants.INFO_COLOR,
692+
label_text=_("Spend Public Key"),
693+
value_text=f"{self.signing_pubkey[:num_chars]}...",
694+
font_name=GUIConstants.FIXED_WIDTH_FONT_NAME,
695+
font_size=GUIConstants.get_body_font_size() + 2,
696+
screen_x=GUIConstants.COMPONENT_PADDING,
697+
screen_y=self.components[-1].screen_y + self.components[-1].height + int(1.5*GUIConstants.COMPONENT_PADDING),
698+
)
699+
self.components.append(self.pubkey_line)
700+
701+
702+
703+
@dataclass
704+
class SeedBIP352LabelEntryScreen(KeyboardScreen):
705+
"""
706+
Currently, labels are restricted to numeric characters only as a basic safeguard.
707+
TODO: Extend support to any arbitrary char string in future updates.
708+
"""
709+
def __post_init__(self):
710+
self.title = _("Enter Label")
711+
self.user_input = ""
712+
713+
# Specify the keys in the keyboard - numeric only
714+
self.rows = 3
715+
self.cols = 5
716+
self.keys_charset = "0123456789"
717+
self.show_save_button = True
718+
719+
super().__post_init__()
720+
721+
722+
574723
@dataclass
575724
class SeedWordsBackupTestPromptScreen(ButtonListScreen):
576725
def __post_init__(self):

src/seedsigner/helpers/embit_utils.py

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,12 @@
33
from binascii import b2a_base64
44
from hashlib import sha256
55

6-
from embit import bip32, compact, ec
6+
from embit import bech32, bip32, compact, ec
77
from embit.bip32 import HDKey
88
from embit.descriptor import Descriptor
99
from embit.networks import NETWORKS
1010
from embit.util import secp256k1
11+
from embit.hashes import tagged_hash
1112

1213

1314
from seedsigner.models.settings_definition import SettingsConstants
@@ -200,3 +201,40 @@ def sign_message(seed_bytes: bytes, derivation: str, msg: bytes, compressed: boo
200201
flag = bytes([27 + flag + c])
201202
ser = flag + secp256k1.ecdsa_signature_serialize_compact(sig._sig)
202203
return b2a_base64(ser).strip().decode()
204+
205+
206+
207+
# Basic BIP-352 Silent Payments support
208+
def encode_silent_payment_address(B_scan: ec.PublicKey, B_m: ec.PublicKey, network: str = "main", version: int = 0) -> str:
209+
"""
210+
Adapted from https://github.com/bitcoin/bips/blob/master/bip-0352/reference.py
211+
212+
Generates the recipient's reusable silent payment address for a given:
213+
* scanning pubkey `B_scan`
214+
* spending pubkey `B_m`
215+
"""
216+
data = bech32.convertbits(B_scan.sec() + B_m.sec(), 8, 5)
217+
hrp = "sp" if network == "main" else "tsp"
218+
return bech32.bech32_encode(bech32.Encoding.BECH32M, hrp, [version] + data)
219+
220+
221+
222+
def encode_labeled_silent_payment_address(b_scan: ec.PrivateKey, B_spend: ec.PublicKey, label, network: str = "main", version: int = 0) -> str:
223+
"""
224+
The spending key is tweaked with the label to generate a labeled silent payment address.
225+
see: https://github.com/bitcoin/bips/blob/master/bip-0352.mediawiki#address-encoding
226+
`label` must be an int, str, or bytes.
227+
"""
228+
if isinstance(label, int):
229+
label_bytes = label.to_bytes(4, "big")
230+
elif isinstance(label, str):
231+
label_bytes = label.encode()
232+
elif isinstance(label, bytes):
233+
label_bytes = label
234+
else:
235+
raise Exception("Label must be an int, str, or bytes.")
236+
237+
tweak = tagged_hash("BIP0352/Label", b_scan.secret + label_bytes)
238+
label_pubkey = ec.PublicKey(secp256k1.ec_pubkey_add(secp256k1.ec_pubkey_parse(B_spend.sec()), tweak))
239+
240+
return encode_silent_payment_address(b_scan.get_public_key(), label_pubkey, network=network, version=version)

src/seedsigner/models/seed.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from typing import List
1010

1111
from seedsigner.models.settings import SettingsConstants
12+
from seedsigner.helpers import embit_utils
1213

1314
logger = logging.getLogger(__name__)
1415

@@ -164,6 +165,45 @@ def get_bip85_child_mnemonic(self, bip85_index: int, bip85_num_words: int, netwo
164165
return bip85.derive_mnemonic(root, bip85_num_words, bip85_index)
165166

166167

168+
# ----------------- BIP-352 Silent Payments support -----------------
169+
def _derive_bip352_key(self, is_scanning_key: bool = True, account: int = 0, network: str = SettingsConstants.MAINNET):
170+
"""
171+
Derives the BIP-352 scanning or signing key.
172+
173+
see: https://github.com/bitcoin/bips/blob/master/bip-0352.mediawiki#key-derivation
174+
"""
175+
purpose = 352 # per BIP-352 spec
176+
coin_type = 0 if network == SettingsConstants.MAINNET else 1 # mainnet coins vs testnet coins
177+
key_type = 1 if is_scanning_key else 0 # per BIP-352 spec; scanning key vs spending key
178+
derivation_path = f"m/{purpose}'/{coin_type}'/{account}'/{key_type}'/0"
179+
root = bip32.HDKey.from_seed(self.seed_bytes, version=NETWORKS[SettingsConstants.map_network_to_embit(network)]["xprv"])
180+
return root.derive(derivation_path)
181+
182+
183+
def derive_bip352_scanning_key(self, account: int = 0, network: str = SettingsConstants.MAINNET):
184+
return self._derive_bip352_key(is_scanning_key=True, account=account, network=network)
185+
186+
187+
def derive_bip352_signing_key(self, account: int = 0, network: str = SettingsConstants.MAINNET):
188+
return self._derive_bip352_key(is_scanning_key=False, account=account, network=network)
189+
190+
191+
def generate_bip352_silent_payment_address(self, network: str = SettingsConstants.MAINNET):
192+
scanning_pk = self.derive_bip352_scanning_key(network=network)
193+
signing_pk = self.derive_bip352_signing_key(network=network)
194+
scanning_pubkey = scanning_pk.get_public_key()
195+
signing_pubkey = signing_pk.get_public_key()
196+
return embit_utils.encode_silent_payment_address(scanning_pubkey, signing_pubkey, network=SettingsConstants.map_network_to_embit(network))
197+
198+
199+
def generate_bip352_silent_payment_labeled_address(self, label: str, network: str = SettingsConstants.MAINNET):
200+
scanning_pk = self.derive_bip352_scanning_key(network=network)
201+
signing_pk = self.derive_bip352_signing_key(network=network)
202+
signing_pubkey = signing_pk.get_public_key()
203+
return embit_utils.encode_labeled_silent_payment_address(scanning_pk, signing_pubkey, label, network=SettingsConstants.map_network_to_embit(network))
204+
# ----------------- BIP-352 Silent Payments support -----------------
205+
206+
167207
### override operators
168208
def __eq__(self, other):
169209
if isinstance(other, Seed):

src/seedsigner/models/settings_definition.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,7 @@ def map_network_to_embit(cls, network) -> str:
334334
SETTING__COMPACT_SEEDQR = "compact_seedqr"
335335
SETTING__BIP85_CHILD_SEEDS = "bip85_child_seeds"
336336
SETTING__ELECTRUM_SEEDS = "electrum_seeds"
337+
SETTING__BIP352_SILENT_PAYMENTS = "bip352_silent_payments"
337338
SETTING__MESSAGE_SIGNING = "message_signing"
338339
SETTING__PRIVACY_WARNINGS = "privacy_warnings"
339340
SETTING__DIRE_WARNINGS = "dire_warnings"
@@ -399,6 +400,17 @@ def map_network_to_embit(cls, network) -> str:
399400
custom_extension = _mft("Custom Extension")
400401
LABEL__CUSTOM_EXTENSION = custom_extension
401402

403+
# BIP-352 Silent Payments options
404+
BIP352__DISABLED = "D"
405+
BIP352__ENABLED = "E"
406+
BIP352__ENABLED_WITH_LABELS = "L"
407+
408+
BIP352_OPTIONS = [
409+
(BIP352__DISABLED, _mft("Disabled")),
410+
(BIP352__ENABLED, _mft("Enable SP")),
411+
(BIP352__ENABLED_WITH_LABELS, _mft("Enable SP + labels")),
412+
]
413+
402414

403415

404416
@dataclass
@@ -699,6 +711,15 @@ class SettingsDefinition:
699711
visibility=SettingsConstants.VISIBILITY__ADVANCED,
700712
default_value=SettingsConstants.OPTION__ENABLED),
701713

714+
SettingsEntry(category=SettingsConstants.CATEGORY__FEATURES,
715+
attr_name=SettingsConstants.SETTING__BIP352_SILENT_PAYMENTS,
716+
abbreviated_name="bip352",
717+
display_name="BIP-352 silent payments",
718+
type=SettingsConstants.TYPE__SELECT_1,
719+
visibility=SettingsConstants.VISIBILITY__ADVANCED,
720+
selection_options=SettingsConstants.BIP352_OPTIONS,
721+
default_value=SettingsConstants.BIP352__DISABLED),
722+
702723

703724
# Hardware config
704725
SettingsEntry(category=SettingsConstants.CATEGORY__SYSTEM,
@@ -728,7 +749,8 @@ class SettingsDefinition:
728749
# display_name="Debug",
729750
# visibility=SettingsConstants.VISIBILITY__DEVELOPER,
730751
# default_value=SettingsConstants.OPTION__DISABLED),
731-
752+
753+
732754
# "Hidden" settings with no UI interaction
733755
SettingsEntry(category=SettingsConstants.CATEGORY__SYSTEM,
734756
attr_name=SettingsConstants.SETTING__QR_BRIGHTNESS,

0 commit comments

Comments
 (0)