Add a complete C implementation of secp256k1 elliptic curve operations
alongside the existing Kotlin implementation, enabling direct comparison
and extraction of maximum performance from each platform (ARM64, x86_64).
C Implementation (quartz/src/main/c/secp256k1/):
- field.h/c: 5x52-bit limb field arithmetic with __int128 support and
lazy reduction (12-bit headroom per limb vs Kotlin's fully-packed 4x64)
- scalar.h/c: Scalar mod n arithmetic, GLV decomposition, wNAF encoding
- point.h/c: Jacobian point operations (3M+4S double, 8M+3S mixed add),
GLV+wNAF scalar multiplication, Strauss/Shamir dual scalar multiply,
Montgomery batch-to-affine, precomputed G tables (wNAF-12)
- schnorr.c: BIP-340 Schnorr sign/verify/verifyFast/verifyBatch with
pubkey decompression cache and precomputed tag hash prefixes
- sha256.c: Self-contained SHA-256 for BIP-340 tagged hashes
- secp256k1_c.h: Public API matching the Kotlin Secp256k1 object
- jni_bridge.c: JNI bridge for JVM/Android integration
- benchmark.c: Standalone C benchmark (cmake build)
- CMakeLists.txt: Build system with ARM64/x86_64 optimization flags
Kotlin Integration:
- Secp256k1InstanceC: expect/actual wrapper (commonMain/jvmMain/androidMain/nativeMain)
- Secp256k1C: JVM JNI binding class
- Secp256k1TripleBenchmark: Three-way JVM benchmark (ACINQ vs Kotlin vs Custom C)
- Secp256k1CBenchmark: Android benchmark for the C implementation
Current status: sign works correctly (verified against BIP-340 test vectors),
verify path needs ecmult_double_g debugging (GLV wNAF-12 table issue). The
comb table for ecmult_gen also needs fixing (currently falls back to GLV+wNAF).
Field arithmetic is fully verified: 5x52 limbs with R=0x1000003D10 fold.
https://claude.ai/code/session_011KVZhDcV2G7idNWEBz12GY
- CallActivity.onStop: move finishAndRemoveTask inside coroutine so
hangup signaling completes before Activity destruction; add
hangupInitiated flag to prevent double-hangup in onDestroy
- CallController: add per-peer renegotiation debouncing via
pendingRenegotiation map to prevent queuing multiple createOffer calls
when video is toggled rapidly
- CallController: guard ensureForegroundService in onPeerConnected
callback with state check to prevent restarting the service after
cleanup
- RemoteVideoMonitor: synchronize onRemoteVideoTrack, onPeerRemoved,
and dispose with trackLock to prevent non-atomic map mutations and
leaked video sinks from concurrent WebRTC callback threads
- CallMediaManager: add @Synchronized to createVideoResources to
prevent check-then-act race between IO and main threads
https://claude.ai/code/session_017HrFJNxD6zrGwiZ3s69xTh
negentropy-kmp v1.0.2 now publishes a macosArm64 artifact, unblocking
the macOS native target. Wired macosMain/macosTest source sets through
appleMain/appleTest and updated run_all.sh to run the K/Native
benchmark on both Linux (linuxX64) and macOS (macosArm64).
https://claude.ai/code/session_01WSNE6QKiYM2ZutQD2UihCW
macosArm64 target cannot be enabled until negentropy-kmp publishes a
macosArm64 artifact — commented out with explanation. Reverted run_all.sh
back to Linux-only for K/Native benchmark.
Added Kotlin/Native toolchain dependencies (GCC sysroot, LLDB, LLVM,
libffi) to session-start.sh so K/N compilation works in Claude Code
remote environments where Gradle's own downloader fails through the
proxy.
https://claude.ai/code/session_01WSNE6QKiYM2ZutQD2UihCW
- Declare macosX64() and macosArm64() KMP targets in build.gradle.kts
- Wire macosMain/macosTest source sets through appleMain/appleTest
- Move Secp256k1NativeBenchmark from linuxX64Test to nativeTest so it
runs on all native targets (Linux, macOS, iOS)
- Use platform() for dynamic labels instead of hardcoded "linuxX64"
- Update run_all.sh to pick the correct native target per OS/arch
https://claude.ai/code/session_01WSNE6QKiYM2ZutQD2UihCW
Three issues fixed:
- Replace fragile -newer comparison against the script file (breaks
after any script edit) with a timestamp file created just before the
Gradle task runs.
- Search connected_android_test_additional_output for pulled benchmark
JSON (where AndroidX Benchmark actually writes results via Gradle).
- Extract benchmark data from XML via CDATA parsing instead of dumping
raw XML, consistent with the JVM and K/Native sections.
https://claude.ai/code/session_01WSNE6QKiYM2ZutQD2UihCW
The ACINQ secp256k1-kmp-jni dylib ships with a relative LC_ID_DYLIB
("build/darwin/libsecp256k1-jni.dylib"). macOS dyld resolves this
literally, ignoring the -Wl,-rpath passed at link time, causing an
immediate abort at launch. Using install_name_tool to rewrite the
install name to @rpath/libsecp256k1-jni.dylib lets dyld find the
library via the rpath we already set.
https://claude.ai/code/session_01WSNE6QKiYM2ZutQD2UihCW
- CallActivity.onDestroy: use standalone CoroutineScope instead of
lifecycleScope which is cancelled during super.onDestroy(), ensuring
hangup/reject signaling events are reliably published
- disposePeerSession: remove videoSenders entry for the departing peer
to prevent stale RtpSender references leaking after PeerConnection
disposal
- initiateGroupCall: detect when all PeerConnection creations fail and
hang up immediately instead of leaving the call in Offering state
until the 60-second timeout
https://claude.ai/code/session_017HrFJNxD6zrGwiZ3s69xTh
- Send hangup/reject to peer when WebRTC init or PeerConnection creation
fails, so remote phone stops ringing instead of timing out after 60s
- Throw on null PeerConnection from factory to fail fast instead of
silently no-oping all subsequent WebRTC operations
- Start foreground service during IncomingCall to protect ringtone
playback from being killed on Android 14+
- Make cleanup() idempotent with AtomicBoolean guard to prevent double
disposal when Ended state and ViewModel.onCleared race
- Replace mutableMapOf with ConcurrentHashMap for videoSenders accessed
from UI and WebRTC callback threads
- Add @Volatile to peerConnection, videoPausedByProximity, and
foregroundServiceStarted for cross-thread visibility
- Capture peerConnection into local variable in dispose() to prevent
TOCTOU race between close() and null assignment
- Replace leaked MainScope() in CallNotificationReceiver with structured
CoroutineScope that is cancelled after work completes
- Remove self-wraps in group answer/reject to avoid wasting bandwidth
sending encrypted messages to ourselves
- Move startTimeout inside stateMutex in initiateCall for consistency
https://claude.ai/code/session_017HrFJNxD6zrGwiZ3s69xTh
Bug fixes:
- Fix RemoteVideoMonitor killing group monitor job when primary track switches
- Add mutex protection to CallManager.initiateCall() to prevent state races
- Fix ICE restart offer never being sent to remote peer (was immediately
replaced by a second offer from onRenegotiationNeeded)
- Fix duplicate duration timer in PiP connected call UI
- Fix error snackbar dismiss button not clearing the error
- Make PeerSessionManager thread-safe with synchronized blocks (accessed
from WebRTC native threads and coroutine dispatchers concurrently)
- Make CallManager event handlers private (only called from onSignalingEvent)
Improvements:
- Replace fragile ICE candidate regex parsing with kotlinx.serialization JSON
- Respect DND/silent mode: only ring in NORMAL mode, only vibrate in VIBRATE
- Signal camera-off to remote peer by removing video track sender (instead
of sending frozen/black frame)
- Clear CallSessionBridge on AccountViewModel.onCleared() to prevent stale
references on account switch
- Custom TURN servers now replace defaults (instead of appending) so
credentials can be rotated without an app update
New features:
- Front/back camera switch button (visible when video is enabled)
- Network transition handling: ConnectivityManager.NetworkCallback triggers
ICE restart on all peers when network changes (WiFi/cellular handoff)
https://claude.ai/code/session_01JHn7skAibTrkVqsoWutgYe
The foreground service was only started after onPeerConnected, leaving
the Offering/Connecting phases unprotected. If the user backgrounded
the app during connecting, Android 14+ could block the later
startForegroundService() call, killing the call.
Changes:
- Start foreground service on Offering state (user just tapped call
button, so app is guaranteed to be in foreground)
- Update notification text on Connecting/Connected transitions
- Add ACTION_UPDATE to CallForegroundService to change notification
without restarting the service
- onPeerConnected now uses ensureForegroundService() as a safety net
https://claude.ai/code/session_01F5RF2yzngiMr1v2gr7f1GP
Bug fixes:
- Fix invitePeer() bypassing CallManager state tracking, causing
invited peers to not appear in pendingPeerPubKeys
- Remove 10-minute proximity wake lock timeout so it lasts the
full call duration (released on cleanup)
- Send hangup to peers on caller timeout so callees stop ringing
immediately instead of waiting for their own 60s timeout
- Remove duplicate cleanup() call on Ended→Idle transition
New feature:
- Add Call Settings screen (TURN servers + video quality)
- Users can configure custom TURN servers for restrictive networks
- Default STUN/TURN servers are always active and displayed
- Video resolution options: 480p, 720p (default), 1080p
- Configurable max video bitrate: 750kbps, 1.5Mbps, 3Mbps
- Settings wired into IceServerConfig and CallMediaManager
https://claude.ai/code/session_01F5RF2yzngiMr1v2gr7f1GP
On Apple (iOS/macOS) and Linux targets, LargeCache.forEach() iterates
the underlying map directly. When another coroutine modifies the map
during iteration (e.g., NostrClient.syncFilters running while
subscriptions are added), a ConcurrentModificationException is thrown.
On JVM/Android this is not an issue because ConcurrentSkipListMap
handles concurrent iteration safely. On Kotlin/Native (iOS), this
exception is fatal — K/N calls abort() for unhandled exceptions,
crashing the app immediately after account creation when relays
connect and subscriptions start syncing.
Fix: call .entries.toList() before iterating to create a snapshot,
matching the JVM behavior where concurrent modifications during
iteration are tolerated.
Benchmarked two approaches for hardware 128-bit multiply on K/N:
1. Full mulWide via C interop (memScoped + allocArray + fe4_mul_reduce):
FieldP.mul: 44ns → 116ns (2.6x SLOWER — copy/marshal overhead)
2. Per-call umulh via C interop (fe4_umulh, 20 calls per field mul):
FieldP.mul: 44ns → 331ns (7.5x SLOWER — ~15ns bridge per call)
Conclusion: K/N cinterop bridge adds ~15ns per call, making fine-grained
C interop unviable for the multiply-high hot path (20+ calls per field op).
The pure-Kotlin fused approach (4 IMUL per 128-bit product) remains optimal
at 44ns/op until K/N supports hardware MUL natively.
Updated FieldMulPlatform.native.kt docs with benchmarked rationale.
Fixed remaining LongArray references in native benchmark test.
https://claude.ai/code/session_01Sxi6Gpxbstuj3Y8TBY7XrU
Critical fixes:
- Fix PSK/ExternalInit proposals by Reference dropped from key schedule:
processCommit now collects ALL resolved proposals (inline + by-reference)
into resolvedProposals list used for PSK and ExternalInit computation
- Fix decrypt() missing blank-leaf membership check: validate sender leaf
is non-null (occupied) before proceeding with decryption
High fixes:
- Fix MlsGroupManager.decrypt() now mutex-protected to prevent concurrent
SecretTree ratchet corruption and potential nonce reuse
Medium fixes:
- Fix externalJoin: verify GroupInfo signature before trusting tree/keys
- Fix parentHash verification: COMMIT leaf nodes must have non-empty
parentHash (no longer silently skipped)
- Fix proposal application order: Updates/Removes applied before Adds
per RFC 9420 §12.4.2 (frees blank slots before reuse)
- Add encryption key uniqueness check in RatchetTree.addLeaf() per §7.3
- Add LeafNode capabilities validation: verify version and ciphersuite
support in applyProposalAdd per §12.1.1
- Remove redundant confirmation tag recomputation in processCommit
https://claude.ai/code/session_017SjKXS4Vpu4xRg9zHTgpmC
Move foregroundServiceStarted flag check and onPeerDisconnected()
inside scope.launch to avoid accessing main-thread-only state from
WebRTC's internal observer thread.
https://claude.ai/code/session_01DE9BUAuLJSwT3jq7S53NJ6
Full migration of the secp256k1 library from LongArray(4)/LongArray(8)
to Fe4/Wide8 struct types with @JvmField named Long fields. This
eliminates all array bounds checks from the hot path.
Files migrated (13 source + 7 test + 2 benchmark):
- U256.kt, FieldP.kt, ScalarN.kt, Glv.kt, ECPoint.kt
- FieldMulPlatform.kt (expect + 3 actuals), FieldMulFused.kt
- PointTypes.kt (MutablePoint, AffinePoint, PointScratch)
- KeyCodec.kt, Secp256k1.kt
- All test files and benchmarks
Bytecode impact:
Before: 464 laload/lastore (bounds-checked) in core arithmetic
After: 0 laload/lastore, all getfield/putfield (no checks)
The public API (Secp256k1 object) is unchanged - it still accepts
and returns ByteArray. Fe4 conversion happens at the API boundary
via U256.fromBytes()/U256.toBytes().
All secp256k1 unit tests pass on JVM.
https://claude.ai/code/session_01Sxi6Gpxbstuj3Y8TBY7XrU
- Fix enableVideo() not restarting camera after disable/re-enable
- Add hangup action to foreground service notification with tap-to-open
- Register BluetoothSco receiver with RECEIVER_NOT_EXPORTED flag
- Replace GlobalScope with lifecycleScope+NonCancellable in CallActivity
- Replace GlobalScope with goAsync()+MainScope in CallNotificationReceiver
- Reduce proximity WakeLock timeout from 1 hour to 10 minutes
- Add try-catch to CallMediaManager.initialize() to prevent EglBase leak
- Add ICE restart attempt before giving up on FAILED state
- Add VideoRenderer update block to handle track reference changes
https://claude.ai/code/session_01DE9BUAuLJSwT3jq7S53NJ6
The script was hardcoded for Linux (searching for .so files and
linux-x86_64 paths). Add platform detection via uname so it finds
the correct native library on macOS (darwin .dylib) and Linux
(aarch64 and x86_64). Skip K/Native benchmark on non-Linux since
only linuxX64 target exists.
https://claude.ai/code/session_01JZbyrS9xZEtJ9Y4yfsmnz1
run_all.sh runs all secp256k1 benchmarks and produces a formatted
comparison table. Runs C native, Kotlin/Native, JVM (always), and
Android (only if device/emulator connected via adb).
Output includes:
- ops/sec with comma-separated numbers
- libsecp256k1 vs Quartz column headers
- verifySchnorrFast and signSchnorr (cached pk) as Quartz-only rows
- Ratio table: C vs K/Native, JNI vs JVM Kotlin (apples-to-apples)
- Android column and ratios when device is connected
Run from repo root: ./quartz/benchmarks/run_all.sh
https://claude.ai/code/session_015CtM5k88rF7WFgX8o2AGNR
Standalone C program that links against the ACINQ secp256k1-kmp-jni
.so to benchmark raw C libsecp256k1 performance without any JVM, JNI,
or ART overhead. Uses the same test vectors as the Kotlin benchmarks.
Useful as a baseline when comparing Kotlin/Native or Android results
against the C library on the same hardware.
https://claude.ai/code/session_015CtM5k88rF7WFgX8o2AGNR
Add optional PointScratch parameter to ECPoint.mul and ECPoint.mulG
(default to scratch.get() for backward compat). All callers in
Secp256k1.kt now pass their already-fetched scratch through.
From the ECDH trace: ECPoint.mul was calling scratch.get() (ThreadLocal)
redundantly — the caller already had the scratch. On ART, each
ThreadLocal.get costs ~6µs (hash table probe), and ECDH had 10 calls
totaling 64µs.
With this change, all hot-path EC operations (verify, sign, ECDH,
pubkey create, tweak mul) fetch the scratch once at the entry point
and pass it through the entire call chain.
https://claude.ai/code/session_015CtM5k88rF7WFgX8o2AGNR
Replace every non-inline uLt() call with uLtInline() across FieldP.kt,
U256.kt, and ScalarN.kt. The expect/actual uLt() can't be inline
(KMP limitation), costing ~84ns per call on ART as a real function
dispatch. From the trace: 12,394 uLt calls × 84ns = 1.035ms per
verify (1.2% of total).
uLtInline uses the same XOR-with-MIN_VALUE trick but as a package-level
inline function — zero dispatch overhead.
Also inline isInfinity() body directly: was delegating to U256.isZero()
(double dispatch), now computes (z[0] or z[1] or z[2] or z[3]) == 0L
directly. 190 calls × 347ns = 66µs saved.
https://claude.ai/code/session_015CtM5k88rF7WFgX8o2AGNR
Pass PointScratch through mulDoubleG instead of re-fetching from
ThreadLocal. The verify path was calling ThreadLocal.get() 2x:
once in verifySchnorrFast and again in mulDoubleG. On ART, each
ThreadLocal.get costs ~41µs (hash table probe), totaling ~83µs
per verify (~1% of total).
mulDoubleG now accepts an optional PointScratch parameter
(defaults to scratch.get() for backward compat). The verify
path passes its already-fetched scratch through.
Also fix all remaining LongArray copyInto calls with default params:
- MutablePoint.copyFrom: 3 calls per copy (x, y, z)
- MutablePoint.setAffine: 2 calls (x, y)
- mulDoubleG P-table build: 2 calls per table entry
- mul P-table build: 2 calls per table entry
- batchToAffine: 2 calls
- U256.copyInto: was delegating with defaults
Each copyInto$default adds a bitmask check + 3 branches + arraylength
per call. With ~13 LongArray copies per verify, this eliminates ~52
extra branch instructions from the hot path.
https://claude.ai/code/session_015CtM5k88rF7WFgX8o2AGNR