diff --git a/tapdb/burn_tree.go b/tapdb/burn_tree.go index bfb602da6..ed19fca9e 100644 --- a/tapdb/burn_tree.go +++ b/tapdb/burn_tree.go @@ -117,11 +117,17 @@ func insertBurnsInternal(ctx context.Context, db BaseUniverseStore, IsBurn: true, } + var blockHeight lfn.Option[uint32] + height := burnLeaf.BurnProof.BlockHeight + if height > 0 { + blockHeight = lfn.Some(height) + } + // Call the generic upsert function for the burn sub-tree to // update DB records. MetaReveal is nil for burns. _, err = universeUpsertProofLeaf( ctx, db, subNs, supplycommit.BurnTreeType.String(), - groupKey, leafKey, leaf, nil, + groupKey, leafKey, leaf, nil, blockHeight, ) if err != nil { return nil, fmt.Errorf("unable to upsert burn "+ diff --git a/tapdb/ignore_tree.go b/tapdb/ignore_tree.go index 892046bb4..6ed2f1434 100644 --- a/tapdb/ignore_tree.go +++ b/tapdb/ignore_tree.go @@ -113,6 +113,21 @@ func addTuplesInternal(ctx context.Context, db BaseUniverseStore, "universe root: %w", err) } + var blockHeight lfn.Option[uint32] + height := tup.IgnoreTuple.Val.BlockHeight + if height > 0 { + blockHeight = lfn.Some(height) + } + + sqlBlockHeight := lfn.MapOptionZ( + blockHeight, func(num uint32) sql.NullInt32 { + return sql.NullInt32{ + Int32: int32(num), + Valid: true, + } + }, + ) + scriptKey := ignoreTup.ScriptKey err = db.UpsertUniverseLeaf(ctx, UpsertUniverseLeaf{ AssetGenesisID: assetGenID, @@ -121,6 +136,7 @@ func addTuplesInternal(ctx context.Context, db BaseUniverseStore, LeafNodeKey: smtKey[:], LeafNodeNamespace: namespace, MintingPoint: ignorePointBytes, + BlockHeight: sqlBlockHeight, }) if err != nil { return nil, fmt.Errorf("failed to upsert ignore "+ diff --git a/tapdb/migrations.go b/tapdb/migrations.go index ba06ffbd6..43b9a045e 100644 --- a/tapdb/migrations.go +++ b/tapdb/migrations.go @@ -24,7 +24,7 @@ const ( // daemon. // // NOTE: This MUST be updated when a new migration is added. - LatestMigrationVersion = 39 + LatestMigrationVersion = 40 ) // DatabaseBackend is an interface that contains all methods our different diff --git a/tapdb/multiverse.go b/tapdb/multiverse.go index 4e1695736..1bc172d58 100644 --- a/tapdb/multiverse.go +++ b/tapdb/multiverse.go @@ -776,11 +776,16 @@ func (b *MultiverseStore) UpsertProofLeaf(ctx context.Context, execTxFunc := func(dbTx BaseMultiverseStore) error { // Register issuance in the asset (group) specific universe - // tree. - var err error + // tree. We don't need to decode the whole proof, we just + // need the block height. + blockHeight, err := SparseDecodeBlockHeight(leaf.RawProof) + if err != nil { + return err + } + uniProof, err = universeUpsertProofLeaf( ctx, dbTx, id.String(), id.ProofType.String(), - id.GroupKey, key, leaf, metaReveal, + id.GroupKey, key, leaf, metaReveal, blockHeight, ) if err != nil { return fmt.Errorf("failed universe upsert: %w", err) @@ -847,13 +852,22 @@ func (b *MultiverseStore) UpsertProofLeafBatch(ctx context.Context, for idx := range items { item := items[idx] + // We don't need to decode the whole proof, we + // just need the block height. + blockHeight, err := SparseDecodeBlockHeight( + item.Leaf.RawProof, + ) + if err != nil { + return err + } + // Upsert into the specific universe tree to // start with. uniProof, err := universeUpsertProofLeaf( ctx, store, item.ID.String(), item.ID.ProofType.String(), item.ID.GroupKey, item.Key, item.Leaf, - item.MetaReveal, + item.MetaReveal, blockHeight, ) if err != nil { return fmt.Errorf("failed universe "+ diff --git a/tapdb/sqlc/migrations/000040_universe_leaf_height.down.sql b/tapdb/sqlc/migrations/000040_universe_leaf_height.down.sql new file mode 100644 index 000000000..94cc5dfb9 --- /dev/null +++ b/tapdb/sqlc/migrations/000040_universe_leaf_height.down.sql @@ -0,0 +1 @@ +ALTER TABLE universe_leaves DROP COLUMN block_height; diff --git a/tapdb/sqlc/migrations/000040_universe_leaf_height.up.sql b/tapdb/sqlc/migrations/000040_universe_leaf_height.up.sql new file mode 100644 index 000000000..762901c03 --- /dev/null +++ b/tapdb/sqlc/migrations/000040_universe_leaf_height.up.sql @@ -0,0 +1 @@ +ALTER TABLE universe_leaves ADD COLUMN block_height INTEGER; diff --git a/tapdb/sqlc/models.go b/tapdb/sqlc/models.go index 15d234edd..3a3e1df52 100644 --- a/tapdb/sqlc/models.go +++ b/tapdb/sqlc/models.go @@ -430,6 +430,7 @@ type UniverseLeafe struct { UniverseRootID int64 LeafNodeKey []byte LeafNodeNamespace string + BlockHeight sql.NullInt32 } type UniverseRoot struct { diff --git a/tapdb/sqlc/querier.go b/tapdb/sqlc/querier.go index dbc75c662..160103615 100644 --- a/tapdb/sqlc/querier.go +++ b/tapdb/sqlc/querier.go @@ -165,6 +165,7 @@ type Querier interface { QueryProofTransferAttempts(ctx context.Context, arg QueryProofTransferAttemptsParams) ([]time.Time, error) QuerySupplyCommitStateMachine(ctx context.Context, groupKey []byte) (QuerySupplyCommitStateMachineRow, error) QuerySupplyCommitment(ctx context.Context, commitID int64) (SupplyCommitment, error) + QuerySupplyLeavesByHeight(ctx context.Context, arg QuerySupplyLeavesByHeightParams) ([]QuerySupplyLeavesByHeightRow, error) QuerySupplyUpdateEvents(ctx context.Context, transitionID int64) ([]QuerySupplyUpdateEventsRow, error) // TODO(roasbeef): use the universe id instead for the grouping? so namespace // root, simplifies queries diff --git a/tapdb/sqlc/queries/supply_tree.sql b/tapdb/sqlc/queries/supply_tree.sql index 205454893..7183e02cd 100644 --- a/tapdb/sqlc/queries/supply_tree.sql +++ b/tapdb/sqlc/queries/supply_tree.sql @@ -43,6 +43,25 @@ JOIN universe_supply_roots r WHERE r.id = @supply_root_id AND (l.sub_tree_type = sqlc.narg('sub_tree_type') OR sqlc.narg('sub_tree_type') IS NULL); +-- name: QuerySupplyLeavesByHeight :many +SELECT + leaves.script_key_bytes, + gen.gen_asset_id, + nodes.value AS supply_leaf_bytes, + nodes.sum AS sum_amt, + gen.asset_id, + leaves.block_height +FROM universe_leaves AS leaves +JOIN mssmt_nodes AS nodes + ON leaves.leaf_node_key = nodes.key + AND leaves.leaf_node_namespace = nodes.namespace +JOIN genesis_info_view AS gen + ON leaves.asset_genesis_id = gen.gen_asset_id +WHERE + leaves.leaf_node_namespace = @namespace AND + (leaves.block_height >= sqlc.narg('start_height') OR sqlc.narg('start_height') IS NULL) AND + (leaves.block_height <= sqlc.narg('end_height') OR sqlc.narg('end_height') IS NULL); + -- name: DeleteUniverseSupplyLeaves :exec DELETE FROM universe_supply_leaves WHERE supply_root_id = ( diff --git a/tapdb/sqlc/queries/universe.sql b/tapdb/sqlc/queries/universe.sql index ba94fb189..90a4c9d6d 100644 --- a/tapdb/sqlc/queries/universe.sql +++ b/tapdb/sqlc/queries/universe.sql @@ -38,17 +38,18 @@ WHERE namespace_root = @namespace_root; -- name: UpsertUniverseLeaf :exec INSERT INTO universe_leaves ( - asset_genesis_id, script_key_bytes, universe_root_id, leaf_node_key, - leaf_node_namespace, minting_point + asset_genesis_id, script_key_bytes, universe_root_id, leaf_node_key, + leaf_node_namespace, minting_point, block_height ) VALUES ( @asset_genesis_id, @script_key_bytes, @universe_root_id, @leaf_node_key, - @leaf_node_namespace, @minting_point + @leaf_node_namespace, @minting_point, @block_height ) ON CONFLICT (minting_point, script_key_bytes, leaf_node_namespace) -- This is a NOP, minting_point and script_key_bytes are the unique fields -- that caused the conflict. DO UPDATE SET minting_point = EXCLUDED.minting_point, script_key_bytes = EXCLUDED.script_key_bytes, - leaf_node_namespace = EXCLUDED.leaf_node_namespace; + leaf_node_namespace = EXCLUDED.leaf_node_namespace, + block_height = EXCLUDED.block_height; -- name: DeleteUniverseLeaves :exec DELETE FROM universe_leaves diff --git a/tapdb/sqlc/schemas/generated_schema.sql b/tapdb/sqlc/schemas/generated_schema.sql index f1c6c8192..c9188032d 100644 --- a/tapdb/sqlc/schemas/generated_schema.sql +++ b/tapdb/sqlc/schemas/generated_schema.sql @@ -899,7 +899,7 @@ CREATE TABLE "universe_leaves" ( universe_root_id BIGINT NOT NULL REFERENCES universe_roots(id), leaf_node_key BLOB, leaf_node_namespace VARCHAR NOT NULL -); +, block_height INTEGER); CREATE INDEX universe_leaves_key_idx ON universe_leaves(leaf_node_key); diff --git a/tapdb/sqlc/supply_tree.sql.go b/tapdb/sqlc/supply_tree.sql.go index f0126aa15..0dead5ab4 100644 --- a/tapdb/sqlc/supply_tree.sql.go +++ b/tapdb/sqlc/supply_tree.sql.go @@ -71,6 +71,71 @@ func (q *Queries) FetchUniverseSupplyRoot(ctx context.Context, namespaceRoot str return i, err } +const QuerySupplyLeavesByHeight = `-- name: QuerySupplyLeavesByHeight :many +SELECT + leaves.script_key_bytes, + gen.gen_asset_id, + nodes.value AS supply_leaf_bytes, + nodes.sum AS sum_amt, + gen.asset_id, + leaves.block_height +FROM universe_leaves AS leaves +JOIN mssmt_nodes AS nodes + ON leaves.leaf_node_key = nodes.key + AND leaves.leaf_node_namespace = nodes.namespace +JOIN genesis_info_view AS gen + ON leaves.asset_genesis_id = gen.gen_asset_id +WHERE + leaves.leaf_node_namespace = $1 AND + (leaves.block_height >= $2 OR $2 IS NULL) AND + (leaves.block_height <= $3 OR $3 IS NULL) +` + +type QuerySupplyLeavesByHeightParams struct { + Namespace string + StartHeight sql.NullInt32 + EndHeight sql.NullInt32 +} + +type QuerySupplyLeavesByHeightRow struct { + ScriptKeyBytes []byte + GenAssetID int64 + SupplyLeafBytes []byte + SumAmt int64 + AssetID []byte + BlockHeight sql.NullInt32 +} + +func (q *Queries) QuerySupplyLeavesByHeight(ctx context.Context, arg QuerySupplyLeavesByHeightParams) ([]QuerySupplyLeavesByHeightRow, error) { + rows, err := q.db.QueryContext(ctx, QuerySupplyLeavesByHeight, arg.Namespace, arg.StartHeight, arg.EndHeight) + if err != nil { + return nil, err + } + defer rows.Close() + var items []QuerySupplyLeavesByHeightRow + for rows.Next() { + var i QuerySupplyLeavesByHeightRow + if err := rows.Scan( + &i.ScriptKeyBytes, + &i.GenAssetID, + &i.SupplyLeafBytes, + &i.SumAmt, + &i.AssetID, + &i.BlockHeight, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const QueryUniverseSupplyLeaves = `-- name: QueryUniverseSupplyLeaves :many SELECT r.group_key, l.sub_tree_type, smt_nodes.value AS sub_tree_root_hash, smt_nodes.sum AS sub_tree_root_sum diff --git a/tapdb/sqlc/universe.sql.go b/tapdb/sqlc/universe.sql.go index d2b42310a..887ae5ce7 100644 --- a/tapdb/sqlc/universe.sql.go +++ b/tapdb/sqlc/universe.sql.go @@ -1011,7 +1011,7 @@ func (q *Queries) QueryUniverseStats(ctx context.Context) (QueryUniverseStatsRow } const UniverseLeaves = `-- name: UniverseLeaves :many -SELECT id, asset_genesis_id, minting_point, script_key_bytes, universe_root_id, leaf_node_key, leaf_node_namespace FROM universe_leaves +SELECT id, asset_genesis_id, minting_point, script_key_bytes, universe_root_id, leaf_node_key, leaf_node_namespace, block_height FROM universe_leaves ` func (q *Queries) UniverseLeaves(ctx context.Context) ([]UniverseLeafe, error) { @@ -1031,6 +1031,7 @@ func (q *Queries) UniverseLeaves(ctx context.Context) ([]UniverseLeafe, error) { &i.UniverseRootID, &i.LeafNodeKey, &i.LeafNodeNamespace, + &i.BlockHeight, ); err != nil { return nil, err } @@ -1292,17 +1293,18 @@ func (q *Queries) UpsertMultiverseRoot(ctx context.Context, arg UpsertMultiverse const UpsertUniverseLeaf = `-- name: UpsertUniverseLeaf :exec INSERT INTO universe_leaves ( - asset_genesis_id, script_key_bytes, universe_root_id, leaf_node_key, - leaf_node_namespace, minting_point + asset_genesis_id, script_key_bytes, universe_root_id, leaf_node_key, + leaf_node_namespace, minting_point, block_height ) VALUES ( $1, $2, $3, $4, - $5, $6 + $5, $6, $7 ) ON CONFLICT (minting_point, script_key_bytes, leaf_node_namespace) -- This is a NOP, minting_point and script_key_bytes are the unique fields -- that caused the conflict. DO UPDATE SET minting_point = EXCLUDED.minting_point, script_key_bytes = EXCLUDED.script_key_bytes, - leaf_node_namespace = EXCLUDED.leaf_node_namespace + leaf_node_namespace = EXCLUDED.leaf_node_namespace, + block_height = EXCLUDED.block_height ` type UpsertUniverseLeafParams struct { @@ -1312,6 +1314,7 @@ type UpsertUniverseLeafParams struct { LeafNodeKey []byte LeafNodeNamespace string MintingPoint []byte + BlockHeight sql.NullInt32 } func (q *Queries) UpsertUniverseLeaf(ctx context.Context, arg UpsertUniverseLeafParams) error { @@ -1322,6 +1325,7 @@ func (q *Queries) UpsertUniverseLeaf(ctx context.Context, arg UpsertUniverseLeaf arg.LeafNodeKey, arg.LeafNodeNamespace, arg.MintingPoint, + arg.BlockHeight, ) return err } diff --git a/tapdb/sqlutils.go b/tapdb/sqlutils.go index 20803c4fc..192812c18 100644 --- a/tapdb/sqlutils.go +++ b/tapdb/sqlutils.go @@ -1,6 +1,7 @@ package tapdb import ( + "bytes" "context" "database/sql" "encoding/binary" @@ -14,7 +15,9 @@ import ( "github.com/btcsuite/btcd/wire" "github.com/lightninglabs/taproot-assets/fn" "github.com/lightninglabs/taproot-assets/internal/test" + "github.com/lightninglabs/taproot-assets/proof" "github.com/lightninglabs/taproot-assets/tapdb/sqlc" + lfn "github.com/lightningnetwork/lnd/fn/v2" "github.com/stretchr/testify/require" "golang.org/x/exp/constraints" ) @@ -94,6 +97,25 @@ func sqlStr(s string) sql.NullString { } } +// SparseDecodeBlockHeight sparse decodes a proof to extract the block height. +func SparseDecodeBlockHeight(rawProof []byte) (lfn.Option[uint32], error) { + var blockHeightVal uint32 + err := proof.SparseDecode( + bytes.NewReader(rawProof), + proof.BlockHeightRecord(&blockHeightVal), + ) + if err != nil { + return lfn.None[uint32](), fmt.Errorf("unable to "+ + "sparse decode proof: %w", err) + } + + if blockHeightVal == 0 { + return lfn.None[uint32](), nil + } + + return lfn.Some(blockHeightVal), nil +} + // extractSqlInt64 turns a NullInt64 into a numerical type. This can be useful // when reading directly from the database, as this function handles extracting // the inner value from the "option"-like struct. diff --git a/tapdb/supply_tree.go b/tapdb/supply_tree.go index 6504db77c..e5c0826a7 100644 --- a/tapdb/supply_tree.go +++ b/tapdb/supply_tree.go @@ -1,14 +1,18 @@ package tapdb import ( + "bytes" "context" + "database/sql" "encoding/hex" + "errors" "fmt" "github.com/btcsuite/btcd/btcec/v2" "github.com/lightninglabs/taproot-assets/asset" "github.com/lightninglabs/taproot-assets/mssmt" "github.com/lightninglabs/taproot-assets/proof" + "github.com/lightninglabs/taproot-assets/tapdb/sqlc" "github.com/lightninglabs/taproot-assets/universe" "github.com/lightninglabs/taproot-assets/universe/supplycommit" @@ -274,7 +278,8 @@ func (s *SupplyTreeStore) FetchRootSupplyTree(ctx context.Context, // NOTE: This function must be called within a database transaction. func registerMintSupplyInternal(ctx context.Context, dbTx BaseUniverseStore, assetSpec asset.Specifier, key universe.LeafKey, leaf *universe.Leaf, - metaReveal *proof.MetaReveal) (*universe.Proof, error) { + metaReveal *proof.MetaReveal, + blockHeight lfn.Option[uint32]) (*universe.Proof, error) { groupKey, err := assetSpec.UnwrapGroupKeyOrErr() if err != nil { @@ -286,7 +291,7 @@ func registerMintSupplyInternal(ctx context.Context, dbTx BaseUniverseStore, // Upsert the leaf into the mint supply sub-tree SMT and DB. mintSupplyProof, err := universeUpsertProofLeaf( ctx, dbTx, subNs, supplycommit.MintTreeType.String(), groupKey, - key, leaf, metaReveal, + key, leaf, metaReveal, blockHeight, ) if err != nil { return nil, fmt.Errorf("failed mint supply universe "+ @@ -312,15 +317,21 @@ func (s *SupplyTreeStore) RegisterMintSupply(ctx context.Context, var ( writeTx BaseUniverseStoreOptions - err error mintSupplyProof *universe.Proof newRootSupplyRoot mssmt.Node ) dbErr := s.db.ExecTx(ctx, &writeTx, func(dbTx BaseUniverseStore) error { + // We don't need to decode the whole proof, we just need the + // block height. + blockHeight, err := SparseDecodeBlockHeight(leaf.RawProof) + if err != nil { + return err + } + // Upsert the leaf into the mint supply sub-tree SMT and DB // first. mintSupplyProof, err = registerMintSupplyInternal( - ctx, dbTx, spec, key, leaf, nil, + ctx, dbTx, spec, key, leaf, nil, blockHeight, ) if err != nil { return fmt.Errorf("failed mint supply universe "+ @@ -395,9 +406,15 @@ func applySupplyUpdatesInternal(ctx context.Context, dbTx BaseUniverseStore, "type: %T", update) } + var blockHeight lfn.Option[uint32] + height := mintEvent.BlockHeight() + if height > 0 { + blockHeight = lfn.Some(height) + } + mintProof, err := registerMintSupplyInternal( ctx, dbTx, spec, mintEvent.LeafKey, - &mintEvent.IssuanceProof, nil, + &mintEvent.IssuanceProof, nil, blockHeight, ) if err != nil { return nil, fmt.Errorf("failed to register "+ @@ -586,3 +603,112 @@ func (s *SupplyTreeStore) ApplySupplyUpdates(ctx context.Context, return finalRoot, nil } + +// SupplyUpdate is a struct that holds a supply update event and its block +// height. +type SupplyUpdate struct { + supplycommit.SupplyUpdateEvent + BlockHeight uint32 +} + +// FetchSupplyLeavesByHeight fetches all supply leaves for a given asset +// specifier within a given block height range. +func (s *SupplyTreeStore) FetchSupplyLeavesByHeight(ctx context.Context, + spec asset.Specifier, startHeight, endHeight uint32) ([]SupplyUpdate, error) { + + groupKey, err := spec.UnwrapGroupKeyOrErr() + if err != nil { + return nil, fmt.Errorf("group key must be "+ + "specified for supply tree: %w", err) + } + + var updates []SupplyUpdate + + readTx := NewBaseUniverseReadTx() + dbErr := s.db.ExecTx(ctx, &readTx, func(db BaseUniverseStore) error { + for _, treeType := range []supplycommit.SupplySubTree{ + supplycommit.MintTreeType, supplycommit.BurnTreeType, + supplycommit.IgnoreTreeType, + } { + namespace := subTreeNamespace(groupKey, treeType) + + leaves, err := db.QuerySupplyLeavesByHeight( + ctx, sqlc.QuerySupplyLeavesByHeightParams{ + Namespace: namespace, + StartHeight: sqlInt32(startHeight), + EndHeight: sqlInt32(endHeight), + }, + ) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + continue + } + + return fmt.Errorf("failed to query "+ + "supply leaves: %w", err) + } + + for _, leaf := range leaves { + var event supplycommit.SupplyUpdateEvent + switch treeType { + case supplycommit.MintTreeType: + var mintEvent supplycommit.NewMintEvent + err = mintEvent.Decode( + bytes.NewReader( + leaf.SupplyLeafBytes, + ), + ) + if err != nil { + return fmt.Errorf("failed "+ + "to decode mint "+ + "event: %w", err) + } + + event = &mintEvent + + case supplycommit.BurnTreeType: + var burnEvent supplycommit.NewBurnEvent + err = burnEvent.Decode( + bytes.NewReader( + leaf.SupplyLeafBytes, + ), + ) + if err != nil { + return fmt.Errorf("failed "+ + "to decode burn "+ + "event: %w", err) + } + + event = &burnEvent + + case supplycommit.IgnoreTreeType: + var ignoreEvent supplycommit.NewIgnoreEvent + err = ignoreEvent.Decode( + bytes.NewReader( + leaf.SupplyLeafBytes, + ), + ) + if err != nil { + return fmt.Errorf("failed "+ + "to decode ignore "+ + "event: %w", err) + } + event = &ignoreEvent + } + + updates = append(updates, SupplyUpdate{ + SupplyUpdateEvent: event, + BlockHeight: extractSqlInt32[uint32]( + leaf.BlockHeight, + ), + }) + } + } + return nil + }) + if dbErr != nil { + return nil, dbErr + } + + return updates, nil +} diff --git a/tapdb/supply_tree_test.go b/tapdb/supply_tree_test.go index 5ff9419f3..adbd0be44 100644 --- a/tapdb/supply_tree_test.go +++ b/tapdb/supply_tree_test.go @@ -248,7 +248,8 @@ func randIgnoreTupleGen(t *rapid.T, ScriptKey: asset.ToSerialized(scriptKey.PubKey), OutPoint: op, }, - Amount: 100, + Amount: 100, + BlockHeight: rapid.Uint32Range(1, 1000).Draw(t, "block_height"), } // Create a signature for the ignore tuple. @@ -367,6 +368,101 @@ func setupSupplyTreeTestForProps(t *testing.T) (*SupplyTreeStore, return supplyStore, spec, eventGen } +// createMintEventWithHeight creates a mint event with a specific block height. +func createMintEventWithHeight(t *testing.T, groupKey *btcec.PublicKey, + height uint32) *supplycommit.NewMintEvent { + + mintAsset := asset.RandAsset(t, asset.Normal) + mintAsset.GroupKey = &asset.GroupKey{GroupPubKey: *groupKey} + mintAsset.GroupKey.Witness = mintAsset.PrevWitnesses[0].TxWitness + + mintProof := randProof(t, mintAsset) + mintProof.BlockHeight = height + mintProof.GroupKeyReveal = asset.NewGroupKeyRevealV0( + asset.ToSerialized(groupKey), nil, + ) + + var proofBuf bytes.Buffer + require.NoError(t, mintProof.Encode(&proofBuf)) + + mintLeaf := universe.Leaf{ + GenesisWithGroup: universe.GenesisWithGroup{ + Genesis: mintAsset.Genesis, + GroupKey: mintAsset.GroupKey, + }, + Asset: &mintProof.Asset, + Amt: mintProof.Asset.Amount, + RawProof: proofBuf.Bytes(), + } + + mintKey := universe.AssetLeafKey{ + BaseLeafKey: universe.BaseLeafKey{ + OutPoint: mintProof.OutPoint(), + ScriptKey: &mintProof.Asset.ScriptKey, + }, + AssetID: mintProof.Asset.ID(), + } + + return &supplycommit.NewMintEvent{ + LeafKey: mintKey, + IssuanceProof: mintLeaf, + } +} + +// createBurnEventWithHeight creates a burn event with a specific block height. +func createBurnEventWithHeight(t *testing.T, baseGenesis asset.Genesis, + groupKey *asset.GroupKey, db BatchedUniverseTree, + height uint32) *supplycommit.NewBurnEvent { + + burnAsset := createBurnAsset(t) + burnAsset.Genesis = baseGenesis + burnAsset.GroupKey = groupKey + + burnProof := randProof(t, burnAsset) + burnProof.BlockHeight = height + burnProof.GenesisReveal = &baseGenesis + + // Ensure genesis exists for this burn leaf in the DB. + ctx := context.Background() + genesisPointID, err := upsertGenesisPoint( + ctx, db, burnAsset.Genesis.FirstPrevOut, + ) + require.NoError(t, err) + _, err = upsertGenesis( + ctx, db, genesisPointID, burnAsset.Genesis, + ) + require.NoError(t, err) + + burnLeaf := &universe.BurnLeaf{ + UniverseKey: universe.AssetLeafKey{ + BaseLeafKey: universe.BaseLeafKey{ + OutPoint: burnProof.OutPoint(), + ScriptKey: &burnProof.Asset.ScriptKey, + }, + AssetID: burnProof.Asset.ID(), + }, + BurnProof: burnProof, + } + + return &supplycommit.NewBurnEvent{ + BurnLeaf: *burnLeaf, + } +} + +// createIgnoreEventWithHeight creates an ignore event with a specific block +// height. +func createIgnoreEventWithHeight(t *testing.T, baseAssetID asset.ID, + db BatchedUniverseTree, height uint32) *supplycommit.NewIgnoreEvent { + + signedTuple := randIgnoreTuple(t, db) + signedTuple.IgnoreTuple.Val.ID = baseAssetID + signedTuple.IgnoreTuple.Val.BlockHeight = height + + return &supplycommit.NewIgnoreEvent{ + SignedIgnoreTuple: signedTuple, + } +} + // TestSupplyTreeStoreApplySupplyUpdates tests that the ApplySupplyUpdates meets // a series of key invariant via property based testing. func TestSupplyTreeStoreApplySupplyUpdates(t *testing.T) { @@ -544,3 +640,114 @@ func TestSupplyTreeStoreApplySupplyUpdates(t *testing.T) { ) require.NoError(t, err) } + +// TestSupplyTreeStoreFetchSupplyLeavesByHeight tests the +// FetchSupplyLeavesByHeight method. +func TestSupplyTreeStoreFetchSupplyLeavesByHeight(t *testing.T) { + t.Parallel() + + supplyStore, spec, _ := setupSupplyTreeTestForProps(t) + ctxb := context.Background() + dbTxer := supplyStore.db + + groupKey, err := spec.UnwrapGroupKeyOrErr() + require.NoError(t, err) + assetID := spec.UnwrapIdToPtr() + + fullGroupKey := &asset.GroupKey{ + GroupPubKey: *groupKey, + } + + // Create events with specific block heights, we'll use these heights + // below to ensure that the new leaf height is properly set/read all the + // way down the call stack. + mintEvent100 := createMintEventWithHeight(t, groupKey, 100) + burnEvent200 := createBurnEventWithHeight( + t, asset.RandGenesis(t, asset.Normal), fullGroupKey, dbTxer, + 200, + ) + ignoreEvent300 := createIgnoreEventWithHeight(t, *assetID, dbTxer, 300) + mintEvent400 := createMintEventWithHeight(t, groupKey, 400) + + updates := []supplycommit.SupplyUpdateEvent{ + mintEvent100, burnEvent200, ignoreEvent300, mintEvent400, + } + + // Apply updates. + _, err = supplyStore.ApplySupplyUpdates(ctxb, spec, updates) + require.NoError(t, err) + + testCases := []struct { + name string + startHeight uint32 + endHeight uint32 + expectedCount int + expectedHeights []uint32 + }{ + { + name: "range including first", + startHeight: 0, + endHeight: 150, + expectedCount: 1, + expectedHeights: []uint32{100}, + }, + { + name: "range including second", + startHeight: 150, + endHeight: 250, + expectedCount: 1, + expectedHeights: []uint32{200}, + }, + { + name: "range including all", + startHeight: 0, + endHeight: 500, + expectedCount: 4, + expectedHeights: []uint32{100, 200, 300, 400}, + }, + { + name: "exact range", + startHeight: 100, + endHeight: 400, + expectedCount: 4, + expectedHeights: []uint32{100, 200, 300, 400}, + }, + { + name: "inner range", + startHeight: 101, + endHeight: 399, + expectedCount: 2, + expectedHeights: []uint32{200, 300}, + }, + { + name: "range after all", + startHeight: 501, + endHeight: 1000, + expectedCount: 0, + expectedHeights: nil, + }, + { + name: "range before all", + startHeight: 0, + endHeight: 99, + expectedCount: 0, + expectedHeights: nil, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + leaves, err := supplyStore.FetchSupplyLeavesByHeight( + ctxb, spec, tc.startHeight, tc.endHeight, + ) + require.NoError(t, err) + require.Len(t, leaves, tc.expectedCount) + + var heights []uint32 + for _, leaf := range leaves { + heights = append(heights, leaf.BlockHeight) + } + require.ElementsMatch(t, tc.expectedHeights, heights) + }) + } +} diff --git a/tapdb/universe.go b/tapdb/universe.go index 93a2b178e..fd3697a03 100644 --- a/tapdb/universe.go +++ b/tapdb/universe.go @@ -58,6 +58,10 @@ type ( // UpsertUniverseSupplyLeaf is used to upsert a universe supply leaf. UpsertUniverseSupplyLeaf = sqlc.UpsertUniverseSupplyLeafParams + + // QuerySupplyLeavesByHeightParams is used to query for supply leaves + // by height. + QuerySupplyLeavesByHeightParams = sqlc.QuerySupplyLeavesByHeightParams ) // BaseUniverseStore is the main interface for the Taproot Asset universe store. @@ -128,6 +132,12 @@ type BaseUniverseStore interface { // supply tree for a given asset. UpsertUniverseSupplyRoot(ctx context.Context, arg UpsertUniverseSupplyRoot) (int64, error) + + // QuerySupplyLeavesByHeight is used to query for supply leaves by + // height. + QuerySupplyLeavesByHeight(ctx context.Context, + arg QuerySupplyLeavesByHeightParams) ( + []sqlc.QuerySupplyLeavesByHeightRow, error) } // getUniverseTreeSum retrieves the sum of a universe tree specified by its @@ -570,9 +580,17 @@ func (b *BaseUniverseTree) UpsertProofLeaf(ctx context.Context, ) dbErr := b.db.ExecTx(ctx, &writeTx, func(dbTx BaseUniverseStore) error { namespace := b.id.String() + + // We don't need to decode the whole proof, we just need the + // block height. + blockHeight, err := SparseDecodeBlockHeight(leaf.RawProof) + if err != nil { + return err + } + issuanceProof, err := universeUpsertProofLeaf( - ctx, dbTx, namespace, b.id.ProofType.String(), - b.id.GroupKey, key, leaf, metaReveal, + ctx, dbTx, namespace, b.id.ProofType.String(), b.id.GroupKey, + key, leaf, metaReveal, blockHeight, ) if err != nil { return fmt.Errorf("failed universe upsert: %w", err) @@ -702,7 +720,8 @@ func upsertMultiverseLeafEntry(ctx context.Context, dbTx BaseUniverseStore, func universeUpsertProofLeaf(ctx context.Context, dbTx BaseUniverseStore, namespace string, proofTypeStr string, groupKey *btcec.PublicKey, key universe.LeafKey, leaf *universe.Leaf, - metaReveal *proof.MetaReveal) (*universe.Proof, error) { + metaReveal *proof.MetaReveal, blockHeight lfn.Option[uint32]) (*universe.Proof, + error) { // With the tree store created, we'll now obtain byte representation of // the minting key, as that'll be the key in the SMT itself. @@ -773,6 +792,23 @@ func universeUpsertProofLeaf(ctx context.Context, dbTx BaseUniverseStore, return nil, err } + // If the block height isn't specified, then we'll attempt to extract it + // from the proof itself. + if blockHeight.IsNone() { + if leafProof.BlockHeight > 0 { + blockHeight = lfn.Some(leafProof.BlockHeight) + } + } + + sqlBlockHeight := lfn.MapOptionZ( + blockHeight, func(num uint32) sql.NullInt32 { + return sql.NullInt32{ + Int32: int32(num), + Valid: true, + } + }, + ) + scriptKey := key.LeafScriptKey() scriptKeyBytes := schnorr.SerializePubKey(scriptKey.PubKey) err = dbTx.UpsertUniverseLeaf(ctx, UpsertUniverseLeaf{ @@ -782,6 +818,7 @@ func universeUpsertProofLeaf(ctx context.Context, dbTx BaseUniverseStore, LeafNodeKey: smtKey[:], LeafNodeNamespace: namespace, MintingPoint: mintingPointBytes, + BlockHeight: sqlBlockHeight, }) if err != nil { return nil, err diff --git a/universe/ignore_records.go b/universe/ignore_records.go index b39d2a10b..43305f759 100644 --- a/universe/ignore_records.go +++ b/universe/ignore_records.go @@ -29,6 +29,9 @@ type IgnoreTuple struct { // Amount is the total asset unit amount associated with asset.PrevID. Amount uint64 + + // BlockHeight is the height of the block that contains the ignore. + BlockHeight uint32 } func ignoreTupleEncoder(w io.Writer, val any, buf *[8]byte) error { @@ -45,7 +48,11 @@ func ignoreTupleEncoder(w io.Writer, val any, buf *[8]byte) error { return err } - return tlv.EUint64(w, &t.Amount, buf) + if err := tlv.EUint64(w, &t.Amount, buf); err != nil { + return err + } + + return tlv.EUint32(w, &t.BlockHeight, buf) } return tlv.NewTypeForEncodingErr(val, "*PrevID") } @@ -73,9 +80,15 @@ func ignoreTupleDecoder(r io.Reader, val any, buf *[8]byte, l uint64) error { return err } + var height uint32 + if err = tlv.DUint32(r, &height, buf, 4); err != nil { + return err + } + *typ = IgnoreTuple{ - PrevID: prevID, - Amount: amt, + PrevID: prevID, + Amount: amt, + BlockHeight: height, } return nil } @@ -84,7 +97,8 @@ func ignoreTupleDecoder(r io.Reader, val any, buf *[8]byte, l uint64) error { // Record returns the TLV record for the IgnoreTuple. func (i *IgnoreTuple) Record() tlv.Record { - const recordSize = 8 + 36 + sha256.Size + btcec.PubKeyBytesLenCompressed + const recordSize = 8 + 36 + sha256.Size + + btcec.PubKeyBytesLenCompressed + 4 return tlv.MakeStaticRecord( 0, i, recordSize, diff --git a/universe/ignore_records_test.go b/universe/ignore_records_test.go index 04a5dd64e..ae7ddcb2d 100644 --- a/universe/ignore_records_test.go +++ b/universe/ignore_records_test.go @@ -31,8 +31,9 @@ var IgnoreSigGen = rapid.Custom(func(t *rapid.T) IgnoreSig { // (PrevID) objects. It reuses the NonGenesisPrevIDGen from the asset package. var IgnoreTupleGen = rapid.Custom(func(t *rapid.T) IgnoreTuple { return IgnoreTuple{ - PrevID: asset.NonGenesisPrevIDGen.Draw(t, "ignore_tuple"), - Amount: rapid.Uint64().Draw(t, "amount"), + PrevID: asset.NonGenesisPrevIDGen.Draw(t, "ignore_tuple"), + Amount: rapid.Uint64().Draw(t, "amount"), + BlockHeight: rapid.Uint32().Draw(t, "block_height"), } }) diff --git a/universe/supplycommit/state_machine_test.go b/universe/supplycommit/state_machine_test.go index 5bd6b7d70..ebea578a7 100644 --- a/universe/supplycommit/state_machine_test.go +++ b/universe/supplycommit/state_machine_test.go @@ -92,6 +92,7 @@ func newTestMintEvent(t *testing.T, scriptKey *btcec.PublicKey, return &NewMintEvent{ LeafKey: leafKey, IssuanceProof: issuanceProof, + MintHeight: 1000, } } @@ -1175,6 +1176,7 @@ func TestSupplyUpdateEventTypes(t *testing.T) { GroupKey: randomFullProof.Asset.GroupKey, } originalMintEvent.IssuanceProof.Amt = randomFullProof.Asset.Amount + originalMintEvent.MintHeight = randomFullProof.BlockHeight rawProofBytes, err := randomFullProof.Bytes() require.NoError(t, err) diff --git a/universe/supplycommit/states.go b/universe/supplycommit/states.go index e5e668306..fe460d26e 100644 --- a/universe/supplycommit/states.go +++ b/universe/supplycommit/states.go @@ -92,6 +92,9 @@ type SupplyUpdateEvent interface { // UniverseLeafNode returns the leaf node to use when inserting this // update into a universe MS-SMT tree. UniverseLeafNode() (*mssmt.LeafNode, error) + + // BlockHeight returns the block height of the update. + BlockHeight() uint32 } // NewIgnoreEvent signals that a caller wishes to update the ignore portion of @@ -103,6 +106,11 @@ type NewIgnoreEvent struct { // eventSealed is a special method that is used to seal the interface. func (n *NewIgnoreEvent) eventSealed() {} +// BlockHeight returns the block height of the update. +func (n *NewIgnoreEvent) BlockHeight() uint32 { + return n.SignedIgnoreTuple.IgnoreTuple.Val.BlockHeight +} + // ScriptKey returns the script key that is used to identify the target asset. func (n *NewIgnoreEvent) ScriptKey() asset.SerializedKey { return n.IgnoreTuple.Val.ScriptKey @@ -138,6 +146,11 @@ type NewBurnEvent struct { // eventSealed is a special method that is used to seal the interface. func (n *NewBurnEvent) eventSealed() {} +// BlockHeight returns the block height of the update. +func (n *NewBurnEvent) BlockHeight() uint32 { + return n.BurnLeaf.BurnProof.BlockHeight +} + // ScriptKey returns the script key that is used to identify the target asset. func (n *NewBurnEvent) ScriptKey() asset.SerializedKey { leafKey := n.BurnLeaf.UniverseKey.LeafScriptKey() @@ -171,7 +184,16 @@ type NewMintEvent struct { // LeafKey is the universe leaf key for the asset issuance or spend. LeafKey universe.UniqueLeafKey + // IssuanceProof is the universe leaf for the issuance. IssuanceProof universe.Leaf + + // MintHeight is the height of the block that contains the mint. + MintHeight uint32 +} + +// BlockHeight returns the block height of the update. +func (n *NewMintEvent) BlockHeight() uint32 { + return n.MintHeight } // eventSealed is a special method that is used to seal the interface. @@ -224,6 +246,7 @@ func (n *NewMintEvent) Decode(r io.Reader) error { return fmt.Errorf("decode mint event: %w", err) } + n.MintHeight = issuanceProof.BlockHeight n.IssuanceProof.GenesisWithGroup = universe.GenesisWithGroup{ Genesis: issuanceProof.Asset.Genesis, GroupKey: issuanceProof.Asset.GroupKey,