mirror of
https://github.com/lightningnetwork/lnd.git
synced 2026-08-13 12:32:48 +02:00
paymentsdb: normalize orphaned blinded total
The KV route format stores blinded fields independently. Routes accepted through SendToRouteV2 could therefore contain a blinded total amount without encrypted recipient data. The SQL migration treated the total as proof of a blinded hop and bound nil to the required encrypted-data column, preventing LND from starting. Use encrypted recipient data as the blinded-hop discriminator and normalize only the known total-only case. Reject blinding-point-only records with payment, attempt and hop context instead of exposing an opaque SQL constraint error. Log normalized totals, account for them during migration validation, and cover both cases with regression tests.
This commit is contained in:
parent
ffc62509b6
commit
04da2fa583
3 changed files with 202 additions and 11 deletions
|
|
@ -394,11 +394,26 @@ func normalizePaymentForCompare(payment *MPPayment) {
|
|||
}
|
||||
|
||||
for j := range htlc.Route.Hops {
|
||||
if len(htlc.Route.Hops[j].CustomRecords) == 0 {
|
||||
htlc.Route.Hops[j].CustomRecords =
|
||||
hop := htlc.Route.Hops[j]
|
||||
if len(hop.CustomRecords) == 0 {
|
||||
hop.CustomRecords =
|
||||
record.CustomSet{}
|
||||
}
|
||||
|
||||
// The migration treats nil and empty encrypted data as
|
||||
// absent, so it omits the blinded child row. SQL reads
|
||||
// the hop back with nil encrypted data and a zero
|
||||
// total. Apply the same transformation to the KV
|
||||
// copy before comparing the payments. A blinding point
|
||||
// without data is rejected during migration, so it
|
||||
// cannot reach this comparison.
|
||||
if len(hop.EncryptedData) == 0 &&
|
||||
hop.BlindingPoint == nil {
|
||||
|
||||
hop.EncryptedData = nil
|
||||
hop.TotalAmtMsat = 0
|
||||
}
|
||||
|
||||
// LegacyPayload was a hint used by the KV store to
|
||||
// determine how to serialize and deserialize the hop
|
||||
// payload (i.e. whether to use the legacy format or
|
||||
|
|
@ -406,7 +421,7 @@ func normalizePaymentForCompare(payment *MPPayment) {
|
|||
// all — each field is stored natively in its own
|
||||
// column — so this flag has no meaning there and is
|
||||
// never persisted.
|
||||
htlc.Route.Hops[j].LegacyPayload = false
|
||||
hop.LegacyPayload = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -734,8 +734,12 @@ func migrateHTLCAttempt(ctx context.Context, paymentID int64,
|
|||
// Insert route hops.
|
||||
for hopIndex := range htlc.Route.Hops {
|
||||
hop := htlc.Route.Hops[hopIndex]
|
||||
|
||||
// Use the parent hash for diagnostics. For AMP payments this
|
||||
// is the set ID, which identifies the payment containing the
|
||||
// shard.
|
||||
err = migrateRouteHop(
|
||||
ctx, attemptIndex, hopIndex, hop,
|
||||
ctx, parentPaymentHash, attemptIndex, hopIndex, hop,
|
||||
sqlDB, stats,
|
||||
)
|
||||
if err != nil {
|
||||
|
|
@ -807,8 +811,8 @@ func migrateHTLCAttempt(ctx context.Context, paymentID int64,
|
|||
|
||||
// migrateRouteHop migrates a single route hop.
|
||||
func migrateRouteHop(ctx context.Context,
|
||||
attemptID int64, hopIndex int, hop *Hop, sqlDB SQLQueries,
|
||||
stats *MigrationStats) error {
|
||||
parentPaymentHash lntypes.Hash, attemptIndex int64, hopIndex int,
|
||||
hop *Hop, sqlDB SQLQueries, stats *MigrationStats) error {
|
||||
|
||||
// Convert channel ID to string representation of uint64.
|
||||
// The SCID is stored as a decimal string to match the converter
|
||||
|
|
@ -817,7 +821,7 @@ func migrateRouteHop(ctx context.Context,
|
|||
|
||||
// Insert route hop.
|
||||
hopID, err := sqlDB.InsertRouteHop(ctx, sqlc.InsertRouteHopParams{
|
||||
HtlcAttemptIndex: attemptID,
|
||||
HtlcAttemptIndex: attemptIndex,
|
||||
HopIndex: int32(hopIndex),
|
||||
PubKey: hop.PubKeyBytes[:],
|
||||
Scid: scidStr,
|
||||
|
|
@ -829,10 +833,35 @@ func migrateRouteHop(ctx context.Context,
|
|||
return fmt.Errorf("insert hop: %w", err)
|
||||
}
|
||||
|
||||
// Check for blinded route data (route blinding).
|
||||
if len(hop.EncryptedData) > 0 || hop.BlindingPoint != nil ||
|
||||
hop.TotalAmtMsat != 0 {
|
||||
// Non-empty encrypted recipient data identifies a blinded hop.
|
||||
// The RPC boundary has required encrypted data with a blinding point
|
||||
// since these fields were introduced. Internally built blinded routes
|
||||
// also always contain both. Unlike an orphaned total, a point without
|
||||
// encrypted data is not a supported legacy encoding. Report it as
|
||||
// malformed instead of silently discarding it. Keep this rejection in
|
||||
// sync with normalizePaymentForCompare, which only normalizes hops
|
||||
// without a blinding point.
|
||||
hasEncryptedData := len(hop.EncryptedData) > 0
|
||||
if !hasEncryptedData && hop.BlindingPoint != nil {
|
||||
return fmt.Errorf("invalid blinded hop: payment_hash=%x, "+
|
||||
"attempt_index=%d, hop=%d: blinding point requires "+
|
||||
"encrypted recipient data", parentPaymentHash[:8],
|
||||
attemptIndex, hopIndex)
|
||||
}
|
||||
|
||||
// SendToRouteV2 historically allowed a blinded total amount without
|
||||
// blinded hop data. Omit such an orphaned total rather than creating a
|
||||
// blinded-hop row.
|
||||
if !hasEncryptedData && hop.TotalAmtMsat != 0 {
|
||||
log.Warnf("Ignoring orphaned blinded total amount: "+
|
||||
"payment_hash=%x, attempt_index=%d, hop=%d, "+
|
||||
"total_amt_msat=%d", parentPaymentHash[:8],
|
||||
attemptIndex, hopIndex, hop.TotalAmtMsat)
|
||||
}
|
||||
|
||||
// The blinding point and total amount are only associated fields. Use
|
||||
// the length so nil and empty encrypted data are handled consistently.
|
||||
if hasEncryptedData {
|
||||
var blindingPoint []byte
|
||||
if hop.BlindingPoint != nil {
|
||||
blindingPoint = hop.BlindingPoint.SerializeCompressed()
|
||||
|
|
|
|||
|
|
@ -940,6 +940,147 @@ func TestMigratePaymentWithBlindedRoute(t *testing.T) {
|
|||
assertPaymentDataMatches(t, ctx, kvDB, sqlStore, paymentHash)
|
||||
}
|
||||
|
||||
// TestMigrateOrphanedBlindedTotalAmount tests that a blinded total amount
|
||||
// without encrypted recipient data is normalized during migration.
|
||||
func TestMigrateOrphanedBlindedTotalAmount(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runTest := func(t *testing.T, hashString string,
|
||||
encryptedData []byte) {
|
||||
|
||||
t.Helper()
|
||||
|
||||
ctx := context.Background()
|
||||
kvDB := setupTestKVDB(t)
|
||||
|
||||
var paymentHash [32]byte
|
||||
copy(paymentHash[:], []byte(hashString))
|
||||
|
||||
err := kvdb.Update(kvDB, func(tx kvdb.RwTx) error {
|
||||
paymentsBucket, err := tx.CreateTopLevelBucket(
|
||||
paymentsRootBucket,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
indexBucket, err := tx.CreateTopLevelBucket(
|
||||
paymentsIndexBucket,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return createTestPayment(
|
||||
t, paymentsBucket, indexBucket,
|
||||
paymentTestConfig{
|
||||
hash: paymentHash,
|
||||
seqNum: 1,
|
||||
value: 120000,
|
||||
creationTime: time.Unix(1, 0),
|
||||
paymentRequest: hashString,
|
||||
attemptID: 1,
|
||||
numHops: 1,
|
||||
baseChannelID: 400000,
|
||||
baseTimeLock: 800000,
|
||||
hopConfigurator: func(hop *Hop, _ int,
|
||||
_ bool) {
|
||||
|
||||
hop.EncryptedData = encryptedData
|
||||
hop.TotalAmtMsat = 119400
|
||||
},
|
||||
},
|
||||
)
|
||||
}, func() {})
|
||||
require.NoError(t, err)
|
||||
|
||||
sqlStore := setupTestSQLDB(t)
|
||||
err = runPaymentsMigration(ctx, kvDB, sqlStore)
|
||||
require.NoError(t, err)
|
||||
|
||||
var hash lntypes.Hash
|
||||
copy(hash[:], paymentHash[:])
|
||||
payment, err := sqlStore.FetchPayment(ctx, hash)
|
||||
require.NoError(t, err)
|
||||
require.Zero(
|
||||
t, payment.HTLCs[0].Route.Hops[0].TotalAmtMsat,
|
||||
)
|
||||
|
||||
assertPaymentDataMatches(t, ctx, kvDB, sqlStore, paymentHash)
|
||||
}
|
||||
|
||||
t.Run("nil encrypted data", func(t *testing.T) {
|
||||
runTest(t, "orphaned_total_nil", nil)
|
||||
})
|
||||
t.Run("empty encrypted data", func(t *testing.T) {
|
||||
runTest(t, "orphaned_total_empty", []byte{})
|
||||
})
|
||||
}
|
||||
|
||||
// TestMigrateBlindingPointWithoutEncryptedData tests that migration reports a
|
||||
// malformed blinded hop with enough context to locate the affected attempt.
|
||||
func TestMigrateBlindingPointWithoutEncryptedData(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
kvDB := setupTestKVDB(t)
|
||||
|
||||
var paymentHash [32]byte
|
||||
copy(paymentHash[:], []byte("blinding_point_without_data"))
|
||||
var attemptHash lntypes.Hash
|
||||
copy(attemptHash[:], []byte("individual_amp_htlc_hash"))
|
||||
|
||||
err := kvdb.Update(kvDB, func(tx kvdb.RwTx) error {
|
||||
paymentsBucket, err := tx.CreateTopLevelBucket(
|
||||
paymentsRootBucket,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
indexBucket, err := tx.CreateTopLevelBucket(
|
||||
paymentsIndexBucket,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return createTestPayment(
|
||||
t, paymentsBucket, indexBucket, paymentTestConfig{
|
||||
hash: paymentHash,
|
||||
attemptHash: &attemptHash,
|
||||
seqNum: 1,
|
||||
value: 120000,
|
||||
creationTime: time.Unix(1, 0),
|
||||
paymentRequest: "blinding-point-without-data",
|
||||
attemptID: 1,
|
||||
numHops: 1,
|
||||
baseChannelID: 400000,
|
||||
baseTimeLock: 800000,
|
||||
hopConfigurator: func(hop *Hop, _ int, _ bool) {
|
||||
blindingKey, err := btcec.NewPrivateKey()
|
||||
require.NoError(t, err)
|
||||
|
||||
hop.BlindingPoint = blindingKey.PubKey()
|
||||
},
|
||||
},
|
||||
)
|
||||
}, func() {})
|
||||
require.NoError(t, err)
|
||||
|
||||
sqlStore := setupTestSQLDB(t)
|
||||
err = runPaymentsMigration(ctx, kvDB, sqlStore)
|
||||
require.ErrorContains(t, err, "blinding point requires encrypted "+
|
||||
"recipient data")
|
||||
require.ErrorContains(t, err, "attempt_index=1, hop=0")
|
||||
require.ErrorContains(t, err, fmt.Sprintf(
|
||||
"payment_hash=%x", paymentHash[:8],
|
||||
))
|
||||
require.NotContains(t, err.Error(), fmt.Sprintf(
|
||||
"payment_hash=%x", attemptHash[:8],
|
||||
))
|
||||
}
|
||||
|
||||
// TestMigratePaymentWithMetadata tests migration of a payment with hop
|
||||
// metadata.
|
||||
func TestMigratePaymentWithMetadata(t *testing.T) {
|
||||
|
|
@ -1934,6 +2075,7 @@ func assertPaymentDataMatches(t *testing.T, ctx context.Context,
|
|||
// features.
|
||||
type paymentTestConfig struct {
|
||||
hash [32]byte
|
||||
attemptHash *lntypes.Hash
|
||||
seqNum uint64
|
||||
value lnwire.MilliSatoshi
|
||||
creationTime time.Time
|
||||
|
|
@ -2128,6 +2270,11 @@ func createTestPayment(t *testing.T, paymentsBucket, indexBucket kvdb.RwBucket,
|
|||
}
|
||||
|
||||
// Create and serialize attempt info.
|
||||
attemptHash := (*lntypes.Hash)(&cfg.hash)
|
||||
if cfg.attemptHash != nil {
|
||||
attemptHash = cfg.attemptHash
|
||||
}
|
||||
|
||||
attemptInfo := &HTLCAttemptInfo{
|
||||
AttemptID: cfg.attemptID,
|
||||
sessionKey: sessionKeyBytes,
|
||||
|
|
@ -2139,7 +2286,7 @@ func createTestPayment(t *testing.T, paymentsBucket, indexBucket kvdb.RwBucket,
|
|||
FirstHopWireCustomRecords: cfg.attemptCustomRecs,
|
||||
},
|
||||
AttemptTime: cfg.creationTime.Add(time.Minute),
|
||||
Hash: (*lntypes.Hash)(&cfg.hash),
|
||||
Hash: attemptHash,
|
||||
}
|
||||
|
||||
if err = writeHTLCAttempt(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue