Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions src/krux/pages/home_pages/home.py
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,13 @@ def mnemonic_xor(self):

return MENU_CONTINUE

def silent_payment(self):
"""Handler for the 'Silent Payment' menu item"""
from .silent_payment import SilentPayment

sp = SilentPayment(self.ctx)
return sp.export()

def wallet(self):
"""Handler for the 'wallet' menu item"""

Expand All @@ -191,6 +198,7 @@ def wallet(self):
(t("Passphrase"), self.passphrase),
(t("Customize"), self.customize),
("BIP85", self.bip85),
(t("Silent Payment"), self.silent_payment),
(t("Mnemonic XOR"), self.mnemonic_xor),
],
)
Expand Down
98 changes: 98 additions & 0 deletions src/krux/pages/home_pages/silent_payment.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
from ...display import FONT_HEIGHT, DEFAULT_PADDING
from ...krux_settings import t
from .. import (
Menu,
Page,
MENU_CONTINUE,
)


class SilentPayment(Page):
"""UI for BIP-352 Silent Payment address generation"""

def export(self):
"""Main entry point: show silent payment options menu"""
submenu = Menu(
self.ctx,
[
(t("SP Address"), self._generate_address),
(t("SP Address with Label"), self._generate_labeled_address),
],
)
submenu.run_loop()
return MENU_CONTINUE

def _generate_address(self):
"""Generate and display a silent payment address without label"""
return self._show_sp_address(label=None)

def _generate_labeled_address(self):
"""Prompt user for a numeric label, then generate address"""
from ..utils import Utils

utils = Utils(self.ctx)
label = ""

while label == "":
label = utils.capture_index_from_keypad(t("Label"))
if label is None:
return MENU_CONTINUE
return self._show_sp_address(label=label)

def _show_sp_address(self, label=None):
"""Derive and display the silent payment address"""
from ...silent_payment import (
derive_bip352_key,
encode_silent_payment_address,
)

self.ctx.display.clear()
self.ctx.display.draw_centered_text(t("Processing…"))

root = self.ctx.wallet.key.root
network = self.ctx.wallet.key.network

# Determine network name for address HRP (coin_type 0 = mainnet)
embit_network = "main" if network.get("bip32") == 0 else "test"

scan_hd = derive_bip352_key(root, network, is_scan_key=True)
spend_hd = derive_bip352_key(root, network, is_scan_key=False)

scan_privkey = scan_hd.key
spend_pubkey = spend_hd.key.get_public_key()

sp_address = encode_silent_payment_address(
scan_privkey, spend_pubkey, label=label, network=embit_network
)

info = t("Silent Payment Address")
info += (
"\n\n" + t("Label") + ": " + str(label)
if label is not None
else ""
)
info += "\n\n" + sp_address

while True:
menu_items = [
(
t("QR Code"),
lambda: self._show_qr(sp_address),
),
]
self.ctx.display.clear()
info_len = self.ctx.display.draw_hcentered_text(info)
info_len = info_len * FONT_HEIGHT + DEFAULT_PADDING
submenu = Menu(
self.ctx,
menu_items,
offset=info_len,
)
index, _ = submenu.run_loop()
if index == submenu.back_index:
break
return MENU_CONTINUE

def _show_qr(self, sp_address):
"""Display the silent payment address as a QR code"""
self.display_qr_codes(data=sp_address)
55 changes: 55 additions & 0 deletions src/krux/silent_payment.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
"""BIP-352 Silent Payments support.

Reference: https://github.com/bitcoin/bips/blob/master/bip-0352.mediawiki

TODO: we should eventually get rid of this file and replace with embit implementation.
"""

from embit import bech32
from embit.ec import PublicKey
from embit.hashes import tagged_hash
from embit.util import secp256k1


# BIP-352 derivation: m/352'/coin_type'/account'/key_type'/0
# key_type: 0 = spend, 1 = scan
BIP352_PURPOSE = 352


def _bip352_derivation_path(network, account=0, is_scan_key=True):
"""Returns the BIP-352 derivation path for scan or spend key"""
coin_type = network["bip32"]
key_type = 1 if is_scan_key else 0
return "m/%dh/%dh/%dh/%dh/0" % (BIP352_PURPOSE, coin_type, account, key_type)


def derive_bip352_key(root, network, account=0, is_scan_key=True):
"""Derive a BIP-352 HD key (scan or spend) from the root key"""
path = _bip352_derivation_path(network, account, is_scan_key)
return root.derive(path)


def encode_silent_payment_address(
scan_privkey, spend_pubkey, label=None, network="main", version=0
):
"""Encode a BIP-352 silent payment address."""
scan_pubkey = scan_privkey.get_public_key()

if label is not None:
if not isinstance(label, int):
raise ValueError("label must be an integer")
if not (0 <= label <= 0xFFFFFFFF):
raise ValueError("label must be a 32-bit unsigned integer")
label_bytes = label.to_bytes(4, "big")
tweak = tagged_hash(
"BIP0352/Label", scan_privkey.secret + label_bytes
)
spend_pubkey = PublicKey(
secp256k1.ec_pubkey_add(
secp256k1.ec_pubkey_parse(spend_pubkey.sec()), tweak
)
)

data = bech32.convertbits(scan_pubkey.sec() + spend_pubkey.sec(), 8, 5)
hrp = "sp" if network == "main" else "tsp"
return bech32.bech32_encode(bech32.Encoding.BECH32M, hrp, [version] + data)
6 changes: 3 additions & 3 deletions tests/pages/home_pages/test_home.py
Original file line number Diff line number Diff line change
Expand Up @@ -508,7 +508,7 @@ def test_load_bip85_from_wallet_menu(mocker, amigo, tdata):
BUTTON_ENTER, # Load words
BUTTON_PAGE_PREV, # Move to "< Back"
BUTTON_ENTER, # Leave BIP85
*([BUTTON_PAGE] * 2), # Move to "Back"
*([BUTTON_PAGE] * 3), # Move to "Back"
BUTTON_ENTER, # Exit
]

Expand All @@ -531,7 +531,7 @@ def test_load_xor_not_derive(mocker, amigo, tdata):
from krux.input import BUTTON_ENTER, BUTTON_PAGE, BUTTON_PAGE_PREV

BUTTON_SEQUENCE = [
*([BUTTON_PAGE] * 4),
*([BUTTON_PAGE] * 5),
BUTTON_ENTER,
BUTTON_PAGE,
BUTTON_ENTER,
Expand Down Expand Up @@ -559,7 +559,7 @@ def test_load_xor_from_wallet_menu(mocker, amigo, tdata):
from krux.input import BUTTON_ENTER, BUTTON_PAGE, BUTTON_PAGE_PREV

BTN_SEQUENCE = [
*([BUTTON_PAGE] * 4), # Go to Mnemonic XOR
*([BUTTON_PAGE] * 5), # Go to Mnemonic XOR
BUTTON_ENTER, # Enter Mnemonic XOR
BUTTON_ENTER, # Accept Derive
BUTTON_PAGE_PREV, # Go to back
Expand Down
121 changes: 121 additions & 0 deletions tests/pages/home_pages/test_silent_payment.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
from .test_home import create_ctx, tdata

# Expected SP addresses for tdata.SINGLESIG_12_WORD_KEY
# (mnemonic: "olympic term tissue route sense program under choose bean emerge velvet absurd", mainnet)
EXPECTED_SP_ADDRESS = "sp1qqf702ka9tkw9au8txt9q2z4yv3glhfs2g26msmr2fe96vcmdpegryq6w0c0tw5sctj7fg09t09dl6xjka2zhx06u0wrg4ykedazt3vadyujmlsu7"
EXPECTED_SP_ADDRESS_LABEL_42 = "sp1qqf702ka9tkw9au8txt9q2z4yv3glhfs2g26msmr2fe96vcmdpegryqectr7zkr53sjuur6dwz794aye3gnsmyreergr2a2hmasx07m0k0gsymg4h"


def test_silent_payment_export_back(mocker, amigo, tdata):
"""Test navigating into the SP menu and going back"""
from krux.pages.home_pages.silent_payment import SilentPayment
from krux.wallet import Wallet
from krux.input import BUTTON_ENTER, BUTTON_PAGE_PREV

wallet = Wallet(tdata.SINGLESIG_12_WORD_KEY)
ctx = create_ctx(
mocker,
[
BUTTON_PAGE_PREV, # Move to "< Back"
BUTTON_ENTER, # Press "< Back"
],
wallet,
)
sp = SilentPayment(ctx)
sp.export()

assert ctx.input.wait_for_button.call_count == 2


def test_silent_payment_generate_address(mocker, amigo, tdata):
"""Test generating a silent payment address (no label)"""
from krux.pages.home_pages.silent_payment import SilentPayment
from krux.wallet import Wallet
from krux.input import BUTTON_ENTER, BUTTON_PAGE_PREV

wallet = Wallet(tdata.SINGLESIG_12_WORD_KEY)
ctx = create_ctx(
mocker,
[
BUTTON_ENTER, # SP Address
BUTTON_PAGE_PREV, # Move to "< Back" in address display
BUTTON_ENTER, # Press "< Back"
BUTTON_PAGE_PREV, # Move to "< Back" in main menu
BUTTON_ENTER, # Press "< Back"
],
wallet,
)
sp = SilentPayment(ctx)
sp.export()

# Verify the exact expected address was drawn
found_sp_address = any(
isinstance(arg, str) and EXPECTED_SP_ADDRESS in arg
for call in ctx.display.draw_hcentered_text.call_args_list
for arg in (call[0] if call[0] else [])
)
assert found_sp_address


def test_silent_payment_generate_labeled_address(mocker, amigo, tdata):
"""Test generating a labeled silent payment address"""
from krux.pages.home_pages.silent_payment import SilentPayment
from krux.wallet import Wallet
from krux.input import BUTTON_ENTER, BUTTON_PAGE, BUTTON_PAGE_PREV

mocker.patch(
"krux.pages.utils.Utils.capture_index_from_keypad",
return_value=42,
)
wallet = Wallet(tdata.SINGLESIG_12_WORD_KEY)
ctx = create_ctx(
mocker,
[
BUTTON_PAGE, # Move to "SP Address with Label"
BUTTON_ENTER, # Select "SP Address with Label"
BUTTON_PAGE_PREV, # Move to "< Back" in address display
BUTTON_ENTER, # Press "< Back"
BUTTON_PAGE, # Move to "< Back" in main menu (from index 1 → 2)
BUTTON_ENTER, # Press "< Back"
],
wallet,
)
sp = SilentPayment(ctx)
sp.export()

found_sp_address = any(
isinstance(arg, str) and EXPECTED_SP_ADDRESS_LABEL_42 in arg
for call in ctx.display.draw_hcentered_text.call_args_list
for arg in (call[0] if call[0] else [])
)
assert found_sp_address


def test_silent_payment_generate_address_m5(mocker, m5stickv, tdata):
"""Test generating a silent payment address on m5stickv"""
from krux.pages.home_pages.silent_payment import SilentPayment
from krux.wallet import Wallet
from krux.input import BUTTON_ENTER, BUTTON_PAGE

wallet = Wallet(tdata.SINGLESIG_12_WORD_KEY)
ctx = create_ctx(
mocker,
[
BUTTON_ENTER, # SP Address
BUTTON_PAGE, # Move to "< Back" in address display (1 item + back)
BUTTON_ENTER, # Press "< Back"
BUTTON_PAGE, # Move past "SP Address"
BUTTON_PAGE, # Move past "SP Address with Label" to "< Back"
BUTTON_ENTER, # Press "< Back"
],
wallet,
)
sp = SilentPayment(ctx)
sp.export()

found_sp_address = any(
isinstance(arg, str) and EXPECTED_SP_ADDRESS in arg
for call in ctx.display.draw_hcentered_text.call_args_list
for arg in (call[0] if call[0] else [])
)
assert found_sp_address
52 changes: 52 additions & 0 deletions tests/test_silent_payment.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
from binascii import unhexlify
import pytest
from embit.ec import PrivateKey


BASIC_TEST_VECTORS = [
{
"scan_priv_key": "0f694e068028a717f8af6b9411f9a133dd3565258714cc226594b34db90c1f2c",
"spend_priv_key": "9d6ad855ce3417ef84e836892e5a56392bfba05fa5d97ccea30e266f540e08b3",
"sp_address": "sp1qqgste7k9hx0qftg6qmwlkqtwuy6cycyavzmzj85c6qdfhjdpdjtdgqjuexzk6murw56suy3e0rd2cgqvycxttddwsvgxe2usfpxumr70xc9pkqwv",
},
{
"scan_priv_key": "0000000000000000000000000000000000000000000000000000000000000002",
"spend_priv_key": "0000000000000000000000000000000000000000000000000000000000000001",
"sp_address": "sp1qqtrqglu5g8kh6mfsg4qxa9wq0nv9cauwfwxw70984wkqnw2uwz0w2qnehen8a7wuhwk9tgrzjh8gwzc8q2dlekedec5djk0js9d3d7qhnq6lqj3s",
},
]

LABEL_TEST_VECTORS = {
"scan_priv_key": "0f694e068028a717f8af6b9411f9a133dd3565258714cc226594b34db90c1f2c",
"spend_priv_key": "9d6ad855ce3417ef84e836892e5a56392bfba05fa5d97ccea30e266f540e08b3",
"labels": [2, 3, 1001337],
"addresses": [
"sp1qqgste7k9hx0qftg6qmwlkqtwuy6cycyavzmzj85c6qdfhjdpdjtdgqjex54dmqmmv6rw353tsuqhs99ydvadxzrsy9nuvk74epvee55drs734pqq",
"sp1qqgste7k9hx0qftg6qmwlkqtwuy6cycyavzmzj85c6qdfhjdpdjtdgqsg59z2rppn4qlkx0yz9sdltmjv3j8zgcqadjn4ug98m3t6plujsq9qvu5n",
"sp1qqgste7k9hx0qftg6qmwlkqtwuy6cycyavzmzj85c6qdfhjdpdjtdgq7c2zfthc6x3a5yecwc52nxa0kfd20xuz08zyrjpfw4l2j257yq6qgnkdh5",
],
}


def test_generate_silent_payment_address(m5stickv):
"""Basic SP address derivation against BIP-352 official test vectors"""
from krux.silent_payment import encode_silent_payment_address

for v in BASIC_TEST_VECTORS:
scan_privkey = PrivateKey(unhexlify(v["scan_priv_key"]))
spend_pubkey = PrivateKey(unhexlify(v["spend_priv_key"])).get_public_key()
assert encode_silent_payment_address(scan_privkey, spend_pubkey) == v["sp_address"]


def test_generate_labeled_silent_payment_address(m5stickv):
"""Labeled SP address derivation against BIP-352 official test vectors"""
from krux.silent_payment import encode_silent_payment_address

scan_privkey = PrivateKey(unhexlify(LABEL_TEST_VECTORS["scan_priv_key"]))
spend_pubkey = PrivateKey(unhexlify(LABEL_TEST_VECTORS["spend_priv_key"])).get_public_key()

for label, expected in zip(LABEL_TEST_VECTORS["labels"], LABEL_TEST_VECTORS["addresses"]):
assert encode_silent_payment_address(scan_privkey, spend_pubkey, label=label) == expected

with pytest.raises(ValueError):
encode_silent_payment_address(scan_privkey, spend_pubkey, label=1.0)
Loading