From be8d51b2161a2148cb33849da06447b771110b4c Mon Sep 17 00:00:00 2001 From: Ryan Armstrong Date: Sat, 21 Jan 2023 15:58:54 -0800 Subject: [PATCH 1/8] add error for invalid vanity characters --- nostr/key.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/nostr/key.py b/nostr/key.py index 19eadd8..981bbcf 100644 --- a/nostr/key.py +++ b/nostr/key.py @@ -112,6 +112,15 @@ def mine_vanity_key(prefix: str = None, suffix: str = None) -> PrivateKey: if prefix is None and suffix is None: raise ValueError("Expected at least one of 'prefix' or 'suffix' arguments") + bech32_chars = '023456789acdefghjklmnpqrstuvwxyz' + for pattern in [prefix, suffix]: + if pattern is not None: + missing_chars = [c for c in pattern if c not in bech32_chars] + if len(missing_chars): + raise ValueError( + f'{missing_chars} are not valid characters' + f'for a bech32 key. Valid characters include ({bech32_chars})') + while True: sk = PrivateKey() if prefix is not None and not sk.public_key.bech32()[5:5+len(prefix)] == prefix: From 11b2fbdcdfec711e4170624a42ed7495862ba410 Mon Sep 17 00:00:00 2001 From: Ryan Armstrong Date: Sat, 21 Jan 2023 15:59:28 -0800 Subject: [PATCH 2/8] add test for mine_vanity_key --- test/test_key.py | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/test/test_key.py b/test/test_key.py index 70d8522..69503db 100644 --- a/test/test_key.py +++ b/test/test_key.py @@ -1,4 +1,5 @@ -from nostr.key import PrivateKey +import pytest +from nostr.key import PrivateKey, mine_vanity_key def test_eq_true(): @@ -21,3 +22,21 @@ def test_from_nsec(): pk1 = PrivateKey() pk2 = PrivateKey.from_nsec(pk1.bech32()) assert pk1.raw_secret == pk2.raw_secret + + +def test_mine_vanity_key(): + """ test vanity key mining """ + pattern = '23' + + # mine a valid pattern as prefix + sk = mine_vanity_key(prefix=pattern) + sk.public_key.bech32() + assert sk.public_key.bech32().startswith(f'npub1{pattern}') + + # mine a valid pattern as suffix + sk = mine_vanity_key(suffix=pattern) + assert sk.public_key.bech32().endswith(pattern) + + # mine an invalid pattern + with pytest.raises(ValueError) as e: + mine_vanity_key(prefix='1') From d0f84140d40cc53fa21519b3ac2fd9ce22bbf9b0 Mon Sep 17 00:00:00 2001 From: Ryan Armstrong Date: Sat, 21 Jan 2023 16:05:35 -0800 Subject: [PATCH 3/8] move mine_vanity_key to pow in prep for refactor --- nostr/key.py | 22 ---------------------- nostr/pow.py | 24 ++++++++++++++++++++++++ test/test_key.py | 20 +------------------- 3 files changed, 25 insertions(+), 41 deletions(-) diff --git a/nostr/key.py b/nostr/key.py index 981bbcf..17cf537 100644 --- a/nostr/key.py +++ b/nostr/key.py @@ -108,28 +108,6 @@ def sign_delegation(self, delegation: Delegation) -> None: def __eq__(self, other): return self.raw_secret == other.raw_secret -def mine_vanity_key(prefix: str = None, suffix: str = None) -> PrivateKey: - if prefix is None and suffix is None: - raise ValueError("Expected at least one of 'prefix' or 'suffix' arguments") - - bech32_chars = '023456789acdefghjklmnpqrstuvwxyz' - for pattern in [prefix, suffix]: - if pattern is not None: - missing_chars = [c for c in pattern if c not in bech32_chars] - if len(missing_chars): - raise ValueError( - f'{missing_chars} are not valid characters' - f'for a bech32 key. Valid characters include ({bech32_chars})') - - while True: - sk = PrivateKey() - if prefix is not None and not sk.public_key.bech32()[5:5+len(prefix)] == prefix: - continue - if suffix is not None and not sk.public_key.bech32()[-len(suffix):] == suffix: - continue - break - - return sk ffi = FFI() @ffi.callback("int (unsigned char *, const unsigned char *, const unsigned char *, void *)") diff --git a/nostr/pow.py b/nostr/pow.py index e006288..946734d 100644 --- a/nostr/pow.py +++ b/nostr/pow.py @@ -52,3 +52,27 @@ def mine_key(difficulty: int) -> PrivateKey: num_leading_zero_bits = count_leading_zero_bits(sk.public_key.hex()) return sk + + +def mine_vanity_key(prefix: str = None, suffix: str = None) -> PrivateKey: + if prefix is None and suffix is None: + raise ValueError("Expected at least one of 'prefix' or 'suffix' arguments") + + bech32_chars = '023456789acdefghjklmnpqrstuvwxyz' + for pattern in [prefix, suffix]: + if pattern is not None: + missing_chars = [c for c in pattern if c not in bech32_chars] + if len(missing_chars): + raise ValueError( + f'{missing_chars} are not valid characters' + f'for a bech32 key. Valid characters include ({bech32_chars})') + + while True: + sk = PrivateKey() + if prefix is not None and not sk.public_key.bech32()[5:5+len(prefix)] == prefix: + continue + if suffix is not None and not sk.public_key.bech32()[-len(suffix):] == suffix: + continue + break + + return sk \ No newline at end of file diff --git a/test/test_key.py b/test/test_key.py index 69503db..090cabd 100644 --- a/test/test_key.py +++ b/test/test_key.py @@ -1,5 +1,5 @@ import pytest -from nostr.key import PrivateKey, mine_vanity_key +from nostr.key import PrivateKey def test_eq_true(): @@ -22,21 +22,3 @@ def test_from_nsec(): pk1 = PrivateKey() pk2 = PrivateKey.from_nsec(pk1.bech32()) assert pk1.raw_secret == pk2.raw_secret - - -def test_mine_vanity_key(): - """ test vanity key mining """ - pattern = '23' - - # mine a valid pattern as prefix - sk = mine_vanity_key(prefix=pattern) - sk.public_key.bech32() - assert sk.public_key.bech32().startswith(f'npub1{pattern}') - - # mine a valid pattern as suffix - sk = mine_vanity_key(suffix=pattern) - assert sk.public_key.bech32().endswith(pattern) - - # mine an invalid pattern - with pytest.raises(ValueError) as e: - mine_vanity_key(prefix='1') From 4eabc398d2c13832f7f12bfbab28444640739907 Mon Sep 17 00:00:00 2001 From: Ryan Armstrong Date: Sat, 21 Jan 2023 16:08:30 -0800 Subject: [PATCH 4/8] add pow tests --- test/test_pow.py | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 test/test_pow.py diff --git a/test/test_pow.py b/test/test_pow.py new file mode 100644 index 0000000..647d8c9 --- /dev/null +++ b/test/test_pow.py @@ -0,0 +1,38 @@ +import pytest +from nostr.key import PrivateKey +from nostr.event import EventKind +from nostr import pow +from nostr.pow import mine_event, mine_key, mine_vanity_key + +def test_mine_event(): + """ test mining an event with specific difficulty """ + public_key = PrivateKey().public_key.hex() + difficulty = 8 + event = mine_event(content='test',difficulty=difficulty, + public_key=public_key, kind=EventKind.TEXT_NOTE) + assert pow.count_leading_zero_bits(event.id) >= difficulty + + +def test_mine_key(): + """ test mining a public key with specific difficulty """ + difficulty = 8 + sk = mine_key(difficulty) + assert pow.count_leading_zero_bits(sk.public_key.hex()) >= difficulty + + +def test_mine_vanity_key(): + """ test vanity key mining """ + pattern = '23' + + # mine a valid pattern as prefix + sk = mine_vanity_key(prefix=pattern) + sk.public_key.bech32() + assert sk.public_key.bech32().startswith(f'npub1{pattern}') + + # mine a valid pattern as suffix + sk = mine_vanity_key(suffix=pattern) + assert sk.public_key.bech32().endswith(pattern) + + # mine an invalid pattern + with pytest.raises(ValueError) as e: + mine_vanity_key(prefix='1') From e7aa319e09a5c6ada9485a48578c6e06f96bc639 Mon Sep 17 00:00:00 2001 From: Ryan Armstrong Date: Sat, 21 Jan 2023 16:38:01 -0800 Subject: [PATCH 5/8] refactor pow guesses out of mining loops --- nostr/pow.py | 47 +++++++++++++++++++++++++++++++---------------- 1 file changed, 31 insertions(+), 16 deletions(-) diff --git a/nostr/pow.py b/nostr/pow.py index 946734d..4540017 100644 --- a/nostr/pow.py +++ b/nostr/pow.py @@ -25,40 +25,56 @@ def count_leading_zero_bits(hex_str: str) -> int: return total + +def _guess_event(content: str, public_key: str, kind: int, tags: list=[]) -> Event: + event = Event(public_key, content, kind, tags) + num_leading_zero_bits = count_leading_zero_bits(event.id) + return num_leading_zero_bits, event + + def mine_event(content: str, difficulty: int, public_key: str, kind: int, tags: list=[]) -> Event: all_tags = [["nonce", "1", str(difficulty)]] all_tags.extend(tags) - created_at = int(time.time()) - event_id = Event.compute_id(public_key, created_at, kind, all_tags, content) - num_leading_zero_bits = count_leading_zero_bits(event_id) + num_leading_zero_bits, event = _guess_event(content, public_key, kind, all_tags) attempts = 1 while num_leading_zero_bits < difficulty: attempts += 1 all_tags[0][1] = str(attempts) - created_at = int(time.time()) - event_id = Event.compute_id(public_key, created_at, kind, all_tags, content) - num_leading_zero_bits = count_leading_zero_bits(event_id) + num_leading_zero_bits, event = _guess_event(content, public_key, kind, all_tags) + num_leading_zero_bits = count_leading_zero_bits(event.id) - return Event(public_key, content, created_at, kind, all_tags, event_id) + return event -def mine_key(difficulty: int) -> PrivateKey: + +def _guess_key(): sk = PrivateKey() num_leading_zero_bits = count_leading_zero_bits(sk.public_key.hex()) + return num_leading_zero_bits, sk + + +def mine_key(difficulty: int) -> PrivateKey: + num_leading_zero_bits, sk = _guess_key() while num_leading_zero_bits < difficulty: - sk = PrivateKey() - num_leading_zero_bits = count_leading_zero_bits(sk.public_key.hex()) + num_leading_zero_bits, sk = _guess_key() return sk +bech32_chars = '023456789acdefghjklmnpqrstuvwxyz' + + +def _guess_vanity_key(): + sk = PrivateKey() + vk = sk.public_key.bech32() + return sk, vk + + def mine_vanity_key(prefix: str = None, suffix: str = None) -> PrivateKey: if prefix is None and suffix is None: raise ValueError("Expected at least one of 'prefix' or 'suffix' arguments") - - bech32_chars = '023456789acdefghjklmnpqrstuvwxyz' for pattern in [prefix, suffix]: if pattern is not None: missing_chars = [c for c in pattern if c not in bech32_chars] @@ -66,12 +82,11 @@ def mine_vanity_key(prefix: str = None, suffix: str = None) -> PrivateKey: raise ValueError( f'{missing_chars} are not valid characters' f'for a bech32 key. Valid characters include ({bech32_chars})') - while True: - sk = PrivateKey() - if prefix is not None and not sk.public_key.bech32()[5:5+len(prefix)] == prefix: + sk, vk = _guess_vanity_key() + if prefix is not None and not vk[5:5+len(prefix)] == prefix: continue - if suffix is not None and not sk.public_key.bech32()[-len(suffix):] == suffix: + if suffix is not None and not vk[-len(suffix):] == suffix: continue break From 43590bcda90fdd2d9aaf3660b5efc21013fab724 Mon Sep 17 00:00:00 2001 From: Ryan Armstrong Date: Sat, 21 Jan 2023 16:38:29 -0800 Subject: [PATCH 6/8] add estimators for mining time --- nostr/pow.py | 43 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/nostr/pow.py b/nostr/pow.py index 4540017..3b4d971 100644 --- a/nostr/pow.py +++ b/nostr/pow.py @@ -90,4 +90,45 @@ def mine_vanity_key(prefix: str = None, suffix: str = None) -> PrivateKey: continue break - return sk \ No newline at end of file + return sk + + +def get_hashrate(operation, n_guesses: int = 1e4, **operation_kwargs): + n_guesses = int(n_guesses) + def _time_operation(): + start = time.perf_counter() + operation(**operation_kwargs) + end = time.perf_counter() + return end - start + t = sum([_time_operation() for _ in range(n_guesses)]) / n_guesses + hashrate = 1 / t + return hashrate + + +def expected_time(n_pattern: int, n_options: int, hashrate: float): + p = 1 / n_options + expected_guesses = 1 / (p ** n_pattern) + time_seconds = expected_guesses / hashrate + return time_seconds + + +def estimate_event_time(content: str, difficulty: int, public_key: str, + kind: int, tags: list=[]) -> float: + all_tags = [["nonce", "1", str(difficulty)]] + all_tags.extend(tags) + hashrate = get_hashrate(_guess_event, + content=content, + public_key=public_key, + kind=kind, + tags=all_tags) + return expected_time(difficulty, 2, hashrate) + + +def estimate_vanity_time(n_pattern: int): + hashrate = get_hashrate(_guess_vanity_key) + return expected_time(n_pattern, len(bech32_chars), hashrate) + + +def estimate_key_time(difficulty: int) -> float: + hashrate = get_hashrate(_guess_key) + return expected_time(difficulty, 2, hashrate) From 41efb3737d86b86048113a39fee6fb5ea79fdbe8 Mon Sep 17 00:00:00 2001 From: Ryan Armstrong Date: Sat, 21 Jan 2023 16:40:57 -0800 Subject: [PATCH 7/8] add tests for pow estimation --- test/test_pow.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/test/test_pow.py b/test/test_pow.py index 647d8c9..3376423 100644 --- a/test/test_pow.py +++ b/test/test_pow.py @@ -20,6 +20,19 @@ def test_mine_key(): assert pow.count_leading_zero_bits(sk.public_key.hex()) >= difficulty +def test_time_estimates(): + """ test functions to estimate POW time """ + public_key = PrivateKey().public_key.hex() + + # test successful run of all estimators + pow.estimate_event_time('test', + 8, + public_key, + EventKind.TEXT_NOTE) + pow.estimate_key_time(8) + pow.estimate_vanity_time(8) + + def test_mine_vanity_key(): """ test vanity key mining """ pattern = '23' @@ -36,3 +49,16 @@ def test_mine_vanity_key(): # mine an invalid pattern with pytest.raises(ValueError) as e: mine_vanity_key(prefix='1') + + +def test_expected_pow_times(): + """ sense check expected calculations using known patterns """ + # assume constant hashrate + hashrate = 10000 + + # calculate expected time to get a 32-difficulty bit key + e1 = pow.expected_time(32, 2, hashrate) + + # caluclate 8 leading 0 hex key, which is equivalent + e2 = pow.expected_time(8, 16, hashrate) + assert e1 == e2 \ No newline at end of file From 2c7ca931db218b59823774055f29519add427a5a Mon Sep 17 00:00:00 2001 From: Ryan Armstrong Date: Sat, 21 Jan 2023 17:16:18 -0800 Subject: [PATCH 8/8] cleaning up character error --- nostr/pow.py | 6 +++--- test/test_pow.py | 5 ++++- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/nostr/pow.py b/nostr/pow.py index 3b4d971..9fe3ac1 100644 --- a/nostr/pow.py +++ b/nostr/pow.py @@ -78,10 +78,10 @@ def mine_vanity_key(prefix: str = None, suffix: str = None) -> PrivateKey: for pattern in [prefix, suffix]: if pattern is not None: missing_chars = [c for c in pattern if c not in bech32_chars] - if len(missing_chars): + if len(missing_chars) > 0: raise ValueError( - f'{missing_chars} are not valid characters' - f'for a bech32 key. Valid characters include ({bech32_chars})') + f"{missing_chars} not in valid list of bech32 chars: ({bech32_chars})" + ) while True: sk, vk = _guess_vanity_key() if prefix is not None and not vk[5:5+len(prefix)] == prefix: diff --git a/test/test_pow.py b/test/test_pow.py index 3376423..3cf036b 100644 --- a/test/test_pow.py +++ b/test/test_pow.py @@ -47,8 +47,11 @@ def test_mine_vanity_key(): assert sk.public_key.bech32().endswith(pattern) # mine an invalid pattern + pattern = '1' + expected_error = "not in valid list of bech32 chars" with pytest.raises(ValueError) as e: - mine_vanity_key(prefix='1') + sk = mine_vanity_key(prefix=pattern) + assert expected_error in str(e) def test_expected_pow_times():