secp256k1: drop pure-Python fallback, add reproducible Docker build environment#135
secp256k1: drop pure-Python fallback, add reproducible Docker build environment#135kdmukai wants to merge 7 commits into
Conversation
Delete py_secp256k1.py and require libsecp256k1 at import time. The wrapper raises Libsecp256k1NotAvailable (an ImportError subclass) when the backend can't load. Add docker/ as a fully-pinned build environment for libsecp256k1, with CI jobs that build via the container, build natively on macOS, and publish SHA256SUMS for the cross-compiled targets. The zkp submodule pointer and the ctypes wrapper bindings are deliberately untouched.
There was a problem hiding this comment.
Pull request overview
Note
Copilot was unable to run its full agentic suite in this review.
This PR makes libsecp256k1 a hard runtime requirement on CPython, removes the pure-Python secp256k1 fallback, and adds a Docker-based (reproducible) build workflow plus CI coverage for building/staging the required native library.
Changes:
- Replace “optional ctypes backend + Python fallback” with a fail-fast import error (
Libsecp256k1NotAvailable) when bindings can’t be loaded. - Add Docker build tooling/docs and CI jobs to build/stage
libsecp256k1for supported targets. - Update tests to skip cleanly when optional secp256k1 modules (ECDH / Liquid-ZKP extras) are missing.
Reviewed changes
Copilot reviewed 12 out of 14 changed files in this pull request and generated 9 comments.
Show a summary per file
| File | Description |
|---|---|
src/embit/util/secp256k1.py |
Centralizes backend selection and enforces fail-fast behavior when bindings aren’t loadable. |
src/embit/util/py_secp256k1.py |
Removes the pure-Python fallback implementation. |
tests/tests/test_bindings.py |
Updates bindings tests to target ctypes backend only (no Python parity). |
tests/tests/test_ecdh.py |
Adds capability-based skipping for optional ECDH module. |
tests/tests/test_liquid.py |
Adds capability-based skipping for Liquid/ZKP-required symbols. |
docker/Dockerfile |
Introduces pinned, reproducible cross-toolchain build image for libsecp256k1. |
docker/build.sh |
Adds a build wrapper to invoke the Makefile for named targets and stage artifacts. |
docker/README.md |
Documents Docker build workflow, targets, and reproducibility properties. |
secp256k1/README.md |
Updates native build + staging instructions and clarifies “no fallback on CPython”. |
.github/workflows/ci.yml |
Builds/stages libsecp256k1 in CI (Linux+macOS) and validates cross-compiled outputs. |
.gitignore |
Ignores local build outputs (secp256k1/build/) and ccache state (.ccache/). |
README.md |
Updates installation/runtime expectations and points to the new build paths. |
secp256k1/Makefile |
Adds clarifying comments about the zkp fork dependency. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…cible windows builds
odudex
left a comment
There was a problem hiding this comment.
tACK
Great improvement, using THE single source of true is good for reliability and management, especially on future updates.
Some AI nit catches:
1. The ec_privkey_tweak_mul gate is inert (Medium).
secp256k1.py:80-82 prunes ec_privkey_tweak_mul when the C symbol is missing, but _init() dereferences it unconditionally at ctypes_secp256k1.py:188 (core block, not a try/except like the optional modules). So on an upstream libsecp ≥ v0.7.0 that dropped the symbol, line 188 raises AttributeError → the whole ctypes_secp256k1 import fails → secp256k1.py raises Libsecp256k1NotAvailable, and the pruning loop never runs. In the exact scenario the gate documents, test_liquid doesn't skip cleanly — collection fails outright. No impact today (the pinned zkp has the symbol), but the selector-side gate alone is misleading. Suggest folding it into the same follow-up that wraps line 188 in try/except, or a one-line comment that the entry is inert until then.
2. build.sh all leaves a wrong-arch .so staged (Low).
Each target stages to .libs/libsecp256k1.so (build.sh:170); in all the loop runs amd64 → armv6l → armv7l → aarch64, so the staged .so ends up aarch64. A dev who runs docker run … all on x86_64 then pytest hits a wrong-arch lib at the loader-first path; the loader falls through (OSError caught), so with no system libsecp the import fails. CI is unaffected (tests uses single-target amd64; cross-compile-validation reads build/). Suggest having all skip staging / stage only the native arch, or a note in docker/README.md.
3. test_liquid gates on ec_pubkey_tweak_mul, which is never pruned (Low).
_LIQUID_REQUIRED_FUNCS (test_liquid.py:20) lists ec_pubkey_tweak_mul, but only the privkey variant is in _OPTIONAL_SYMBOLS, and pubkey_tweak_mul is set unconditionally in _init() — so the hasattr is always True. Harmless, but the comment "the tweak_mul operations upstream secp256k1 deprecated" overstates it: upstream removed only the seckey/privkey variant; secp256k1_ec_pubkey_tweak_mul still exists.
Verified clean: exception chaining + Libsecp256k1NotAvailable-before-ImportError ordering; selector pruning for the genuinely-optional ecdh/schnorr/recovery/zkp symbols; replacement of cross-backend parity with self-consistency + 1·G KAT; action pinning (third-party SHA-pinned, first-party tag-pinned per policy); CI --user ownership, deterministic fixed-order sha256sum, file | grep arch assertions failing correctly under bash -eo pipefail, submodules: recursive everywhere; PE32 --no-insert-timestamp reproducibility.
Recommendation: address #1 (at least a comment) before merge; #2 and #3 are optional polish.
Summary
Replace the pure-Python
secp256k1fallback (src/embit/util/py_secp256k1.py) with a hardlibsecp256k1requirement at import time, and adddocker/as a fully-pinned build environment that replaces the prebuilt-binary distribution path removed in #92e016b.Motivation
The pure-Python implementation diverges from libsecp behavior. See #125 discussion r3243767405: the two backends produce different results on the same Liquid call sites because the ctypes path mutates through a C pointer while the Python path tries to mutate an immutable
bytes. Maintaining two implementations of the same primitives is not sustainable, and this PR continues the direction set by92e016b.Changes
src/embit/util/py_secp256k1.py.src/embit/util/secp256k1.pyto fail fast at import withLibsecp256k1NotAvailable(a subclass ofImportError) when no backend resolves. The underlying load error is chained as__cause__.docker/directory (Dockerfile, build.sh, README) providing a reproducible build environment forlinux/amd64,armv6l(Pi Zero),armv7l,aarch64, andwindows/amd64. Pinning details indocker/README.md.testsjob to build via the container, add atests-macosnative build job, add across-compile-validationjob that builds all five targets and publishes aSHA256SUMSartifact and workflow step-summary.test_bindings.py(replaced with self-consistency checks). Update the skip messages on the existing@skipUnless(hasattr(secp256k1, ...))gates intest_ecdh.py,test_liquid.py,test_threading.pyto remove references to the deleted pure-Python fallback. The gates still serve their original purpose: skipping cleanly when the loaded library lacks an optional module.README.md,secp256k1/README.md,docker/README.mdupdated. One-line comment insecp256k1/Makefileabout the future upstream swap.Deliberately out of scope
secp256k1-zkpsubmodule pointer is not bumped.ctypes_secp256k1.pyis not modified. The wrapper's existing legacyprivkey_*and pre-BIP340schnorrsig_*bindings continue to work against the currently pinned zkp commit, but will fail against modern zkp or upstream. Adding aseckey_*fallback and a Schnorr ABI probe is a prerequisite for any submodule bump and belongs in a follow-up PR.Verification
Locally on Apple Silicon: 96 tests pass; Docker image builds and produces working binaries; action-pin policy is green. The local-vs-CI SHA256SUMS comparison can only be exercised once this PR's CI run completes.