diff --git a/.github/workflows/pr-severity.yml b/.github/workflows/pr-severity.yml index 59ca1366ff..9be6048abe 100644 --- a/.github/workflows/pr-severity.yml +++ b/.github/workflows/pr-severity.yml @@ -31,11 +31,19 @@ jobs: with: fetch-depth: 1 + - name: Setup Bun + id: setup-bun + uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 + with: + bun-version: 1.3.6 + token: '' + - name: Classify PR with Claude uses: anthropics/claude-code-action@v1 with: claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} - github_token: ${{ secrets.PR_SEVERITY_BOT_TOKEN }} + github_token: ${{ github.token }} + path_to_bun_executable: ${{ steps.setup-bun.outputs.bun-path }} # Allow any user since this workflow only reads PR metadata via API # and doesn't execute any code from the PR. Tool permissions are diff --git a/config_builder.go b/config_builder.go index 0f563d6f26..5ab60a3b99 100644 --- a/config_builder.go +++ b/config_builder.go @@ -82,6 +82,11 @@ const ( // paymentMigration is the version number for the payments migration // that migrates KV payments to the native SQL schema. paymentMigration = 14 + + // taprootV2Migration is the version number for the migration that + // converts private taproot channels from V1 workaround storage to + // canonical V2 storage. + taprootV2Migration = 17 ) // GrpcRegistrar is an interface that must be satisfied by an external subserver @@ -1209,6 +1214,20 @@ func (d *DefaultDatabaseBuilder) BuildDatabase( continue + case taprootV2Migration: + if !d.cfg.Dev.GetSkipTaprootV2Migration() { //nolint:ll + taprootMig := func( + tx *sqlc.Queries, + ) error { + return graphdb.MigratePrivateTaprootToV2( //nolint:ll + ctx, tx, + ) + } + migrations[i].MigrationFn = taprootMig //nolint:ll + } + + continue + default: } diff --git a/docs/release-notes/release-notes-0.21.0.md b/docs/release-notes/release-notes-0.21.0.md index 64b1975988..6f52232121 100644 --- a/docs/release-notes/release-notes-0.21.0.md +++ b/docs/release-notes/release-notes-0.21.0.md @@ -275,6 +275,12 @@ [4](https://github.com/lightningnetwork/lnd/pull/10542), [5](https://github.com/lightningnetwork/lnd/pull/10572), [6](https://github.com/lightningnetwork/lnd/pull/10582). +* [Migrate private taproot channels in the SQL graph backend from the legacy + V1 workaround to canonical V2 storage](https://github.com/lightningnetwork/lnd/pull/10676). + Existing private taproot channels stored as V1 gossip objects with taproot + staging bits are rewritten as V2 channel rows, while a temporary `SQLStore` + shim keeps V1 graph callers and policy updates working until full gossip V2 + support lands. * Updated waiting proof persistence for gossip upgrades by introducing typed waiting proof keys and payloads, with a DB migration to rewrite legacy waiting proof records to the new key/value format diff --git a/graph/db/sql_store.go b/graph/db/sql_store.go index ff1170a92b..34aaa7be8a 100644 --- a/graph/db/sql_store.go +++ b/graph/db/sql_store.go @@ -1013,7 +1013,28 @@ func (s *SQLStore) ForEachNodeDirectedChannel(ctx context.Context, cb func(channel *DirectedChannel) error, reset func()) error { return s.db.ExecTx(ctx, sqldb.ReadTxOpt(), func(db SQLQueries) error { - return forEachNodeDirectedChannel(ctx, db, v, nodePub, cb) + err := forEachNodeDirectedChannel( + ctx, db, v, nodePub, cb, + ) + if err != nil { + return err + } + + // Also include our own V2 private taproot channels that + // were migrated from V1 workaround storage. This is safe + // because no real V2 channels exist in the DB until the + // gossiper gains V2 support, at which point this shim is + // removed. + // + // TODO(elle): remove when gossiper/builder are fully + // V2-aware. + if v == gossipV1 { + return forEachNodeDirectedChannel( + ctx, db, gossipV2, nodePub, cb, + ) + } + + return nil }, reset) } @@ -1057,6 +1078,18 @@ func (s *SQLStore) ForEachNodeChannel(ctx context.Context, cb func(*models.ChannelEdgeInfo, *models.ChannelEdgePolicy, *models.ChannelEdgePolicy) error, reset func()) error { + // projectedCb wraps the callback to project V2 edge info and + // policies back to V1. + // + // TODO(elle): remove when gossiper/builder are fully V2-aware. + projectedCb := func(edge *models.ChannelEdgeInfo, + p1, p2 *models.ChannelEdgePolicy) error { + + e, pp1, pp2 := projectV2EdgeInfoToV1(edge, p1, p2) + + return cb(e, pp1, pp2) + } + return s.db.ExecTx(ctx, sqldb.ReadTxOpt(), func(db SQLQueries) error { dbNode, err := db.GetNodeByPubKey( ctx, sqlc.GetNodeByPubKeyParams{ @@ -1070,7 +1103,39 @@ func (s *SQLStore) ForEachNodeChannel(ctx context.Context, return fmt.Errorf("unable to fetch node: %w", err) } - return forEachNodeChannel(ctx, db, s.cfg, v, dbNode.ID, cb) + err = forEachNodeChannel( + ctx, db, s.cfg, v, dbNode.ID, cb, + ) + if err != nil { + return err + } + + // Also include V2 private taproot channels that were + // migrated from V1 workaround storage. + // + // TODO(elle): remove when gossiper/builder are fully + // V2-aware. + if v == gossipV1 { + dbNode2, err := db.GetNodeByPubKey( + ctx, sqlc.GetNodeByPubKeyParams{ + Version: int16(gossipV2), + PubKey: nodePub[:], + }, + ) + if errors.Is(err, sql.ErrNoRows) { + return nil + } else if err != nil { + return fmt.Errorf("unable to fetch v2 "+ + "node: %w", err) + } + + return forEachNodeChannel( + ctx, db, s.cfg, gossipV2, + dbNode2.ID, projectedCb, + ) + } + + return nil }, reset) } @@ -1626,10 +1691,42 @@ func (s *SQLStore) ForEachChannelCacheable(ctx context.Context, ) } - return sqldb.ExecutePaginatedQuery( + err := sqldb.ExecutePaginatedQuery( ctx, s.cfg.QueryCfg, int64(-1), queryFunc, extractCursor, handleChannel, ) + if err != nil { + return err + } + + // Also include V2 private taproot channels in the cache + // so that pathfinding uses them. + // + // TODO(elle): remove when gossiper/builder are fully + // V2-aware. + if v == gossipV1 { + //nolint:ll + v2QueryFunc := func(ctx context.Context, lastID int64, + limit int32) ([]sqlc.ListChannelsWithPoliciesForCachePaginatedRow, + error) { + + return db.ListChannelsWithPoliciesForCachePaginated( //nolint:ll + ctx, sqlc.ListChannelsWithPoliciesForCachePaginatedParams{ + Version: int16(gossipV2), + ID: lastID, + Limit: limit, + }, + ) + } + + return sqldb.ExecutePaginatedQuery( + ctx, s.cfg.QueryCfg, int64(-1), + v2QueryFunc, extractCursor, + handleChannel, + ) + } + + return nil }, reset) } @@ -1654,7 +1751,33 @@ func (s *SQLStore) ForEachChannel(ctx context.Context, } return s.db.ExecTx(ctx, sqldb.ReadTxOpt(), func(db SQLQueries) error { - return forEachChannelWithPolicies(ctx, db, s.cfg, v, cb) + err := forEachChannelWithPolicies(ctx, db, s.cfg, v, cb) + if err != nil { + return err + } + + // Also include V2 private taproot channels that were + // migrated from V1 workaround storage. + // + // TODO(elle): remove when gossiper/builder are fully + // V2-aware. + if v == gossipV1 { + projectedCb := func(edge *models.ChannelEdgeInfo, + p1, p2 *models.ChannelEdgePolicy) error { + + e, pp1, pp2 := projectV2EdgeInfoToV1( + edge, p1, p2, + ) + + return cb(e, pp1, pp2) + } + + return forEachChannelWithPolicies( + ctx, db, s.cfg, gossipV2, projectedCb, + ) + } + + return nil }, reset) } @@ -2144,7 +2267,21 @@ func (s *SQLStore) FetchChannelEdgesByID(ctx context.Context, }, ) if errors.Is(err, sql.ErrNoRows) { - return ErrEdgeNotFound + // If this was a V1 lookup, try falling back + // to V2 in case the channel was migrated + // from the private taproot V1 workaround to + // canonical V2 storage. + // + // TODO(elle): remove when gossiper/builder + // are fully V2-aware. + if v != gossipV1 { + return ErrEdgeNotFound + } + + return fetchV2TaprootFallbackBySCID( + ctx, s.cfg, db, chanIDB, + &edge, &policy1, &policy2, + ) } else if err != nil { return fmt.Errorf("unable to check if "+ "channel is zombie: %w", err) @@ -2251,6 +2388,18 @@ func (s *SQLStore) FetchChannelEdgesByOutpoint(ctx context.Context, }, ) if errors.Is(err, sql.ErrNoRows) { + // If this was a V1 lookup, try falling back to V2 + // in case this is a migrated private taproot channel. + // + // TODO(elle): remove when gossiper/builder are fully + // V2-aware. + if v == gossipV1 { + return fetchV2TaprootFallbackByOutpoint( + ctx, s.cfg, db, op.String(), + &edge, &policy1, &policy2, + ) + } + return ErrEdgeNotFound } else if err != nil { return fmt.Errorf("unable to fetch channel: %w", err) @@ -2481,6 +2630,28 @@ func (s *SQLStore) HasChannelEdge(ctx context.Context, "is zombie: %w", err) } + // If the V1 lookup found nothing (not even a + // zombie), try falling back to V2 in case this + // is a migrated private taproot channel. + // + // TODO(elle): remove when gossiper/builder are + // fully V2-aware. + if !isZombie && v == gossipV1 { + v2Chan, v2Err := db.GetChannelBySCID( + ctx, sqlc.GetChannelBySCIDParams{ + Scid: chanIDB, + Version: int16(gossipV2), + }, + ) + if v2Err == nil && isPrivateTaprootV2( + v2Chan, + ) { + exists = true + + return nil + } + } + return nil } else if err != nil { return fmt.Errorf("unable to fetch channel: %w", err) @@ -2684,6 +2855,58 @@ func (s *SQLStore) FetchChanInfos(ctx context.Context, edges[c.Info.ChannelID] = c } + // For any channel IDs not found as V1, try V2 fallback + // for migrated private taproot channels. + // + // TODO(elle): remove when gossiper/builder are fully + // V2-aware. + if v == gossipV1 { + var missingIDs []uint64 + for _, id := range chanIDs { + if _, ok := edges[id]; !ok { + missingIDs = append(missingIDs, id) + } + } + + if len(missingIDs) > 0 { + var v2Rows []sqlc.GetChannelsBySCIDWithPoliciesRow //nolint:ll + v2CB := func(ctx context.Context, + row sqlc.GetChannelsBySCIDWithPoliciesRow) error { //nolint:ll + + v2Rows = append(v2Rows, row) + + return nil + } + + err := s.forEachChanWithPoliciesInSCIDList( + ctx, db, gossipV2, v2CB, + missingIDs, + ) + if err != nil { + return err + } + + v2Chans, err := batchBuildChannelEdges( + ctx, s.cfg, db, v2Rows, + ) + if err != nil { + return err + } + + for _, c := range v2Chans { + e, p1, p2 := projectV2EdgeInfoToV1( + c.Info, c.Policy1, + c.Policy2, + ) + edges[e.ChannelID] = ChannelEdge{ + Info: e, + Policy1: p1, + Policy2: p2, + } + } + } + } + return err }, func() { clear(edges) @@ -3147,11 +3370,23 @@ func (s *SQLStore) ChannelView(ctx context.Context, ) } - return sqldb.ExecuteCollectAndBatchWithSharedDataQuery( + err := sqldb.ExecuteCollectAndBatchWithSharedDataQuery( ctx, s.cfg.QueryCfg, int64(-1), queryFunc, extractCursor, collectID, loadChannelFeatures, handleChannel, ) + if err != nil { + return err + } + + // Also include V2 private taproot channels that + // were migrated from V1 workaround storage. + // + // TODO(elle): remove when gossiper/builder are + // fully V2-aware. + return appendV2TaprootEdgePoints( + ctx, s.cfg.QueryCfg, db, &edgePoints, + ) case gossipV2: handleChannel := func(_ context.Context, @@ -3519,9 +3754,21 @@ func (s *sqlNodeTraverser) ForEachNodeDirectedChannel( ctx context.Context, nodePub route.Vertex, cb func(channel *DirectedChannel) error, _ func()) error { - return forEachNodeDirectedChannel( + err := forEachNodeDirectedChannel( ctx, s.db, lnwire.GossipVersion1, nodePub, cb, ) + if err != nil { + return err + } + + // Also include our own V2 private taproot channels that were + // migrated from V1 workaround storage. Safe because no real V2 + // channels exist until the gossiper gains V2 support. + // + // TODO(elle): remove when gossiper/builder are fully V2-aware. + return forEachNodeDirectedChannel( + ctx, s.db, gossipV2, nodePub, cb, + ) } // FetchNodeFeatures returns the features of the given node. If the node is @@ -3830,7 +4077,30 @@ func updateChanEdgePolicy(ctx context.Context, tx SQLQueries, }, ) if errors.Is(err, sql.ErrNoRows) { - return node1Pub, node2Pub, false, ErrEdgeNotFound + // If this was a V1 policy update and the V1 channel is not + // found, try falling back to V2 in case this is a migrated + // private taproot channel. Convert the policy to V2 and + // proceed. + // + // TODO(elle): remove when gossiper/builder are fully + // V2-aware. + if version == gossipV1 { + dbChan2, err2 := tx.GetChannelAndNodesBySCID( + ctx, sqlc.GetChannelAndNodesBySCIDParams{ + Scid: chanIDB, + Version: int16(gossipV2), + }, + ) + if err2 == nil && len(dbChan2.Signature) == 0 { + dbChan = dbChan2 + edge = projectV1PolicyToV2(edge) + version = gossipV2 + } + } + + if dbChan.ID == 0 { + return node1Pub, node2Pub, false, ErrEdgeNotFound + } } else if err != nil { return node1Pub, node2Pub, false, fmt.Errorf("unable to "+ "fetch channel(%v): %w", edge.ChannelID, err) diff --git a/graph/db/taproot_v2_migration.go b/graph/db/taproot_v2_migration.go new file mode 100644 index 0000000000..e981788f68 --- /dev/null +++ b/graph/db/taproot_v2_migration.go @@ -0,0 +1,399 @@ +package graphdb + +import ( + "context" + "database/sql" + "fmt" + "time" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/lightningnetwork/lnd/fn/v2" + "github.com/lightningnetwork/lnd/input" + "github.com/lightningnetwork/lnd/lnwire" + "github.com/lightningnetwork/lnd/sqldb" + "github.com/lightningnetwork/lnd/sqldb/sqlc" +) + +// taprootMigrationCandidate holds a V1 channel with its node pubkeys for +// migration to V2. +type taprootMigrationCandidate struct { + channel sqlc.GraphChannel + node1PubKey []byte + node2PubKey []byte +} + +// MigratePrivateTaprootToV2 migrates private taproot channels from the V1 +// workaround storage format (V1 channel with SimpleTaprootChannelsStaging +// feature bit) to canonical V2 storage. This is the entry point used by the +// migration framework via config_builder. +// +// The migration: +// - Finds V1 channels with the taproot staging feature bit +// - Creates V2 shell nodes for both channel endpoints +// - Computes and stores the taproot funding script +// - Converts policies from timestamp-based to block-height-based +// - Copies features (minus the taproot staging bit), extras, and policies +// - Deletes the old V1 rows (CASCADE cleans up dependent rows) +// +// The migration is idempotent: after running, there are no more V1 channels +// with the taproot staging feature bit, so re-running is a no-op. +func MigratePrivateTaprootToV2(ctx context.Context, + tx *sqlc.Queries) error { + + return migratePrivateTaprootToV2(ctx, tx) +} + +// RunTaprootV2Migration runs the private taproot V1→V2 migration within the +// SQL store's transaction context. This is used in tests to run the migration +// against a test store. +func (s *SQLStore) RunTaprootV2Migration(ctx context.Context) error { + return s.db.ExecTx( + ctx, sqldb.WriteTxOpt(), func(db SQLQueries) error { + return migratePrivateTaprootToV2(ctx, db) + }, sqldb.NoOpReset, + ) +} + +// migratePrivateTaprootToV2 is the internal implementation that works with +// any type implementing the SQLQueries interface (which both *sqlc.Queries +// and the transaction wrapper satisfy). +func migratePrivateTaprootToV2(ctx context.Context, + tx SQLQueries) error { + + log.Infof("Starting private taproot V1→V2 migration") + t0 := time.Now() + + candidates, err := findTaprootV1Channels(ctx, tx) + if err != nil { + return fmt.Errorf("finding taproot v1 channels: %w", err) + } + + if len(candidates) == 0 { + log.Infof("No private taproot V1 channels found, skipping") + + return nil + } + + log.Infof("Found %d private taproot V1 channel(s) to migrate", + len(candidates)) + + for _, c := range candidates { + if err := migrateOneChannel(ctx, tx, c); err != nil { + return fmt.Errorf("migrating channel %x: %w", + c.channel.Scid, err) + } + } + + log.Infof("Finished private taproot V1→V2 migration of %d "+ + "channel(s) in %v", len(candidates), time.Since(t0)) + + return nil +} + +// findTaprootV1Channels returns all V1 channels that have the taproot staging +// feature bit set, along with their node pubkeys. +func findTaprootV1Channels(ctx context.Context, + tx SQLQueries) ([]taprootMigrationCandidate, error) { + + var result []taprootMigrationCandidate + + // Iterate V1 channels in pages using the full-row query which + // includes node pubkeys. + var lastID int64 = -1 + for { + rows, err := tx.ListChannelsWithPoliciesPaginated( + ctx, sqlc.ListChannelsWithPoliciesPaginatedParams{ + Version: int16(gossipV1), + ID: lastID, + Limit: 100, + }, + ) + if err != nil { + return nil, fmt.Errorf("listing channels: %w", err) + } + + if len(rows) == 0 { + break + } + + // Collect DB IDs for batch feature lookup. + ids := make([]int64, 0, len(rows)) + idToRow := make( + map[int64]sqlc.ListChannelsWithPoliciesPaginatedRow, + len(rows), + ) + for _, row := range rows { + ids = append(ids, row.GraphChannel.ID) + idToRow[row.GraphChannel.ID] = row + lastID = row.GraphChannel.ID + } + + // Batch load features for these channels. + features, err := tx.GetChannelFeaturesBatch(ctx, ids) + if err != nil { + return nil, fmt.Errorf("fetching features: %w", err) + } + + // Find channels with the taproot staging bit. + seen := make(map[int64]bool) + for _, f := range features { + if !isTaprootFeatureBit(int(f.FeatureBit)) { + continue + } + + if seen[f.ChannelID] { + continue + } + seen[f.ChannelID] = true + + row, ok := idToRow[f.ChannelID] + if !ok { + continue + } + + result = append(result, taprootMigrationCandidate{ + channel: row.GraphChannel, + node1PubKey: row.Node1Pubkey, + node2PubKey: row.Node2Pubkey, + }) + } + } + + return result, nil +} + +// migrateOneChannel migrates a single private taproot V1 channel to V2. +func migrateOneChannel(ctx context.Context, tx SQLQueries, + c taprootMigrationCandidate) error { + + ch := c.channel + + // 1. Parse bitcoin keys and compute taproot funding script. + pubKey1, err := btcec.ParsePubKey(ch.BitcoinKey1) + if err != nil { + return fmt.Errorf("parsing bitcoin key 1: %w", err) + } + + pubKey2, err := btcec.ParsePubKey(ch.BitcoinKey2) + if err != nil { + return fmt.Errorf("parsing bitcoin key 2: %w", err) + } + + var merkleRoot fn.Option[chainhash.Hash] + if len(ch.MerkleRootHash) > 0 { + var hash chainhash.Hash + copy(hash[:], ch.MerkleRootHash) + merkleRoot = fn.Some(hash) + } + + fundingScript, _, err := input.GenTaprootFundingScript( + pubKey1, pubKey2, 0, merkleRoot, + ) + if err != nil { + return fmt.Errorf("generating funding script: %w", err) + } + + // 2. Create V2 shell nodes (or get existing V2 node IDs). + v2Node1ID, err := tx.UpsertNode(ctx, sqlc.UpsertNodeParams{ + Version: int16(gossipV2), + PubKey: c.node1PubKey, + }) + if err != nil { + return fmt.Errorf("creating v2 shell node 1: %w", err) + } + + v2Node2ID, err := tx.UpsertNode(ctx, sqlc.UpsertNodeParams{ + Version: int16(gossipV2), + PubKey: c.node2PubKey, + }) + if err != nil { + return fmt.Errorf("creating v2 shell node 2: %w", err) + } + + // 3. Create V2 channel row. + v2ChanID, err := tx.CreateChannel(ctx, sqlc.CreateChannelParams{ + Version: int16(gossipV2), + Scid: ch.Scid, + NodeID1: v2Node1ID, + NodeID2: v2Node2ID, + Outpoint: ch.Outpoint, + Capacity: ch.Capacity, + BitcoinKey1: ch.BitcoinKey1, + BitcoinKey2: ch.BitcoinKey2, + FundingPkScript: fundingScript, + MerkleRootHash: ch.MerkleRootHash, + }) + if err != nil { + return fmt.Errorf("creating v2 channel: %w", err) + } + + // 4. Copy features (excluding taproot staging bits). + v1Features, err := tx.GetChannelFeaturesBatch(ctx, []int64{ch.ID}) + if err != nil { + return fmt.Errorf("fetching v1 features: %w", err) + } + + for _, f := range v1Features { + if isTaprootFeatureBit(int(f.FeatureBit)) { + continue + } + + err := tx.InsertChannelFeature( + ctx, sqlc.InsertChannelFeatureParams{ + ChannelID: v2ChanID, + FeatureBit: f.FeatureBit, + }, + ) + if err != nil { + return fmt.Errorf("copying feature %d: %w", + f.FeatureBit, err) + } + } + + // 5. Copy channel extra types. + v1Extras, err := tx.GetChannelExtrasBatch(ctx, []int64{ch.ID}) + if err != nil { + return fmt.Errorf("fetching v1 channel extras: %w", err) + } + + for _, extra := range v1Extras { + err := tx.UpsertChannelExtraType( + ctx, sqlc.UpsertChannelExtraTypeParams{ + ChannelID: v2ChanID, + Type: extra.Type, + Value: extra.Value, + }, + ) + if err != nil { + return fmt.Errorf("copying channel extra: %w", err) + } + } + + // 6. Convert and copy policies. + if err := migratePolicies(ctx, tx, ch, c, v2ChanID); err != nil { + return fmt.Errorf("migrating policies: %w", err) + } + + // 7. Delete old V1 channel (CASCADE handles features, extras, + // policies, policy extras). + err = tx.DeleteChannels(ctx, []int64{ch.ID}) + if err != nil { + return fmt.Errorf("deleting v1 channel: %w", err) + } + + return nil +} + +// migratePolicies converts V1 policies for a channel to V2 and inserts them. +func migratePolicies(ctx context.Context, tx SQLQueries, + v1Chan sqlc.GraphChannel, c taprootMigrationCandidate, + v2ChanID int64) error { + + // Map V1 node IDs to V2 node IDs via pubkey lookup. We already + // created the V2 shell nodes in migrateOneChannel. + v1NodeIDToV2 := make(map[int64]int64) + for _, entry := range []struct { + v1NodeID int64 + pubKey []byte + }{ + {v1Chan.NodeID1, c.node1PubKey}, + {v1Chan.NodeID2, c.node2PubKey}, + } { + v2ID, err := tx.GetNodeIDByPubKey( + ctx, sqlc.GetNodeIDByPubKeyParams{ + Version: int16(gossipV2), + PubKey: entry.pubKey, + }, + ) + if err != nil { + return fmt.Errorf("fetching v2 node ID: %w", err) + } + + v1NodeIDToV2[entry.v1NodeID] = v2ID + } + + // Fetch and convert policies for both directions. + for _, nodeID := range []int64{v1Chan.NodeID1, v1Chan.NodeID2} { + policy, err := tx.GetChannelPolicyByChannelAndNode( + ctx, sqlc.GetChannelPolicyByChannelAndNodeParams{ + Version: int16(gossipV1), + ChannelID: v1Chan.ID, + NodeID: nodeID, + }, + ) + if err != nil { + if err == sql.ErrNoRows { + continue + } + + return fmt.Errorf("fetching v1 policy: %w", err) + } + + // Convert timestamp to approximate block height. + var blockHeight sql.NullInt64 + if policy.LastUpdate.Valid { + ts := time.Unix(policy.LastUpdate.Int64, 0) + h := timestampToApproxBlockHeight(ts) + blockHeight = sqldb.SQLInt64(int64(h)) + } + + // Convert disabled bool to disable flags. + var disableFlags sql.NullInt16 + if policy.Disabled.Valid && policy.Disabled.Bool { + disableFlags = sqldb.SQLInt16( + lnwire.ChanUpdateDisableIncoming | + lnwire.ChanUpdateDisableOutgoing, + ) + } else { + disableFlags = sqldb.SQLInt16(0) + } + + v2NodeID := v1NodeIDToV2[nodeID] + + v2PolicyID, err := tx.UpsertEdgePolicy( + ctx, sqlc.UpsertEdgePolicyParams{ + Version: int16(gossipV2), + ChannelID: v2ChanID, + NodeID: v2NodeID, + Timelock: policy.Timelock, + FeePpm: policy.FeePpm, + BaseFeeMsat: policy.BaseFeeMsat, + MinHtlcMsat: policy.MinHtlcMsat, + MaxHtlcMsat: policy.MaxHtlcMsat, + BlockHeight: blockHeight, + DisableFlags: disableFlags, + InboundBaseFeeMsat: policy.InboundBaseFeeMsat, + InboundFeeRateMilliMsat: policy.InboundFeeRateMilliMsat, //nolint:ll + Signature: policy.Signature, + }, + ) + if err != nil { + return fmt.Errorf("upserting v2 policy: %w", err) + } + + // Copy policy extra types. + policyExtras, err := tx.GetChannelPolicyExtraTypesBatch( + ctx, []int64{policy.ID}, + ) + if err != nil { + return fmt.Errorf("fetching policy extras: %w", err) + } + + for _, extra := range policyExtras { + err := tx.UpsertChanPolicyExtraType( + ctx, sqlc.UpsertChanPolicyExtraTypeParams{ + ChannelPolicyID: v2PolicyID, + Type: extra.Type, + Value: extra.Value, + }, + ) + if err != nil { + return fmt.Errorf("copying policy extra: %w", + err) + } + } + } + + return nil +} diff --git a/graph/db/taproot_v2_migration_test.go b/graph/db/taproot_v2_migration_test.go new file mode 100644 index 0000000000..8fe7e34e0a --- /dev/null +++ b/graph/db/taproot_v2_migration_test.go @@ -0,0 +1,215 @@ +package graphdb + +import ( + "testing" + "time" + + "github.com/lightningnetwork/lnd/graph/db/models" + "github.com/lightningnetwork/lnd/lnwire" + "github.com/stretchr/testify/require" +) + +// TestMigratePrivateTaprootToV2 tests the full migration of a private taproot +// channel from V1 workaround storage to canonical V2. +func TestMigratePrivateTaprootToV2(t *testing.T) { + t.Parallel() + + if !isSQLDB { + t.Skip("migration test requires SQL backend") + } + + ctx := t.Context() + store := NewTestDB(t) + + // Create two test nodes and a source node. + sourceNode := createTestVertex(t, lnwire.GossipVersion1) + require.NoError(t, store.AddNode(ctx, sourceNode)) + require.NoError(t, store.SetSourceNode(ctx, sourceNode)) + + node1 := createTestVertex(t, lnwire.GossipVersion1) + node2 := createTestVertex(t, lnwire.GossipVersion1) + require.NoError(t, store.AddNode(ctx, node1)) + require.NoError(t, store.AddNode(ctx, node2)) + + // Create a V1 channel with the taproot staging feature bit and + // NO auth proof (simulating a private taproot channel). + edgeInfo, shortChanID := createEdge( + lnwire.GossipVersion1, 100, 1, 0, 0, + node1, node2, + true, // skipProof = true (private channel). + ) + + // Set the taproot staging feature bit on the channel. + taprootFeatures := lnwire.NewRawFeatureVector( + lnwire.SimpleTaprootChannelsRequiredStaging, + ) + edgeInfo.Features = lnwire.NewFeatureVector( + taprootFeatures, lnwire.Features, + ) + + require.NoError(t, store.AddChannelEdge(ctx, edgeInfo)) + + // Add a policy for the channel. + edgePolicy := &models.ChannelEdgePolicy{ + Version: lnwire.GossipVersion1, + ChannelID: shortChanID.ToUint64(), + LastUpdate: time.Unix(1690200000, 0), + MessageFlags: lnwire.ChanUpdateRequiredMaxHtlc, + ChannelFlags: 0, // Node1 direction, enabled. + TimeLockDelta: 40, + MinHTLC: 1000, + MaxHTLC: 500000000, + FeeBaseMSat: 1000, + FeeProportionalMillionths: 100, + } + _, _, err := store.UpdateEdgePolicy(ctx, edgePolicy) + require.NoError(t, err) + + // Verify the V1 channel exists before migration. + chanID := shortChanID.ToUint64() + dbEdge, p1, _, err := store.FetchChannelEdgesByID( + ctx, lnwire.GossipVersion1, chanID, + ) + require.NoError(t, err) + require.Equal(t, lnwire.GossipVersion1, dbEdge.Version) + require.True(t, dbEdge.Features.HasFeature( + lnwire.SimpleTaprootChannelsOptionalStaging, + )) + require.Nil(t, dbEdge.AuthProof) + require.NotNil(t, p1) + + // Run the migration. + sqlStore, ok := store.(*SQLStore) + require.True(t, ok, "test requires SQL store") + require.NoError(t, sqlStore.RunTaprootV2Migration(ctx)) + + // After migration, the V1 workaround row is gone and a V2 row + // exists. Thanks to the shim, querying as V1 should still work. + dbEdge2, p1After, _, err := store.FetchChannelEdgesByID( + ctx, lnwire.GossipVersion1, chanID, + ) + require.NoError(t, err) + + // The shim projects V2 back to V1, so the version should be V1 + // with the taproot feature bit re-added. + require.Equal(t, lnwire.GossipVersion1, dbEdge2.Version) + require.True(t, dbEdge2.Features.HasFeature( + lnwire.SimpleTaprootChannelsOptionalStaging, + )) + require.Nil(t, dbEdge2.AuthProof) + + // Verify the policy survived (projected back to V1). + require.NotNil(t, p1After) + require.Equal(t, uint16(40), p1After.TimeLockDelta) + require.Equal(t, lnwire.MilliSatoshi(1000), p1After.FeeBaseMSat) + require.Equal(t, lnwire.MilliSatoshi(100), + p1After.FeeProportionalMillionths) + + // HasChannelEdge should still find it. + exists, isZombie, err := store.HasChannelEdge( + ctx, lnwire.GossipVersion1, chanID, + ) + require.NoError(t, err) + require.True(t, exists) + require.False(t, isZombie) + + // ChannelView should include it (for startup chain filter). + edgePoints, err := store.ChannelView( + ctx, lnwire.GossipVersion1, + ) + require.NoError(t, err) + + found := false + for _, ep := range edgePoints { + if ep.OutPoint == edgeInfo.ChannelPoint { + found = true + + break + } + } + require.True(t, found, "migrated channel should appear in "+ + "ChannelView") +} + +// TestMigrateIdempotent verifies that running the migration twice is safe. +func TestMigrateIdempotent(t *testing.T) { + t.Parallel() + + if !isSQLDB { + t.Skip("migration test requires SQL backend") + } + + ctx := t.Context() + store := NewTestDB(t) + + sourceNode := createTestVertex(t, lnwire.GossipVersion1) + require.NoError(t, store.AddNode(ctx, sourceNode)) + require.NoError(t, store.SetSourceNode(ctx, sourceNode)) + + node1 := createTestVertex(t, lnwire.GossipVersion1) + node2 := createTestVertex(t, lnwire.GossipVersion1) + require.NoError(t, store.AddNode(ctx, node1)) + require.NoError(t, store.AddNode(ctx, node2)) + + // Create a private taproot V1 channel. + edgeInfo, _ := createEdge( + lnwire.GossipVersion1, 200, 1, 0, 0, + node1, node2, true, + ) + taprootFeatures := lnwire.NewRawFeatureVector( + lnwire.SimpleTaprootChannelsRequiredStaging, + ) + edgeInfo.Features = lnwire.NewFeatureVector( + taprootFeatures, lnwire.Features, + ) + require.NoError(t, store.AddChannelEdge(ctx, edgeInfo)) + + sqlStore := store.(*SQLStore) + + // Run migration twice — second run should be a no-op. + require.NoError(t, sqlStore.RunTaprootV2Migration(ctx)) + require.NoError(t, sqlStore.RunTaprootV2Migration(ctx)) +} + +// TestMigrateNonTaprootUntouched verifies that regular V1 channels are not +// affected by the migration. +func TestMigrateNonTaprootUntouched(t *testing.T) { + t.Parallel() + + if !isSQLDB { + t.Skip("migration test requires SQL backend") + } + + ctx := t.Context() + store := NewTestDB(t) + + sourceNode := createTestVertex(t, lnwire.GossipVersion1) + require.NoError(t, store.AddNode(ctx, sourceNode)) + require.NoError(t, store.SetSourceNode(ctx, sourceNode)) + + node1 := createTestVertex(t, lnwire.GossipVersion1) + node2 := createTestVertex(t, lnwire.GossipVersion1) + require.NoError(t, store.AddNode(ctx, node1)) + require.NoError(t, store.AddNode(ctx, node2)) + + // Create a regular V1 channel (no taproot feature bit, with proof). + edgeInfo, shortChanID := createEdge( + lnwire.GossipVersion1, 300, 1, 0, 0, + node1, node2, + ) + require.NoError(t, store.AddChannelEdge(ctx, edgeInfo)) + + sqlStore := store.(*SQLStore) + require.NoError(t, sqlStore.RunTaprootV2Migration(ctx)) + + // The regular channel should still be V1 and unchanged. + dbEdge, _, _, err := store.FetchChannelEdgesByID( + ctx, lnwire.GossipVersion1, shortChanID.ToUint64(), + ) + require.NoError(t, err) + require.Equal(t, lnwire.GossipVersion1, dbEdge.Version) + require.False(t, dbEdge.Features.HasFeature( + lnwire.SimpleTaprootChannelsOptionalStaging, + )) + require.NotNil(t, dbEdge.AuthProof) +} diff --git a/graph/db/taproot_v2_shim.go b/graph/db/taproot_v2_shim.go new file mode 100644 index 0000000000..95496920ec --- /dev/null +++ b/graph/db/taproot_v2_shim.go @@ -0,0 +1,457 @@ +package graphdb + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + "github.com/btcsuite/btcd/wire" + "github.com/lightningnetwork/lnd/fn/v2" + "github.com/lightningnetwork/lnd/graph/db/models" + "github.com/lightningnetwork/lnd/lnwire" + "github.com/lightningnetwork/lnd/sqldb" + "github.com/lightningnetwork/lnd/sqldb/sqlc" +) + +// This file contains helpers for the private taproot V1→V2 migration shim. +// +// Private taproot channels were historically stored as V1 gossip objects with +// the SimpleTaprootChannelsRequiredStaging feature bit (180) set as a +// workaround. After the SQL migration upgrades these to canonical V2 storage, +// the shim projects them back to V1 for callers that are not yet V2-aware. +// +// When the gossiper and builder gain full V2 support (the g175-HEAD branch), +// this shim becomes dead code and can be removed. + +const ( + // bitcoinGenesisTimestamp is the Unix timestamp of the Bitcoin genesis + // block, used for rough timestamp↔block-height approximation. + bitcoinGenesisTimestamp int64 = 1231006505 + + // avgBlockInterval is the average time between Bitcoin blocks in + // seconds, used for rough timestamp↔block-height approximation. + avgBlockInterval int64 = 600 +) + +// timestampToApproxBlockHeight converts a Unix timestamp to an approximate +// Bitcoin block height. This is intentionally rough — it is only used for +// migrated private taproot channels where exact freshness ordering is not +// critical. +func timestampToApproxBlockHeight(t time.Time) uint32 { + secs := t.Unix() - bitcoinGenesisTimestamp + if secs <= 0 { + return 0 + } + + return uint32(secs / avgBlockInterval) +} + +// approxBlockHeightToTimestamp converts a block height back to an approximate +// Unix timestamp. This is the inverse of timestampToApproxBlockHeight. +func approxBlockHeightToTimestamp(h uint32) time.Time { + return time.Unix( + int64(h)*avgBlockInterval+bitcoinGenesisTimestamp, 0, + ) +} + +// isPrivateTaprootV2 returns true if the given SQL channel row represents a +// private taproot channel that was migrated to V2 storage. In the near term, +// all V2 channels in the database are migrated private taproot channels since +// the gossiper does not yet create V2 channels. When full V2 gossip support +// lands, this function (and the entire shim) can be removed. +func isPrivateTaprootV2(ch sqlc.GraphChannel) bool { + return ch.Version == int16(gossipV2) && len(ch.Signature) == 0 +} + +// projectV2EdgeToV1 takes a canonical V2 ChannelEdgeInfo (from a migrated +// private taproot channel) and projects it back to a V1 representation with +// the taproot staging feature bit re-added. This allows callers that only +// understand V1 to continue operating on the channel. +func projectV2EdgeToV1( + edge *models.ChannelEdgeInfo) *models.ChannelEdgeInfo { + + // Build the V1 feature vector with the taproot staging bit. + fv := lnwire.EmptyFeatureVector() + if edge.Features != nil { + for bit := range edge.Features.Features() { + fv.Set(bit) + } + } + fv.Set(lnwire.SimpleTaprootChannelsRequiredStaging) + + // Convert ExtraSignedFields back to ExtraOpaqueData. + var extraOpaque []byte + if len(edge.ExtraSignedFields) > 0 { + recs, err := lnwire.CustomRecords( + edge.ExtraSignedFields, + ).Serialize() + if err == nil && recs != nil { + extraOpaque = recs + } + } + + return &models.ChannelEdgeInfo{ + Version: lnwire.GossipVersion1, + ChannelID: edge.ChannelID, + ChainHash: edge.ChainHash, + NodeKey1Bytes: edge.NodeKey1Bytes, + NodeKey2Bytes: edge.NodeKey2Bytes, + BitcoinKey1Bytes: edge.BitcoinKey1Bytes, + BitcoinKey2Bytes: edge.BitcoinKey2Bytes, + Features: fv, + AuthProof: nil, // Private channel. + ChannelPoint: edge.ChannelPoint, + Capacity: edge.Capacity, + MerkleRootHash: edge.MerkleRootHash, + ExtraOpaqueData: extraOpaque, + } +} + +// projectV2PolicyToV1 takes a canonical V2 ChannelEdgePolicy and projects it +// back to V1 representation. The block height is converted to an approximate +// timestamp, and the V2-specific direction/disable fields are mapped to V1 +// ChannelFlags. +func projectV2PolicyToV1( + p *models.ChannelEdgePolicy) *models.ChannelEdgePolicy { + + if p == nil { + return nil + } + + // Build V1 ChannelFlags from V2 direction and disable state. + var chanFlags lnwire.ChanUpdateChanFlags + if p.SecondPeer { + chanFlags |= lnwire.ChanUpdateDirection + } + if !p.DisableFlags.IsEnabled() { + chanFlags |= lnwire.ChanUpdateDisabled + } + + // MaxHTLC is always present for V2, so set the message flag. + msgFlags := lnwire.ChanUpdateRequiredMaxHtlc + + // Convert ExtraSignedFields back to ExtraOpaqueData. + var extraOpaque lnwire.ExtraOpaqueData + if len(p.ExtraSignedFields) > 0 { + recs, err := lnwire.CustomRecords( + p.ExtraSignedFields, + ).Serialize() + if err == nil && recs != nil { + extraOpaque = recs + } + } + + return &models.ChannelEdgePolicy{ + Version: lnwire.GossipVersion1, + SigBytes: p.SigBytes, + ChannelID: p.ChannelID, + LastUpdate: approxBlockHeightToTimestamp(p.LastBlockHeight), //nolint:ll + MessageFlags: msgFlags, + ChannelFlags: chanFlags, + TimeLockDelta: p.TimeLockDelta, + MinHTLC: p.MinHTLC, + MaxHTLC: p.MaxHTLC, + FeeBaseMSat: p.FeeBaseMSat, + FeeProportionalMillionths: p.FeeProportionalMillionths, + ToNode: p.ToNode, + InboundFee: p.InboundFee, + ExtraOpaqueData: extraOpaque, + } +} + +// projectV1PolicyToV2 takes a V1 ChannelEdgePolicy (from a caller updating a +// migrated private taproot channel) and converts it to V2 representation so it +// can be stored alongside the V2 channel row. +func projectV1PolicyToV2( + p *models.ChannelEdgePolicy) *models.ChannelEdgePolicy { + + if p == nil { + return nil + } + + // Derive V2 direction from V1 ChannelFlags. + secondPeer := p.ChannelFlags&lnwire.ChanUpdateDirection != 0 + + // Derive V2 disable flags from V1 ChannelFlags. + var disableFlags lnwire.ChanUpdateDisableFlags + if p.ChannelFlags.IsDisabled() { + disableFlags = lnwire.ChanUpdateDisableIncoming | + lnwire.ChanUpdateDisableOutgoing + } + + // Convert ExtraOpaqueData to ExtraSignedFields. + var extraSigned map[uint64][]byte + if len(p.ExtraOpaqueData) > 0 { + m, err := marshalExtraOpaqueData(p.ExtraOpaqueData) + if err == nil { + extraSigned = m + } + } + + return &models.ChannelEdgePolicy{ + Version: lnwire.GossipVersion2, + SigBytes: p.SigBytes, + ChannelID: p.ChannelID, + LastBlockHeight: timestampToApproxBlockHeight(p.LastUpdate), //nolint:ll + SecondPeer: secondPeer, + DisableFlags: disableFlags, + TimeLockDelta: p.TimeLockDelta, + MinHTLC: p.MinHTLC, + MaxHTLC: p.MaxHTLC, + FeeBaseMSat: p.FeeBaseMSat, + FeeProportionalMillionths: p.FeeProportionalMillionths, + ToNode: p.ToNode, + InboundFee: p.InboundFee, + ExtraSignedFields: extraSigned, + } +} + +// projectV2EdgeInfoToV1 is a convenience wrapper for projection that also +// projects policies. It projects a V2 private taproot edge and its associated +// policies back to V1 representation. +func projectV2EdgeInfoToV1(edge *models.ChannelEdgeInfo, + p1, p2 *models.ChannelEdgePolicy) (*models.ChannelEdgeInfo, + *models.ChannelEdgePolicy, *models.ChannelEdgePolicy) { + + return projectV2EdgeToV1(edge), + projectV2PolicyToV1(p1), + projectV2PolicyToV1(p2) +} + +// isTaprootFeatureBit returns true if the given feature bit is one of the +// taproot staging feature bits used in the V1 workaround. +func isTaprootFeatureBit(bit int) bool { + return bit == int(lnwire.SimpleTaprootChannelsRequiredStaging) || + bit == int(lnwire.SimpleTaprootChannelsOptionalStaging) +} + +// featureVectorWithoutTaproot returns a new feature vector with the taproot +// staging bits removed. Used during migration to strip the workaround bits +// from the canonical V2 representation. +func featureVectorWithoutTaproot( + fv *lnwire.FeatureVector) *lnwire.FeatureVector { + + if fv == nil { + return lnwire.EmptyFeatureVector() + } + + result := lnwire.EmptyFeatureVector() + for bit := range fv.Features() { + if !isTaprootFeatureBit(int(bit)) { + result.Set(bit) + } + } + + return result +} + +// v1EdgeIsPrivateTaproot checks if a V1 ChannelEdgeInfo is a private taproot +// workaround channel by examining the feature bits. Used to identify channels +// that need migration. +func v1EdgeIsPrivateTaproot(edge *models.ChannelEdgeInfo) bool { + if edge.Version != lnwire.GossipVersion1 { + return false + } + + if edge.Features == nil { + return false + } + + return edge.Features.HasFeature( + lnwire.SimpleTaprootChannelsOptionalStaging, + ) +} + +// v2PrivateTaprootFallbackVersion returns gossipV2 if the requested version is +// gossipV1, indicating the shim should try a V2 fallback lookup. Returns +// fn.None if no fallback is applicable. +func v2PrivateTaprootFallbackVersion( + v lnwire.GossipVersion) fn.Option[lnwire.GossipVersion] { + + if v == gossipV1 { + return fn.Some(gossipV2) + } + + return fn.None[lnwire.GossipVersion]() +} + +// fetchV2TaprootFallbackBySCID attempts to find a V2 private taproot channel +// by SCID when a V1 lookup has failed. If found, it projects the result back +// to V1 and populates the output pointers. Returns ErrEdgeNotFound if no V2 +// fallback exists. +// +// TODO(elle): remove when gossiper/builder are fully V2-aware. +func fetchV2TaprootFallbackBySCID(ctx context.Context, cfg *SQLStoreConfig, + db SQLQueries, chanIDB []byte, + edge **models.ChannelEdgeInfo, + pol1, pol2 **models.ChannelEdgePolicy) error { + + row, err := db.GetChannelBySCIDWithPolicies( + ctx, sqlc.GetChannelBySCIDWithPoliciesParams{ + Scid: chanIDB, + Version: int16(gossipV2), + }, + ) + if errors.Is(err, sql.ErrNoRows) { + return ErrEdgeNotFound + } else if err != nil { + return fmt.Errorf("unable to fetch v2 fallback channel: %w", + err) + } + + // Only project private taproot V2 channels. + if !isPrivateTaprootV2(row.GraphChannel) { + return ErrEdgeNotFound + } + + node1, node2, err := buildNodeVertices( + row.GraphNode.PubKey, row.GraphNode_2.PubKey, + ) + if err != nil { + return err + } + + v2Edge, err := getAndBuildEdgeInfo( + ctx, cfg, db, row.GraphChannel, node1, node2, + ) + if err != nil { + return fmt.Errorf("unable to build v2 fallback channel "+ + "info: %w", err) + } + + dbPol1, dbPol2, err := extractChannelPolicies(row) + if err != nil { + return fmt.Errorf("unable to extract v2 fallback "+ + "policies: %w", err) + } + + v2Pol1, v2Pol2, err := getAndBuildChanPolicies( + ctx, cfg.QueryCfg, db, dbPol1, dbPol2, v2Edge.ChannelID, + node1, node2, + ) + if err != nil { + return fmt.Errorf("unable to build v2 fallback "+ + "policies: %w", err) + } + + // Project back to V1. + *edge, *pol1, *pol2 = projectV2EdgeInfoToV1(v2Edge, v2Pol1, v2Pol2) + + return nil +} + +// fetchV2TaprootFallbackByOutpoint attempts to find a V2 private taproot +// channel by outpoint when a V1 lookup has failed. If found, it projects the +// result back to V1 and populates the output pointers. Returns ErrEdgeNotFound +// if no V2 fallback exists. +// +// TODO(elle): remove when gossiper/builder are fully V2-aware. +func fetchV2TaprootFallbackByOutpoint(ctx context.Context, cfg *SQLStoreConfig, + db SQLQueries, outpoint string, + edge **models.ChannelEdgeInfo, + pol1, pol2 **models.ChannelEdgePolicy) error { + + row, err := db.GetChannelByOutpointWithPolicies( + ctx, sqlc.GetChannelByOutpointWithPoliciesParams{ + Outpoint: outpoint, + Version: int16(gossipV2), + }, + ) + if errors.Is(err, sql.ErrNoRows) { + return ErrEdgeNotFound + } else if err != nil { + return fmt.Errorf("unable to fetch v2 fallback channel: %w", + err) + } + + // Only project private taproot V2 channels. + if !isPrivateTaprootV2(row.GraphChannel) { + return ErrEdgeNotFound + } + + node1, node2, err := buildNodeVertices( + row.Node1Pubkey, row.Node2Pubkey, + ) + if err != nil { + return err + } + + v2Edge, err := getAndBuildEdgeInfo( + ctx, cfg, db, row.GraphChannel, node1, node2, + ) + if err != nil { + return fmt.Errorf("unable to build v2 fallback channel "+ + "info: %w", err) + } + + dbPol1, dbPol2, err := extractChannelPolicies(row) + if err != nil { + return fmt.Errorf("unable to extract v2 fallback "+ + "policies: %w", err) + } + + v2Pol1, v2Pol2, err := getAndBuildChanPolicies( + ctx, cfg.QueryCfg, db, dbPol1, dbPol2, v2Edge.ChannelID, + node1, node2, + ) + if err != nil { + return fmt.Errorf("unable to build v2 fallback "+ + "policies: %w", err) + } + + // Project back to V1. + *edge, *pol1, *pol2 = projectV2EdgeInfoToV1(v2Edge, v2Pol1, v2Pol2) + + return nil +} + +// appendV2TaprootEdgePoints iterates V2 channels and appends EdgePoints for +// any private taproot channels to the given slice. This is used by ChannelView +// in the V1 path to include migrated private taproot channels in the startup +// chain filter. +// +// TODO(elle): remove when gossiper/builder are fully V2-aware. +func appendV2TaprootEdgePoints(ctx context.Context, + queryCfg *sqldb.QueryConfig, db SQLQueries, + edgePoints *[]EdgePoint) error { + + handleChannel := func(_ context.Context, + channel sqlc.ListChannelsPaginatedV2Row) error { + + op, err := wire.NewOutPointFromString(channel.Outpoint) + if err != nil { + return err + } + + *edgePoints = append(*edgePoints, EdgePoint{ + FundingPkScript: channel.FundingPkScript, + OutPoint: *op, + }) + + return nil + } + + queryFunc := func(ctx context.Context, lastID int64, + limit int32) ([]sqlc.ListChannelsPaginatedV2Row, error) { + + return db.ListChannelsPaginatedV2( + ctx, sqlc.ListChannelsPaginatedV2Params{ + ID: lastID, + Limit: limit, + }, + ) + } + + extractCursor := func( + row sqlc.ListChannelsPaginatedV2Row) int64 { + + return row.ID + } + + return sqldb.ExecutePaginatedQuery( + ctx, queryCfg, int64(-1), queryFunc, + extractCursor, handleChannel, + ) +} diff --git a/graph/db/taproot_v2_shim_test.go b/graph/db/taproot_v2_shim_test.go new file mode 100644 index 0000000000..daaa3dbc2b --- /dev/null +++ b/graph/db/taproot_v2_shim_test.go @@ -0,0 +1,290 @@ +package graphdb + +import ( + "testing" + "time" + + "github.com/lightningnetwork/lnd/fn/v2" + "github.com/lightningnetwork/lnd/graph/db/models" + "github.com/lightningnetwork/lnd/lnwire" + "github.com/lightningnetwork/lnd/routing/route" + "github.com/lightningnetwork/lnd/sqldb/sqlc" + "github.com/stretchr/testify/require" +) + +// TestTimestampBlockHeightRoundTrip tests that the approximate +// timestamp↔block-height conversion round-trips within one block interval. +func TestTimestampBlockHeightRoundTrip(t *testing.T) { + t.Parallel() + + // Pick a timestamp corresponding to a known block height. + // Block 800000 was mined around 2023-07-24. The exact mapping + // doesn't matter — we just verify that the round-trip is + // within one block interval. + original := time.Unix(1690200000, 0) + + height := timestampToApproxBlockHeight(original) + recovered := approxBlockHeightToTimestamp(height) + + diff := original.Sub(recovered) + if diff < 0 { + diff = -diff + } + require.Less(t, diff, time.Duration(avgBlockInterval)*time.Second, + "round-trip should be within one block interval") +} + +// TestTimestampToBlockHeightZero tests that timestamps before genesis return +// block height 0. +func TestTimestampToBlockHeightZero(t *testing.T) { + t.Parallel() + + require.Equal(t, uint32(0), + timestampToApproxBlockHeight(time.Unix(0, 0))) + require.Equal(t, uint32(0), + timestampToApproxBlockHeight(time.Unix( + bitcoinGenesisTimestamp-1, 0))) +} + +// TestIsPrivateTaprootV2 tests the detection helper for migrated private +// taproot channels. +func TestIsPrivateTaprootV2(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + ch sqlc.GraphChannel + expect bool + }{ + { + name: "v2 no signature - private taproot", + ch: sqlc.GraphChannel{ + Version: int16(gossipV2), + Signature: nil, + }, + expect: true, + }, + { + name: "v2 with signature - public", + ch: sqlc.GraphChannel{ + Version: int16(gossipV2), + Signature: []byte{0x01, 0x02}, + }, + expect: false, + }, + { + name: "v1 no signature - regular private v1", + ch: sqlc.GraphChannel{ + Version: int16(gossipV1), + Signature: nil, + }, + expect: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.expect, isPrivateTaprootV2(tt.ch)) + }) + } +} + +// TestProjectV2EdgeToV1 tests that a V2 edge is correctly projected back to +// V1 with the taproot staging feature bit re-added. +func TestProjectV2EdgeToV1(t *testing.T) { + t.Parallel() + + node1 := route.Vertex{0x01} + node2 := route.Vertex{0x02} + btcKey1 := route.Vertex{0x03} + btcKey2 := route.Vertex{0x04} + + v2Edge := &models.ChannelEdgeInfo{ + Version: lnwire.GossipVersion2, + ChannelID: 12345, + NodeKey1Bytes: node1, + NodeKey2Bytes: node2, + BitcoinKey1Bytes: fn.Some(btcKey1), + BitcoinKey2Bytes: fn.Some(btcKey2), + Features: lnwire.EmptyFeatureVector(), + Capacity: 1000000, + } + + v1Edge := projectV2EdgeToV1(v2Edge) + + require.Equal(t, lnwire.GossipVersion1, v1Edge.Version) + require.Equal(t, uint64(12345), v1Edge.ChannelID) + require.Equal(t, node1, v1Edge.NodeKey1Bytes) + require.Equal(t, node2, v1Edge.NodeKey2Bytes) + require.Equal(t, fn.Some(btcKey1), v1Edge.BitcoinKey1Bytes) + require.Equal(t, fn.Some(btcKey2), v1Edge.BitcoinKey2Bytes) + require.Nil(t, v1Edge.AuthProof) + + // The taproot staging bit should be present. + require.True(t, v1Edge.Features.HasFeature( + lnwire.SimpleTaprootChannelsOptionalStaging, + )) +} + +// TestProjectV2PolicyToV1 tests that a V2 policy is correctly projected back +// to V1 with approximate timestamp and V1-style flags. +func TestProjectV2PolicyToV1(t *testing.T) { + t.Parallel() + + v2Policy := &models.ChannelEdgePolicy{ + Version: lnwire.GossipVersion2, + ChannelID: 12345, + LastBlockHeight: 800000, + SecondPeer: true, + DisableFlags: 0, + TimeLockDelta: 40, + MinHTLC: 1000, + MaxHTLC: 500000000, + FeeBaseMSat: 1000, + FeeProportionalMillionths: 100, + } + + v1Policy := projectV2PolicyToV1(v2Policy) + + require.Equal(t, lnwire.GossipVersion1, v1Policy.Version) + require.Equal(t, uint64(12345), v1Policy.ChannelID) + + // Timestamp should be approximately correct. + expectedTime := approxBlockHeightToTimestamp(800000) + require.Equal(t, expectedTime, v1Policy.LastUpdate) + + // Direction bit should be set (SecondPeer = true). + require.True(t, + v1Policy.ChannelFlags&lnwire.ChanUpdateDirection != 0) + + // Disabled bit should NOT be set (DisableFlags = 0 = enabled). + require.False(t, v1Policy.ChannelFlags.IsDisabled()) + + // MaxHTLC flag should be set. + require.True(t, v1Policy.MessageFlags.HasMaxHtlc()) + + // Fee/HTLC fields should be preserved. + require.Equal(t, uint16(40), v1Policy.TimeLockDelta) + require.Equal(t, lnwire.MilliSatoshi(1000), v1Policy.MinHTLC) + require.Equal(t, lnwire.MilliSatoshi(500000000), v1Policy.MaxHTLC) + require.Equal(t, lnwire.MilliSatoshi(1000), v1Policy.FeeBaseMSat) + require.Equal(t, lnwire.MilliSatoshi(100), + v1Policy.FeeProportionalMillionths) +} + +// TestProjectV2PolicyToV1Disabled tests that a disabled V2 policy correctly +// sets the disabled flag in the V1 projection. +func TestProjectV2PolicyToV1Disabled(t *testing.T) { + t.Parallel() + + v2Policy := &models.ChannelEdgePolicy{ + Version: lnwire.GossipVersion2, + LastBlockHeight: 800000, + DisableFlags: lnwire.ChanUpdateDisableIncoming | + lnwire.ChanUpdateDisableOutgoing, + } + + v1Policy := projectV2PolicyToV1(v2Policy) + require.True(t, v1Policy.ChannelFlags.IsDisabled()) +} + +// TestProjectV1PolicyToV2 tests that a V1 policy is correctly converted to +// V2 representation for writing to a migrated V2 channel. +func TestProjectV1PolicyToV2(t *testing.T) { + t.Parallel() + + ts := time.Unix(1690200000, 0) + v1Policy := &models.ChannelEdgePolicy{ + Version: lnwire.GossipVersion1, + ChannelID: 12345, + LastUpdate: ts, + ChannelFlags: lnwire.ChanUpdateDirection | lnwire.ChanUpdateDisabled, //nolint:ll + MessageFlags: lnwire.ChanUpdateRequiredMaxHtlc, + TimeLockDelta: 40, + MinHTLC: 1000, + MaxHTLC: 500000000, + FeeBaseMSat: 1000, + FeeProportionalMillionths: 100, + } + + v2Policy := projectV1PolicyToV2(v1Policy) + + require.Equal(t, lnwire.GossipVersion2, v2Policy.Version) + require.Equal(t, uint64(12345), v2Policy.ChannelID) + + // Block height should be approximately correct. + expectedHeight := timestampToApproxBlockHeight(ts) + require.Equal(t, expectedHeight, v2Policy.LastBlockHeight) + + // Direction: ChanUpdateDirection set means SecondPeer = true. + require.True(t, v2Policy.SecondPeer) + + // Disabled: both incoming and outgoing should be disabled. + require.True(t, v2Policy.DisableFlags.IncomingDisabled()) + require.True(t, v2Policy.DisableFlags.OutgoingDisabled()) + + // Fee/HTLC fields should be preserved. + require.Equal(t, uint16(40), v2Policy.TimeLockDelta) + require.Equal(t, lnwire.MilliSatoshi(1000), v2Policy.MinHTLC) + require.Equal(t, lnwire.MilliSatoshi(500000000), v2Policy.MaxHTLC) + require.Equal(t, lnwire.MilliSatoshi(1000), v2Policy.FeeBaseMSat) + require.Equal(t, lnwire.MilliSatoshi(100), + v2Policy.FeeProportionalMillionths) +} + +// TestProjectV2PolicyToV1Nil tests that nil policies are handled gracefully. +func TestProjectV2PolicyToV1Nil(t *testing.T) { + t.Parallel() + + require.Nil(t, projectV2PolicyToV1(nil)) + require.Nil(t, projectV1PolicyToV2(nil)) +} + +// TestFeatureVectorWithoutTaproot tests that the taproot staging bits are +// stripped while other bits are preserved. +func TestFeatureVectorWithoutTaproot(t *testing.T) { + t.Parallel() + + fv := lnwire.EmptyFeatureVector() + fv.Set(lnwire.SimpleTaprootChannelsRequiredStaging) + fv.Set(lnwire.SimpleTaprootChannelsOptionalStaging) + fv.Set(lnwire.StaticRemoteKeyRequired) // Should survive. + + result := featureVectorWithoutTaproot(fv) + + require.False(t, result.HasFeature( + lnwire.SimpleTaprootChannelsOptionalStaging, + )) + require.True(t, result.HasFeature( + lnwire.StaticRemoteKeyRequired, + )) +} + +// TestV1EdgeIsPrivateTaproot tests the V1 workaround detection. +func TestV1EdgeIsPrivateTaproot(t *testing.T) { + t.Parallel() + + // V1 edge with taproot feature bit. + fv := lnwire.EmptyFeatureVector() + fv.Set(lnwire.SimpleTaprootChannelsRequiredStaging) + + taprootEdge := &models.ChannelEdgeInfo{ + Version: lnwire.GossipVersion1, + Features: fv, + } + require.True(t, v1EdgeIsPrivateTaproot(taprootEdge)) + + // V1 edge without taproot feature bit. + normalEdge := &models.ChannelEdgeInfo{ + Version: lnwire.GossipVersion1, + Features: lnwire.EmptyFeatureVector(), + } + require.False(t, v1EdgeIsPrivateTaproot(normalEdge)) + + // V2 edge should not match. + v2Edge := &models.ChannelEdgeInfo{ + Version: lnwire.GossipVersion2, + Features: fv, + } + require.False(t, v1EdgeIsPrivateTaproot(v2Edge)) +} diff --git a/itest/list_on_test.go b/itest/list_on_test.go index 9bc384a2c6..007a1951b3 100644 --- a/itest/list_on_test.go +++ b/itest/list_on_test.go @@ -515,6 +515,10 @@ var allTestCases = []*lntest.TestCase{ Name: "simple taproot channel activation", TestFunc: testSimpleTaprootChannelActivation, }, + { + Name: "private taproot v2 migration", + TestFunc: testPrivateTaprootV2Migration, + }, { Name: "wallet import pubkey", TestFunc: testWalletImportPubKey, diff --git a/itest/lnd_taproot_v2_migration_test.go b/itest/lnd_taproot_v2_migration_test.go new file mode 100644 index 0000000000..0afb4cc6f2 --- /dev/null +++ b/itest/lnd_taproot_v2_migration_test.go @@ -0,0 +1,174 @@ +package itest + +import ( + "github.com/lightningnetwork/lnd/lnrpc" + "github.com/lightningnetwork/lnd/lntest" + "github.com/stretchr/testify/require" +) + +// testPrivateTaprootV2Migration tests that a private taproot channel survives +// the V1→V2 SQL data migration. The test: +// +// 1. Starts Alice with --dev.skip-taproot-v2-migration so the migration +// does not run on first startup. +// 2. Opens a private taproot channel between Alice and Bob. +// 3. Sends a payment from Alice to Bob to verify the channel works. +// 4. Restarts Alice WITHOUT the skip flag so the migration runs. +// 5. Verifies the channel is still active and payments still work. +// 6. Bob updates his channel policy and Alice correctly receives and +// persists the V1 update against the now-V2-stored channel. +func testPrivateTaprootV2Migration(ht *lntest.HarnessTest) { + // Args for taproot channel support. + taprootArgs := lntest.NodeArgsForCommitType( + lnrpc.CommitmentType_SIMPLE_TAPROOT, + ) + + // Start Alice with the skip flag so the migration doesn't run yet. + skipMigArgs := append( + taprootArgs, "--dev.skip-taproot-v2-migration", + ) + alice := ht.NewNodeWithCoins("Alice", skipMigArgs) + bob := ht.NewNodeWithCoins("Bob", taprootArgs) + + ht.EnsureConnected(alice, bob) + + // Open a private taproot channel. + const chanAmt = 1_000_000 + params := lntest.OpenChannelParams{ + Amt: chanAmt, + CommitmentType: lnrpc.CommitmentType_SIMPLE_TAPROOT, + Private: true, + } + pendingChan := ht.OpenChannelAssertPending(alice, bob, params) + chanPoint := lntest.ChanPointFromPendingUpdate(pendingChan) + + // Mine blocks to confirm the channel. + ht.MineBlocksAndAssertNumTxes(6, 1) + ht.AssertChannelActive(alice, chanPoint) + ht.AssertChannelActive(bob, chanPoint) + + // Send a payment pre-migration to verify the channel works. + const paymentAmt = 10_000 + invoice := bob.RPC.AddInvoice(&lnrpc.Invoice{ + Value: paymentAmt, + }) + ht.CompletePaymentRequests(alice, []string{invoice.PaymentRequest}) + + // Restart Alice WITHOUT the skip flag. This allows the taproot V2 + // migration to run, converting the private taproot channel from V1 + // workaround storage to canonical V2. + alice.SetExtraArgs(taprootArgs) + ht.RestartNode(alice) + + // Ensure reconnection. + ht.EnsureConnected(alice, bob) + + // Verify the channel is still active after migration. + ht.AssertChannelActive(alice, chanPoint) + + // Send another payment post-migration to verify the channel still + // works for pathfinding and forwarding. + invoice2 := bob.RPC.AddInvoice(&lnrpc.Invoice{ + Value: paymentAmt, + }) + ht.CompletePaymentRequests(alice, []string{invoice2.PaymentRequest}) + + // Also send a payment in the other direction to verify both policy + // directions survived the migration. + invoice3 := alice.RPC.AddInvoice(&lnrpc.Invoice{ + Value: paymentAmt, + }) + ht.CompletePaymentRequests(bob, []string{invoice3.PaymentRequest}) + + // Now test that Bob can update his channel policy and Alice + // correctly receives the V1 ChannelUpdate and persists it against + // the now-V2-stored channel. This exercises the UpdateEdgePolicy + // shim that converts incoming V1 policy updates to V2 for migrated + // private taproot channels. + const ( + newBaseFee = 5000 + newFeeRate = 500 + newTimeLockDelta = 80 + ) + + expectedPolicy := &lnrpc.RoutingPolicy{ + FeeBaseMsat: newBaseFee, + FeeRateMilliMsat: newFeeRate, + TimeLockDelta: newTimeLockDelta, + MinHtlc: 1000, + MaxHtlcMsat: 990_000_000, + } + + req := &lnrpc.PolicyUpdateRequest{ + BaseFeeMsat: newBaseFee, + FeeRate: float64(newFeeRate) / 1_000_000, + TimeLockDelta: newTimeLockDelta, + Scope: &lnrpc.PolicyUpdateRequest_ChanPoint{ + ChanPoint: chanPoint, + }, + } + updateResp := bob.RPC.UpdateChannelPolicy(req) + require.Empty(ht, updateResp.FailedUpdates) + + // Wait for Alice to receive Bob's policy update. Use + // includeUnannounced=true since this is a private channel. + ht.AssertChannelPolicyUpdate( + alice, bob, expectedPolicy, chanPoint, true, + ) + + // Verify Alice can still route a payment to Bob using the + // updated policy. This confirms the policy was correctly + // persisted as V2 in Alice's graph. + invoice4 := bob.RPC.AddInvoice(&lnrpc.Invoice{ + Value: paymentAmt, + }) + ht.CompletePaymentRequests(alice, []string{invoice4.PaymentRequest}) + + // Now test the reverse: Alice (migrated, channel stored as V2) + // updates her own policy. This exercises the path where Alice's + // gossiper reads the V2 channel via the shim (projected as V1), + // builds and signs a V1 ChannelUpdate, persists it as V2 via + // the UpdateEdgePolicy shim, and sends the V1 update to Bob + // (the legacy peer). Bob must correctly receive and apply it. + const ( + aliceBaseFee = 3000 + aliceFeeRate = 300 + aliceTimeLockDelta = 60 + ) + + aliceExpectedPolicy := &lnrpc.RoutingPolicy{ + FeeBaseMsat: aliceBaseFee, + FeeRateMilliMsat: aliceFeeRate, + TimeLockDelta: aliceTimeLockDelta, + MinHtlc: 1000, + MaxHtlcMsat: 990_000_000, + } + + aliceReq := &lnrpc.PolicyUpdateRequest{ + BaseFeeMsat: aliceBaseFee, + FeeRate: float64(aliceFeeRate) / 1_000_000, + TimeLockDelta: aliceTimeLockDelta, + Scope: &lnrpc.PolicyUpdateRequest_ChanPoint{ + ChanPoint: chanPoint, + }, + } + aliceUpdateResp := alice.RPC.UpdateChannelPolicy(aliceReq) + require.Empty(ht, aliceUpdateResp.FailedUpdates) + + // Wait for Bob (legacy peer, no migration) to receive Alice's + // V1 policy update. + ht.AssertChannelPolicyUpdate( + bob, alice, aliceExpectedPolicy, chanPoint, true, + ) + + // Verify Bob can route a payment to Alice using the updated + // policy. This confirms Alice's V1 ChannelUpdate was correctly + // constructed from V2 storage and received by the legacy peer. + invoice5 := alice.RPC.AddInvoice(&lnrpc.Invoice{ + Value: paymentAmt, + }) + ht.CompletePaymentRequests(bob, []string{invoice5.PaymentRequest}) + + // Clean up. + ht.CloseChannel(alice, chanPoint) +} diff --git a/lncfg/dev.go b/lncfg/dev.go index 8e0c9dda45..77b7f67fda 100644 --- a/lncfg/dev.go +++ b/lncfg/dev.go @@ -60,6 +60,12 @@ func (d *DevConfig) GetUnsafeConnect() bool { return false } +// GetSkipTaprootV2Migration returns false for production builds — the +// migration always runs. +func (d *DevConfig) GetSkipTaprootV2Migration() bool { + return false +} + // ChannelCloseConfs returns the config value for channel close confirmations // override, which is always None for production build. func (d *DevConfig) ChannelCloseConfs() fn.Option[uint32] { diff --git a/lncfg/dev_integration.go b/lncfg/dev_integration.go index b299fb4fcd..1bf4302f14 100644 --- a/lncfg/dev_integration.go +++ b/lncfg/dev_integration.go @@ -29,6 +29,7 @@ type DevConfig struct { MaxWaitNumBlocksFundingConf uint32 `long:"maxwaitnumblocksfundingconf" description:"Maximum blocks to wait for funding confirmation before discarding non-initiated channels."` UnsafeConnect bool `long:"unsafeconnect" description:"Allow the rpcserver to connect to a peer even if there's already a connection."` ForceChannelCloseConfs uint32 `long:"force-channel-close-confs" description:"Force a specific number of confirmations for channel closes (dev/test only)"` + SkipTaprootV2Migration bool `long:"skip-taproot-v2-migration" description:"Skip the private taproot V1→V2 migration (dev/test only, for testing pre/post migration behavior)"` } // ChannelReadyWait returns the config value `ProcessChannelReadyWait`. @@ -74,6 +75,12 @@ func (d *DevConfig) GetUnsafeConnect() bool { return d.UnsafeConnect } +// GetSkipTaprootV2Migration returns the config value +// `SkipTaprootV2Migration`. +func (d *DevConfig) GetSkipTaprootV2Migration() bool { + return d.SkipTaprootV2Migration +} + // ChannelCloseConfs returns the forced confirmation count if set, or None if // the default behavior should be used. func (d *DevConfig) ChannelCloseConfs() fn.Option[uint32] { diff --git a/scripts/install_bitcoind.sh b/scripts/install_bitcoind.sh index 8f74efa46a..10d91aa577 100755 --- a/scripts/install_bitcoind.sh +++ b/scripts/install_bitcoind.sh @@ -8,8 +8,10 @@ BITCOIND_VERSION=$1 TAG_SUFFIX= DIR_SUFFIX=.0 -# Useful for testing against an image pushed to a different Docker repo. -REPO=lightninglabs/bitcoin-core +# Useful for testing against a different Docker repo or tarball host. +REPO=${BITCOIND_REPO:-lightninglabs/bitcoin-core} +BITCOIND_TARBALL_BASE_URL=${BITCOIND_TARBALL_BASE_URL:-https://bitcoincore.org/bin} +BITCOIND_INSTALL_PATH=${BITCOIND_INSTALL_PATH:-/usr/local/bin/bitcoind} if [ -z "$BITCOIND_VERSION" ]; then echo "Must specify a version of bitcoind to install." @@ -17,7 +19,60 @@ if [ -z "$BITCOIND_VERSION" ]; then exit 1 fi -docker pull ${REPO}:${BITCOIND_VERSION}${TAG_SUFFIX} -CONTAINER_ID=$(docker create ${REPO}:${BITCOIND_VERSION}${TAG_SUFFIX}) -sudo docker cp $CONTAINER_ID:/opt/bitcoin-${BITCOIND_VERSION}${DIR_SUFFIX}/bin/bitcoind /usr/local/bin/bitcoind -docker rm $CONTAINER_ID +IMAGE_TAG="${REPO}:${BITCOIND_VERSION}${TAG_SUFFIX}" +RELEASE_VERSION="${BITCOIND_VERSION}${DIR_SUFFIX}" +TARBALL="bitcoin-${RELEASE_VERSION}-x86_64-linux-gnu.tar.gz" +TARBALL_URL="${BITCOIND_TARBALL_BASE_URL}/bitcoin-core-${RELEASE_VERSION}/${TARBALL}" + +install_from_docker() { + local container_id + local temp_dir + + if ! docker pull "${IMAGE_TAG}"; then + return 1 + fi + + if ! container_id=$(docker create "${IMAGE_TAG}"); then + return 1 + fi + + temp_dir=$(mktemp -d) + + if ! docker cp \ + "${container_id}:/opt/bitcoin-${RELEASE_VERSION}/bin/bitcoind" \ + "${temp_dir}/bitcoind"; then + + docker rm "${container_id}" || true + rm -rf "${temp_dir}" + return 1 + fi + + docker rm "${container_id}" + install_bitcoind_binary "${temp_dir}/bitcoind" + rm -rf "${temp_dir}" +} + +install_bitcoind_binary() { + local source_path=$1 + + if install -m 0755 "${source_path}" "${BITCOIND_INSTALL_PATH}" 2>/dev/null; then + return 0 + fi + + sudo install -m 0755 "${source_path}" "${BITCOIND_INSTALL_PATH}" +} + +install_from_tarball() { + local temp_dir + + temp_dir=$(mktemp -d) + curl -fsSL "${TARBALL_URL}" -o "${temp_dir}/${TARBALL}" + tar -xzf "${temp_dir}/${TARBALL}" -C "${temp_dir}" + install_bitcoind_binary "${temp_dir}/bitcoin-${RELEASE_VERSION}/bin/bitcoind" + rm -rf "${temp_dir}" +} + +if ! install_from_docker; then + echo "Docker-based bitcoind install failed, falling back to tarball ${TARBALL_URL}" + install_from_tarball +fi diff --git a/sqldb/migrations.go b/sqldb/migrations.go index c2a3ac2c58..7eb2182857 100644 --- a/sqldb/migrations.go +++ b/sqldb/migrations.go @@ -126,6 +126,14 @@ var ( Version: 16, SchemaVersion: 13, }, + { + Name: "taproot_v1_to_v2_migration", + Version: 17, + SchemaVersion: 13, + // A migration function is attached to this migration + // to convert private taproot channels from the V1 + // workaround storage format to canonical V2 storage. + }, }, migrationAdditions...) // ErrMigrationMismatch is returned when a migrated record does not