diff --git a/database/bfgd/database.go b/database/bfgd/database.go index 6d5b4681..0bdc68b8 100644 --- a/database/bfgd/database.go +++ b/database/bfgd/database.go @@ -1,4 +1,4 @@ -// Copyright (c) 2024 Hemi Labs, Inc. +// Copyright (c) 2025 Hemi Labs, Inc. // Use of this source code is governed by the MIT License, // which can be found in the LICENSE file. @@ -19,7 +19,7 @@ type Database interface { // L2 keystone table L2KeystonesInsert(ctx context.Context, l2ks []L2Keystone) error L2KeystoneByAbrevHash(ctx context.Context, aHash [32]byte) (*L2Keystone, error) - L2KeystonesMostRecentN(ctx context.Context, n uint32) ([]L2Keystone, error) + L2KeystonesMostRecentN(ctx context.Context, n uint32, page uint32) ([]L2Keystone, error) // Btc block table BtcBlockInsert(ctx context.Context, bb *BtcBlock) error @@ -47,6 +47,10 @@ type Database interface { BtcTransactionBroadcastRequestConfirmBroadcast(ctx context.Context, txId string) error BtcTransactionBroadcastRequestSetLastError(ctx context.Context, txId string, lastErr string) error BtcTransactionBroadcastRequestTrim(ctx context.Context) error + + L2KeystoneLowestBtcBlockUpsert(ctx context.Context, l2KeystoneAbrevHash database.ByteArray) error + + BackfillL2KeystonesLowestBtcBlocks(ctx context.Context) error } // NotificationName identifies a database notification type. @@ -54,6 +58,7 @@ const ( NotificationBtcBlocks database.NotificationName = "btc_blocks" NotificationAccessPublicKeyDelete database.NotificationName = "access_public_keys" NotificationL2Keystones database.NotificationName = "l2_keystones" + NotificationPopBasis database.NotificationName = "pop_basis" ) // NotificationPayload returns the data structure corresponding to the given @@ -69,6 +74,7 @@ var notifications = map[database.NotificationName]any{ NotificationBtcBlocks: BtcBlock{}, NotificationAccessPublicKeyDelete: AccessPublicKey{}, NotificationL2Keystones: []L2Keystone{}, + NotificationPopBasis: PopBasis{}, } // we use the `deep:"-"` tag to ignore checking for these @@ -107,7 +113,7 @@ type PopBasis struct { BtcMerklePath []string PopTxId database.ByteArray PopMinerPublicKey database.ByteArray - L2KeystoneAbrevHash database.ByteArray + L2KeystoneAbrevHash database.ByteArray `json:"l2_keystone_abrev_hash"` CreatedAt database.Timestamp `deep:"-"` UpdatedAt database.Timestamp `deep:"-"` } diff --git a/database/bfgd/database_ext_test.go b/database/bfgd/database_ext_test.go index ad14f60e..2af7abb9 100644 --- a/database/bfgd/database_ext_test.go +++ b/database/bfgd/database_ext_test.go @@ -211,7 +211,7 @@ func TestDatabasePostgres(t *testing.T) { } } // Most recent - l2ksOut, err := db.L2KeystonesMostRecentN(ctx, 1) + l2ksOut, err := db.L2KeystonesMostRecentN(ctx, 1, 0) if err != nil { t.Fatalf("Failed to get most recent L2 keystone: %v", err) } @@ -827,7 +827,7 @@ func TestL2KeystoneInsertMostRecentNMoreThanSaved(t *testing.T) { t.Fatal(err) } - l2KeystonesSaved, err := db.L2KeystonesMostRecentN(ctx, 5) + l2KeystonesSaved, err := db.L2KeystonesMostRecentN(ctx, 5, 0) if err != nil { t.Fatal(err) } @@ -880,7 +880,7 @@ func TestL2KeystoneInsertMostRecentNFewerThanSaved(t *testing.T) { t.Fatal(err) } - l2KeystonesSaved, err := db.L2KeystonesMostRecentN(ctx, 1) + l2KeystonesSaved, err := db.L2KeystonesMostRecentN(ctx, 1, 0) if err != nil { t.Fatal(err) } @@ -931,7 +931,7 @@ func TestL2KeystoneInsertMostRecentNLimit100(t *testing.T) { t.Fatal(err) } - l2KeystonesSaved, err := db.L2KeystonesMostRecentN(ctx, 1000) + l2KeystonesSaved, err := db.L2KeystonesMostRecentN(ctx, 1000, 0) if err != nil { t.Fatal(err) } @@ -1027,7 +1027,7 @@ func TestL2KeystoneInsertDuplicateOK(t *testing.T) { t.Fatalf("received unexpected error: %s", err) } - l2Keystones, err := db.L2KeystonesMostRecentN(ctx, 5) + l2Keystones, err := db.L2KeystonesMostRecentN(ctx, 5, 0) if err != nil { t.Fatal(err) } @@ -1565,11 +1565,7 @@ func TestBtcBlockGetCanonicalChainWithForks(t *testing.T) { l2BlockNumber := uint32(1000) lastHash := []byte{} for i, blockCountAtHeight := range tti.chainPattern { - tmp := height - if tti.unconfirmedIndices[i] == true { - tmp = -1 - } - _onChainBlocks := createBtcBlocksAtStaticHeight(ctx, t, db, blockCountAtHeight, true, tmp, lastHash, l2BlockNumber) + _onChainBlocks := createBtcBlocksAtStaticHeight(ctx, t, db, blockCountAtHeight, true, height, lastHash, l2BlockNumber) l2BlockNumber++ height++ lastHash = _onChainBlocks[0].Hash @@ -1579,20 +1575,34 @@ func TestBtcBlockGetCanonicalChainWithForks(t *testing.T) { } } - bfs, err := db.L2BTCFinalityMostRecent(ctx, 100) + rows, err := sdb.QueryContext(ctx, ` + SELECT hash FROM btc_blocks_can ORDER BY height DESC + `) if err != nil { t.Fatal(err) } - if len(onChainBlocks) != len(bfs) { - t.Fatalf("length of onChainBlocks and pbs differs %d != %d", len(onChainBlocks), len(bfs)) + defer rows.Close() + + hashes := []database.ByteArray{} + + for rows.Next() { + var hash database.ByteArray + if err := rows.Scan(&hash); err != nil { + t.Fatal(err) + } + hashes = append(hashes, hash) + } + + if len(onChainBlocks) != len(hashes) { + t.Fatalf("length of onChainBlocks and pbs differs %d != %d", len(onChainBlocks), len(hashes)) } slices.Reverse(onChainBlocks) for i := range onChainBlocks { - if slices.Equal(onChainBlocks[i].Hash, bfs[i].BTCPubHeaderHash[:]) == false { - t.Fatalf("hash mismatch: %s != %s", onChainBlocks[i].Hash, bfs[i].BTCPubHeaderHash) + if slices.Equal(onChainBlocks[i].Hash, hashes[i]) == false { + t.Fatalf("hash mismatch: %s != %s", onChainBlocks[i].Hash, hashes[i]) } } }) @@ -1658,6 +1668,165 @@ func TestPublications(t *testing.T) { } } +func TestL2KeystoneLowestBtcBlockUpsert(t *testing.T) { + type result struct { + btcBlockHash database.ByteArray + btcBlockHeight uint64 + l2KeystoneAbrevHash database.ByteArray + } + + type testTableItem struct { + name string + l2KeystoneAbrevHashes []database.ByteArray + blockHashes []database.ByteArray + heights []uint64 + expected []result + } + + testTable := []testTableItem{ + testTableItem{ + name: "none exist", + l2KeystoneAbrevHashes: []database.ByteArray{fillOutBytes("myl2hash", 32)}, + blockHashes: []database.ByteArray{fillOutBytes("myhash", 32)}, + heights: []uint64{9}, + expected: []result{ + result{ + btcBlockHeight: 9, + btcBlockHash: fillOutBytes("myhash", 32), + l2KeystoneAbrevHash: fillOutBytes("myl2hash", 32), + }, + }, + }, + testTableItem{ + name: "overwrite one", + l2KeystoneAbrevHashes: []database.ByteArray{fillOutBytes("myl2hash", 32), fillOutBytes("myl2hash", 32)}, + blockHashes: []database.ByteArray{fillOutBytes("myhash", 32), fillOutBytes("myotherhash", 32)}, + heights: []uint64{77, 4}, + expected: []result{ + result{ + btcBlockHeight: 4, + btcBlockHash: fillOutBytes("myotherhash", 32), + l2KeystoneAbrevHash: fillOutBytes("myl2hash", 32), + }, + }, + }, + testTableItem{ + name: "two", + l2KeystoneAbrevHashes: []database.ByteArray{fillOutBytes("myl2hash", 32), fillOutBytes("myotherl2hash", 32)}, + blockHashes: []database.ByteArray{fillOutBytes("myhash", 32), fillOutBytes("myotherhash", 32)}, + heights: []uint64{77, 4}, + expected: []result{ + result{ + btcBlockHeight: 77, + btcBlockHash: fillOutBytes("myhash", 32), + l2KeystoneAbrevHash: fillOutBytes("myl2hash", 32), + }, + result{ + btcBlockHeight: 4, + btcBlockHash: fillOutBytes("myotherhash", 32), + l2KeystoneAbrevHash: fillOutBytes("myotherl2hash", 32), + }, + }, + }, + } + + for _, tti := range testTable { + for _, withBackfill := range []bool{true, false} { + t.Run(fmt.Sprintf("%s:withBackfill=%t", tti.name, withBackfill), func(t *testing.T) { + ctx, cancel := defaultTestContext() + defer cancel() + + db, sdb, cleanup := createTestDB(ctx, t) + defer func() { + db.Close() + sdb.Close() + cleanup() + }() + + t.Logf(tti.name) + for i := range tti.blockHashes { + btcBlock := bfgd.BtcBlock{ + Hash: tti.blockHashes[i], + Height: tti.heights[i], + Header: fillOutBytes("someheader", 80), + } + + if err := db.BtcBlockInsert(ctx, &btcBlock); err != nil { + t.Fatal(err) + } + + l2Keystone := bfgd.L2Keystone{ + Version: 1, + L1BlockNumber: 11, + L2BlockNumber: 22, + ParentEPHash: fillOutBytes("parentephash", 32), + PrevKeystoneEPHash: fillOutBytes("prevkeystoneephash", 32), + StateRoot: fillOutBytes("stateroot", 32), + EPHash: fillOutBytes("ephash", 32), + Hash: tti.l2KeystoneAbrevHashes[i], + } + + if err := db.L2KeystonesInsert(ctx, []bfgd.L2Keystone{l2Keystone}); err != nil { + t.Fatal(err) + } + + txIndex := uint64(99) + + popBasis := bfgd.PopBasis{ + BtcTxId: fillOutBytes("btctxid", 32), + BtcRawTx: []byte("btcrawtx"), + PopTxId: fillOutBytes("poptxid", 32), + L2KeystoneAbrevHash: tti.l2KeystoneAbrevHashes[i], + PopMinerPublicKey: fillOutBytes("popminerpublickey", 32), + BtcHeaderHash: tti.blockHashes[i], + BtcTxIndex: &txIndex, + } + + if err := db.PopBasisInsertFull(ctx, &popBasis); err != nil { + t.Fatal(err) + } + } + + for i := range tti.l2KeystoneAbrevHashes { + if withBackfill { + if err := db.BackfillL2KeystonesLowestBtcBlocks(ctx); err != nil { + t.Fatal(err) + } + } else { + if err := db.L2KeystoneLowestBtcBlockUpsert(ctx, tti.l2KeystoneAbrevHashes[i]); err != nil { + t.Fatal(err) + } + } + } + + results := []result{} + rows, err := sdb.QueryContext(ctx, ` + SELECT + btc_block_height, btc_block_hash, l2_keystone_abrev_hash + FROM l2_keystones_lowest_btc_block + ORDER BY btc_block_height DESC + `) + if err != nil { + t.Fatal(err) + } + + for rows.Next() { + r := result{} + if err := rows.Scan(&r.btcBlockHeight, &r.btcBlockHash, &r.l2KeystoneAbrevHash); err != nil { + t.Fatal(err) + } + + results = append(results, r) + } + + if diff := deep.Equal(tti.expected, results); len(diff) > 0 { + t.Fatalf("unexpected diff: %s", diff) + } + }) + } + } +} + func TestL2BtcFinalitiesByL2Keystone(t *testing.T) { ctx, cancel := defaultTestContext() defer cancel() @@ -1671,13 +1840,17 @@ func TestL2BtcFinalitiesByL2Keystone(t *testing.T) { createBtcBlocksAtStartingHeight(ctx, t, db, 2, true, 8987, []byte{}, 646464) - l2Keystones, err := db.L2KeystonesMostRecentN(ctx, 2) + l2Keystones, err := db.L2KeystonesMostRecentN(ctx, 2, 0) if err != nil { t.Fatal(err) } firstKeystone := l2Keystones[0] + if err := db.L2KeystoneLowestBtcBlockUpsert(ctx, firstKeystone.Hash); err != nil { + t.Fatal(err) + } + finalities, err := db.L2BTCFinalityByL2KeystoneAbrevHash( ctx, []database.ByteArray{firstKeystone.Hash}, @@ -1715,13 +1888,17 @@ func TestL2BtcFinalitiesByL2KeystoneNotPublishedHeight(t *testing.T) { createBtcBlocksAtStaticHeight(ctx, t, db, 1, true, -1, []byte{}, 646464) - l2Keystones, err := db.L2KeystonesMostRecentN(ctx, 2) + l2Keystones, err := db.L2KeystonesMostRecentN(ctx, 2, 0) if err != nil { t.Fatal(err) } firstKeystone := l2Keystones[0] + if err := db.L2KeystoneLowestBtcBlockUpsert(ctx, firstKeystone.Hash); err != nil { + t.Fatal(err) + } + finalities, err := db.L2BTCFinalityByL2KeystoneAbrevHash( ctx, []database.ByteArray{firstKeystone.Hash}, @@ -2389,6 +2566,10 @@ func createBtcBlock(ctx context.Context, t *testing.T, db bfgd.Database, count i t.Fatal(err) } + if err := db.L2KeystoneLowestBtcBlockUpsert(ctx, popBasis.L2KeystoneAbrevHash); err != nil { + t.Fatal(err) + } + return bfgd.BtcBlock{} } @@ -2407,6 +2588,10 @@ func createBtcBlock(ctx context.Context, t *testing.T, db bfgd.Database, count i t.Fatal(err) } + if err := db.L2KeystoneLowestBtcBlockUpsert(ctx, popBasis.L2KeystoneAbrevHash); err != nil { + t.Fatal(err) + } + return btcBlock } diff --git a/database/bfgd/postgres/postgres.go b/database/bfgd/postgres/postgres.go index 4db58aae..4773dcd7 100644 --- a/database/bfgd/postgres/postgres.go +++ b/database/bfgd/postgres/postgres.go @@ -1,4 +1,4 @@ -// Copyright (c) 2024 Hemi Labs, Inc. +// Copyright (c) 2025 Hemi Labs, Inc. // Use of this source code is governed by the MIT License, // which can be found in the LICENSE file. @@ -7,6 +7,7 @@ package postgres import ( "context" "database/sql" + "encoding/hex" "encoding/json" "errors" "fmt" @@ -20,7 +21,7 @@ import ( ) const ( - bfgdVersion = 11 + bfgdVersion = 12 logLevel = "INFO" verbose = false @@ -205,7 +206,7 @@ func (p *pgdb) L2KeystoneByAbrevHash(ctx context.Context, aHash [32]byte) (*bfgd return l2ks, nil } -func (p *pgdb) L2KeystonesMostRecentN(ctx context.Context, n uint32) ([]bfgd.L2Keystone, error) { +func (p *pgdb) L2KeystonesMostRecentN(ctx context.Context, n uint32, page uint32) ([]bfgd.L2Keystone, error) { log.Tracef("L2KeystonesMostRecentN") defer log.Tracef("L2KeystonesMostRecentN exit") @@ -227,11 +228,11 @@ func (p *pgdb) L2KeystonesMostRecentN(ctx context.Context, n uint32) ([]bfgd.L2K updated_at FROM l2_keystones - ORDER BY l2_block_number DESC LIMIT $1 + ORDER BY l2_block_number DESC, l2_keystone_abrev_hash DESC OFFSET $1 LIMIT $2 ` var ks []bfgd.L2Keystone - rows, err := p.db.QueryContext(ctx, q, n) + rows, err := p.db.QueryContext(ctx, q, page*n, n) if err != nil { return nil, err } @@ -549,141 +550,58 @@ func (p *pgdb) PopBasisByL2KeystoneAbrevHash(ctx context.Context, aHash [32]byte return pbs, nil } -// nextL2BTCFinalitiesPublished , given a block number (lessThanL2BlockNumber) -// will find the next smallest published finality on the canoncial chain -func (p *pgdb) nextL2BTCFinalitiesPublished(ctx context.Context, lessThanL2BlockNumber uint32, limit int) ([]bfgd.L2BTCFinality, error) { - sql := fmt.Sprintf(` - SELECT - btc_blocks_can.hash, - btc_blocks_can.height, - l2_keystones.l2_keystone_abrev_hash, - l2_keystones.l1_block_number, - l2_keystones.l2_block_number, - l2_keystones.parent_ep_hash, - l2_keystones.prev_keystone_ep_hash, - l2_keystones.state_root, - l2_keystones.ep_hash, - l2_keystones.version, - %s, - COALESCE((SELECT MAX(height) FROM btc_blocks_can), 0) - FROM btc_blocks_can - - INNER JOIN pop_basis ON pop_basis.btc_block_hash = btc_blocks_can.hash - INNER JOIN l2_keystones ON l2_keystones.l2_keystone_abrev_hash - = pop_basis.l2_keystone_abrev_hash - - WHERE l2_keystones.l2_block_number <= $1 - ORDER BY height DESC, l2_keystones.l2_block_number DESC LIMIT $2 - `, effectiveHeightSql) - - rows, err := p.db.QueryContext(ctx, sql, lessThanL2BlockNumber, limit) - if err != nil { - return nil, err - } - - defer rows.Close() - - finalities := []bfgd.L2BTCFinality{} - - for rows.Next() { - var l2BtcFinality bfgd.L2BTCFinality - err = rows.Scan( - &l2BtcFinality.BTCPubHeaderHash, - &l2BtcFinality.BTCPubHeight, - &l2BtcFinality.L2Keystone.Hash, - &l2BtcFinality.L2Keystone.L1BlockNumber, - &l2BtcFinality.L2Keystone.L2BlockNumber, - &l2BtcFinality.L2Keystone.ParentEPHash, - &l2BtcFinality.L2Keystone.PrevKeystoneEPHash, - &l2BtcFinality.L2Keystone.StateRoot, - &l2BtcFinality.L2Keystone.EPHash, - &l2BtcFinality.L2Keystone.Version, - &l2BtcFinality.EffectiveHeight, - &l2BtcFinality.BTCTipHeight, +func (p *pgdb) L2KeystoneLowestBtcBlockUpsert(ctx context.Context, l2KeystoneAbrevHash database.ByteArray) error { + sql := ` + WITH lowest_btc_block AS ( + SELECT btc_blocks_can.hash, btc_blocks_can.height + FROM pop_basis + INNER JOIN btc_blocks_can ON btc_blocks_can.hash = pop_basis.btc_block_hash + WHERE pop_basis.l2_keystone_abrev_hash = $1 + ORDER BY btc_blocks_can.height ASC LIMIT 1 ) - if err != nil { - return nil, err - } - - finalities = append(finalities, l2BtcFinality) - } + INSERT INTO l2_keystones_lowest_btc_block (l2_keystone_abrev_hash, btc_block_hash, btc_block_height) + VALUES ( + $1, + (SELECT hash FROM lowest_btc_block), + (SELECT height FROM lowest_btc_block) + ) + ON CONFLICT (l2_keystone_abrev_hash) DO UPDATE SET btc_block_hash = EXCLUDED.btc_block_hash + ` - if rows.Err() != nil { - return nil, rows.Err() + _, err := p.db.ExecContext(ctx, sql, l2KeystoneAbrevHash) + if err != nil { + return err } - return finalities, nil + return nil } -// nextL2BTCFinalitiesAssumedUnpublished , given a block number -// (lessThanL2BlockNumber) will find the next smallest published finality that -// is not within explicitExcludeL2BlockNumbers and assume it is unpublished -// (returning nothing for BTC fields) -func (p *pgdb) nextL2BTCFinalitiesAssumedUnpublished(ctx context.Context, lessThanL2BlockNumber uint32, limit int, explicitExcludeL2BlockNumbers []uint32) ([]bfgd.L2BTCFinality, error) { - sql := fmt.Sprintf(` - SELECT - NULL, - 0, - l2_keystones.l2_keystone_abrev_hash, - l2_keystones.l1_block_number, - l2_keystones.l2_block_number, - l2_keystones.parent_ep_hash, - l2_keystones.prev_keystone_ep_hash, - l2_keystones.state_root, - l2_keystones.ep_hash, - l2_keystones.version, - %s, - COALESCE((SELECT MAX(height) FROM btc_blocks_can),0) - - FROM l2_keystones - WHERE l2_block_number != ANY($3) - AND l2_block_number <= $1 - ORDER BY l2_block_number DESC LIMIT $2 - `, effectiveHeightSql) +// BackfillL2KeystonesLowestBtcBlocks (should only) runs on startup and is +// a quick check that all existing keystones have an associated lowest btc +// block if it exists. this is essential for new deploys +func (p *pgdb) BackfillL2KeystonesLowestBtcBlocks(ctx context.Context) error { + limit := uint32(1) + page := uint32(0) - rows, err := p.db.QueryContext( - ctx, - sql, - lessThanL2BlockNumber, - limit, - pq.Array(explicitExcludeL2BlockNumbers), - ) - if err != nil { - return nil, err - } - - defer rows.Close() - - finalities := []bfgd.L2BTCFinality{} + for { + l2ks, err := p.L2KeystonesMostRecentN(ctx, limit, page) + if err != nil && !errors.Is(err, database.ErrNotFound) { + return err + } - for rows.Next() { - var l2BtcFinality bfgd.L2BTCFinality - err = rows.Scan( - &l2BtcFinality.BTCPubHeaderHash, - &l2BtcFinality.BTCPubHeight, - &l2BtcFinality.L2Keystone.Hash, - &l2BtcFinality.L2Keystone.L1BlockNumber, - &l2BtcFinality.L2Keystone.L2BlockNumber, - &l2BtcFinality.L2Keystone.ParentEPHash, - &l2BtcFinality.L2Keystone.PrevKeystoneEPHash, - &l2BtcFinality.L2Keystone.StateRoot, - &l2BtcFinality.L2Keystone.EPHash, - &l2BtcFinality.L2Keystone.Version, - &l2BtcFinality.EffectiveHeight, - &l2BtcFinality.BTCTipHeight, - ) - if err != nil { - return nil, err + if len(l2ks) == 0 { + log.Infof("done backfilling l2 keystones <-> lowest btc block") + return nil } - l2BtcFinality.BTCPubHeight = -1 - finalities = append(finalities, l2BtcFinality) - } - if rows.Err() != nil { - return nil, rows.Err() + for _, ks := range l2ks { + log.Tracef("backfilling l2keystone=%s, l2blocknumber=%d", hex.EncodeToString(ks.Hash), ks.L2BlockNumber) + if err := p.L2KeystoneLowestBtcBlockUpsert(ctx, ks.Hash); err != nil { + return err + } + } + page++ } - - return finalities, nil } // L2BTCFinalityMostRecent gets the most recent L2BtcFinalities sorted @@ -696,100 +614,37 @@ func (p *pgdb) L2BTCFinalityMostRecent(ctx context.Context, limit uint32) ([]bfg ) } - tip, err := p.canonicalChainTipL2BlockNumber(ctx) + l2Keystones, err := p.L2KeystonesMostRecentN(ctx, limit, 0) if err != nil { return nil, err } - // we found no canonical tip, return nothing - if tip == nil { - return []bfgd.L2BTCFinality{}, nil - } - finalities := []bfgd.L2BTCFinality{} - // first, get all of the most recent published finalities up to the limit - // from the tip - publishedFinalities, err := p.nextL2BTCFinalitiesPublished( - ctx, - *tip, - int(limit), - ) - if err != nil { - return nil, err - } - pfi := 0 - - // it is possible that there will be some unpublished finalities between - // the published - // ones, get all finalities up to the limit that are NOT in published. - // IMPORTANT NOTE: we call these explicity "assumed unpublished" - // instead of explicity looking for unpublished - // finalities, because a finality could get published between these two - // queries. this is why we call these "assumed". the idea is to make - // this worst-case scenario slighty out-of-date, rather than incorrect - excludeL2BlockNumbers := []uint32{} - for _, v := range publishedFinalities { - excludeL2BlockNumbers = append( - excludeL2BlockNumbers, - v.L2Keystone.L2BlockNumber, - ) - } - - unpublishedFinalities, err := p.nextL2BTCFinalitiesAssumedUnpublished( - ctx, - *tip, - int(limit), - excludeL2BlockNumbers) - if err != nil { - return nil, err + hashes := []database.ByteArray{} + for _, l := range l2Keystones { + hashes = append(hashes, l.Hash) } + page := uint32(0) for { - - var publishedFinality *bfgd.L2BTCFinality - if pfi < len(publishedFinalities) { - publishedFinality = &publishedFinalities[pfi] - } - - var finality *bfgd.L2BTCFinality - - var unpublishedFinality *bfgd.L2BTCFinality - for _, u := range unpublishedFinalities { - if u.L2Keystone.L2BlockNumber <= *tip { - unpublishedFinality = &u - break - } - } - - if publishedFinality == nil { - finality = unpublishedFinality - } else if unpublishedFinality == nil { - finality = publishedFinality - pfi++ - } else if publishedFinality.L2Keystone.L2BlockNumber >= - unpublishedFinality.L2Keystone.L2BlockNumber { - finality = publishedFinality - pfi++ - } else { - finality = unpublishedFinality - } - - // if we couldn't find finality, there are no more possibilities - if finality == nil { - break + finalitiesTmp, err := p.L2BTCFinalityByL2KeystoneAbrevHash(ctx, hashes, page, 100) + if err != nil { + return nil, err } - finalities = append(finalities, *finality) - if uint32(len(finalities)) >= limit { + if len(finalitiesTmp) == 0 { break } - if finality.L2Keystone.L2BlockNumber == 0 { - break + for _, f := range finalitiesTmp { + finalities = append(finalities, f) + if uint32(len(finalities)) >= limit { + return finalities, nil + } } - *tip = finality.L2Keystone.L2BlockNumber - 1 + page++ } return finalities, nil @@ -814,8 +669,8 @@ func (p *pgdb) L2BTCFinalityByL2KeystoneAbrevHash(ctx context.Context, l2Keyston sql := fmt.Sprintf(` SELECT - btc_blocks_can.hash, - COALESCE(btc_blocks_can.height, 0), + btc_block_hash, + COALESCE(btc_block_height, 0), l2_keystones.l2_keystone_abrev_hash, l2_keystones.l1_block_number, l2_keystones.l2_block_number, @@ -828,10 +683,8 @@ func (p *pgdb) L2BTCFinalityByL2KeystoneAbrevHash(ctx context.Context, l2Keyston COALESCE((SELECT height FROM btc_blocks_can ORDER BY height DESC LIMIT 1),0) FROM l2_keystones - LEFT JOIN pop_basis ON l2_keystones.l2_keystone_abrev_hash - = pop_basis.l2_keystone_abrev_hash - LEFT JOIN btc_blocks_can ON pop_basis.btc_block_hash - = btc_blocks_can.hash + LEFT JOIN l2_keystones_lowest_btc_block + ON l2_keystones.l2_keystone_abrev_hash = l2_keystones_lowest_btc_block.l2_keystone_abrev_hash WHERE l2_keystones.l2_keystone_abrev_hash = ANY($1) @@ -1024,32 +877,6 @@ func (p *pgdb) BtcBlocksHeightsWithNoChildren(ctx context.Context) ([]uint64, er } } -// canonicalChainTipL2BlockNumber gets our best guess of the canonical tip -// and returns it. it finds the highest btc block with an associated -// l2 keystone where only 1 btc block exists at that height -func (p *pgdb) canonicalChainTipL2BlockNumber(ctx context.Context) (*uint32, error) { - log.Tracef("canonicalChainTipL2BlockNumber") - defer log.Tracef("canonicalChainTipL2BlockNumber exit") - - const q = ` - SELECT l2_keystones.l2_block_number - FROM btc_blocks_can - - INNER JOIN pop_basis ON pop_basis.btc_block_hash = btc_blocks_can.hash - INNER JOIN l2_keystones ON l2_keystones.l2_keystone_abrev_hash - = pop_basis.l2_keystone_abrev_hash - - ORDER BY l2_block_number DESC LIMIT 1 - ` - - var l2BlockNumber uint32 - if err := p.db.QueryRowContext(ctx, q).Scan(&l2BlockNumber); err != nil { - return nil, err - } - - return &l2BlockNumber, nil -} - func (p *pgdb) refreshBTCBlocksCanonical(ctx context.Context) error { // XXX this probably should be REFRESH MATERIALIZED VIEW CONCURRENTLY // however, this is more testable at the moment and we're in a time crunch, diff --git a/database/bfgd/scripts/0012.sql b/database/bfgd/scripts/0012.sql new file mode 100644 index 00000000..6b2e6de7 --- /dev/null +++ b/database/bfgd/scripts/0012.sql @@ -0,0 +1,19 @@ + +-- Copyright (c) 2025 Hemi Labs, Inc. +-- Use of this source code is governed by the MIT License, +-- which can be found in the LICENSE file. + +BEGIN; + +UPDATE version SET version = 12; + +CREATE TABLE l2_keystones_lowest_btc_block ( + l2_keystone_abrev_hash BYTEA NOT NULL PRIMARY KEY REFERENCES l2_keystones(l2_keystone_abrev_hash), + btc_block_hash BYTEA REFERENCES btc_blocks(hash) DEFAULT NULL, + btc_block_height BIGINT NULL +); + +CREATE TRIGGER pop_basis_upsert AFTER INSERT OR DELETE OR UPDATE + ON pop_basis FOR EACH ROW EXECUTE PROCEDURE notify_event(); + +COMMIT; \ No newline at end of file diff --git a/e2e/e2e_ext_test.go b/e2e/e2e_ext_test.go index d25699bb..b450e7f4 100644 --- a/e2e/e2e_ext_test.go +++ b/e2e/e2e_ext_test.go @@ -51,6 +51,7 @@ import ( "github.com/hemilabs/heminetwork/api/bfgapi" "github.com/hemilabs/heminetwork/api/bssapi" "github.com/hemilabs/heminetwork/api/protocol" + "github.com/hemilabs/heminetwork/database" "github.com/hemilabs/heminetwork/database/bfgd" "github.com/hemilabs/heminetwork/database/bfgd/postgres" "github.com/hemilabs/heminetwork/ethereum" @@ -619,6 +620,17 @@ func assertPing(ctx context.Context, t *testing.T, c *websocket.Conn, cmd protoc } } +// fillOutBytesWith0s will take a string and return a slice of bytes +// with values from the string suffixed until a size with bytes '_' +func fillOutBytesWith0s(prefix string, size int) []byte { + result := []byte(prefix) + for len(result) < size { + result = append(result, 0) + } + + return result +} + // fillOutBytes will take a string and return a slice of bytes // with values from the string suffixed until a size with bytes '_' func fillOutBytes(prefix string, size int) []byte { @@ -1553,6 +1565,36 @@ func TestBitcoinBroadcast(t *testing.T) { break } } + + l2k, err := db.L2KeystonesMostRecentN(ctx, 100, 0) + if err != nil { + t.Fatal(err) + } + + // assert that the L2Keystone was stored in the database, + // IMPORTANT NOTE: since we derive this from a btc pop tx, only the + // abbreviated keystone is stored. we still want to store this if we + // have not seen it before so it's stored with padded 0 bytes. this will + // go away in the future once we add "missing keystone" logic and + // functionality + if diff := deep.Equal(l2k, []bfgd.L2Keystone{ + bfgd.L2Keystone{ + Version: 1, + L1BlockNumber: 5, + L2BlockNumber: 44, + ParentEPHash: fillOutBytesWith0s("parentephas", 32), + PrevKeystoneEPHash: fillOutBytesWith0s("prevkeystone", 32), + StateRoot: fillOutBytes("stateroot", 32), + EPHash: fillOutBytesWith0s("ephash______", 32), + Hash: hemi.L2KeystoneAbbreviate(l2Keystone).Hash(), + }, + }); len(diff) > 0 { + t.Fatalf("unexpected diff: %s", diff) + } + + if len(l2k) != 1 { + t.Fatalf("unexpected number of keystones: %d", len(l2k)) + } } // TestBitcoinBroadcastDuplicate calls BitcoinBroadcast twice with the same @@ -1802,6 +1844,32 @@ loop: if len(diff) > 0 { t.Fatalf("unexpected diff %s", diff) } + + l2k, err := db.L2KeystonesMostRecentN(ctx, 100, 0) + if err != nil { + t.Fatal(err) + } + + // assert that the L2Keystone was stored in the database, + // IMPORTANT NOTE: since we derive this from a btc pop tx, only the + // abbreviated keystone is stored. we still want to store this if we + // have not seen it before so it's stored with padded 0 bytes. this will + // go away in the future once we add "missing keystone" logic and + // functionality + if diff := deep.Equal(l2k, []bfgd.L2Keystone{ + bfgd.L2Keystone{ + Version: 1, + L1BlockNumber: 5, + L2BlockNumber: 44, + ParentEPHash: fillOutBytesWith0s("parentephas", 32), + PrevKeystoneEPHash: fillOutBytesWith0s("prevkeystone", 32), + StateRoot: fillOutBytes("stateroot", 32), + EPHash: fillOutBytesWith0s("ephash______", 32), + Hash: hemi.L2KeystoneAbbreviate(l2Keystone).Hash(), + }, + }); len(diff) > 0 { + t.Fatalf("unexpected diff: %s", diff) + } } // TestProcessBitcoinBlockNewFullPopBasis takes a full btc tx from the mock @@ -1915,6 +1983,55 @@ loop: if len(diff) > 0 { t.Fatalf("unexpected diff %s", diff) } + + l2k, err := db.L2KeystonesMostRecentN(ctx, 100, 0) + if err != nil { + t.Fatal(err) + } + + // assert that the L2Keystone was stored in the database, + // IMPORTANT NOTE: since we derive this from a btc pop tx, only the + // abbreviated keystone is stored. we still want to store this if we + // have not seen it before so it's stored with padded 0 bytes. this will + // go away in the future once we add "missing keystone" logic and + // functionality + if diff := deep.Equal(l2k, []bfgd.L2Keystone{ + bfgd.L2Keystone{ + Version: 1, + L1BlockNumber: 5, + L2BlockNumber: 44, + ParentEPHash: fillOutBytesWith0s("parentephas", 32), + PrevKeystoneEPHash: fillOutBytesWith0s("prevkeystone", 32), + StateRoot: fillOutBytes("stateroot", 32), + EPHash: fillOutBytesWith0s("ephash______", 32), + Hash: hemi.L2KeystoneAbbreviate(l2Keystone).Hash(), + }, + }); len(diff) > 0 { + t.Fatalf("unexpected diff: %s", diff) + } + + // assert that we have inserted an l2_keystones_lowest_btc_block row + var btcBlockHash database.ByteArray + var btcBlockHeight uint64 + var l2KeystoneAbrevHash database.ByteArray + row := sdb.QueryRowContext(ctx, "SELECT btc_block_hash, btc_block_height, l2_keystone_abrev_hash FROM l2_keystones_lowest_btc_block LIMIT 1") + if err := row.Scan(&btcBlockHash, &btcBlockHeight, &l2KeystoneAbrevHash); err != nil { + t.Fatal(err) + } + + if btcBlockHeight != 2 { + t.Fatalf("unexpected height: %d", btcBlockHeight) + } + + if diff := deep.Equal([]database.ByteArray{ + btcBlockHash, + l2KeystoneAbrevHash, + }, []database.ByteArray{ + popBases[0].BtcHeaderHash, + popBases[0].L2KeystoneAbrevHash, + }); len(diff) > 0 { + t.Fatalf("unexpected diff: %s", diff) + } } // TestBitcoinBroadcastThenUpdate will insert a pop_basis record from diff --git a/service/bfg/bfg.go b/service/bfg/bfg.go index 628a8520..bdc496ac 100644 --- a/service/bfg/bfg.go +++ b/service/bfg/bfg.go @@ -1,4 +1,4 @@ -// Copyright (c) 2024 Hemi Labs, Inc. +// Copyright (c) 2025 Hemi Labs, Inc. // Use of this source code is governed by the MIT License, // which can be found in the LICENSE file. @@ -305,7 +305,16 @@ func (s *Server) queueCheckForInvalidBlocks() { } } +func (s *Server) backfillL2KeystonesLowestBtcBlocks(ctx context.Context) { + defer s.wg.Done() + + if err := s.db.BackfillL2KeystonesLowestBtcBlocks(ctx); err != nil { + log.Errorf("error backfilling lowest block per keystone: %s", err) + } +} + func (s *Server) invalidBlockChecker(ctx context.Context) { + defer s.wg.Done() for { select { case <-ctx.Done(): @@ -433,6 +442,14 @@ func (s *Server) handleOneBroadcastRequest(ctx context.Context, highPriority boo return } + // attempt to insert the abbreviated keystone, this is in case we have + // not heard of this keystone from op node yet + if err := s.db.L2KeystonesInsert(ctx, []bfgd.L2Keystone{ + hemiL2KeystoneAbrevToDb(*tl2.L2Keystone), + }); err != nil { + log.Infof("could not insert l2 keystone: %s", err) + } + _, err = pop.ParsePublicKeyFromSignatureScript(mb.TxIn[0].SignatureScript) if err != nil { log.Errorf("could not parse public key from signature script: %v", err) @@ -745,6 +762,15 @@ func (s *Server) processBitcoinBlock(ctx context.Context, height uint64) error { btcTxIndex := index log.Infof("found tl2: %v at position %d", tl2, btcTxIndex) + // attempt to insert the abbreviated keystone, this is in case we have + // not heard of this keystone from op node yet + if err := s.db.L2KeystonesInsert(ctx, []bfgd.L2Keystone{ + hemiL2KeystoneAbrevToDb(*tl2.L2Keystone), + }); err != nil { + // this is not necessarily an error, should it be trace? + log.Infof("could not insert l2 keystone: %s", err) + } + publicKeyUncompressed, err := pop.ParsePublicKeyFromSignatureScript(mtx.TxIn[0].SignatureScript) if err != nil { return fmt.Errorf("could not parse signature script: %w", err) @@ -788,7 +814,6 @@ func (s *Server) processBitcoinBlock(ctx context.Context, height uint64) error { return err } } - } } @@ -1380,7 +1405,7 @@ func (s *Server) refreshL2KeystoneCache(ctx context.Context) { s.mtx.Lock() defer s.mtx.Unlock() - results, err := s.db.L2KeystonesMostRecentN(ctx, 100) + results, err := s.db.L2KeystonesMostRecentN(ctx, 100, 0) if err != nil { log.Errorf("error getting keystones %v", err) return @@ -1468,6 +1493,27 @@ func (s *Server) handleL2KeystonesNotification() { s.mtx.Unlock() } +func hemiL2KeystoneAbrevToDb(l2ks hemi.L2KeystoneAbrev) bfgd.L2Keystone { + padBytes := func(s []byte) database.ByteArray { + // allocated zeroed array + r := make([]byte, 32) + // copy s into r, this will pad the ending bytes with 0s + copy(r, s) + return database.ByteArray(r) + } + + return bfgd.L2Keystone{ + Hash: l2ks.Hash(), + Version: uint32(l2ks.Version), + L1BlockNumber: l2ks.L1BlockNumber, + L2BlockNumber: l2ks.L2BlockNumber, + ParentEPHash: padBytes(l2ks.ParentEPHash[:]), + PrevKeystoneEPHash: padBytes(l2ks.PrevKeystoneEPHash[:]), + StateRoot: padBytes(l2ks.StateRoot[:]), + EPHash: padBytes(l2ks.EPHash[:]), + } +} + func hemiL2KeystoneToDb(l2ks hemi.L2Keystone) bfgd.L2Keystone { return bfgd.L2Keystone{ Hash: hemi.L2KeystoneAbbreviate(l2ks).Hash(), @@ -1615,6 +1661,34 @@ func (s *Server) handleL2KeystonesChange(table string, action string, payload, p go s.refreshCacheAndNotifiyL2Keystones() } +func (s *Server) handlePopBasisChange(table string, action string, payload, payloadOld any) { + for _, p := range []any{payload, payloadOld} { + if p == nil { + continue + } + + popBasisPayload, ok := p.(*bfgd.PopBasis) + if !ok { + panic(fmt.Sprintf("incorrect type: %T", p)) + } + + if popBasisPayload == nil { + continue + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + log.Tracef("updating lowest btc block for l2 keystone: %s", hex.EncodeToString(popBasisPayload.L2KeystoneAbrevHash)) + + err := s.updateLowestL2Keystone(ctx, popBasisPayload.L2KeystoneAbrevHash) + if err != nil { + log.Errorf("could not update lowest l2 keystone: %s", err) + return + } + } +} + func (s *Server) fetchRemoteL2Keystones(pctx context.Context) { ctx, cancel := context.WithTimeout(pctx, 10*time.Second) defer cancel() @@ -1809,6 +1883,10 @@ func (s *Server) BtcBlockCanonicalHeight(ctx context.Context) (uint64, error) { return height, nil } +func (s *Server) updateLowestL2Keystone(ctx context.Context, l2KeystoneAbrevHash []byte) error { + return s.db.L2KeystoneLowestBtcBlockUpsert(ctx, l2KeystoneAbrevHash) +} + func (s *Server) Run(pctx context.Context) error { log.Tracef("Run") defer log.Tracef("Run exit") @@ -1877,6 +1955,14 @@ func (s *Server) Run(pctx context.Context) error { return err } + popBasisPayload, ok := bfgd.NotificationPayload(bfgd.NotificationPopBasis) + if !ok { + return fmt.Errorf("could not obtain type: %v", bfgd.NotificationPopBasis) + } + if err := s.db.RegisterNotification(ctx, bfgd.NotificationPopBasis, s.handlePopBasisChange, popBasisPayload); err != nil { + return err + } + s.wg.Add(1) go func() { defer s.wg.Done() @@ -2047,8 +2133,14 @@ func (s *Server) Run(pctx context.Context) error { s.wg.Add(1) go s.trackBitcoin(ctx) + + s.wg.Add(1) go s.invalidBlockChecker(ctx) + // backfill known blocks, any new blocks will be handled by notifications + s.wg.Add(1) + go s.backfillL2KeystonesLowestBtcBlocks(ctx) + select { case <-ctx.Done(): err = ctx.Err()