From 9e30aa4d6db08c75448e198ddf3e5d098402a9f1 Mon Sep 17 00:00:00 2001 From: m0wer Date: Fri, 15 May 2026 05:01:05 +0200 Subject: [PATCH] fix(secp256k1): support libsecp256k1 v0.7 seckey symbol rename libsecp256k1 v0.7.0 renamed secp256k1_ec_privkey_tweak_add and secp256k1_ec_privkey_negate to their secp256k1_ec_seckey_* counterparts. Loading the modern library against the old code path failed with an AttributeError on import. Probe for the modern symbol first and fall back to the legacy one, then expose the resolved function under both names on the library handle so downstream code (including bitcointx.core.key) keeps working regardless of which spelling it uses. Add a unit-test module that exercises _add_function_definitions against a fake CDLL exposing legacy-only, modern-only, or both symbol sets, so the compatibility shim is covered without requiring a real libsecp256k1. Closes Simplexum/python-bitcointx#88. --- bitcointx/core/secp256k1.py | 29 +++++- bitcointx/tests/test_secp256k1_compat.py | 124 +++++++++++++++++++++++ 2 files changed, 148 insertions(+), 5 deletions(-) create mode 100644 bitcointx/tests/test_secp256k1_compat.py diff --git a/bitcointx/core/secp256k1.py b/bitcointx/core/secp256k1.py index f5867607..b8be70f0 100644 --- a/bitcointx/core/secp256k1.py +++ b/bitcointx/core/secp256k1.py @@ -199,8 +199,19 @@ def _add_function_definitions(lib: ctypes.CDLL) -> Secp256k1_Capabilities: lib.secp256k1_ec_pubkey_tweak_add.restype = ctypes.c_int lib.secp256k1_ec_pubkey_tweak_add.argtypes = [ctypes.c_void_p, ctypes.c_char_p, ctypes.c_char_p] - lib.secp256k1_ec_privkey_tweak_add.restype = ctypes.c_int - lib.secp256k1_ec_privkey_tweak_add.argtypes = [ctypes.c_void_p, ctypes.c_char_p, ctypes.c_char_p] + # libsecp256k1 v0.7.0 renamed `secp256k1_ec_privkey_*` to + # `secp256k1_ec_seckey_*`. Expose the function under both names on the + # library handle so callers can use either spelling regardless of which + # symbol the underlying library exports. + # See: https://github.com/Simplexum/python-bitcointx/issues/88 + if getattr(lib, 'secp256k1_ec_seckey_tweak_add', None): + _tweak_add = lib.secp256k1_ec_seckey_tweak_add + else: + _tweak_add = lib.secp256k1_ec_privkey_tweak_add + _tweak_add.restype = ctypes.c_int + _tweak_add.argtypes = [ctypes.c_void_p, ctypes.c_char_p, ctypes.c_char_p] + lib.secp256k1_ec_privkey_tweak_add = _tweak_add # type: ignore[attr-defined] + lib.secp256k1_ec_seckey_tweak_add = _tweak_add # type: ignore[attr-defined] lib.secp256k1_ec_pubkey_serialize.restype = ctypes.c_int lib.secp256k1_ec_pubkey_serialize.argtypes = [ctypes.c_void_p, ctypes.c_char_p, ctypes.POINTER(ctypes.c_size_t), ctypes.c_char_p, ctypes.c_uint] @@ -210,10 +221,18 @@ def _add_function_definitions(lib: ctypes.CDLL) -> Secp256k1_Capabilities: lib.secp256k1_ec_pubkey_negate.restype = ctypes.c_int lib.secp256k1_ec_pubkey_negate.argtypes = [ctypes.c_void_p, ctypes.c_char_p] - if getattr(lib, 'secp256k1_ec_privkey_negate', None): + # libsecp256k1 v0.7.0 renamed `secp256k1_ec_privkey_negate` to + # `secp256k1_ec_seckey_negate`. Expose under both names for compatibility. + _negate = ( + getattr(lib, 'secp256k1_ec_seckey_negate', None) + or getattr(lib, 'secp256k1_ec_privkey_negate', None) + ) + if _negate is not None: has_privkey_negate = True - lib.secp256k1_ec_privkey_negate.restype = ctypes.c_int - lib.secp256k1_ec_privkey_negate.argtypes = [ctypes.c_void_p, ctypes.c_char_p] + _negate.restype = ctypes.c_int + _negate.argtypes = [ctypes.c_void_p, ctypes.c_char_p] + lib.secp256k1_ec_privkey_negate = _negate # type: ignore[attr-defined] + lib.secp256k1_ec_seckey_negate = _negate # type: ignore[attr-defined] lib.secp256k1_ec_pubkey_combine.restype = ctypes.c_int lib.secp256k1_ec_pubkey_combine.argtypes = [ctypes.c_void_p, ctypes.c_char_p, ctypes.POINTER(ctypes.c_char_p), ctypes.c_int] diff --git a/bitcointx/tests/test_secp256k1_compat.py b/bitcointx/tests/test_secp256k1_compat.py new file mode 100644 index 00000000..7b1c1c8e --- /dev/null +++ b/bitcointx/tests/test_secp256k1_compat.py @@ -0,0 +1,124 @@ +# Copyright (C) 2026 The python-bitcointx developers +# +# This file is part of python-bitcointx. +# +# It is subject to the license terms in the LICENSE file found in the top-level +# directory of this distribution. + +"""Unit tests for the libsecp256k1 v0.7 compatibility shim. + +These tests use a fake ``ctypes.CDLL``-like object so that we can verify the +function-binding logic in :func:`_add_function_definitions` independently of +which libsecp256k1 happens to be installed on the test runner. See +https://github.com/Simplexum/python-bitcointx/issues/88. +""" + +import ctypes +import unittest +from typing import Any + +from bitcointx.core.secp256k1 import _add_function_definitions + + +class _FakeFunc: + """Minimal stand-in for a ctypes-bound C function.""" + + def __init__(self, name: str) -> None: + self.name = name + self.restype: Any = None + self.argtypes: Any = None + + +class _FakeLib: + """Fake CDLL exporting only the symbols listed in ``exports``. + + Attribute access to any other name returns ``None`` from :func:`getattr` + (mirroring how ``getattr(lib, 'unknown', None)`` behaves in the real code), + and a direct attribute access raises :class:`AttributeError`. + """ + + def __init__(self, exports: set[str]) -> None: + self._exports = exports + for name in exports: + object.__setattr__(self, name, _FakeFunc(name)) + + def __getattr__(self, name: str) -> Any: + raise AttributeError(name) + + +# Minimum set of unconditional symbols that ``_add_function_definitions`` +# always assigns argtypes/restype to, regardless of libsecp256k1 version. +_CORE_SYMBOLS = { + "secp256k1_context_create", + "secp256k1_context_randomize", + "secp256k1_context_set_illegal_callback", + "secp256k1_ecdsa_sign", + "secp256k1_ecdsa_signature_serialize_der", + "secp256k1_ec_pubkey_create", + "secp256k1_ec_seckey_verify", + "secp256k1_ecdsa_signature_parse_der", + "secp256k1_ecdsa_signature_parse_compact", + "secp256k1_ecdsa_signature_normalize", + "secp256k1_ecdsa_verify", + "secp256k1_ec_pubkey_parse", + "secp256k1_ec_pubkey_tweak_add", + "secp256k1_ec_pubkey_serialize", + "secp256k1_ec_pubkey_combine", +} + + +class Test_Secp256k1_v07_Compat(unittest.TestCase): + def test_legacy_privkey_symbol(self) -> None: + """Pre-v0.7 libsecp256k1 only exports `secp256k1_ec_privkey_tweak_add`.""" + lib = _FakeLib(_CORE_SYMBOLS | {"secp256k1_ec_privkey_tweak_add"}) + + _add_function_definitions(lib) # type: ignore[arg-type] + + # Both spellings must resolve to the same underlying function and be + # fully typed. + self.assertIs(lib.secp256k1_ec_privkey_tweak_add, lib.secp256k1_ec_seckey_tweak_add) + self.assertEqual(lib.secp256k1_ec_privkey_tweak_add.restype, ctypes.c_int) + + def test_modern_seckey_symbol(self) -> None: + """v0.7+ libsecp256k1 only exports `secp256k1_ec_seckey_tweak_add`.""" + lib = _FakeLib(_CORE_SYMBOLS | {"secp256k1_ec_seckey_tweak_add"}) + + _add_function_definitions(lib) # type: ignore[arg-type] + + self.assertIs(lib.secp256k1_ec_privkey_tweak_add, lib.secp256k1_ec_seckey_tweak_add) + self.assertEqual(lib.secp256k1_ec_seckey_tweak_add.restype, ctypes.c_int) + + def test_both_symbols_present_prefers_modern(self) -> None: + """If both spellings are exported, the modern `seckey` name wins.""" + lib = _FakeLib( + _CORE_SYMBOLS + | { + "secp256k1_ec_privkey_tweak_add", + "secp256k1_ec_seckey_tweak_add", + } + ) + modern = lib.secp256k1_ec_seckey_tweak_add + + _add_function_definitions(lib) # type: ignore[arg-type] + + self.assertIs(lib.secp256k1_ec_privkey_tweak_add, modern) + self.assertIs(lib.secp256k1_ec_seckey_tweak_add, modern) + + def test_seckey_negate_alias(self) -> None: + """`secp256k1_ec_seckey_negate` (v0.7+) is aliased to the privkey name.""" + lib = _FakeLib( + _CORE_SYMBOLS + | { + "secp256k1_ec_privkey_tweak_add", + "secp256k1_ec_seckey_negate", + } + ) + + cap = _add_function_definitions(lib) # type: ignore[arg-type] + + self.assertTrue(cap.has_privkey_negate) + self.assertIs(lib.secp256k1_ec_privkey_negate, lib.secp256k1_ec_seckey_negate) + + +if __name__ == "__main__": + unittest.main()