From e322b4338c96012b81436e79bd57df6062a80ba4 Mon Sep 17 00:00:00 2001 From: The MMGen Project Date: Thu, 4 Jun 2026 10:41:42 +0000 Subject: [PATCH] Nostr key/address generation Examples: # Generate 10 Nostr addresses from your default wallet: $ mmgen-addrgen --coin=nostr 1-10 # Generate 10 Nostr key/address pairs from your default wallet: $ mmgen-keygen --coin=nostr 1-10 # Generate a random Nostr key/address pair: $ mmgen-tool --coin=nostr randpair # Convert a Nostr address to a hexadecimal pubkey: $ mmgen-tool --coin=nostr addr2pubhex npub1r7m96hpfeegjcj8un2wjgkw4rwv36x9mtmtek5duv3uq29tcwqgq93nwkz # Convert a Nostr hexadecimal pubkey to an address: $ mmgen-tool --coin=nostr pubhex2addr 1fb65d5c29ce512c48fc9a9d2459d51b991d18bb5ed79b51bc64780515787010 Testing: $ test/modtest.py -v nostr $ test/cmdtest.py --coin=nostr ref3_addr $ test/tooltest2.py --coin=nostr --- mmgen/addr.py | 2 ++ mmgen/addrgen.py | 1 + mmgen/bip_hd/__init__.py | 5 +-- mmgen/bip_hd/chainparams.py | 2 +- mmgen/data/release_date | 2 +- mmgen/data/version | 2 +- mmgen/main_tool.py | 1 + mmgen/proto/nostr/params.py | 63 +++++++++++++++++++++++++++++++++++ mmgen/proto/xchain/addrgen.py | 6 ++++ mmgen/protocol.py | 1 + mmgen/tool/coin.py | 4 +++ setup.cfg | 1 + test/cmdtest_d/ref_3seed.py | 28 +++++++++++++++- test/modtest_d/nostr.py | 63 +++++++++++++++++++++++++++++++++++ test/test-release.d/cfg.sh | 2 ++ test/tooltest2_d/data.py | 24 +++++++++++++ 16 files changed, 201 insertions(+), 6 deletions(-) create mode 100755 mmgen/proto/nostr/params.py create mode 100755 test/modtest_d/nostr.py diff --git a/mmgen/addr.py b/mmgen/addr.py index 7aef97cc..c47ec58e 100755 --- a/mmgen/addr.py +++ b/mmgen/addr.py @@ -45,12 +45,14 @@ class MMGenAddrType(HiliteStr, InitErrors, MMGenObject): desc = ImmutableAttr(str) pubhash_fmts = ('p2pkh', 'bech32', 'ethereum') + pubkey_fmts = ('bech32pk') mmtypes = { 'L': ati('legacy', 'std', False,'p2pkh', 'p2pkh', 'wif', (), 'Legacy uncompressed address'), 'C': ati('compressed','std', True, 'p2pkh', 'p2pkh', 'wif', (), 'Compressed P2PKH address'), 'S': ati('segwit', 'std', True, 'segwit', 'p2sh', 'wif', (), 'Segwit P2SH-P2WPKH address'), 'B': ati('bech32', 'std', True, 'bech32', 'bech32', 'wif', (), 'Native Segwit (Bech32) address'), 'X': ati('bech32x', 'std', True, 'p2pkh', 'bech32', 'wif', (), 'Cross-chain Bech32 address'), + 'K': ati('bech32pk', 'std', True, 'bech32pk','bech32', 'wif', (), 'Cross-chain pubkey Bech32 address'), 'E': ati('ethereum', 'std', False,'ethereum','p2pkh', 'privkey', ('wallet_passwd',),'Ethereum address'), 'Z': ati('zcash_z','zcash_z',False,'zcash_z', 'zcash_z', 'wif', ('viewkey',), 'Zcash z-address'), 'M': ati('monero', 'monero', False,'monero', 'monero', 'spendkey',('viewkey','wallet_passwd'),'Monero address')} diff --git a/mmgen/addrgen.py b/mmgen/addrgen.py index bc0543f9..3834d19d 100755 --- a/mmgen/addrgen.py +++ b/mmgen/addrgen.py @@ -60,6 +60,7 @@ def AddrGenerator(cfg, proto, addr_type): 'segwit': 'btc', 'bech32': 'btc', 'bech32x': 'xchain', + 'bech32pk': 'xchain', 'monero': 'xmr', 'ethereum': 'eth', 'zcash_z': 'zec'} diff --git a/mmgen/bip_hd/__init__.py b/mmgen/bip_hd/__init__.py index a4731194..3f954e12 100644 --- a/mmgen/bip_hd/__init__.py +++ b/mmgen/bip_hd/__init__.py @@ -136,7 +136,7 @@ def check_privkey(key_int): class BipHDConfig(Lockable): - supported_coins = ('btc', 'eth', 'doge', 'ltc', 'bch', 'rune') + supported_coins = ('btc', 'eth', 'doge', 'ltc', 'bch', 'rune', 'nostr') def __init__(self, base_cfg, coin, *, network, addr_type, from_path, no_path_checks): @@ -149,7 +149,8 @@ class BipHDConfig(Lockable): 'coin': coin, 'network': network, 'type': addr_type or None, - 'quiet': True}) + 'quiet': True}, + need_amt = False) dfl_type = base_cfg._proto.dfl_mmtype addr_type = MMGenAddrType( diff --git a/mmgen/bip_hd/chainparams.py b/mmgen/bip_hd/chainparams.py index 0461e06d..40fd04b8 100644 --- a/mmgen/bip_hd/chainparams.py +++ b/mmgen/bip_hd/chainparams.py @@ -111,6 +111,7 @@ IDX CHAIN CURVE NW ADDR_CLS VB_PRV VB_PUB VB_WIF VB_ADDR DFL_PA 996 OKT x m Okex x x - - x OKChain Token 1023 ONE x m One x x - - x HARMONY-ONE (Legacy) 1024 ONT nist m Neo x x - spec x Ontology +1237 NOSTR x m BechPK x x - h:npub x Nostr 1729 XTZ edw m Xtz x x - spec 0'/0' Tezos 1815 ADA khol m AdaByronIcarus 0f4331d4 x - spec x Cardano 9000 AVAX x m AvaxXChain x x - - x Avalanche @@ -958,7 +959,6 @@ IDX CHAIN NAME 1170 HOO - Hoo Smart Chain 1234 ALPH - Alephium 1236 --- - Masca -1237 --- - Nostr 1280 --- - Kudos Setler 1284 GLMR - Moonbeam 1285 MOVR - Moonriver diff --git a/mmgen/data/release_date b/mmgen/data/release_date index 6d4c4277..059e8286 100644 --- a/mmgen/data/release_date +++ b/mmgen/data/release_date @@ -1 +1 @@ -May 2026 +June 2026 diff --git a/mmgen/data/version b/mmgen/data/version index 6dbf8228..6d8802d2 100644 --- a/mmgen/data/version +++ b/mmgen/data/version @@ -1 +1 @@ -16.2.dev1 +16.2.dev2 diff --git a/mmgen/main_tool.py b/mmgen/main_tool.py index 383359b7..4d4f4dc2 100755 --- a/mmgen/main_tool.py +++ b/mmgen/main_tool.py @@ -113,6 +113,7 @@ mods = { ), 'coin': ( 'addr2pubhash', + 'addr2pubhex', 'addr2scriptpubkey', 'eth_checksummed_addr', 'hex2wif', diff --git a/mmgen/proto/nostr/params.py b/mmgen/proto/nostr/params.py new file mode 100755 index 00000000..ceba5fd5 --- /dev/null +++ b/mmgen/proto/nostr/params.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +# +# MMGen Wallet, a terminal-based cryptocurrency wallet +# Copyright (C)2013-2026 The MMGen Project +# Licensed under the GNU General Public License, Version 3: +# https://www.gnu.org/licenses +# Public project repositories: +# https://github.com/mmgen/mmgen-wallet +# https://gitlab.com/mmgen/mmgen-wallet + +""" +proto.nostr.params: Nostr protocol +""" + +from ...protocol import CoinProtocol, decoded_wif, decoded_addr, _nw +from ...addr import CoinAddr +from ...contrib import bech32 + +class mainnet(CoinProtocol.Secp256k1): + mod_clsname = 'Nostr' + network_names = _nw('mainnet', None, None) + mmtypes = ('K',) + dfl_mmtype = 'K' + base_proto = 'Nostr' + base_proto_coin = 'NOSTR' + base_coin = 'NOSTR' + bech32_hrp = 'npub' + bech32_hrp_prv = 'nsec' + + def encode_wif(self, privbytes, pubkey_type, *, compressed): # input is preprocessed + return bech32.bech32_encode( + hrp = self.bech32_hrp_prv, + data = bech32.convertbits(privbytes, 8, 5)) + + def decode_wif(self, wif): + hrp, data = bech32.bech32_decode(wif) + assert hrp == self.bech32_hrp_prv, f'Bech32 HRP does not match! ({hrp} != {self.bech32_hrp_prv})' + return decoded_wif( + sec = bytes(bech32.convertbits(data, 5, 8, False)), + pubkey_type = None, + compressed = True) if data else False + + def decode_addr(self, addr): + hrp, data = bech32.bech32_decode(addr) + assert hrp == self.bech32_hrp, f'Bech32 HRP does not match! ({hrp} != {self.bech32_hrp})' + return decoded_addr( + bytes(bech32.convertbits(data, 5, 8, False)), + ver_bytes = None, + fmt = 'bech32pk') if data else False + + def encode_addr_bech32pk(self, pubkey): + match len(pubkey): + case 33: + assert pubkey[0] in (2, 3), f'{pubkey[0]}: invalid first byte for {self.name} protocol public key' + case 32: + pass + case x: + raise ValueError(f'{x}: invalid public key length for {self.name} protocol') + return CoinAddr( + self, + bech32.bech32_encode( + hrp = self.bech32_hrp, + data = bech32.convertbits(pubkey[-32:], 8, 5))) diff --git a/mmgen/proto/xchain/addrgen.py b/mmgen/proto/xchain/addrgen.py index 618a4344..4776e2ec 100755 --- a/mmgen/proto/xchain/addrgen.py +++ b/mmgen/proto/xchain/addrgen.py @@ -20,3 +20,9 @@ class bech32x(addr_generator.base): @check_data def to_addr(self, data): return self.proto.encode_addr_bech32x(hash160(data.pubkey)) + +class bech32pk(addr_generator.base): + + @check_data + def to_addr(self, data): + return self.proto.encode_addr_bech32pk(data.pubkey) diff --git a/mmgen/protocol.py b/mmgen/protocol.py index ad130a87..f00927b3 100755 --- a/mmgen/protocol.py +++ b/mmgen/protocol.py @@ -47,6 +47,7 @@ class CoinProtocol(MMGenObject): 'etc': proto_info('EthereumClassic', 4), 'zec': proto_info('Zcash', 2), 'xmr': proto_info('Monero', 5), + 'nostr': proto_info('Nostr', 4), 'rune': proto_info('THORChain', 4)} class Base(Lockable): diff --git a/mmgen/tool/coin.py b/mmgen/tool/coin.py index b980204f..ba786c0c 100755 --- a/mmgen/tool/coin.py +++ b/mmgen/tool/coin.py @@ -183,6 +183,10 @@ class tool_cmd(tool_cmd_base): die(2, f'{ap.fmt} addresses cannot be converted to {ptype}') return ap.bytes.hex() + def addr2pubhex(self, addr: 'sstr'): + "convert coin address to public key" + return self._addr2pub(addr, ptype='pubkey') + def addr2pubhash(self, addr: 'sstr'): "convert coin address to public key hash" return self._addr2pub(addr, ptype='pubhash') diff --git a/setup.cfg b/setup.cfg index e6eefbb4..b06bce52 100644 --- a/setup.cfg +++ b/setup.cfg @@ -96,6 +96,7 @@ packages = mmgen.proto.eth.tx mmgen.proto.eth.tw mmgen.proto.ltc + mmgen.proto.nostr mmgen.proto.rune mmgen.proto.rune.rpc mmgen.proto.rune.tw diff --git a/test/cmdtest_d/ref_3seed.py b/test/cmdtest_d/ref_3seed.py index f5385e67..ead943f2 100755 --- a/test/cmdtest_d/ref_3seed.py +++ b/test/cmdtest_d/ref_3seed.py @@ -214,7 +214,7 @@ class CmdTestRef3Seed(CmdTestBase, CmdTestShared): class CmdTestRef3Addr(CmdTestRef3Seed): 'generated reference address and key-address files for 128-, 192- and 256-bit seeds' - networks = ('btc', 'btc_tn', 'ltc', 'ltc_tn', 'bch', 'bch_tn') + networks = ('btc', 'btc_tn', 'ltc', 'ltc_tn', 'bch', 'bch_tn', 'nostr') passthru_opts = ('coin', 'testnet', 'cashaddr') tmpdir_nums = [26, 27, 28] shared_deps = ['mmdat', pwfile] @@ -330,6 +330,24 @@ class CmdTestRef3Addr(CmdTestRef3Seed): 'btc': ('D0DD BDE3 87BE 15AE', '7552 D70C AAB8 DEAA'), 'ltc': ('74A0 7DD5 963B 6326', '2CDA A007 4B9F E9A5'), }, + 'refaddrgen_bech32pk_1': { + 'nostr': ('005B CFEC B290 32FE',), + }, + 'refaddrgen_bech32pk_2': { + 'nostr': ('9BB5 F044 6048 2007',), + }, + 'refaddrgen_bech32pk_3': { + 'nostr': ('652F D99A 174F 9055',), + }, + 'refkeyaddrgen_bech32pk_1': { + 'nostr': ('84B5 4714 A15C D72E',), + }, + 'refkeyaddrgen_bech32pk_2': { + 'nostr': ('3802 94B3 27D3 F42A',), + }, + 'refkeyaddrgen_bech32pk_3': { + 'nostr': ('9A54 4897 DC3D 80D0',), + }, } cmd_group = ( @@ -338,10 +356,12 @@ class CmdTestRef3Addr(CmdTestRef3Seed): ('refaddrgen_compressed', 'new refwallet addr chksum (compressed)'), ('refaddrgen_segwit', 'new refwallet addr chksum (segwit)'), ('refaddrgen_bech32', 'new refwallet addr chksum (bech32)'), + ('refaddrgen_bech32pk', 'new refwallet addr chksum (bech32pk)'), ('refkeyaddrgen_legacy', 'new refwallet key-addr chksum (uncompressed)'), ('refkeyaddrgen_compressed', 'new refwallet key-addr chksum (compressed)'), ('refkeyaddrgen_segwit', 'new refwallet key-addr chksum (segwit)'), ('refkeyaddrgen_bech32', 'new refwallet key-addr chksum (bech32)'), + ('refkeyaddrgen_bech32pk', 'new refwallet key-addr chksum (bech32pk)'), ) def call_addrgen(self, mmtype, name='addrgen'): @@ -362,6 +382,9 @@ class CmdTestRef3Addr(CmdTestRef3Seed): def refaddrgen_bech32(self): return self.call_addrgen('bech32') + def refaddrgen_bech32pk(self): + return self.call_addrgen('bech32pk') + def refkeyaddrgen_legacy(self): return self.call_addrgen('legacy', 'keyaddrgen') @@ -374,6 +397,9 @@ class CmdTestRef3Addr(CmdTestRef3Seed): def refkeyaddrgen_bech32(self): return self.call_addrgen('bech32', 'keyaddrgen') + def refkeyaddrgen_bech32pk(self): + return self.call_addrgen('bech32pk', 'keyaddrgen') + class CmdTestRef3Passwd(CmdTestRef3Seed): 'generated reference password files for 128-, 192- and 256-bit seeds' tmpdir_nums = [26, 27, 28] diff --git a/test/modtest_d/nostr.py b/test/modtest_d/nostr.py new file mode 100755 index 00000000..f836cd47 --- /dev/null +++ b/test/modtest_d/nostr.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 + +""" +test.modtest_d.nostr: Nostr unit test for the MMGen suite +""" + +from collections import namedtuple + +from mmgen.bip_hd import BipHDNode, MasterNode +from mmgen.bip39 import bip39 + +from ..include.common import cfg, vmsg + +# Source: https://nostr-nips.com/nip-06 +nv = namedtuple('nostr_test_vector', 'mnemonic privhex nsec pubhex npub') +vecs = [ + nv( 'leader monkey parrot ring guide accident before fence cannon height naive bean', + '7f7ff03d123792d6ac594bfa67bf6d0c0ab55b6b1fdb6249303fe861f1ccba9a', + 'nsec10allq0gjx7fddtzef0ax00mdps9t2kmtrldkyjfs8l5xruwvh2dq0lhhkp', + '17162c921dc4d2518f9a101db33695df1afb56ab82f5ff3e5da6eec3ca5cd917', + 'npub1zutzeysacnf9rru6zqwmxd54mud0k44tst6l70ja5mhv8jjumytsd2x7nu'), + nv( 'what bleak badge arrange retreat wolf trade produce cricket blur garlic valid ' + 'proud rude strong choose busy staff weather area salt hollow arm fade', + 'c15d739894c81a2fcfd3a2df85a0d2c0dbc47a280d092799f144d73d7ae78add', + 'nsec1c9wh8xy5eqdzln7n5t0ctgxjcrdug73gp5yj0x03gntn67h83twssdfhel', + 'd41b22899549e1f3d335a31002cfd382174006e166d3e658e3a5eecdb6463573', + 'npub16sdj9zv4f8sl85e45vgq9n7nsgt5qphpvmf7vk8r5hhvmdjxx4es8rq74h')] + +path = "m/44'/1237'/0'/0/0" + +class unit_tests: + + def path(self, name, ut): + for v in vecs: + vmsg(f'mnemonic: {v.mnemonic}') + seed = bip39().generate_seed(v.mnemonic.split()) + res = BipHDNode.from_path(cfg, seed, path, coin='nostr') + xprv = res.key_extended(public=False) + xpub = res.key_extended(public=True) + vmsg(f'prv: {xprv.key.hex()}') + vmsg(f'pub: {xpub.key.hex()}') + vmsg(f'addr: {res.address}') + vmsg(f'wif: {res.privkey.wif}') + assert res.privkey.hex() == v.privhex + assert res.address.bytes.hex() == v.pubhex + assert xprv.key.hex() == v.privhex + assert xpub.key.hex()[2:] == v.pubhex + assert res.address == v.npub + assert res.privkey.wif == v.nsec + vmsg('') + return True + + def derive(self, name, ut): + for v in vecs: + vmsg(f'mnemonic: {v.mnemonic}') + seed = bip39().generate_seed(v.mnemonic.split()) + res = MasterNode(cfg, seed).to_chain(idx=0, coin='nostr').derive_private(0) + vmsg(f'addr: {res.address}') + vmsg(f'wif: {res.privkey.wif}') + assert res.address == v.npub + assert res.privkey.wif == v.nsec + vmsg('') + return True diff --git a/test/test-release.d/cfg.sh b/test/test-release.d/cfg.sh index fdbb0637..45ae9391 100755 --- a/test/test-release.d/cfg.sh +++ b/test/test-release.d/cfg.sh @@ -135,6 +135,7 @@ init_tests() { - $gentest_py --coin=xmr --use-internal-keccak-module 1 $rounds10x - $gentest_py --coin=zec 1 $rounds10x - $gentest_py --coin=zec --type=zcash_z 1 $rounds10x + - $gentest_py --coin=nostr 1 $rounds10x - # verification against external libraries and tools: - # pycoin - $gentest_py --all-coins --type=legacy 1:pycoin $rounds @@ -321,6 +322,7 @@ init_tests() { e $tooltest2_py --coin=eth --token=mm1 --testnet=1 e $tooltest2_py --coin=etc t $tooltest2_py --coin=rune + t $tooltest2_py --coin=nostr - $tooltest2_py --fork # run once with --fork so commands are actually executed " [ "$SKIP_ALT_DEP" ] && t_tool2_skip='a e t' diff --git a/test/tooltest2_d/data.py b/test/tooltest2_d/data.py index e67b1bd0..2e4c3099 100755 --- a/test/tooltest2_d/data.py +++ b/test/tooltest2_d/data.py @@ -113,6 +113,16 @@ zec_pubhex1 = 'e6a4edbff547f21bcc2a825b6cf70f06e266a452d2da9d6dc5c1da3d99d7e996f rune_addr1 = 'thor1xptlvmwaymaxa7pxkr2u5pn7c0508stcr9tw2z' +nostr_privhex1 = 'c15d739894c81a2fcfd3a2df85a0d2c0dbc47a280d092799f144d73d7ae78add' +nostr_pubhex1 = 'd41b22899549e1f3d335a31002cfd382174006e166d3e658e3a5eecdb6463573' +nostr_wif1 = 'nsec1c9wh8xy5eqdzln7n5t0ctgxjcrdug73gp5yj0x03gntn67h83twssdfhel' +nostr_addr1 = 'npub16sdj9zv4f8sl85e45vgq9n7nsgt5qphpvmf7vk8r5hhvmdjxx4es8rq74h' + +nostr_privhex2 = '7f7ff03d123792d6ac594bfa67bf6d0c0ab55b6b1fdb6249303fe861f1ccba9a' +nostr_pubhex2 = '17162c921dc4d2518f9a101db33695df1afb56ab82f5ff3e5da6eec3ca5cd917' +nostr_wif2 = 'nsec10allq0gjx7fddtzef0ax00mdps9t2kmtrldkyjfs8l5xruwvh2dq0lhhkp' +nostr_addr2 = 'npub1zutzeysacnf9rru6zqwmxd54mud0k44tst6l70ja5mhv8jjumytsd2x7nu' + tests = { 'Mnemonic': { 'hex2mn': ( @@ -414,6 +424,12 @@ tests = { ([rune_addr1], pubhash2), ], }, + 'addr2pubhex': { + 'nostr_mainnet': [ + ([nostr_addr1], nostr_pubhex1), + ([nostr_addr2], nostr_pubhex2), + ], + }, 'eth_checksummed_addr': { 'eth_mainnet': [ (['00a329c0648769a73afac7f9381e08fb43dbea72'], '00a329c0648769A73afAc7F9381E08FB43dBEA72'), @@ -462,6 +478,10 @@ tests = { ([privhex7], [btc_wif2, btc_addr3], ['--type=segwit'], 'segwit'), ([privhex7], [btc_wif2, btc_addr4], ['--type=bech32'], 'bech32'), ], + 'nostr_mainnet': [ + ([nostr_privhex1], [nostr_wif1, nostr_addr1], None, 'bech32pk'), + ([nostr_privhex2], [nostr_wif2, nostr_addr2], None, 'bech32pk'), + ], }, 'privhex2addr': { 'btc_mainnet': [ @@ -529,6 +549,10 @@ tests = { 'zec_mainnet': [ ([zec_pubhex1], zec_addr1, ['--type=zcash_z'], 'zcash_z'), ], + 'nostr_mainnet': [ + ([nostr_pubhex1], nostr_addr1, None, 'bech32pk'), + ([nostr_pubhex2], nostr_addr2, None, 'bech32pk'), + ], }, 'pubhex2redeem_script': { 'btc_mainnet': [