commit 1f1391135fa7e882e21fe74b040e1b48ca9624ae
parent f7f15ccf1061b984962a1bef9b235a910be93098
Author: Jared Tobin <jared@jtobin.io>
Date: Sun, 17 May 2026 18:54:52 -0230
Merge branch 'perf-tweaks': wNAF-accelerated key derivation
Adds apostrophe-suffixed variants of the BOLT3 key derivation
functions that take a precomputed secp256k1 'Context' and use
wNAF for the G-base scalar multiplications. Mirrors the
sign_ecdsa / sign_ecdsa' convention from ppad-secp256k1: callers
build one Context (S.precompute) and thread it through all
derivations.
New exports
-----------
derive_per_commitment_point'
derive_pubkey' (highest leverage)
derive_localpubkey'
derive_local_htlcpubkey'
derive_remote_htlcpubkey'
derive_local_delayedpubkey'
derive_remote_delayedpubkey'
derive_revocationpubkey' is deliberately omitted: its two scalar
mults are basepoint * scalar (arbitrary-point), not G * scalar, so
the wNAF table built by S.precompute doesn't apply. Documented in
the source.
Measured speedups (M-series macOS, -O2)
---------------------------------------
derive_pubkey: 148.7 us -> 45.4 us (3.3x)
derive_per_commitment_point: 141.2 us -> 37.7 us (3.7x)
Downstream callers (e.g. lux's deriveCommitmentKeys: 5x
derive_pubkey + 1x derive_revocationpubkey per call) should see
roughly 2x overall, since the unavoidable derive_revocationpubkey
remains at 290 us.
Test coverage
-------------
One pinned vector for derive_pubkey' against BOLT3 Appendix E,
plus seven QuickCheck properties (one per wNAF variant) asserting
byte equivalence with the non-wNAF counterparts. 55/55 tests pass.
Bench coverage
--------------
Both criterion and weigh suites now include the wNAF variants
alongside the non-wNAF entries so future regressions are caught.
The weigh main forces 'tex' before mainWith so the one-time
~50 KB precompute table doesn't get billed to the first wNAF
entry.
Drive-by finding
----------------
While investigating an apparent buildCommitment allocation
"outlier" in lux, the existing weigh suite confirmed
build_commitment_tx scales linearly at ~16 KB / HTLC (0 htlcs:
27 KB; 100 htlcs: 1.63 MB), driven by the script-construction
overhead in offered_htlc_script / received_htlc_script + the
to_p2wsh wrapper. The "asymmetry" lux saw was a test-data
artifact (all HTLCs on one side), not a bug. Noted; deferred for
a future Builder-based script-construction pass if it becomes a
bottleneck.
Diffstat:
6 files changed, 259 insertions(+), 1 deletion(-)
diff --git a/bench/Main.hs b/bench/Main.hs
@@ -3,6 +3,7 @@
module Main where
+import qualified Crypto.Curve.Secp256k1 as S
import Control.DeepSeq (NFData(..))
import Criterion.Main
import Data.Word (Word64)
@@ -11,6 +12,7 @@ import Lightning.Protocol.BOLT3
import Lightning.Protocol.BOLT3.Types
( Pubkey(..), Point(..)
, PaymentHash(..), PerCommitmentPoint(..)
+ , PerCommitmentSecret(..)
, CommitmentNumber(..)
)
@@ -163,11 +165,25 @@ instance NFData ValidationError where
rnf NoOutputs = ()
rnf (TooManyOutputs a) = rnf a
+-- | Precomputed wNAF context, built once and reused across the
+-- apostrophe-suffixed benches. NOINLINE keeps it from being
+-- re-created on every call site.
+tex :: S.Context
+tex = S.precompute
+{-# NOINLINE tex #-}
+
main :: IO ()
main = defaultMain [
bgroup "key derivation" [
bench "derive_pubkey" $
whnf (derive_pubkey basepoint) perCommitmentPoint
+ , bench "derive_pubkey'" $
+ whnf (derive_pubkey' tex basepoint) perCommitmentPoint
+ , bench "derive_per_commitment_point" $
+ whnf derive_per_commitment_point samplePcs
+ , bench "derive_per_commitment_point'" $
+ whnf (derive_per_commitment_point' tex)
+ samplePcs
, bench "derive_revocationpubkey" $
whnf (derive_revocationpubkey revocationBasepoint) perCommitmentPoint
]
@@ -313,6 +329,10 @@ main = defaultMain [
0xf9, 0x04, 0xf5, 0x50, 0x25, 0x3a, 0x0f, 0x3e, 0xf3, 0xf5, 0xaa,
0x2f, 0xe6, 0x83, 0x8a, 0x95, 0xb2, 0x16, 0x69, 0x14, 0x68, 0xe2]
+ samplePcs :: PerCommitmentSecret
+ samplePcs =
+ PerCommitmentSecret (BS.replicate 32 0x01)
+
-- Secret generation test data
seed = BS.replicate 32 0xFF
diff --git a/bench/Weight.hs b/bench/Weight.hs
@@ -3,6 +3,7 @@
module Main where
+import qualified Crypto.Curve.Secp256k1 as S
import Control.DeepSeq (NFData(..))
import qualified Data.ByteString as BS
import Data.Word (Word32, Word64)
@@ -10,10 +11,17 @@ import Lightning.Protocol.BOLT3
import Lightning.Protocol.BOLT3.Types
( Pubkey(..), Point(..)
, PaymentHash(..), PerCommitmentPoint(..)
+ , PerCommitmentSecret(..)
, CommitmentNumber(..)
)
import Weigh
+-- | Precomputed wNAF context; reused across every apostrophe-
+-- suffixed weigh entry.
+tex :: S.Context
+tex = S.precompute
+{-# NOINLINE tex #-}
+
-- NFData instances for weigh
-- (Satoshi, MilliSatoshi, Point, PaymentHash, PerCommitmentSecret
-- derive NFData via ppad-bolt1)
@@ -165,11 +173,20 @@ instance NFData SecretStore where
rnf ss = ss `seq` ()
main :: IO ()
-main = mainWith $ do
+main = tex `seq` mainWith $ do
+ -- 'tex' is forced before mainWith so the one-time wNAF
+ -- precompute (~50 KB) doesn't get billed to whichever
+ -- apostrophe-suffixed func happens to evaluate it first.
setColumns [Case, Allocated, GCs, Max]
-- Key derivation allocations
func "derive_pubkey" (derive_pubkey basepoint) perCommitmentPoint
+ func "derive_pubkey'"
+ (derive_pubkey' tex basepoint) perCommitmentPoint
+ func "derive_per_commitment_point"
+ derive_per_commitment_point samplePcs
+ func "derive_per_commitment_point'"
+ (derive_per_commitment_point' tex) samplePcs
func "derive_revocationpubkey"
(derive_revocationpubkey revocationBasepoint) perCommitmentPoint
@@ -306,6 +323,10 @@ main = mainWith $ do
0xf9, 0x04, 0xf5, 0x50, 0x25, 0x3a, 0x0f, 0x3e, 0xf3, 0xf5, 0xaa,
0x2f, 0xe6, 0x83, 0x8a, 0x95, 0xb2, 0x16, 0x69, 0x14, 0x68, 0xe2]
+ samplePcs :: PerCommitmentSecret
+ samplePcs =
+ PerCommitmentSecret (BS.replicate 32 0x01)
+
-- Secret generation test data
seed = BS.replicate 32 0xFF
diff --git a/lib/Lightning/Protocol/BOLT3.hs b/lib/Lightning/Protocol/BOLT3.hs
@@ -146,12 +146,19 @@ module Lightning.Protocol.BOLT3 (
-- * Key derivation
, derive_per_commitment_point
+ , derive_per_commitment_point'
, derive_pubkey
+ , derive_pubkey'
, derive_localpubkey
+ , derive_localpubkey'
, derive_local_htlcpubkey
+ , derive_local_htlcpubkey'
, derive_remote_htlcpubkey
+ , derive_remote_htlcpubkey'
, derive_local_delayedpubkey
+ , derive_local_delayedpubkey'
, derive_remote_delayedpubkey
+ , derive_remote_delayedpubkey'
, derive_revocationpubkey
-- ** Secret generation
diff --git a/lib/Lightning/Protocol/BOLT3/Keys.hs b/lib/Lightning/Protocol/BOLT3/Keys.hs
@@ -23,14 +23,21 @@
module Lightning.Protocol.BOLT3.Keys (
-- * Per-commitment point derivation
derive_per_commitment_point
+ , derive_per_commitment_point'
-- * Key derivation
, derive_pubkey
+ , derive_pubkey'
, derive_localpubkey
+ , derive_localpubkey'
, derive_local_htlcpubkey
+ , derive_local_htlcpubkey'
, derive_remote_htlcpubkey
+ , derive_remote_htlcpubkey'
, derive_local_delayedpubkey
+ , derive_local_delayedpubkey'
, derive_remote_delayedpubkey
+ , derive_remote_delayedpubkey'
-- * Revocation key derivation
, derive_revocationpubkey
@@ -77,6 +84,24 @@ derive_per_commitment_point (PerCommitmentSecret sec) = do
pure $! PerCommitmentPoint (Point bs)
{-# INLINE derive_per_commitment_point #-}
+-- | As 'derive_per_commitment_point', but takes a precomputed
+-- secp256k1 'S.Context' so the @G@-base scalar mult uses wNAF.
+-- Caller threads a single context across many calls.
+--
+-- >>> let !tex = S.precompute
+-- >>> derive_per_commitment_point' tex secret
+-- Just (PerCommitmentPoint ...)
+derive_per_commitment_point'
+ :: S.Context
+ -> PerCommitmentSecret
+ -> Maybe PerCommitmentPoint
+derive_per_commitment_point' tex (PerCommitmentSecret sec) = do
+ sk <- S.parse_int256 sec
+ pk <- S.derive_pub' tex sk
+ let !bs = S.serialize_point pk
+ pure $! PerCommitmentPoint (Point bs)
+{-# INLINE derive_per_commitment_point' #-}
+
-- Key derivation ---------------------------------------------------------
-- | Derive a pubkey from a basepoint and per-commitment point.
@@ -106,6 +131,32 @@ derive_pubkey (Point basepointBs) (PerCommitmentPoint (Point pcpBs)) = do
pure $! Pubkey bs
{-# INLINE derive_pubkey #-}
+-- | As 'derive_pubkey', but takes a precomputed secp256k1
+-- 'S.Context'. The @tweak * G@ multiplication uses wNAF; this is
+-- the highest-leverage variant since 'derive_pubkey' fires five
+-- times per commitment-key derivation.
+--
+-- >>> let !tex = S.precompute
+-- >>> derive_pubkey' tex basepoint per_commitment_point
+-- Just (Pubkey ...)
+derive_pubkey'
+ :: S.Context
+ -> Point -- ^ basepoint
+ -> PerCommitmentPoint -- ^ per_commitment_point
+ -> Maybe Pubkey
+derive_pubkey'
+ tex
+ (Point basepointBs)
+ (PerCommitmentPoint (Point pcpBs)) = do
+ basepoint <- S.parse_point basepointBs
+ let !h = SHA256.hash (pcpBs <> basepointBs)
+ tweak <- S.parse_int256 h
+ tweakPoint <- S.derive_pub' tex tweak
+ let !result = S.add basepoint tweakPoint
+ !bs = S.serialize_point result
+ pure $! Pubkey bs
+{-# INLINE derive_pubkey' #-}
+
-- | Derive localpubkey from payment_basepoint and per_commitment_point.
--
-- >>> derive_localpubkey payment_basepoint per_commitment_point
@@ -118,6 +169,17 @@ derive_localpubkey (PaymentBasepoint pt) pcp =
LocalPubkey <$> derive_pubkey pt pcp
{-# INLINE derive_localpubkey #-}
+-- | As 'derive_localpubkey', but uses 'derive_pubkey'' internally
+-- (wNAF-accelerated).
+derive_localpubkey'
+ :: S.Context
+ -> PaymentBasepoint
+ -> PerCommitmentPoint
+ -> Maybe LocalPubkey
+derive_localpubkey' tex (PaymentBasepoint pt) pcp =
+ LocalPubkey <$> derive_pubkey' tex pt pcp
+{-# INLINE derive_localpubkey' #-}
+
-- | Derive local_htlcpubkey from htlc_basepoint and per_commitment_point.
--
-- >>> derive_local_htlcpubkey htlc_basepoint per_commitment_point
@@ -130,6 +192,16 @@ derive_local_htlcpubkey (HtlcBasepoint pt) pcp =
LocalHtlcPubkey <$> derive_pubkey pt pcp
{-# INLINE derive_local_htlcpubkey #-}
+-- | As 'derive_local_htlcpubkey', wNAF variant.
+derive_local_htlcpubkey'
+ :: S.Context
+ -> HtlcBasepoint
+ -> PerCommitmentPoint
+ -> Maybe LocalHtlcPubkey
+derive_local_htlcpubkey' tex (HtlcBasepoint pt) pcp =
+ LocalHtlcPubkey <$> derive_pubkey' tex pt pcp
+{-# INLINE derive_local_htlcpubkey' #-}
+
-- | Derive remote_htlcpubkey from htlc_basepoint and per_commitment_point.
--
-- >>> derive_remote_htlcpubkey htlc_basepoint per_commitment_point
@@ -142,6 +214,16 @@ derive_remote_htlcpubkey (HtlcBasepoint pt) pcp =
RemoteHtlcPubkey <$> derive_pubkey pt pcp
{-# INLINE derive_remote_htlcpubkey #-}
+-- | As 'derive_remote_htlcpubkey', wNAF variant.
+derive_remote_htlcpubkey'
+ :: S.Context
+ -> HtlcBasepoint
+ -> PerCommitmentPoint
+ -> Maybe RemoteHtlcPubkey
+derive_remote_htlcpubkey' tex (HtlcBasepoint pt) pcp =
+ RemoteHtlcPubkey <$> derive_pubkey' tex pt pcp
+{-# INLINE derive_remote_htlcpubkey' #-}
+
-- | Derive local_delayedpubkey from delayed_payment_basepoint and
-- per_commitment_point.
--
@@ -155,6 +237,16 @@ derive_local_delayedpubkey (DelayedPaymentBasepoint pt) pcp =
LocalDelayedPubkey <$> derive_pubkey pt pcp
{-# INLINE derive_local_delayedpubkey #-}
+-- | As 'derive_local_delayedpubkey', wNAF variant.
+derive_local_delayedpubkey'
+ :: S.Context
+ -> DelayedPaymentBasepoint
+ -> PerCommitmentPoint
+ -> Maybe LocalDelayedPubkey
+derive_local_delayedpubkey' tex (DelayedPaymentBasepoint pt) pcp =
+ LocalDelayedPubkey <$> derive_pubkey' tex pt pcp
+{-# INLINE derive_local_delayedpubkey' #-}
+
-- | Derive remote_delayedpubkey from delayed_payment_basepoint and
-- per_commitment_point.
--
@@ -168,8 +260,23 @@ derive_remote_delayedpubkey (DelayedPaymentBasepoint pt) pcp =
RemoteDelayedPubkey <$> derive_pubkey pt pcp
{-# INLINE derive_remote_delayedpubkey #-}
+-- | As 'derive_remote_delayedpubkey', wNAF variant.
+derive_remote_delayedpubkey'
+ :: S.Context
+ -> DelayedPaymentBasepoint
+ -> PerCommitmentPoint
+ -> Maybe RemoteDelayedPubkey
+derive_remote_delayedpubkey' tex (DelayedPaymentBasepoint pt) pcp =
+ RemoteDelayedPubkey <$> derive_pubkey' tex pt pcp
+{-# INLINE derive_remote_delayedpubkey' #-}
+
-- Revocation key derivation ----------------------------------------------
+-- Note: no @derive_revocationpubkey'@ is provided. The two scalar
+-- mults inside this function are general (basepoint * scalar, not
+-- @G * scalar@), so the wNAF table built by 'S.precompute' — which
+-- stores multiples of the generator — doesn't apply.
+
-- | Derive revocationpubkey from revocation_basepoint and
-- per_commitment_point.
--
diff --git a/ppad-bolt3.cabal b/ppad-bolt3.cabal
@@ -56,6 +56,7 @@ test-suite bolt3-tests
, base16-bytestring
, bytestring
, ppad-bolt3
+ , ppad-secp256k1 >= 0.5.4 && < 0.6
, QuickCheck
, tasty
, tasty-hunit
@@ -76,6 +77,7 @@ benchmark bolt3-bench
, criterion
, deepseq
, ppad-bolt3
+ , ppad-secp256k1 >= 0.5.4 && < 0.6
benchmark bolt3-weigh
type: exitcode-stdio-1.0
@@ -91,5 +93,6 @@ benchmark bolt3-weigh
, bytestring
, deepseq
, ppad-bolt3
+ , ppad-secp256k1 >= 0.5.4 && < 0.6
, weigh
diff --git a/test/Main.hs b/test/Main.hs
@@ -2,6 +2,7 @@
module Main where
+import qualified Crypto.Curve.Secp256k1 as S
import qualified Data.ByteString as BS
import qualified Data.ByteString.Base16 as B16
import Data.Maybe (isJust, isNothing)
@@ -13,8 +14,16 @@ import Lightning.Protocol.BOLT3
import Lightning.Protocol.BOLT3.Types
( Pubkey(..), Point(..)
, PaymentHash(..), PerCommitmentPoint(..)
+ , PerCommitmentSecret(..)
)
+-- Module-level wNAF context. Built once; reused across every
+-- equivalence test below. Mirrors how downstream callers should use
+-- it.
+tex :: S.Context
+tex = S.precompute
+{-# NOINLINE tex #-}
+
main :: IO ()
main = defaultMain $ testGroup "ppad-bolt3" [
testGroup "Key derivation" [
@@ -61,6 +70,17 @@ keyDerivationTests = testGroup "BOLT #3 Appendix E" [
Nothing -> assertFailure "derive_pubkey returned Nothing"
Just (Pubkey pk) -> pk @?= expected
+ , testCase "derive_pubkey' matches vector" $ do
+ let basepoint = Point $ hex
+ "036d6caac248af96f6afa7f904f550253a0f3ef3f5aa2fe6838a95b216691468e2"
+ perCommitmentPoint = PerCommitmentPoint $ Point $ hex
+ "025f7117a78150fe2ef97db7cfc83bd57b2e2c0d0dd25eaf467a4a1c2a45ce1486"
+ expected = hex
+ "0235f2dbfaa89b57ec7b055afe29849ef7ddfeb1cefdb9ebdc43f5494984db29e5"
+ case derive_pubkey' tex basepoint perCommitmentPoint of
+ Nothing -> assertFailure "derive_pubkey' returned Nothing"
+ Just (Pubkey pk) -> pk @?= expected
+
, testCase "derive_revocationpubkey" $ do
let revocationBasepoint = RevocationBasepoint $ Point $ hex
"036d6caac248af96f6afa7f904f550253a0f3ef3f5aa2fe6838a95b216691468e2"
@@ -381,8 +401,88 @@ propertyTests = testGroup "invariants" [
propTrimPartition
, testProperty "commitment_fee increases with HTLCs"
propFeeMonotonic
+ , testProperty "derive_per_commitment_point' ≡ derive_per_commitment_point"
+ propDerivePcpEquiv
+ , testProperty "derive_pubkey' ≡ derive_pubkey"
+ propDerivePubkeyEquiv
+ , testProperty "derive_localpubkey' ≡ derive_localpubkey"
+ propDeriveLocalEquiv
+ , testProperty "derive_local_htlcpubkey' ≡ derive_local_htlcpubkey"
+ propDeriveLocalHtlcEquiv
+ , testProperty "derive_remote_htlcpubkey' ≡ derive_remote_htlcpubkey"
+ propDeriveRemoteHtlcEquiv
+ , testProperty "derive_local_delayedpubkey' ≡ derive_local_delayedpubkey"
+ propDeriveLocalDelEquiv
+ , testProperty "derive_remote_delayedpubkey' ≡ derive_remote_delayedpubkey"
+ propDeriveRemoteDelEquiv
]
+-- | Random 32-byte secret, then derive a basepoint and per-commit
+-- point from it. Returns (basepoint, pcp). Using derived points
+-- keeps us on the curve without having to generate valid points
+-- directly.
+genPointPair :: Gen (Point, PerCommitmentPoint)
+genPointPair = do
+ sk1 <- vectorOf 32 (choose (0, 255 :: Int))
+ sk2 <- vectorOf 32 (choose (0, 255 :: Int))
+ let bs1 = BS.pack (fmap fromIntegral sk1)
+ bs2 = BS.pack (fmap fromIntegral sk2)
+ mkPt b = case S.parse_int256 b of
+ Nothing -> Nothing
+ Just w -> S.serialize_point <$> S.derive_pub w
+ case (mkPt bs1, mkPt bs2) of
+ (Just p1, Just p2) ->
+ pure (Point p1, PerCommitmentPoint (Point p2))
+ _ -> genPointPair -- ~negligible retry; sk=0 or sk>=q
+
+propDerivePcpEquiv :: Property
+propDerivePcpEquiv =
+ forAll (vectorOf 32 (choose (0, 255 :: Int))) $ \sk ->
+ let bs = BS.pack (fmap fromIntegral sk)
+ sec = PerCommitmentSecret bs
+ in derive_per_commitment_point' tex sec
+ === derive_per_commitment_point sec
+
+propDerivePubkeyEquiv :: Property
+propDerivePubkeyEquiv =
+ forAll genPointPair $ \(bp, pcp) ->
+ derive_pubkey' tex bp pcp === derive_pubkey bp pcp
+
+propDeriveLocalEquiv :: Property
+propDeriveLocalEquiv =
+ forAll genPointPair $ \(bp, pcp) ->
+ let pbp = PaymentBasepoint bp
+ in derive_localpubkey' tex pbp pcp
+ === derive_localpubkey pbp pcp
+
+propDeriveLocalHtlcEquiv :: Property
+propDeriveLocalHtlcEquiv =
+ forAll genPointPair $ \(bp, pcp) ->
+ let hbp = HtlcBasepoint bp
+ in derive_local_htlcpubkey' tex hbp pcp
+ === derive_local_htlcpubkey hbp pcp
+
+propDeriveRemoteHtlcEquiv :: Property
+propDeriveRemoteHtlcEquiv =
+ forAll genPointPair $ \(bp, pcp) ->
+ let hbp = HtlcBasepoint bp
+ in derive_remote_htlcpubkey' tex hbp pcp
+ === derive_remote_htlcpubkey hbp pcp
+
+propDeriveLocalDelEquiv :: Property
+propDeriveLocalDelEquiv =
+ forAll genPointPair $ \(bp, pcp) ->
+ let dbp = DelayedPaymentBasepoint bp
+ in derive_local_delayedpubkey' tex dbp pcp
+ === derive_local_delayedpubkey dbp pcp
+
+propDeriveRemoteDelEquiv :: Property
+propDeriveRemoteDelEquiv =
+ forAll genPointPair $ \(bp, pcp) ->
+ let dbp = DelayedPaymentBasepoint bp
+ in derive_remote_delayedpubkey' tex dbp pcp
+ === derive_remote_delayedpubkey dbp pcp
+
-- | commitment_number accepts values in [0, 2^48-1] and
-- rejects values >= 2^48.
propCommitmentNumberRange :: Property