bolt3

Lightning transaction and script formats, per BOLT #3 (docs.ppad.tech/bolt3).
git clone git://git.ppad.tech/bolt3.git
Log | Files | Refs | README | LICENSE

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:
Mbench/Main.hs | 20++++++++++++++++++++
Mbench/Weight.hs | 23++++++++++++++++++++++-
Mlib/Lightning/Protocol/BOLT3.hs | 7+++++++
Mlib/Lightning/Protocol/BOLT3/Keys.hs | 107+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mppad-bolt3.cabal | 3+++
Mtest/Main.hs | 100+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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