Skip to content

Commit 5bb93ee

Browse files
committed
keychain: bound BtcWalletKeyRing ECDH derived private key cache
Each call to BtcWalletKeyRing.ECDH went through DerivePrivKey, which opens a read-write wallet DB transaction and forces a bbolt meta-page write plus fdatasync per call. Memoize the derived private key on the keyring, keyed by the descriptor's compressed public key, so repeated ECDH operations against the same key (every brontide handshake against the node identity key, every onion message decrypt, every watchtower session ECDH) stay entirely in memory after the first call. The cache is a neutrino LRU bounded at 1000 entries. ECDH callers in practice use a small set of keys (the node identity key on the onion hot path plus per-channel revocation roots, base-encryption keys, and signrpc-supplied descriptors), so 1000 comfortably covers the working set while keeping memory bounded against any caller that drives an unusually wide range of descriptors. A wrapper type carries the private key and reports Size 1 so the LRU bounds entry count rather than bytes. The cache lives on BtcWalletKeyRing rather than on PubKeyECDH so the private material stays behind the type that already owns it and every ECDHRing.ECDH caller benefits, not just those that go through the PubKeyECDH wrapper. Keying by the serialized compressed public key keeps lookups correct regardless of whether DerivePrivKey takes the path-based or the PubKey-scan branch: the same priv.PubKey() always maps to the same cache slot, with no cross-key collision risk. Descriptors without a PubKey (uncommon on the ECDH hot path) bypass the cache and forward to DerivePrivKey unchanged. Remote-signer deployments use RPCKeyRing instead and are not affected.
1 parent 4bd29bc commit 5bb93ee

1 file changed

Lines changed: 78 additions & 1 deletion

File tree

keychain/btcwallet.go

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package keychain
22

33
import (
44
"crypto/sha256"
5+
"errors"
56
"fmt"
67

78
"github.com/btcsuite/btcd/btcec/v2"
@@ -12,6 +13,8 @@ import (
1213
"github.com/btcsuite/btcwallet/waddrmgr"
1314
"github.com/btcsuite/btcwallet/wallet"
1415
"github.com/btcsuite/btcwallet/walletdb"
16+
"github.com/lightninglabs/neutrino/cache"
17+
"github.com/lightninglabs/neutrino/cache/lru"
1518
)
1619

1720
const (
@@ -22,6 +25,13 @@ const (
2225
// CoinTypeTestnet specifies the BIP44 coin type for all testnet key
2326
// derivation.
2427
CoinTypeTestnet = 1
28+
29+
// ecdhPrivKeyCacheSize bounds the number of derived ECDH private keys
30+
// that we'll keep memoized at any one time. In practice ECDH is only
31+
// invoked against a handful of our own keys (one per key family used
32+
// for onion decryption), so this size is well above the expected
33+
// working set.
34+
ecdhPrivKeyCacheSize = 1000
2535
)
2636

2737
var (
@@ -57,6 +67,28 @@ type BtcWalletKeyRing struct {
5767
// lightningScope is a pointer to the scope that we'll be using as a
5868
// sub key manager to derive all the keys that we require.
5969
lightningScope *waddrmgr.ScopedKeyManager
70+
71+
// ecdhPrivKeyCache memoizes the private keys derived for ECDH so that
72+
// each subsequent ECDH call against the same key avoids the read-write
73+
// wallet DB transaction (and the bbolt fdatasync that transaction
74+
// forces). The cache is keyed by the compressed-serialized public key
75+
// and bounded by ecdhPrivKeyCacheSize via an LRU eviction policy.
76+
ecdhPrivKeyCache *lru.Cache[
77+
[btcec.PubKeyBytesLenCompressed]byte, *cachedPrivKey,
78+
]
79+
}
80+
81+
// cachedPrivKey stores a btcec.PrivateKey by value so it can be stored in the
82+
// LRU cache, which requires values to implement the cache.Value interface.
83+
type cachedPrivKey struct {
84+
key btcec.PrivateKey
85+
}
86+
87+
// Size returns the "size" of an entry. We return 1 so that the LRU cache
88+
// bounds the total number of entries rather than doing byte-accurate
89+
// accounting.
90+
func (c *cachedPrivKey) Size() (uint64, error) {
91+
return 1, nil
6092
}
6193

6294
// NewBtcWalletKeyRing creates a new implementation of the
@@ -76,6 +108,9 @@ func NewBtcWalletKeyRing(w wallet.Interface, coinType uint32) SecretKeyRing {
76108
return &BtcWalletKeyRing{
77109
wallet: w,
78110
chainKeyScope: chainKeyScope,
111+
ecdhPrivKeyCache: lru.NewCache[
112+
[btcec.PubKeyBytesLenCompressed]byte, *cachedPrivKey,
113+
](ecdhPrivKeyCacheSize),
79114
}
80115
}
81116

@@ -385,11 +420,15 @@ func (b *BtcWalletKeyRing) DerivePrivKey(keyDesc KeyDescriptor) (
385420
//
386421
// sx := k*P s := sha256(sx.SerializeCompressed())
387422
//
423+
// The derived private key for keyDesc is memoized after the first call so
424+
// repeated ECDH operations against the same key avoid reopening a read-write
425+
// wallet DB transaction (and the bbolt fdatasync per call) on the hot path.
426+
//
388427
// NOTE: This is part of the keychain.ECDHRing interface.
389428
func (b *BtcWalletKeyRing) ECDH(keyDesc KeyDescriptor,
390429
pub *btcec.PublicKey) ([32]byte, error) {
391430

392-
privKey, err := b.DerivePrivKey(keyDesc)
431+
privKey, err := b.derivePrivKeyForECDH(keyDesc)
393432
if err != nil {
394433
return [32]byte{}, err
395434
}
@@ -408,6 +447,44 @@ func (b *BtcWalletKeyRing) ECDH(keyDesc KeyDescriptor,
408447
return h, nil
409448
}
410449

450+
// derivePrivKeyForECDH returns the private key for keyDesc, consulting and
451+
// populating the per-keyring ECDH cache. The cache is keyed by the
452+
// compressed-serialized public key, which uniquely identifies the private key
453+
// regardless of whether DerivePrivKey takes the path-based or the PubKey-scan
454+
// branch. When the descriptor has no public key set we cannot cache (we have
455+
// nothing collision-free to key on without first deriving) and forward to
456+
// DerivePrivKey directly. Production ECDH callers always supply a PubKey, so
457+
// the no-PubKey path is not on the hot path.
458+
func (b *BtcWalletKeyRing) derivePrivKeyForECDH(keyDesc KeyDescriptor) (
459+
*btcec.PrivateKey, error) {
460+
461+
if keyDesc.PubKey == nil {
462+
return b.DerivePrivKey(keyDesc)
463+
}
464+
465+
var cacheKey [btcec.PubKeyBytesLenCompressed]byte
466+
copy(cacheKey[:], keyDesc.PubKey.SerializeCompressed())
467+
468+
if v, err := b.ecdhPrivKeyCache.Get(cacheKey); err == nil {
469+
privKey := v.key
470+
return &privKey, nil
471+
} else if !errors.Is(err, cache.ErrElementNotFound) {
472+
return nil, err
473+
}
474+
475+
priv, err := b.DerivePrivKey(keyDesc)
476+
if err != nil {
477+
return nil, err
478+
}
479+
480+
// Insertion is best-effort: a Put failure (e.g. the entry exceeds
481+
// capacity, which can't happen here since each entry is size 1) only
482+
// means the next ECDH against this key takes the slow path again.
483+
_, _ = b.ecdhPrivKeyCache.Put(cacheKey, &cachedPrivKey{key: *priv})
484+
485+
return priv, nil
486+
}
487+
411488
// SignMessage signs the given message, single or double SHA256 hashing it
412489
// first, with the private key described in the key locator.
413490
//

0 commit comments

Comments
 (0)