diff --git a/tapdb/burn_tree.go b/tapdb/burn_tree.go index eadf198f7..bfb602da6 100644 --- a/tapdb/burn_tree.go +++ b/tapdb/burn_tree.go @@ -13,10 +13,15 @@ import ( "github.com/lightninglabs/taproot-assets/mssmt" "github.com/lightninglabs/taproot-assets/proof" "github.com/lightninglabs/taproot-assets/universe" + "github.com/lightninglabs/taproot-assets/universe/supplycommit" lfn "github.com/lightningnetwork/lnd/fn/v2" ) +// ErrMissingGroupKey is returned when an operation requires an asset specifier +// with a group key, but none is provided. +var ErrMissingGroupKey = errors.New("asset specifier missing group key") + // BurnUniverseTree is a structure that holds the DB for burn operations. type BurnUniverseTree struct { db BatchedUniverseTree @@ -31,110 +36,154 @@ func NewBurnUniverseTree(db BatchedUniverseTree) *BurnUniverseTree { func (bt *BurnUniverseTree) Sum(ctx context.Context, spec asset.Specifier) universe.BurnTreeSum { - // Derive identifier from the asset.Specifier. - id, err := specifierToIdentifier(spec, universe.ProofTypeBurn) + groupKey, err := spec.UnwrapGroupKeyOrErr() if err != nil { - return lfn.Err[lfn.Option[uint64]](err) + return lfn.Errf[lfn.Option[uint64]]("%w: %w", + ErrMissingGroupKey, err) } + namespace := subTreeNamespace(groupKey, supplycommit.BurnTreeType) // Use the generic helper to get the sum. - return getUniverseTreeSum(ctx, bt.db, id) + return getUniverseTreeSum(ctx, bt.db, namespace) } // ErrNotBurn is returned when a proof is not a burn proof. var ErrNotBurn = errors.New("not a burn proof") -// InsertBurns attempts to insert a set of new burn leaves into the burn tree -// identified by the passed asset.Specifier. If a given proof isn't a true burn -// proof, then an error is returned. This check is performed upfront. If the -// proof is valid, then the burn leaf is inserted into the tree, with a new -// merkle proof returned. -func (bt *BurnUniverseTree) InsertBurns(ctx context.Context, - spec asset.Specifier, - burnLeaves ...*universe.BurnLeaf) universe.BurnLeafResp { +// insertBurnsInternal performs the insertion of burn leaves within a database +// transaction. It also updates the main supply tree with the new burn sub-tree +// root. +// +// NOTE: This function must be called within a database transaction. +func insertBurnsInternal(ctx context.Context, db BaseUniverseStore, + spec asset.Specifier, burnLeaves ...*universe.BurnLeaf, +) ([]*universe.AuthenticatedBurnLeaf, error) { if len(burnLeaves) == 0 { - return lfn.Err[[]*universe.AuthenticatedBurnLeaf]( - fmt.Errorf("no burn leaves provided"), - ) + return nil, fmt.Errorf("no burn leaves provided") } - // Derive identifier (and thereby the namespace) from the - // asset.Specifier. - id, err := specifierToIdentifier(spec, universe.ProofTypeBurn) + groupKey, err := spec.UnwrapGroupKeyOrErr() if err != nil { - return lfn.Err[[]*universe.AuthenticatedBurnLeaf](err) + return nil, fmt.Errorf("%w: %w", ErrMissingGroupKey, err) } + // Given the group key, and the sub-tree type, we'll derive a unique + // namespace for this tree. + subNs := subTreeNamespace(groupKey, supplycommit.BurnTreeType) + // Perform upfront validation for all proofs. Make sure that all the // assets are actually burns. for _, burnLeaf := range burnLeaves { if !burnLeaf.BurnProof.Asset.IsBurn() { - return lfn.Err[[]*universe.AuthenticatedBurnLeaf]( - fmt.Errorf("%w: proof for asset %v is not a "+ - "burn proof, has type %v", - ErrNotBurn, - burnLeaf.BurnProof.Asset.ID(), - burnLeaf.BurnProof.Asset.Type), - ) + return nil, fmt.Errorf("%w: proof for asset %v is "+ + "not a burn proof, has type %v", + ErrNotBurn, + burnLeaf.BurnProof.Asset.ID(), + burnLeaf.BurnProof.Asset.Type) } } + tree := mssmt.NewCompactedTree( + newTreeStoreWrapperTx(db, subNs), + ) + var finalResults []*universe.AuthenticatedBurnLeaf - var writeTx BaseUniverseStoreOptions - txErr := bt.db.ExecTx(ctx, &writeTx, func(db BaseUniverseStore) error { - for _, burnLeaf := range burnLeaves { - leafKey := burnLeaf.UniverseKey - - // Encode the burn proof to get the raw bytes. - var proofBuf bytes.Buffer - err := burnLeaf.BurnProof.Encode(&proofBuf) - if err != nil { - return fmt.Errorf("unable to encode burn "+ - "proof: %w", err) - } - rawProofBytes := proofBuf.Bytes() - - // Construct the universe.Leaf required by - // universeUpsertProofLeaf. - burnProof := burnLeaf.BurnProof - leaf := &universe.Leaf{ - GenesisWithGroup: universe.GenesisWithGroup{ - Genesis: burnProof.Asset.Genesis, - GroupKey: burnProof.Asset.GroupKey, - }, - RawProof: rawProofBytes, - Asset: &burnLeaf.BurnProof.Asset, - Amt: burnLeaf.BurnProof.Asset.Amount, - } - - // Call the generic upsert function. MetaReveal is nil - // for burns, as this isn't an issuance instance. We - // also skip inserting into the multi-verse tree for - // now. - uniProof, err := universeUpsertProofLeaf( - ctx, db, id, leafKey, leaf, nil, true, - ) - if err != nil { - return fmt.Errorf("unable to upsert burn "+ - "leaf for key %v: %w", leafKey, err) - } - - authLeaf := &universe.AuthenticatedBurnLeaf{ - BurnLeaf: burnLeaf, - BurnTreeRoot: uniProof.UniverseRoot, - BurnProof: uniProof.UniverseInclusionProof, - } - finalResults = append(finalResults, authLeaf) + // First, insert all burn leaves into the burn sub-tree SMT. + for _, burnLeaf := range burnLeaves { + leafKey := burnLeaf.UniverseKey + + // Encode the burn proof to get the raw bytes. + var proofBuf bytes.Buffer + err := burnLeaf.BurnProof.Encode(&proofBuf) + if err != nil { + return nil, fmt.Errorf("unable to encode burn "+ + "proof: %w", err) + } + rawProofBytes := proofBuf.Bytes() + + // Construct the universe.Leaf required by + // universeUpsertProofLeaf. + burnProof := burnLeaf.BurnProof + leaf := &universe.Leaf{ + GenesisWithGroup: universe.GenesisWithGroup{ + Genesis: burnProof.Asset.Genesis, + GroupKey: burnProof.Asset.GroupKey, + }, + RawProof: rawProofBytes, + Asset: &burnLeaf.BurnProof.Asset, + Amt: burnLeaf.BurnProof.Asset.Amount, + IsBurn: true, + } + + // 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, + ) + if err != nil { + return nil, fmt.Errorf("unable to upsert burn "+ + "leaf DB records for key %v: %w", leafKey, err) + } + } + + // Fetch the final burn sub-tree root after all insertions. + finalBurnRoot, err := tree.Root(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get burn tree root: %w", err) + } + + // Now, construct the AuthenticatedBurnLeaf results by fetching proofs + // against the final tree root. + for _, burnLeaf := range burnLeaves { + leafKey := burnLeaf.UniverseKey.UniverseKey() + inclusionProof, err := tree.MerkleProof(ctx, leafKey) + if err != nil { + return nil, fmt.Errorf("failed to get burn proof "+ + "for key %v: %w", burnLeaf.UniverseKey, err) + } + + authLeaf := &universe.AuthenticatedBurnLeaf{ + BurnLeaf: burnLeaf, + BurnTreeRoot: finalBurnRoot, + BurnProof: inclusionProof, } + finalResults = append(finalResults, authLeaf) + } + + return finalResults, nil +} + +// InsertBurns attempts to insert a set of new burn leaves into the burn tree +// identified by the passed asset.Specifier. If a given proof isn't a true burn +// proof, then an error is returned. This check is performed upfront. If the +// proof is valid, then the burn leaf is inserted into the tree, with a new +// merkle proof returned. +func (bt *BurnUniverseTree) InsertBurns(ctx context.Context, + spec asset.Specifier, + burnLeaves ...*universe.BurnLeaf) universe.BurnLeafResp { + + var ( + writeTx BaseUniverseStoreOptions + finalResults []*universe.AuthenticatedBurnLeaf + err error + ) + txErr := bt.db.ExecTx(ctx, &writeTx, func(db BaseUniverseStore) error { + finalResults, err = insertBurnsInternal( + ctx, db, spec, burnLeaves..., + ) - return nil + // TODO(roasbeef): also update the root supply tree? + return err }) if txErr != nil { return lfn.Err[[]*universe.AuthenticatedBurnLeaf](txErr) } + // TODO(roasbeef): cache invalidation? + return lfn.Ok(finalResults) } @@ -143,14 +192,11 @@ func queryBurnLeaves(ctx context.Context, dbtx BaseUniverseStore, spec asset.Specifier, burnPoints ...wire.OutPoint) ([]UniverseLeaf, error) { - uniNamespace, err := specifierToIdentifier( - spec, universe.ProofTypeBurn, - ) + groupKey, err := spec.UnwrapGroupKeyOrErr() if err != nil { - return nil, fmt.Errorf("error deriving identifier: %w", err) + return nil, fmt.Errorf("%w: %w", ErrMissingGroupKey, err) } - - namespace := uniNamespace.String() + namespace := subTreeNamespace(groupKey, supplycommit.BurnTreeType) // If no burn points are provided, we query all leaves in the namespace. if len(burnPoints) == 0 { @@ -166,7 +212,7 @@ func queryBurnLeaves(ctx context.Context, dbtx BaseUniverseStore, } if err != nil { return nil, fmt.Errorf("error querying all leaves "+ - "for namespace %s: %w", &uniNamespace, err) + "for namespace %s: %w", namespace, err) } return dbLeaves, nil @@ -264,19 +310,19 @@ func (bt *BurnUniverseTree) QueryBurns(ctx context.Context, spec asset.Specifier, burnPoints ...wire.OutPoint) universe.BurnLeafQueryResp { - // Derive identifier from the asset.Specifier. - id, err := specifierToIdentifier(spec, universe.ProofTypeBurn) + groupKey, err := spec.UnwrapGroupKeyOrErr() if err != nil { - return lfn.Err[lfn.Option[[]*universe.AuthenticatedBurnLeaf]]( - err, + return lfn.Errf[lfn.Option[[]*universe.AuthenticatedBurnLeaf]]( + "%w: %w", ErrMissingGroupKey, err, ) } + namespace := subTreeNamespace(groupKey, supplycommit.BurnTreeType) // Use the generic list helper to list the leaves from the universe // Tree. We pass in our custom decode function to handle the logic // specific to BurnLeaf. return queryUniverseLeavesAndProofs( - ctx, bt.db, spec, id, queryBurnLeaves, + ctx, bt.db, spec, namespace, queryBurnLeaves, decodeAndBuildAuthBurnLeaf, buildAuthBurnLeaf, burnPoints..., ) } @@ -306,13 +352,15 @@ func (bt *BurnUniverseTree) ListBurns(ctx context.Context, spec asset.Specifier) universe.ListBurnsResp { // Derive identifier from the asset.Specifier. - id, err := specifierToIdentifier(spec, universe.ProofTypeBurn) + groupKey, err := spec.UnwrapGroupKeyOrErr() if err != nil { - return lfn.Err[lfn.Option[[]*universe.BurnDesc]](err) + return lfn.Errf[lfn.Option[[]*universe.BurnDesc]]( + "%w: %w", ErrMissingGroupKey, err, + ) } + namespace := subTreeNamespace(groupKey, supplycommit.BurnTreeType) - // Use the generic list helper. - return listUniverseLeaves(ctx, bt.db, id, decodeBurnDesc) + return listUniverseLeaves(ctx, bt.db, namespace, decodeBurnDesc) } // Compile-time assertion to ensure BurnUniverseTree implements the diff --git a/tapdb/burn_tree_test.go b/tapdb/burn_tree_test.go index 823d2562f..90502f1c5 100644 --- a/tapdb/burn_tree_test.go +++ b/tapdb/burn_tree_test.go @@ -154,6 +154,23 @@ func TestBurnUniverseTreeInsertBurns(t *testing.T) { ) require.True(t, valid) } + + // Verify that all returned proofs share the same final tree. + if len(authLeaves) > 1 { + firstRoot := authLeaves[0].BurnTreeRoot + for i := 1; i < len(authLeaves); i++ { + require.Equal(t, + firstRoot.NodeHash(), + authLeaves[i].BurnTreeRoot.NodeHash(), + "root hash mismatch at index %d", i, + ) + require.Equal(t, + firstRoot.NodeSum(), + authLeaves[i].BurnTreeRoot.NodeSum(), + "root sum mismatch at index %d", i, + ) + } + } }) // Test case 2: Inserting with no burn leaves should return an error. @@ -173,9 +190,7 @@ func TestBurnUniverseTreeInsertBurns(t *testing.T) { ctx, invalidSpec, burnLeaves..., ) require.Error(t, result.Err()) - require.Contains( - t, result.Err().Error(), "group key must be set", - ) + require.ErrorIs(t, result.Err(), ErrMissingGroupKey) }) // Test case 4: Inserting non-burn proof should fail. @@ -414,9 +429,7 @@ func TestBurnUniverseTreeSum(t *testing.T) { var invalidSpec asset.Specifier result := burnTree.Sum(ctx, invalidSpec) require.Error(t, result.Err()) - require.Contains( - t, result.Err().Error(), "group key must be set", - ) + require.ErrorIs(t, result.Err(), ErrMissingGroupKey) }) } @@ -522,9 +535,7 @@ func TestBurnUniverseTreeQueryBurns(t *testing.T) { var invalidSpec asset.Specifier result := burnTree.QueryBurns(ctx, invalidSpec) require.Error(t, result.Err()) - require.Contains( - t, result.Err().Error(), "group key must be set", - ) + require.ErrorIs(t, result.Err(), ErrMissingGroupKey) }) } @@ -607,9 +618,7 @@ func TestBurnUniverseTreeListBurns(t *testing.T) { invalidSpec := asset.Specifier{} result := burnTree.ListBurns(ctx, invalidSpec) require.Error(t, result.Err()) - require.Contains( - t, result.Err().Error(), "group key must be set", - ) + require.ErrorIs(t, result.Err(), ErrMissingGroupKey) }) } diff --git a/tapdb/ignore_tree.go b/tapdb/ignore_tree.go index 72c3c59d9..892046bb4 100644 --- a/tapdb/ignore_tree.go +++ b/tapdb/ignore_tree.go @@ -10,6 +10,7 @@ import ( "github.com/lightninglabs/taproot-assets/asset" "github.com/lightninglabs/taproot-assets/mssmt" "github.com/lightninglabs/taproot-assets/universe" + "github.com/lightninglabs/taproot-assets/universe/supplycommit" lfn "github.com/lightningnetwork/lnd/fn/v2" ) @@ -24,132 +25,158 @@ func NewIgnoreUniverseTree(db BatchedUniverseTree) *IgnoreUniverseTree { return &IgnoreUniverseTree{db: db} } -// AddTuples adds a new ignore tuples to the ignore tree. -func (it *IgnoreUniverseTree) AddTuples(ctx context.Context, - spec asset.Specifier, tuples ...universe.SignedIgnoreTuple, -) lfn.Result[universe.AuthIgnoreTuples] { +// addTuplesInternal performs the insertion of ignore tuples within a database +// transaction. +// +// NOTE: This function must be called within a database transaction. +func addTuplesInternal(ctx context.Context, db BaseUniverseStore, + spec asset.Specifier, tuples ...*universe.SignedIgnoreTuple, +) ([]universe.AuthenticatedIgnoreTuple, error) { if len(tuples) == 0 { - return lfn.Err[[]universe.AuthenticatedIgnoreTuple]( - fmt.Errorf("no tuples provided"), - ) + return nil, fmt.Errorf("no tuples provided") } - // Derive identifier (and thereby the namespace) from the - // asset.Specifier. - id, err := specifierToIdentifier(spec, universe.ProofTypeIgnore) + groupKey, err := spec.UnwrapGroupKeyOrErr() if err != nil { - return lfn.Err[universe.AuthIgnoreTuples](err) + return nil, fmt.Errorf("%w: %w", ErrMissingGroupKey, err) } - namespace := id.String() + // Derive identifier (and thereby the namespace) from the + // asset.Specifier. + namespace := subTreeNamespace(groupKey, supplycommit.IgnoreTreeType) - groupKeyBytes := schnorr.SerializePubKey(id.GroupKey) + groupKeyBytes := schnorr.SerializePubKey(groupKey) var finalResults []universe.AuthenticatedIgnoreTuple - var writeTx BaseUniverseStoreOptions - txErr := it.db.ExecTx(ctx, &writeTx, func(db BaseUniverseStore) error { - tree := mssmt.NewCompactedTree( - newTreeStoreWrapperTx(db, namespace), + tree := mssmt.NewCompactedTree( + newTreeStoreWrapperTx(db, namespace), + ) + + // First, insert all tuples into the ignore sub-tree SMT. + for _, tup := range tuples { + smtKey := tup.IgnoreTuple.Val.Hash() + ignoreTup := tup.IgnoreTuple.Val + + leafNode, err := tup.UniverseLeafNode() + if err != nil { + return nil, fmt.Errorf("failed to create leaf "+ + "node: %w", err) + } + _, err = tree.Insert(ctx, smtKey, leafNode) + if err != nil { + return nil, fmt.Errorf("failed to insert into "+ + "ignore tree: %w", err) + } + + // To insert the universe leaf below, we'll need both the db the + // outpoint to be ignored. primary key for the asset genesis, + // and also the raw bytes of the ignore point. + assetGenID, err := db.FetchGenesisIDByAssetID( + ctx, ignoreTup.ID[:], + ) + + // If the genesis ID doesn't exist, we can't insert the leaf. + // This might happen if the asset wasn't properly registered + // first. + if errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("genesis ID not found for "+ + "asset %v", ignoreTup.ID) + } + if err != nil { + return nil, fmt.Errorf("error looking up genesis "+ + "ID for asset %v: %w", ignoreTup.ID, err) + } + ignorePointBytes, err := encodeOutpoint( + tup.IgnoreTuple.Val.OutPoint, + ) + if err != nil { + return nil, fmt.Errorf("failed to encode "+ + "ignore point: %w", err) + } + + // With the leaf inserted into the tree, we'll now + // create the universe leaf that references the SMT + // leaf. + universeRootID, err := db.UpsertUniverseRoot( + ctx, NewUniverseRoot{ + NamespaceRoot: namespace, + GroupKey: groupKeyBytes, + ProofType: sqlStr( + supplycommit.IgnoreTreeType.String(), + ), + }, ) + if err != nil { + return nil, fmt.Errorf("failed to upsert ignore "+ + "universe root: %w", err) + } - // First, insert all tuples into the tree. This'll create a new - // insert universe leaves to reference the SMT leafs. set of - // normal SMT leafs. Once inserted, we'll then also obtain - // inclusion proofs for each. - for _, tup := range tuples { - smtKey := tup.IgnoreTuple.Val.Hash() - - ignoreTup := tup.IgnoreTuple.Val - - leafNode, err := tup.UniverseLeafNode() - if err != nil { - return err - } - _, err = tree.Insert(ctx, smtKey, leafNode) - if err != nil { - return err - } - - // To insert the universe leaf below, we'll need both - // the db primary key for the asset genesis, and also - // the raw bytes of the outpoint to be ignored. - assetGenID, err := db.FetchGenesisIDByAssetID( - ctx, ignoreTup.ID[:], - ) - if err != nil { - return fmt.Errorf("error looking up genesis "+ - "ID for asset %v: %w", ignoreTup.ID, - err) - } - ignorePointBytes, err := encodeOutpoint( - tup.IgnoreTuple.Val.OutPoint, - ) - if err != nil { - return err - } - - // With the leaf inserted into the tree, we'll now - // create the universe leaf that references the SMT - // leaf. - universeRootID, err := db.UpsertUniverseRoot( - ctx, NewUniverseRoot{ - NamespaceRoot: namespace, - GroupKey: groupKeyBytes, - ProofType: sqlStr( - id.ProofType.String(), - ), - }, - ) - if err != nil { - return err - } - - scriptKey := ignoreTup.ScriptKey - err = db.UpsertUniverseLeaf(ctx, UpsertUniverseLeaf{ - AssetGenesisID: assetGenID, - ScriptKeyBytes: scriptKey.SchnorrSerialized(), //nolint:lll - UniverseRootID: universeRootID, - LeafNodeKey: smtKey[:], - LeafNodeNamespace: namespace, - MintingPoint: ignorePointBytes, - }) - if err != nil { - return err - } + scriptKey := ignoreTup.ScriptKey + err = db.UpsertUniverseLeaf(ctx, UpsertUniverseLeaf{ + AssetGenesisID: assetGenID, + ScriptKeyBytes: scriptKey.SchnorrSerialized(), + UniverseRootID: universeRootID, + LeafNodeKey: smtKey[:], + LeafNodeNamespace: namespace, + MintingPoint: ignorePointBytes, + }) + if err != nil { + return nil, fmt.Errorf("failed to upsert ignore "+ + "universe leaf: %w", err) } + } - // Fetch the final tree root after all insertions. - finalRoot, err := tree.Root(ctx) + // Fetch the final ignore sub-tree root after all insertions. + finalIgnoreRoot, err := tree.Root(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get ignore tree "+ + "root: %w", err) + } + + // Next, for each inserted tuple, generate its inclusion proof from the + // final tree and build its AuthenticatedIgnoreTuple. + for _, tup := range tuples { + smtKey := tup.IgnoreTuple.Val.Hash() + proof, err := tree.MerkleProof(ctx, smtKey) if err != nil { - return err + return nil, fmt.Errorf("failed to get ignore "+ + "proof: %w", err) } - // Next, for each inserted tuple, generate its inclusion proof - // from the final tree and build its AuthenticatedIgnoreTuple. - for _, tup := range tuples { - smtKey := tup.IgnoreTuple.Val.Hash() - proof, err := tree.MerkleProof(ctx, smtKey) - if err != nil { - return err - } - - authTup := universe.AuthenticatedIgnoreTuple{ - SignedIgnoreTuple: tup, - InclusionProof: proof, - IgnoreTreeRoot: finalRoot, - } - - finalResults = append(finalResults, authTup) + authTup := universe.AuthenticatedIgnoreTuple{ + SignedIgnoreTuple: *tup, + InclusionProof: proof, + IgnoreTreeRoot: finalIgnoreRoot, } - return nil + finalResults = append(finalResults, authTup) + } + + return finalResults, nil +} + +// AddTuples adds a new ignore tuples to the ignore tree. +func (it *IgnoreUniverseTree) AddTuples(ctx context.Context, + spec asset.Specifier, tuples ...*universe.SignedIgnoreTuple, +) lfn.Result[[]universe.AuthenticatedIgnoreTuple] { + + var ( + writeTx BaseUniverseStoreOptions + finalResults []universe.AuthenticatedIgnoreTuple + err error + ) + txErr := it.db.ExecTx(ctx, &writeTx, func(db BaseUniverseStore) error { + finalResults, err = addTuplesInternal(ctx, db, spec, tuples...) + return err }) if txErr != nil { return lfn.Err[universe.AuthIgnoreTuples](txErr) } + // TODO(roasbeef): cache invalidation? + return lfn.Ok(finalResults) } @@ -158,13 +185,16 @@ func (it *IgnoreUniverseTree) Sum(ctx context.Context, spec asset.Specifier) universe.SumQueryResp { // Derive identifier from the asset.Specifier. - id, err := specifierToIdentifier(spec, universe.ProofTypeIgnore) + groupKey, err := spec.UnwrapGroupKeyOrErr() if err != nil { - return lfn.Err[lfn.Option[uint64]](err) + return lfn.Errf[lfn.Option[uint64]]("%w: %w", + ErrMissingGroupKey, err) } + namespace := subTreeNamespace(groupKey, supplycommit.IgnoreTreeType) + // Use the generic helper to get the sum of the universe tree. - return getUniverseTreeSum(ctx, it.db, id) + return getUniverseTreeSum(ctx, it.db, namespace) } // decodeIgnoreTuple decodes the raw bytes into an IgnoreTuple. @@ -184,16 +214,18 @@ func decodeIgnoreTuple(dbLeaf UniverseLeaf) (*universe.IgnoreTuple, error) { func (it *IgnoreUniverseTree) ListTuples(ctx context.Context, spec asset.Specifier) universe.ListTuplesResp { - // Derive identifier from the asset.Specifier. - id, err := specifierToIdentifier(spec, universe.ProofTypeIgnore) + groupKey, err := spec.UnwrapGroupKeyOrErr() if err != nil { - return lfn.Err[lfn.Option[universe.IgnoreTuples]](err) + return lfn.Errf[lfn.Option[universe.IgnoreTuples]]( + "%w: %w", ErrMissingGroupKey, err, + ) } + namespace := subTreeNamespace(groupKey, supplycommit.IgnoreTreeType) // Use the generic list helper to list the leaves from the universe // Tree. We pass in our custom decode function to handle the logic // specific to IgnoreTuples. - return listUniverseLeaves(ctx, it.db, id, decodeIgnoreTuple) + return listUniverseLeaves(ctx, it.db, namespace, decodeIgnoreTuple) } // queryIgnoreLeaves retrieves UniverseLeaf records based on IgnoreTuple @@ -202,12 +234,11 @@ func queryIgnoreLeaves(ctx context.Context, dbtx BaseUniverseStore, spec asset.Specifier, tuples ...universe.IgnoreTuple) ([]UniverseLeaf, error) { - uniNamespace, err := specifierToIdentifier( - spec, universe.ProofTypeIgnore, - ) + groupKey, err := spec.UnwrapGroupKeyOrErr() if err != nil { - return nil, fmt.Errorf("error deriving identifier: %w", err) + return nil, fmt.Errorf("%w: %w", ErrMissingGroupKey, err) } + namespace := subTreeNamespace(groupKey, supplycommit.IgnoreTreeType) var allLeaves []UniverseLeaf for _, queryTuple := range tuples { @@ -218,7 +249,7 @@ func queryIgnoreLeaves(ctx context.Context, dbtx BaseUniverseStore, scriptKey := queryTuple.ScriptKey leafQuery := UniverseLeafQuery{ ScriptKeyBytes: scriptKey.SchnorrSerialized(), - Namespace: uniNamespace.String(), + Namespace: namespace, } leaves, err := dbtx.QueryUniverseLeaves(ctx, leafQuery) @@ -269,23 +300,24 @@ func (it *IgnoreUniverseTree) QueryTuples(ctx context.Context, spec asset.Specifier, queryTuples ...universe.IgnoreTuple) universe.TupleQueryResp { + type retType = []universe.AuthenticatedIgnoreTuple + if len(queryTuples) == 0 { - return lfn.Ok(lfn.None[[]universe.AuthenticatedIgnoreTuple]()) + return lfn.Ok(lfn.None[retType]()) } - // Derive identifier from the asset.Specifier. - id, err := specifierToIdentifier(spec, universe.ProofTypeIgnore) + groupKey, err := spec.UnwrapGroupKeyOrErr() if err != nil { - return lfn.Err[lfn.Option[[]universe.AuthenticatedIgnoreTuple]]( - err, - ) + return lfn.Errf[lfn.Option[retType]]("%w: %w", + ErrMissingGroupKey, err) } + namespace := subTreeNamespace(groupKey, supplycommit.IgnoreTreeType) // Use the generic query helper, which will handle: doing the initial // query, decoding the ignore tuples, and finally building the merkle // proof for the tuples. return queryUniverseLeavesAndProofs( - ctx, it.db, spec, id, queryIgnoreLeaves, + ctx, it.db, spec, namespace, queryIgnoreLeaves, parseDbSignedIgnoreTuple, universe.NewAuthIgnoreTuple, queryTuples..., ) diff --git a/tapdb/ignore_tree_test.go b/tapdb/ignore_tree_test.go index 85cb1535e..c85128dfc 100644 --- a/tapdb/ignore_tree_test.go +++ b/tapdb/ignore_tree_test.go @@ -75,7 +75,7 @@ func randIgnoreTuple(t *testing.T, // setupIgnoreTreeTest sets up a test environment for IgnoreUniverseTree // testing. func setupIgnoreTreeTest(t *testing.T) (*IgnoreUniverseTree, asset.Specifier, - []universe.SignedIgnoreTuple) { + []*universe.SignedIgnoreTuple) { // Create the ignore tree instance backed by the usual set of batched db // abstractions. @@ -108,11 +108,11 @@ func setupIgnoreTreeTest(t *testing.T) (*IgnoreUniverseTree, asset.Specifier, ) require.NoError(t, err) - tuples := make([]universe.SignedIgnoreTuple, numTuples) + tuples := make([]*universe.SignedIgnoreTuple, numTuples) for i := 0; i < numTuples; i++ { signedTuple := randIgnoreTuple(t, dbTxer) - tuples[i] = signedTuple + tuples[i] = &signedTuple } genesisOutpoint := genesis.FirstPrevOut @@ -152,7 +152,7 @@ func TestIgnoreUniverseTreeAddTuples(t *testing.T) { require.NotNil(t, tuple.InclusionProof) require.NotNil(t, tuple.IgnoreTreeRoot) require.Equal( - t, signedTuples[i], tuple.SignedIgnoreTuple, + t, *signedTuples[i], tuple.SignedIgnoreTuple, ) // The returned inclusion proof should be valid. @@ -181,9 +181,7 @@ func TestIgnoreUniverseTreeAddTuples(t *testing.T) { ctx, invalidSpec, signedTuples..., ) require.Error(t, result.Err()) - require.Contains( - t, result.Err().Error(), "group key must be set", - ) + require.ErrorIs(t, result.Err(), ErrMissingGroupKey) }) } @@ -223,7 +221,7 @@ func TestIgnoreUniverseTreeSum(t *testing.T) { expectedTupleSum := fn.Reduce( signedTuples, - func(acc int, tuple universe.SignedIgnoreTuple) int { + func(acc int, tuple *universe.SignedIgnoreTuple) int { return acc + int(tuple.IgnoreTuple.Val.Amount) }, ) @@ -234,7 +232,7 @@ func TestIgnoreUniverseTreeSum(t *testing.T) { // updated. extraTuple := randIgnoreTuple(t, ignoreTree.db) extraSum := extraTuple.IgnoreTuple.Val.Amount - addResult = ignoreTree.AddTuples(ctx, spec, extraTuple) + addResult = ignoreTree.AddTuples(ctx, spec, &extraTuple) require.NoError(t, addResult.Err()) newSumRes := ignoreTree.Sum(ctx, spec) @@ -254,9 +252,7 @@ func TestIgnoreUniverseTreeSum(t *testing.T) { var invalidSpec asset.Specifier result := ignoreTree.Sum(ctx, invalidSpec) require.Error(t, result.Err()) - require.Contains( - t, result.Err().Error(), "group key must be set", - ) + require.ErrorIs(t, result.Err(), ErrMissingGroupKey) }) } @@ -309,7 +305,7 @@ func TestIgnoreUniverseTreeListTuples(t *testing.T) { // If we add another new tuple, it should show up in the list. extraTuple := randIgnoreTuple(t, ignoreTree.db) - addResult = ignoreTree.AddTuples(ctx, spec, extraTuple) + addResult = ignoreTree.AddTuples(ctx, spec, &extraTuple) require.NoError(t, addResult.Err()) newTuples, err := ignoreTree.ListTuples(ctx, spec).Unpack() @@ -324,9 +320,7 @@ func TestIgnoreUniverseTreeListTuples(t *testing.T) { invalidSpec := asset.Specifier{} result := ignoreTree.ListTuples(ctx, invalidSpec) require.Error(t, result.Err()) - require.Contains( - t, result.Err().Error(), "group key must be set", - ) + require.ErrorIs(t, result.Err(), ErrMissingGroupKey) }) } @@ -353,9 +347,7 @@ func TestIgnoreUniverseTreeQueryTuples(t *testing.T) { ctx, invalidSpec, signedTuples[0].IgnoreTuple.Val, ) require.Error(t, result.Err()) - require.Contains( - t, result.Err().Error(), "group key must be set", - ) + require.ErrorIs(t, result.Err(), ErrMissingGroupKey) }) // Test case 3: Query for a tuple that doesn't exist should return None. @@ -399,7 +391,7 @@ func TestIgnoreUniverseTreeQueryTuples(t *testing.T) { ) queriedTuple := queriedTuples[0] - require.Equal(t, tuple, queriedTuple.SignedIgnoreTuple) + require.Equal(t, *tuple, queriedTuple.SignedIgnoreTuple) leafNode, err := queriedTuple.UniverseLeafNode() require.NoError(t, err) diff --git a/tapdb/migrations.go b/tapdb/migrations.go index b53050307..39f95e03f 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 = 37 + LatestMigrationVersion = 38 ) // DatabaseBackend is an interface that contains all methods our different diff --git a/tapdb/multiverse.go b/tapdb/multiverse.go index 07da5f68e..4e1695736 100644 --- a/tapdb/multiverse.go +++ b/tapdb/multiverse.go @@ -176,7 +176,9 @@ func namespaceForProof(proofType universe.ProofType) (string, error) { return transferMultiverseNS, nil default: - return "", fmt.Errorf("unknown proof type: %d", int(proofType)) + return "", fmt.Errorf("unsupported proof type for "+ + "multiverse: %v", + proofType) } } @@ -632,9 +634,18 @@ func (b *MultiverseStore) FetchProofLeaf(ctx context.Context, return err } - // Populate multiverse specific fields of proofs. - // - // Retrieve a handle to the multiverse MS-SMT tree. + // Populate multiverse specific fields of proofs. Re-check proof + // type to decide if multiverse proof is needed. + if id.ProofType != universe.ProofTypeIssuance && + id.ProofType != universe.ProofTypeTransfer { + + log.Tracef("Skipping multiverse proof fetch for "+ + "proof type %v", id.ProofType) + return nil + } + + // Now we know multiverseNS is valid and corresponds to issuance + // or transfer. Retrieve a handle to the multiverse MS-SMT tree. multiverseTree := mssmt.NewCompactedTree( newTreeStoreWrapperTx(tx, multiverseNS), ) @@ -757,19 +768,33 @@ func (b *MultiverseStore) UpsertProofLeaf(ctx context.Context, metaReveal *proof.MetaReveal) (*universe.Proof, error) { var ( - writeTx BaseMultiverseOptions - issuanceProof *universe.Proof + writeTx BaseMultiverseOptions + uniProof *universe.Proof + multiverseRoot mssmt.Node + multiverseProof *mssmt.Proof ) execTxFunc := func(dbTx BaseMultiverseStore) error { // Register issuance in the asset (group) specific universe // tree. var err error - issuanceProof, err = universeUpsertProofLeaf( - ctx, dbTx, id, key, leaf, metaReveal, false, + uniProof, err = universeUpsertProofLeaf( + ctx, dbTx, id.String(), id.ProofType.String(), + id.GroupKey, key, leaf, metaReveal, ) if err != nil { - return err + return fmt.Errorf("failed universe upsert: %w", err) + } + + // Now, attempt to insert the universe root into the main + // multiverse tree. + // + // nolint:lll + multiverseRoot, multiverseProof, err = upsertMultiverseLeafEntry( + ctx, dbTx, id, uniProof.UniverseRoot, + ) + if err != nil { + return fmt.Errorf("failed multiverse upsert: %w", err) } return nil @@ -779,6 +804,11 @@ func (b *MultiverseStore) UpsertProofLeaf(ctx context.Context, return nil, dbErr } + // Populate the multiverse fields in the proof object now that the + // transaction is complete. + uniProof.MultiverseRoot = multiverseRoot + uniProof.MultiverseInclusionProof = multiverseProof + // Invalidate the cache since we just updated the root. b.rootNodeCache.wipeCache() b.proofCache.delProofsForAsset(id) @@ -786,9 +816,11 @@ func (b *MultiverseStore) UpsertProofLeaf(ctx context.Context, b.syncerCache.addOrReplace(universe.Root{ ID: id, AssetName: leaf.Asset.Tag, - Node: issuanceProof.UniverseRoot, + Node: uniProof.UniverseRoot, }) + b.leafKeysCache.wipeCache(id.String()) + // Notify subscribers about the new proof leaf, now that we're sure we // have written it to the database. But we only care about transfer // proofs, as the events are received by the custodian to finalize @@ -797,7 +829,7 @@ func (b *MultiverseStore) UpsertProofLeaf(ctx context.Context, b.transferProofDistributor.NotifySubscribers(leaf.RawProof) } - return issuanceProof, nil + return uniProof, nil } // UpsertProofLeafBatch upserts a proof leaf batch within the multiverse tree @@ -805,17 +837,6 @@ func (b *MultiverseStore) UpsertProofLeaf(ctx context.Context, func (b *MultiverseStore) UpsertProofLeafBatch(ctx context.Context, items []*universe.Item) error { - insertProof := func(item *universe.Item, - dbTx BaseMultiverseStore) (*universe.Proof, error) { - - // Upsert proof leaf into the asset (group) specific universe - // tree. - return universeUpsertProofLeaf( - ctx, dbTx, item.ID, item.Key, item.Leaf, - item.MetaReveal, false, - ) - } - var ( writeTx BaseMultiverseOptions uniProofs []*universe.Proof @@ -825,12 +846,40 @@ func (b *MultiverseStore) UpsertProofLeafBatch(ctx context.Context, uniProofs = make([]*universe.Proof, len(items)) for idx := range items { item := items[idx] - uniProof, err := insertProof(item, store) + + // 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, + ) if err != nil { - return err + return fmt.Errorf("failed universe "+ + "upsert for item %d: %w", + idx, err) } - uniProofs[idx] = uniProof + + // Next we'll, attempt to insert the universe + // root into the main multiverse tree. + // + //nolint:lll + multiRoot, multiProof, err := upsertMultiverseLeafEntry( + ctx, store, item.ID, + uniProof.UniverseRoot, + ) + if err != nil { + return fmt.Errorf("failed multiverse "+ + "upsert for item %d: %w", + idx, err) + } + + // Update the proof object with multiverse + // details. + uniProofs[idx].MultiverseRoot = multiRoot + uniProofs[idx].MultiverseInclusionProof = multiProof //nolint:lll } return nil diff --git a/tapdb/sqlc/migrations/000038_supply_tree.down.sql b/tapdb/sqlc/migrations/000038_supply_tree.down.sql new file mode 100644 index 000000000..e8bc60bba --- /dev/null +++ b/tapdb/sqlc/migrations/000038_supply_tree.down.sql @@ -0,0 +1,9 @@ +DROP INDEX IF EXISTS universe_supply_leaves_supply_root_id_type_idx; +DROP INDEX IF EXISTS universe_supply_leaves_supply_root_id_idx; +DROP INDEX IF EXISTS universe_supply_roots_group_key_idx; + +DROP TABLE IF EXISTS universe_supply_leaves; +DROP TABLE IF EXISTS universe_supply_roots; + +-- Note: We typically don't remove enum values ('burn', 'ignore') from +-- proof_types in a down migration to avoid breaking other potential uses. diff --git a/tapdb/sqlc/migrations/000038_supply_tree.up.sql b/tapdb/sqlc/migrations/000038_supply_tree.up.sql new file mode 100644 index 000000000..7ba478045 --- /dev/null +++ b/tapdb/sqlc/migrations/000038_supply_tree.up.sql @@ -0,0 +1,44 @@ +-- Ensure the required proof types for supply sub-trees exist. +-- Note: 'issuance' is assumed to exist from previous migrations. +INSERT INTO proof_types (proof_type) VALUES ('burn'), ('ignore'), ('mint_supply') + ON CONFLICT (proof_type) DO NOTHING; + +-- Table representing the root of a supply tree for a specific asset group. +CREATE TABLE universe_supply_roots ( + id INTEGER PRIMARY KEY, + + -- The namespace root of the MS-SMT representing this supply tree. + -- We set the foreign key constraint evaluation to be deferred until after + -- the database transaction ends. Otherwise, if the root of the SMT is + -- deleted temporarily before inserting a new root, then this constraint + -- is violated. + namespace_root VARCHAR UNIQUE NOT NULL REFERENCES mssmt_roots(namespace) DEFERRABLE INITIALLY DEFERRED, + + -- The tweaked group key identifying the asset group this supply tree belongs to. + group_key BLOB UNIQUE NOT NULL CHECK(length(group_key) = 33) +); + +-- Table representing the leaves within a root supply tree. +-- Each leaf corresponds to the root of a sub-tree (mint, burn, ignore). +CREATE TABLE universe_supply_leaves ( + id INTEGER PRIMARY KEY, + + -- Reference to the root supply tree this leaf belongs to. + supply_root_id BIGINT NOT NULL REFERENCES universe_supply_roots(id) ON DELETE CASCADE, + + -- The type of sub-tree this leaf represents (mint_supply, burn, ignore). + sub_tree_type TEXT NOT NULL REFERENCES proof_types(proof_type), + + -- The key used for this leaf within the root supply tree's MS-SMT. + -- This typically corresponds to a hash identifying the sub-tree type. + leaf_node_key BLOB NOT NULL, + + -- The namespace within mssmt_nodes where the actual sub-tree root node resides. + leaf_node_namespace VARCHAR NOT NULL +); + +-- Add indexes for frequent lookups. +CREATE INDEX universe_supply_roots_group_key_idx ON universe_supply_roots(group_key); + + -- Ensure each supply root has only one leaf per sub-tree type. +CREATE UNIQUE INDEX universe_supply_leaves_supply_root_id_type_idx ON universe_supply_leaves(supply_root_id, sub_tree_type); diff --git a/tapdb/sqlc/models.go b/tapdb/sqlc/models.go index 10749dfe0..f852317b9 100644 --- a/tapdb/sqlc/models.go +++ b/tapdb/sqlc/models.go @@ -405,3 +405,17 @@ type UniverseStat struct { GroupKey []byte ProofType sql.NullString } + +type UniverseSupplyLeafe struct { + ID int64 + SupplyRootID int64 + SubTreeType string + LeafNodeKey []byte + LeafNodeNamespace string +} + +type UniverseSupplyRoot struct { + ID int64 + NamespaceRoot string + GroupKey []byte +} diff --git a/tapdb/sqlc/querier.go b/tapdb/sqlc/querier.go index f5fcc50b2..fa16c70fb 100644 --- a/tapdb/sqlc/querier.go +++ b/tapdb/sqlc/querier.go @@ -41,6 +41,9 @@ type Querier interface { DeleteUniverseLeaves(ctx context.Context, namespace string) error DeleteUniverseRoot(ctx context.Context, namespaceRoot string) error DeleteUniverseServer(ctx context.Context, arg DeleteUniverseServerParams) error + DeleteUniverseSupplyLeaf(ctx context.Context, arg DeleteUniverseSupplyLeafParams) error + DeleteUniverseSupplyLeaves(ctx context.Context, namespaceRoot string) error + DeleteUniverseSupplyRoot(ctx context.Context, namespaceRoot string) error FetchAddrByTaprootOutputKey(ctx context.Context, taprootOutputKey []byte) (FetchAddrByTaprootOutputKeyRow, error) FetchAddrEvent(ctx context.Context, id int64) (FetchAddrEventRow, error) FetchAddrEventByAddrKeyAndOutpoint(ctx context.Context, arg FetchAddrEventByAddrKeyAndOutpointParams) (FetchAddrEventByAddrKeyAndOutpointRow, error) @@ -95,6 +98,7 @@ type Querier interface { FetchTransferOutputs(ctx context.Context, transferID int64) ([]FetchTransferOutputsRow, error) FetchUniverseKeys(ctx context.Context, arg FetchUniverseKeysParams) ([]FetchUniverseKeysRow, error) FetchUniverseRoot(ctx context.Context, namespace string) (FetchUniverseRootRow, error) + FetchUniverseSupplyRoot(ctx context.Context, namespaceRoot string) (FetchUniverseSupplyRootRow, error) FetchUnknownTypeScriptKeys(ctx context.Context) ([]FetchUnknownTypeScriptKeysRow, error) GenesisAssets(ctx context.Context) ([]GenesisAsset, error) GenesisPoints(ctx context.Context) ([]GenesisPoint, error) @@ -152,6 +156,7 @@ type Querier interface { QueryUniverseLeaves(ctx context.Context, arg QueryUniverseLeavesParams) ([]QueryUniverseLeavesRow, error) QueryUniverseServers(ctx context.Context, arg QueryUniverseServersParams) ([]UniverseServer, error) QueryUniverseStats(ctx context.Context) (QueryUniverseStatsRow, error) + QueryUniverseSupplyLeaves(ctx context.Context, arg QueryUniverseSupplyLeavesParams) ([]QueryUniverseSupplyLeavesRow, error) ReAnchorPassiveAssets(ctx context.Context, arg ReAnchorPassiveAssetsParams) error SetAddrManaged(ctx context.Context, arg SetAddrManagedParams) error SetAssetSpent(ctx context.Context, arg SetAssetSpentParams) (int64, error) @@ -190,6 +195,8 @@ type Querier interface { UpsertTapscriptTreeRootHash(ctx context.Context, arg UpsertTapscriptTreeRootHashParams) (int64, error) UpsertUniverseLeaf(ctx context.Context, arg UpsertUniverseLeafParams) error UpsertUniverseRoot(ctx context.Context, arg UpsertUniverseRootParams) (int64, error) + UpsertUniverseSupplyLeaf(ctx context.Context, arg UpsertUniverseSupplyLeafParams) (int64, error) + UpsertUniverseSupplyRoot(ctx context.Context, arg UpsertUniverseSupplyRootParams) (int64, error) } var _ Querier = (*Queries)(nil) diff --git a/tapdb/sqlc/queries/supply_tree.sql b/tapdb/sqlc/queries/supply_tree.sql new file mode 100644 index 000000000..205454893 --- /dev/null +++ b/tapdb/sqlc/queries/supply_tree.sql @@ -0,0 +1,54 @@ +-- name: UpsertUniverseSupplyRoot :one +INSERT INTO universe_supply_roots (namespace_root, group_key) +VALUES (@namespace_root, @group_key) +ON CONFLICT (namespace_root) + -- This is a no-op to allow returning the ID. + DO UPDATE SET namespace_root = EXCLUDED.namespace_root +RETURNING id; + +-- name: FetchUniverseSupplyRoot :one +SELECT r.group_key, n.hash_key as root_hash, n.sum as root_sum +FROM universe_supply_roots r +JOIN mssmt_roots m + ON r.namespace_root = m.namespace +JOIN mssmt_nodes n + ON m.root_hash = n.hash_key AND + m.namespace = n.namespace +WHERE r.namespace_root = @namespace_root; + +-- name: UpsertUniverseSupplyLeaf :one +INSERT INTO universe_supply_leaves ( + supply_root_id, sub_tree_type, leaf_node_key, leaf_node_namespace +) VALUES ( + @supply_root_id, @sub_tree_type, @leaf_node_key, @leaf_node_namespace +) +ON CONFLICT (supply_root_id, sub_tree_type) + -- This is a no-op to allow returning the ID. + DO UPDATE SET sub_tree_type = EXCLUDED.sub_tree_type +RETURNING id; + +-- name: DeleteUniverseSupplyLeaf :exec +DELETE FROM universe_supply_leaves +WHERE leaf_node_namespace = @namespace AND leaf_node_key = @leaf_node_key; + +-- 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 +FROM universe_supply_leaves l +JOIN mssmt_nodes smt_nodes + ON l.leaf_node_key = smt_nodes.key AND + l.leaf_node_namespace = smt_nodes.namespace +JOIN universe_supply_roots r + ON l.supply_root_id = r.id +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: DeleteUniverseSupplyLeaves :exec +DELETE FROM universe_supply_leaves +WHERE supply_root_id = ( + SELECT id FROM universe_supply_roots WHERE namespace_root = @namespace_root +); + +-- name: DeleteUniverseSupplyRoot :exec +DELETE FROM universe_supply_roots +WHERE namespace_root = @namespace_root; diff --git a/tapdb/sqlc/schemas/generated_schema.sql b/tapdb/sqlc/schemas/generated_schema.sql index 686104740..26b4d8044 100644 --- a/tapdb/sqlc/schemas/generated_schema.sql +++ b/tapdb/sqlc/schemas/generated_schema.sql @@ -875,3 +875,40 @@ JOIN universe_roots roots GROUP BY roots.asset_id, roots.group_key, roots.proof_type ORDER BY roots.asset_id, roots.group_key, roots.proof_type; +CREATE TABLE universe_supply_leaves ( + id INTEGER PRIMARY KEY, + + -- Reference to the root supply tree this leaf belongs to. + supply_root_id BIGINT NOT NULL REFERENCES universe_supply_roots(id) ON DELETE CASCADE, + + -- The type of sub-tree this leaf represents (mint_supply, burn, ignore). + sub_tree_type TEXT NOT NULL REFERENCES proof_types(proof_type), + + -- The key used for this leaf within the root supply tree's MS-SMT. + -- This typically corresponds to a hash identifying the sub-tree type. + leaf_node_key BLOB NOT NULL, + + -- The namespace within mssmt_nodes where the actual sub-tree root node resides. + leaf_node_namespace VARCHAR NOT NULL +); + +CREATE INDEX universe_supply_leaves_supply_root_id_idx ON universe_supply_leaves(supply_root_id); + +CREATE UNIQUE INDEX universe_supply_leaves_supply_root_id_type_idx ON universe_supply_leaves(supply_root_id, sub_tree_type); + +CREATE TABLE universe_supply_roots ( + id INTEGER PRIMARY KEY, + + -- The namespace root of the MS-SMT representing this supply tree. + -- We set the foreign key constraint evaluation to be deferred until after + -- the database transaction ends. Otherwise, if the root of the SMT is + -- deleted temporarily before inserting a new root, then this constraint + -- is violated. + namespace_root VARCHAR UNIQUE NOT NULL REFERENCES mssmt_roots(namespace) DEFERRABLE INITIALLY DEFERRED, + + -- The tweaked group key identifying the asset group this supply tree belongs to. + group_key BLOB UNIQUE NOT NULL CHECK(length(group_key) = 33) +); + +CREATE INDEX universe_supply_roots_group_key_idx ON universe_supply_roots(group_key); + diff --git a/tapdb/sqlc/supply_tree.sql.go b/tapdb/sqlc/supply_tree.sql.go new file mode 100644 index 000000000..f0126aa15 --- /dev/null +++ b/tapdb/sqlc/supply_tree.sql.go @@ -0,0 +1,177 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.28.0 +// source: supply_tree.sql + +package sqlc + +import ( + "context" + "database/sql" +) + +const DeleteUniverseSupplyLeaf = `-- name: DeleteUniverseSupplyLeaf :exec +DELETE FROM universe_supply_leaves +WHERE leaf_node_namespace = $1 AND leaf_node_key = $2 +` + +type DeleteUniverseSupplyLeafParams struct { + Namespace string + LeafNodeKey []byte +} + +func (q *Queries) DeleteUniverseSupplyLeaf(ctx context.Context, arg DeleteUniverseSupplyLeafParams) error { + _, err := q.db.ExecContext(ctx, DeleteUniverseSupplyLeaf, arg.Namespace, arg.LeafNodeKey) + return err +} + +const DeleteUniverseSupplyLeaves = `-- name: DeleteUniverseSupplyLeaves :exec +DELETE FROM universe_supply_leaves +WHERE supply_root_id = ( + SELECT id FROM universe_supply_roots WHERE namespace_root = $1 +) +` + +func (q *Queries) DeleteUniverseSupplyLeaves(ctx context.Context, namespaceRoot string) error { + _, err := q.db.ExecContext(ctx, DeleteUniverseSupplyLeaves, namespaceRoot) + return err +} + +const DeleteUniverseSupplyRoot = `-- name: DeleteUniverseSupplyRoot :exec +DELETE FROM universe_supply_roots +WHERE namespace_root = $1 +` + +func (q *Queries) DeleteUniverseSupplyRoot(ctx context.Context, namespaceRoot string) error { + _, err := q.db.ExecContext(ctx, DeleteUniverseSupplyRoot, namespaceRoot) + return err +} + +const FetchUniverseSupplyRoot = `-- name: FetchUniverseSupplyRoot :one +SELECT r.group_key, n.hash_key as root_hash, n.sum as root_sum +FROM universe_supply_roots r +JOIN mssmt_roots m + ON r.namespace_root = m.namespace +JOIN mssmt_nodes n + ON m.root_hash = n.hash_key AND + m.namespace = n.namespace +WHERE r.namespace_root = $1 +` + +type FetchUniverseSupplyRootRow struct { + GroupKey []byte + RootHash []byte + RootSum int64 +} + +func (q *Queries) FetchUniverseSupplyRoot(ctx context.Context, namespaceRoot string) (FetchUniverseSupplyRootRow, error) { + row := q.db.QueryRowContext(ctx, FetchUniverseSupplyRoot, namespaceRoot) + var i FetchUniverseSupplyRootRow + err := row.Scan(&i.GroupKey, &i.RootHash, &i.RootSum) + return i, err +} + +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 +FROM universe_supply_leaves l +JOIN mssmt_nodes smt_nodes + ON l.leaf_node_key = smt_nodes.key AND + l.leaf_node_namespace = smt_nodes.namespace +JOIN universe_supply_roots r + ON l.supply_root_id = r.id +WHERE r.id = $1 AND + (l.sub_tree_type = $2 OR $2 IS NULL) +` + +type QueryUniverseSupplyLeavesParams struct { + SupplyRootID int64 + SubTreeType sql.NullString +} + +type QueryUniverseSupplyLeavesRow struct { + GroupKey []byte + SubTreeType string + SubTreeRootHash []byte + SubTreeRootSum int64 +} + +func (q *Queries) QueryUniverseSupplyLeaves(ctx context.Context, arg QueryUniverseSupplyLeavesParams) ([]QueryUniverseSupplyLeavesRow, error) { + rows, err := q.db.QueryContext(ctx, QueryUniverseSupplyLeaves, arg.SupplyRootID, arg.SubTreeType) + if err != nil { + return nil, err + } + defer rows.Close() + var items []QueryUniverseSupplyLeavesRow + for rows.Next() { + var i QueryUniverseSupplyLeavesRow + if err := rows.Scan( + &i.GroupKey, + &i.SubTreeType, + &i.SubTreeRootHash, + &i.SubTreeRootSum, + ); 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 UpsertUniverseSupplyLeaf = `-- name: UpsertUniverseSupplyLeaf :one +INSERT INTO universe_supply_leaves ( + supply_root_id, sub_tree_type, leaf_node_key, leaf_node_namespace +) VALUES ( + $1, $2, $3, $4 +) +ON CONFLICT (supply_root_id, sub_tree_type) + -- This is a no-op to allow returning the ID. + DO UPDATE SET sub_tree_type = EXCLUDED.sub_tree_type +RETURNING id +` + +type UpsertUniverseSupplyLeafParams struct { + SupplyRootID int64 + SubTreeType string + LeafNodeKey []byte + LeafNodeNamespace string +} + +func (q *Queries) UpsertUniverseSupplyLeaf(ctx context.Context, arg UpsertUniverseSupplyLeafParams) (int64, error) { + row := q.db.QueryRowContext(ctx, UpsertUniverseSupplyLeaf, + arg.SupplyRootID, + arg.SubTreeType, + arg.LeafNodeKey, + arg.LeafNodeNamespace, + ) + var id int64 + err := row.Scan(&id) + return id, err +} + +const UpsertUniverseSupplyRoot = `-- name: UpsertUniverseSupplyRoot :one +INSERT INTO universe_supply_roots (namespace_root, group_key) +VALUES ($1, $2) +ON CONFLICT (namespace_root) + -- This is a no-op to allow returning the ID. + DO UPDATE SET namespace_root = EXCLUDED.namespace_root +RETURNING id +` + +type UpsertUniverseSupplyRootParams struct { + NamespaceRoot string + GroupKey []byte +} + +func (q *Queries) UpsertUniverseSupplyRoot(ctx context.Context, arg UpsertUniverseSupplyRootParams) (int64, error) { + row := q.db.QueryRowContext(ctx, UpsertUniverseSupplyRoot, arg.NamespaceRoot, arg.GroupKey) + var id int64 + err := row.Scan(&id) + return id, err +} diff --git a/tapdb/supply_tree.go b/tapdb/supply_tree.go new file mode 100644 index 000000000..6504db77c --- /dev/null +++ b/tapdb/supply_tree.go @@ -0,0 +1,588 @@ +package tapdb + +import ( + "context" + "encoding/hex" + "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/universe" + "github.com/lightninglabs/taproot-assets/universe/supplycommit" + + lfn "github.com/lightningnetwork/lnd/fn/v2" + "github.com/lightningnetwork/lnd/lnutils" +) + +const ( + // supplyRootNS is the prefix for the root supply tree namespace. + supplyRootNS = "supply-root" + + // supplySubTreeNS is the prefix for supply sub-tree namespaces. + supplySubTreeNS = "supply-sub" +) + +// SupplyTreeStore implements the persistent storage for supply trees. It +// manages a root supply tree per asset group, where each leaf in the root tree +// corresponds to the root of a sub-tree (mint, burn, ignore). +type SupplyTreeStore struct { + db BatchedUniverseTree +} + +// NewSupplyTreeStore creates a new supply tree DB store handle. +func NewSupplyTreeStore(db BatchedUniverseTree) *SupplyTreeStore { + return &SupplyTreeStore{ + db: db, + } +} + +// rootSupplyNamespace generates the SMT namespace for the root supply tree +// associated with a given group key. +func rootSupplyNamespace(groupKey *btcec.PublicKey) string { + keyHex := hex.EncodeToString(groupKey.SerializeCompressed()) + return fmt.Sprintf("%s-%s", supplyRootNS, keyHex) +} + +// subTreeNamespace generates the SMT namespace for a specific supply sub-tree +// (mint, burn, ignore) associated with a given group key. +func subTreeNamespace(groupKey *btcec.PublicKey, + treeType supplycommit.SupplySubTree) string { + + keyHex := hex.EncodeToString(groupKey.SerializeCompressed()) + return fmt.Sprintf("%s-%s-%s", supplySubTreeNS, + treeType.String(), keyHex) +} + +// upsertSupplyTreeLeaf inserts or updates a leaf in the root supply tree. +// The leaf represents the root of a specific sub-tree (mint, burn, or ignore). +// It returns the new root of the main supply tree. +// +// NOTE: This function must be called within a database transaction. +func upsertSupplyTreeLeaf(ctx context.Context, dbTx BaseUniverseStore, + groupKey *btcec.PublicKey, subTreeType supplycommit.SupplySubTree, + subTreeRootNode mssmt.Node) (mssmt.Node, error) { + + rootNs := rootSupplyNamespace(groupKey) + subNs := subTreeNamespace(groupKey, subTreeType) + + // Ensure the root supply tree entry exists in universe_supply_roots. + rootID, err := dbTx.UpsertUniverseSupplyRoot(ctx, + UpsertUniverseSupplyRoot{ + NamespaceRoot: rootNs, + GroupKey: groupKey.SerializeCompressed(), + }, + ) + if err != nil { + return nil, fmt.Errorf("unable to upsert supply root: %w", err) + } + + // Map the internal enum to the DB string representation for the proof + // type. + subTreeTypeStr := subTreeType.String() + + nodeSum := subTreeRootNode.NodeSum() + + // Create the SMT leaf node for the root supply tree. The value is the + // hash of the sub-tree root, and the sum is the sum of the sub-tree. + leafNode := mssmt.NewLeafNode( + lnutils.ByteSlice(subTreeRootNode.NodeHash()), nodeSum, + ) + + // The key for this leaf in the root supply tree is derived from the + // sub-tree type. + leafKey := subTreeType.UniverseKey() + + // Instantiate the root supply SMT tree. + rootTree := mssmt.NewCompactedTree( + newTreeStoreWrapperTx(dbTx, rootNs), + ) + + // Insert the leaf node representing the sub-tree root. + _, err = rootTree.Insert(ctx, leafKey, leafNode) + if err != nil { + return nil, fmt.Errorf("unable to insert leaf into root "+ + "supply tree: %w", err) + } + + // Upsert the universe_supply_leaves entry to link the root tree, + // sub-tree type, and the sub-tree's namespace. + _, err = dbTx.UpsertUniverseSupplyLeaf(ctx, UpsertUniverseSupplyLeaf{ + SupplyRootID: rootID, + SubTreeType: subTreeTypeStr, + LeafNodeKey: leafKey[:], + LeafNodeNamespace: subNs, + }) + if err != nil { + return nil, fmt.Errorf("unable to upsert supply leaf "+ + "entry: %w", err) + } + + // Return the new root of the root supply tree. + newRootSupplyRoot, err := rootTree.Root(ctx) + if err != nil { + return nil, fmt.Errorf("unable to fetch new root supply "+ + "tree root: %w", err) + } + + return newRootSupplyRoot, nil +} + +// FetchSubTree returns a copy of the sub-tree for the given asset spec and +// sub-tree type. +func (s *SupplyTreeStore) FetchSubTree(ctx context.Context, + spec asset.Specifier, + treeType supplycommit.SupplySubTree) lfn.Result[mssmt.Tree] { + + groupKey, err := spec.UnwrapGroupKeyOrErr() + if err != nil { + return lfn.Errf[mssmt.Tree]("group key must be "+ + "specified for supply tree: %w", err) + } + + var treeCopy mssmt.Tree + readTx := NewBaseUniverseReadTx() + dbErr := s.db.ExecTx(ctx, &readTx, func(db BaseUniverseStore) error { + var internalErr error + treeCopy, internalErr = fetchSubTreeInternal( + ctx, db, groupKey, treeType, + ) + if internalErr != nil { + return internalErr + } + return nil + }) + if dbErr != nil { + return lfn.Err[mssmt.Tree](dbErr) + } + + return lfn.Ok(treeCopy) +} + +// fetchSubTreeInternal fetches and copies a specific sub-tree within an +// existing database transaction. +func fetchSubTreeInternal(ctx context.Context, db BaseUniverseStore, + groupKey *btcec.PublicKey, + treeType supplycommit.SupplySubTree) (mssmt.Tree, error) { + + subNs := subTreeNamespace(groupKey, treeType) + + // Create a wrapper for the persistent tree store using the provided tx. + persistentStore := newTreeStoreWrapperTx(db, subNs) + persistentTree := mssmt.NewCompactedTree(persistentStore) + + // Create a new in-memory tree to copy into. + memTree := mssmt.NewCompactedTree(mssmt.NewDefaultStore()) + + // Copy the persistent tree to the in-memory tree. + err := persistentTree.Copy(ctx, memTree) + if err != nil { + return nil, fmt.Errorf("unable to copy sub-tree %s: %w", + subNs, err) + } + + return memTree, nil +} + +// FetchSubTrees returns copies of all sub-trees (mint, burn, ignore) for the +// given asset spec. +func (s *SupplyTreeStore) FetchSubTrees(ctx context.Context, + spec asset.Specifier) lfn.Result[supplycommit.SupplyTrees] { + + groupKey, err := spec.UnwrapGroupKeyOrErr() + if err != nil { + return lfn.Errf[supplycommit.SupplyTrees]( + "group key must be specified for supply tree: %w", err, + ) + } + + trees := make(supplycommit.SupplyTrees) + readTx := NewBaseUniverseReadTx() + dbErr := s.db.ExecTx(ctx, &readTx, func(db BaseUniverseStore) error { + // For each supply tree type, we'll fetch the corresponding + // sub-tree within this single transaction. + for _, treeType := range []supplycommit.SupplySubTree{ + supplycommit.MintTreeType, supplycommit.BurnTreeType, + supplycommit.IgnoreTreeType, + } { + subTree, fetchErr := fetchSubTreeInternal( + ctx, db, groupKey, treeType, + ) + if fetchErr != nil { + return fmt.Errorf("failed to fetch "+ + "sub-tree %v: %w", treeType, fetchErr) + } + + trees[treeType] = subTree + } + return nil + }) + if dbErr != nil { + return lfn.Err[supplycommit.SupplyTrees](dbErr) + } + + return lfn.Ok(trees) +} + +// FetchRootSupplyTree returns a copy of the root supply tree for the given +// asset spec. +func (s *SupplyTreeStore) FetchRootSupplyTree(ctx context.Context, + spec asset.Specifier) lfn.Result[mssmt.Tree] { + + groupKey, err := spec.UnwrapGroupKeyOrErr() + if err != nil { + return lfn.Errf[mssmt.Tree]( + "group key must be specified for supply tree: %w", err, + ) + } + + rootNs := rootSupplyNamespace(groupKey) + + var treeCopy mssmt.Tree + + readTx := NewBaseUniverseReadTx() + err = s.db.ExecTx(ctx, &readTx, func(db BaseUniverseStore) error { + // Create a wrapper for the persistent tree store. + persistentStore := newTreeStoreWrapperTx(db, rootNs) + persistentTree := mssmt.NewCompactedTree(persistentStore) + + // Create a new in-memory tree to copy into. + memTree := mssmt.NewCompactedTree(mssmt.NewDefaultStore()) + + // Copy the persistent tree to the in-memory tree. + err := persistentTree.Copy(ctx, memTree) + if err != nil { + return fmt.Errorf("unable to copy root supply "+ + "tree %s: %w", rootNs, err) + } + + treeCopy = memTree + return nil + }) + if err != nil { + return lfn.Err[mssmt.Tree](err) + } + + return lfn.Ok(treeCopy) +} + +// registerMintSupplyInternal inserts a new minting leaf into the mint supply +// sub-tree within an existing database transaction. It returns the universe +// proof containing the new sub-tree root. +// +// 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) { + + groupKey, err := assetSpec.UnwrapGroupKeyOrErr() + if err != nil { + return nil, fmt.Errorf("group key must be "+ + "specified for mint supply: %w", err) + } + subNs := subTreeNamespace(groupKey, supplycommit.MintTreeType) + + // 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, + ) + if err != nil { + return nil, fmt.Errorf("failed mint supply universe "+ + "upsert: %w", err) + } + + return mintSupplyProof, nil +} + +// RegisterMintSupply inserts a new minting leaf into the mint supply sub-tree +// and updates the root supply tree. +// +// TODO(roasbeef): don't actually need? public version +func (s *SupplyTreeStore) RegisterMintSupply(ctx context.Context, + spec asset.Specifier, key universe.LeafKey, + leaf *universe.Leaf) (*universe.Proof, mssmt.Node, error) { + + groupKey := leaf.GroupKey + if groupKey == nil { + return nil, nil, fmt.Errorf("group key must be specified " + + "for mint supply") + } + + var ( + writeTx BaseUniverseStoreOptions + err error + mintSupplyProof *universe.Proof + newRootSupplyRoot mssmt.Node + ) + dbErr := s.db.ExecTx(ctx, &writeTx, func(dbTx BaseUniverseStore) error { + // Upsert the leaf into the mint supply sub-tree SMT and DB + // first. + mintSupplyProof, err = registerMintSupplyInternal( + ctx, dbTx, spec, key, leaf, nil, + ) + if err != nil { + return fmt.Errorf("failed mint supply universe "+ + "upsert: %w", err) + } + + // Now, upsert the root of the mint supply sub-tree into the + // main root supply tree. + // + // TODO(roasbeef): or other method will always be used? + newRootSupplyRoot, err = upsertSupplyTreeLeaf( + ctx, dbTx, &groupKey.GroupPubKey, + supplycommit.MintTreeType, mintSupplyProof.UniverseRoot, + ) + if err != nil { + return fmt.Errorf("failed to upsert mint supply leaf "+ + "into root supply tree: %w", err) + } + + return nil + }) + if dbErr != nil { + return nil, nil, dbErr + } + + // TODO(roasbeef): cache invalidation? + + return mintSupplyProof, newRootSupplyRoot, nil +} + +// applySupplyUpdatesInternal applies a list of supply updates within an +// existing database transaction. It updates the relevant sub-trees and the main +// root supply tree. It returns the final root of the main supply tree. +// +// NOTE: This function must be called within a database transaction. +func applySupplyUpdatesInternal(ctx context.Context, dbTx BaseUniverseStore, + spec asset.Specifier, + updates []supplycommit.SupplyUpdateEvent) (mssmt.Node, error) { + + groupKey, err := spec.UnwrapGroupKeyOrErr() + if err != nil { + return nil, fmt.Errorf( + "group key must be specified for supply "+ + "updates: %w", err, + ) + } + + // Group updates by their sub-tree type, this'll simplify the logic + // below, as we can update one sub-tree at a time. + groupedUpdates := make( + map[supplycommit.SupplySubTree][]supplycommit.SupplyUpdateEvent, + ) + for _, update := range updates { + treeType := update.SupplySubTreeType() + groupedUpdates[treeType] = append( + groupedUpdates[treeType], update, + ) + } + + // We'll keep track of the final sub-tree roots after each insertion + // phase. We'll use this to update the leaves of the root supply tree at + // the end. + finalSubTreeRoots := make(map[supplycommit.SupplySubTree]mssmt.Node) + + // Process Mint updates. + if mintUpdates, ok := groupedUpdates[supplycommit.MintTreeType]; ok { + var lastMintProof *universe.Proof + for _, update := range mintUpdates { + mintEvent, ok := update.(*supplycommit.NewMintEvent) + if !ok { + return nil, fmt.Errorf("invalid mint event "+ + "type: %T", update) + } + + mintProof, err := registerMintSupplyInternal( + ctx, dbTx, spec, mintEvent.LeafKey, + &mintEvent.IssuanceProof, nil, + ) + if err != nil { + return nil, fmt.Errorf("failed to register "+ + "mint supply: %w", err) + } + + lastMintProof = mintProof + } + if lastMintProof != nil { + finalSubTreeRoots[supplycommit.MintTreeType] = lastMintProof.UniverseRoot //nolint:lll + } + } + + // Process Burn updates. + if burnUpdates, ok := groupedUpdates[supplycommit.BurnTreeType]; ok { + // First, we'll extract the inner type, shedding the outer + // interface. + burnLeaves := make([]*universe.BurnLeaf, 0, len(burnUpdates)) + for _, update := range burnUpdates { + burnEvent, ok := update.(*supplycommit.NewBurnEvent) + if !ok { + return nil, fmt.Errorf("invalid burn event "+ + "type: %T", update) + } + + burnLeaves = append(burnLeaves, &burnEvent.BurnLeaf) + } + + authLeaves, err := insertBurnsInternal( + ctx, dbTx, spec, burnLeaves..., + ) + if err != nil { + return nil, fmt.Errorf("failed to insert burns: %w", + err) + } + + // All leaves in the batch will have the same final root. We'll + // collect this as we'll want to update the sub-tree root below. + if len(authLeaves) > 0 { + finalSubTreeRoots[supplycommit.BurnTreeType] = authLeaves[0].BurnTreeRoot //nolint:lll + } + } + + // Finally, we'll process any ignore updates. These are a bit different + // as a state transition proof isn't stored as the value. + // + //nolint:lll + if ignoreUpdates, ok := groupedUpdates[supplycommit.IgnoreTreeType]; ok { + ignoreTuples := make( + []*universe.SignedIgnoreTuple, 0, len(ignoreUpdates), + ) + for _, update := range ignoreUpdates { + ignoreEvent, ok := update.(*supplycommit.NewIgnoreEvent) + if !ok { + return nil, fmt.Errorf("invalid ignore event "+ + "type: %T", update) + } + ignoreTuples = append( + ignoreTuples, &ignoreEvent.SignedIgnoreTuple, + ) + } + + // addTuplesInternal already handles batching. + authTuples, err := addTuplesInternal( + ctx, dbTx, spec, ignoreTuples..., + ) + if err != nil { + return nil, fmt.Errorf("failed to add ignore "+ + "tuples: %w", err) + } + + // All tuples in the batch will have the same final root. + if len(authTuples) > 0 { + finalSubTreeRoots[supplycommit.IgnoreTreeType] = authTuples[0].IgnoreTreeRoot //nolint:lll + } + } + + // Update the main root supply tree with the final roots of modified + // sub-trees. + var finalRootSupplyRoot mssmt.Node + for treeType, subTreeRoot := range finalSubTreeRoots { + finalRootSupplyRoot, err = upsertSupplyTreeLeaf( + ctx, dbTx, groupKey, treeType, subTreeRoot, + ) + if err != nil { + return nil, fmt.Errorf("failed to update root supply "+ + "tree for %v: %w", treeType, err) + } + } + + // If no sub-trees were modified, fetch the current root supply root. + if finalRootSupplyRoot == nil { + rootNs := rootSupplyNamespace(groupKey) + rootTree := mssmt.NewCompactedTree( + newTreeStoreWrapperTx(dbTx, rootNs), + ) + + finalRootSupplyRoot, err = rootTree.Root(ctx) + if err != nil { + return nil, fmt.Errorf("failed to fetch existing root "+ + "supply root: %w", err) + } + } + + return finalRootSupplyRoot, nil +} + +// initEmptySupplyTrees creates the initial database entries for a new supply +// tree and its sub-trees, all initialized to the canonical empty SMT state. It +// inserts the necessary root and leaf entries into the universe_supply_* tables +// and ensures the corresponding empty SMT roots exist in mssmt_roots. It MUST +// be called within a database transaction. +// +//nolint:unused +func initEmptySupplyTrees(ctx context.Context, dbTx BaseUniverseStore, + groupKey *btcec.PublicKey) (mssmt.Node, error) { + + // Initialize a map holding the empty root node for each sub-tree type. + emptyRootNode := mssmt.NewComputedNode(mssmt.EmptyTreeRootHash, 0) + emptySubTreeRoots := map[supplycommit.SupplySubTree]mssmt.Node{ + supplycommit.MintTreeType: emptyRootNode, + supplycommit.BurnTreeType: emptyRootNode, + supplycommit.IgnoreTreeType: emptyRootNode, + } + + // Iterate through the empty roots and insert them as leaves into the + // main root supply tree. The upsertSupplyTreeLeaf function handles + // creating the necessary DB entries (universe_supply_roots, + // universe_supply_leaves, and implicitly mssmt_roots via the store + // wrapper). + var ( + finalRootSupplyRoot mssmt.Node + err error + ) + for treeType, subTreeRoot := range emptySubTreeRoots { + finalRootSupplyRoot, err = upsertSupplyTreeLeaf( + ctx, dbTx, groupKey, treeType, subTreeRoot, + ) + if err != nil { + return nil, fmt.Errorf("failed to upsert empty leaf "+ + "for %v: %w", treeType, err) + } + } + + // If the loop didn't run (which shouldn't happen), fetch the root. + // Otherwise, the last call to upsertSupplyTreeLeaf returned the final + // root. + if finalRootSupplyRoot == nil { + rootNs := rootSupplyNamespace(groupKey) + rootTree := mssmt.NewCompactedTree( + newTreeStoreWrapperTx(dbTx, rootNs), + ) + finalRootSupplyRoot, err = rootTree.Root(ctx) + if err != nil { + return nil, fmt.Errorf("failed to fetch initial root "+ + "supply root: %w", err) + } + } + + return finalRootSupplyRoot, nil +} + +// ApplySupplyUpdates atomically applies a list of supply updates. It updates +// the relevant sub-trees and the main root supply tree within a single database +// transaction. It returns the final root of the main supply tree. +func (s *SupplyTreeStore) ApplySupplyUpdates(ctx context.Context, + spec asset.Specifier, + updates []supplycommit.SupplyUpdateEvent) (mssmt.Node, error) { + + var ( + writeTx BaseUniverseStoreOptions + finalRoot mssmt.Node + err error + ) + dbErr := s.db.ExecTx(ctx, &writeTx, func(dbTx BaseUniverseStore) error { + finalRoot, err = applySupplyUpdatesInternal( + ctx, dbTx, spec, updates, + ) + return err + }) + if dbErr != nil { + return nil, dbErr + } + + // TODO(roasbeef): cache invalidation? + + return finalRoot, nil +} diff --git a/tapdb/supply_tree.md b/tapdb/supply_tree.md new file mode 100644 index 000000000..68cfe459d --- /dev/null +++ b/tapdb/supply_tree.md @@ -0,0 +1,146 @@ +# Taproot Asset Supply Tree Persistence + +This document describes the database schema and Go implementation (`tapdb/supply_tree.go`) responsible for persistently storing and managing Taproot Asset Supply Trees within the Universe. + +## Purpose + +The primary goal of the supply tree system is to create a verifiable, on-chain commitment to the total supply changes (mints, burns, ignores) for a specific asset group. While individual asset states are tracked elsewhere, the supply tree provides a consolidated, aggregatable view anchored to the Bitcoin blockchain. This allows anyone to verify the net issuance changes for an asset group over time by inspecting the sequence of on-chain supply root commitments. + +## Schema Overview + +The persistence layer utilizes two main tables, introduced in migration `000035_supply_tree.up.sql`, in conjunction with the existing `mssmt_roots` and `mssmt_nodes` tables which store the actual Merkle Sum Sparse Tree (MS-SMT) data. + +1. **`universe_supply_roots`**: + * Stores the root entry point for the supply tree of a specific asset group. + * `id`: Primary key. + * `namespace_root` (UNIQUE, FK -> `mssmt_roots.namespace`): The unique namespace identifier used in `mssmt_nodes` and `mssmt_roots` for this specific root supply tree. The format is `supply-root-`. + * `group_key` (UNIQUE, BLOB): The compressed public key identifying the asset group this supply tree belongs to. + +2. **`universe_supply_leaves`**: + * Represents the leaves within a specific root supply tree. Each leaf points to the root of a *sub-tree* (Mint, Burn, or Ignore). + * `id`: Primary key. + * `supply_root_id` (FK -> `universe_supply_roots.id`): References the parent root supply tree. + * `sub_tree_type` (TEXT, FK -> `proof_types.proof_type`): The type of sub-tree this leaf represents ('mint_supply', 'burn', 'ignore'). + * `leaf_node_key` (BLOB): The specific key used for this leaf within the *root* supply tree's MS-SMT. This key is derived deterministically from the `sub_tree_type`. + * `leaf_node_namespace` (VARCHAR): The namespace identifier for the *sub-tree* itself within `mssmt_nodes`. The format is `supply-sub--`. + * `UNIQUE(supply_root_id, sub_tree_type)`: Ensures a root tree has only one leaf per sub-tree type. + +### Schema Relationships (Mermaid Diagram) + +```mermaid +erDiagram + universe_supply_roots ||--o{ universe_supply_leaves : contains + universe_supply_roots }|--|| mssmt_roots : uses_namespace + universe_supply_leaves }|--|| mssmt_nodes : points_to_subtree_root + universe_supply_leaves }|--|| proof_types : has_type + universe_supply_roots ||--o| asset_groups : conceptual_link + + universe_supply_roots { + INTEGER id PK + VARCHAR namespace_root UK "FK(mssmt_roots.namespace)" + BLOB group_key UK + } + + universe_supply_leaves { + INTEGER id PK + BIGINT supply_root_id FK + TEXT sub_tree_type "FK(proof_types.proof_type)" + BLOB leaf_node_key + VARCHAR leaf_node_namespace "Namespace for sub-tree in mssmt_nodes" + } + + mssmt_roots { + VARCHAR namespace PK + BLOB root_hash "FK(mssmt_nodes.hash_key, mssmt_nodes.namespace)" + } + + mssmt_nodes { + BLOB hash_key PK + VARCHAR namespace PK + BLOB l_hash_key + BLOB r_hash_key + BLOB key + BLOB value + BIGINT sum + } + + proof_types { + TEXT proof_type PK + } + + asset_groups { + INTEGER group_id PK + BLOB tweaked_group_key UK + BLOB tapscript_root "NULL" + BIGINT internal_key_id FK "REFERENCES internal_keys(key_id)" + BIGINT genesis_point_id FK "REFERENCES genesis_points(genesis_id)" + INTEGER version + INTEGER custom_subtree_root_id FK "REFERENCES tapscript_roots(root_id) NULL" + } +``` +*Note: The link between `universe_supply_leaves` and `mssmt_nodes` is indirect. The `leaf_node_namespace` identifies the sub-tree, and the actual sub-tree root node's hash and sum are stored as the `value` and `sum` of the leaf node identified by `leaf_node_key` within the *root* tree's namespace (`universe_supply_roots.namespace_root`).* + +## Tree Structure + +The system employs a two-tiered tree structure for each asset group: a single **Root Supply Tree** whose leaves point to three distinct **Sub-Trees** (Mint, Burn, Ignore). + +### Root Supply Tree + +* **Purpose:** Aggregates the state of all supply changes (mints, burns, ignores) for a single asset group into one verifiable root hash and sum. This root is what gets committed on-chain. +* **Identifier:** `universe_supply_roots.namespace_root` (`supply-root-`). +* **Storage:** Managed by `universe_supply_roots` and stored within `mssmt_nodes` under the root namespace. +* **Leaves:** + * There are exactly three potential leaves in the root tree, one for each sub-tree type. + * **Key:** Determined by the sub-tree type using `supplycommit.SupplySubTree.UniverseKey()`. This provides a stable key for each sub-tree type (e.g., `sha256("mint_supply")`, `sha256("burn")`, `sha256("ignore")`). + * **Value:** The `NodeHash()` of the root node of the corresponding sub-tree. + * **Sum:** The `NodeSum()` (total aggregated amount/value) of the root node of the corresponding sub-tree. +* **Linkage:** The `universe_supply_leaves` table links the `supply_root_id`, the `sub_tree_type`, the `leaf_node_key` used in the root tree, and the `leaf_node_namespace` where the sub-tree resides. + +### Sub-Trees + +Each asset group has three independent sub-trees, each tracking a specific type of supply event. They are stored as separate MS-SMTs within `mssmt_nodes` using distinct namespaces. + +* **Identifier:** `universe_supply_leaves.leaf_node_namespace` (`supply-sub--`). +* **Storage:** Stored within `mssmt_nodes` under their respective namespaces. The roots of these trees are referenced by the leaves of the main Root Supply Tree. + +1. **Mint Sub-Tree (`supplycommit.MintTreeType`)** + * **Namespace:** `supply-sub-mint_supply-` + * **Purpose:** Tracks all successful asset minting events for the group. + * **Leaves:** + * **Key:** `universe.LeafKey` derived from the minting outpoint and the asset's script key. + * **Value:** The serialized issuance `proof.Proof`. + * **Sum:** The amount of the asset minted in that event. + * **Root:** The root hash represents the commitment to all minting proofs, and the root sum represents the total amount minted for the group. + +2. **Burn Sub-Tree (`supplycommit.BurnTreeType`)** + * **Namespace:** `supply-sub-burn-` + * **Purpose:** Tracks all confirmed asset burn events for the group. + * **Leaves:** + * **Key:** `universe.LeafKey` derived from the burn outpoint and the asset's script key. + * **Value:** The serialized burn `proof.Proof`. + * **Sum:** The amount of the asset burned in that event. + * **Root:** The root hash represents the commitment to all burn proofs, and the root sum represents the total amount burned for the group. + +3. **Ignore Sub-Tree (`supplycommit.IgnoreTreeType`)** + * **Namespace:** `supply-sub-ignore-` + * **Purpose:** Tracks outputs associated with the asset group that are provably unspendable or should otherwise be ignored for supply calculation (e.g., due to script conditions, explicit ignore proofs). + * **Leaves:** + * **Key:** The hash of the `universe.IgnoreTuple` value (`IgnoreTuple.Val.Hash()`). + * **Value:** The serialized `universe.SignedIgnoreTuple`. + * **Sum:** The amount associated with the ignored output. + * **Root:** The root hash represents the commitment to all ignored tuples, and the root sum represents the total amount ignored for the group. + +## Implementation (`tapdb/supply_tree.go`) + +The `tapdb.SupplyTreeStore` provides the Go interface for interacting with the persisted supply trees. + +* **Core Logic:** The `upsertSupplyTreeLeaf` function is central. When a sub-tree (e.g., Mint) is updated, its new root node (hash and sum) is computed. `upsertSupplyTreeLeaf` then takes this new sub-tree root and inserts/updates the corresponding leaf in the *main* Root Supply Tree. It handles creating/updating entries in `universe_supply_roots` and `universe_supply_leaves` as needed. +* **Atomicity:** All updates are performed within database transactions managed by the `BatchedUniverseTree` interface (`ExecTx`), ensuring that updates to sub-trees and the corresponding root tree leaf are atomic. +* **Batching:** `applySupplyUpdatesInternal` processes a batch of `supplycommit.SupplyUpdateEvent`s. It groups them by type, updates the relevant sub-trees (calling helpers like `registerMintSupplyInternal`, `insertBurnsInternal`, `addTuplesInternal`), and then calls `upsertSupplyTreeLeaf` for each modified sub-tree to update the main Root Supply Tree. +* **Initialization:** `initEmptySupplyTrees` sets up the initial database state for a new asset group, creating the root tree entry and the three leaf entries pointing to canonical empty sub-tree roots. +* **Fetching:** Functions like `FetchSubTree`, `FetchSubTrees`, and `FetchRootSupplyTree` allow retrieving copies of the persisted trees for inspection or use elsewhere. They read the data from the database and reconstruct the MS-SMT in memory. +* **Namespacing:** The `rootSupplyNamespace` and `subTreeNamespace` functions generate the unique string identifiers used to partition the different trees within the shared `mssmt_nodes` table. + +## Conclusion + +The supply tree persistence layer provides a robust and verifiable mechanism for tracking and committing to the supply dynamics of Taproot Asset groups. By leveraging MS-SMTs and a two-tiered structure recorded in dedicated SQL tables, it allows for efficient updates and provides the foundation for on-chain supply commitments managed by the `universe/supplycommit` state machine. diff --git a/tapdb/supply_tree_test.go b/tapdb/supply_tree_test.go new file mode 100644 index 000000000..5ff9419f3 --- /dev/null +++ b/tapdb/supply_tree_test.go @@ -0,0 +1,546 @@ +package tapdb + +import ( + "bytes" + "context" + "database/sql" + "encoding/hex" + "math/rand" + "testing" + "time" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcec/v2/schnorr" + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/taproot-assets/asset" + "github.com/lightninglabs/taproot-assets/mssmt" + "github.com/lightninglabs/taproot-assets/proof" + "github.com/lightninglabs/taproot-assets/universe" + "github.com/lightninglabs/taproot-assets/universe/supplycommit" + lfn "github.com/lightningnetwork/lnd/fn/v2" + "github.com/lightningnetwork/lnd/lnutils" + "github.com/lightningnetwork/lnd/lnwire" + "github.com/stretchr/testify/require" + "pgregory.net/rapid" +) + +func randRapidLeafKey(t *rapid.T) universe.UniqueLeafKey { //nolint:unused + scriptKey := asset.ScriptKeyGen.Draw(t, "script_key") + return universe.AssetLeafKey{ + BaseLeafKey: universe.BaseLeafKey{ + OutPoint: asset.OutPointGen.Draw(t, "outpoint"), + ScriptKey: &scriptKey, + }, + AssetID: asset.AssetIDGen.Draw(t, "asset_id"), + } +} + +func randProofGen(t *rapid.T, argAsset *asset.Asset) *proof.Proof { + proofAsset := asset.AssetGen.Draw(t, "asset") + if argAsset != nil { + proofAsset = *argAsset + } + + sliceGen := rapid.SliceOfN(rapid.Byte(), 32, 32) + + witnessData := sliceGen.Draw(t, "witness_data") + + pkScript := sliceGen.Draw(t, "pk_script") + + altAssets := rapid.SliceOfN[asset.Asset]( + asset.AltLeafGen(t), 1, 5, + ).Draw(t, "alt_leaves") + + return &proof.Proof{ + PrevOut: wire.OutPoint{}, + BlockHeader: wire.BlockHeader{ + Timestamp: time.Unix(rand.Int63(), 0), + }, + AnchorTx: wire.MsgTx{ + Version: 2, + TxIn: []*wire.TxIn{{ + Witness: [][]byte{witnessData[:]}, + }}, + TxOut: []*wire.TxOut{{ + PkScript: pkScript[:], + Value: 1000, + }}, + }, + TxMerkleProof: proof.TxMerkleProof{}, + Asset: proofAsset, + InclusionProof: proof.TaprootProof{ + InternalKey: asset.PubKeyGen.Draw(t, "internal_key"), + }, + AltLeaves: asset.ToAltLeaves( + lfn.Map(altAssets, lnutils.Ptr), + ), + } +} + +func randMintingLeafGen(t *rapid.T, assetGen asset.Genesis, + groupKey *btcec.PublicKey) universe.Leaf { + + randProof := randProofGen(t, nil) + + leaf := universe.Leaf{ + GenesisWithGroup: universe.GenesisWithGroup{ + Genesis: assetGen, + }, + Amt: randProof.Asset.Amount, + } + + // The asset within the genesis proof is random; reset the asset genesis + // and group key to match the universe minting leaf. + randProof.Asset.Genesis = assetGen + randProof.GenesisReveal = &assetGen + + if groupKey != nil { + // If the universe leaf needs a group key, the asset inside the + // proof must also have one. We might need to generate a witness + // if the randomly generated proof asset didn't have one. + var witness wire.TxWitness + if randProof.Asset.GroupKey != nil { + witness = randProof.Asset.GroupKey.Witness + } else { + // Generate a witness since the proof asset didn't have + // one. + witness = asset.TxWitnessGen.Draw(t, "group_witness") + + randProof.Asset.PrevWitnesses = []asset.Witness{{ + PrevID: &asset.PrevID{}, + TxWitness: witness, + }} + } + + assetGroupKey := &asset.GroupKey{ + GroupPubKey: *groupKey, + Witness: witness, + } + + leaf.GroupKey = assetGroupKey + randProof.Asset.GroupKey = assetGroupKey + randProof.GroupKeyReveal = asset.NewGroupKeyRevealV0( + asset.ToSerialized(groupKey), nil, + ) + } + + leaf.Asset = &randProof.Asset + + var proofBuf bytes.Buffer + require.NoError(t, randProof.Encode(&proofBuf)) + leaf.RawProof = proofBuf.Bytes() + + return leaf +} + +// randMintEventGen generates a random NewMintEvent. It requires the group +// public key to correctly populate the leaf. +func randMintEventGen(groupPubKey *btcec.PublicKey, +) *rapid.Generator[supplycommit.SupplyUpdateEvent] { + + return rapid.Custom(func(t *rapid.T) supplycommit.SupplyUpdateEvent { + mintGenesis := asset.GenesisGen.Draw(t, "genesis") + mintLeaf := randMintingLeafGen(t, mintGenesis, groupPubKey) + + var p proof.Proof + require.NoError(t, p.Decode(bytes.NewReader(mintLeaf.RawProof))) + + mintKey := universe.AssetLeafKey{ + BaseLeafKey: universe.BaseLeafKey{ + OutPoint: p.OutPoint(), + ScriptKey: &p.Asset.ScriptKey, + }, + AssetID: p.Asset.ID(), + } + + return &supplycommit.NewMintEvent{ + LeafKey: mintKey, + IssuanceProof: mintLeaf, + } + }) +} + +func burnAssetGen(t *rapid.T) *asset.Asset { + a := asset.AssetGen.Draw(t, "asset") + + // Make sure it has two prev witness fields to ensure it isn't mistaken + // as a genesis asset. + a.PrevWitnesses = nil + a.PrevWitnesses = append( + a.PrevWitnesses, asset.Witness{}, asset.Witness{}, + ) + + // Assign a non-zero PrevID that doesn't look like a genesis asset. + nonZeroPrevID := asset.NonGenesisPrevIDGen.Draw(t, "burn_prev_id") + a.PrevWitnesses[0].PrevID = &nonZeroPrevID + + a.ScriptKey = asset.NewScriptKey( + asset.DeriveBurnKey(*a.PrevWitnesses[0].PrevID), + ) + + return &a +} + +// randBurnEventGen generates a random NewBurnEvent. It requires the base +// genesis and group key to correctly populate the leaf, and the DB interface to +// ensure the genesis exists. +func randBurnEventGen(baseGenesis asset.Genesis, groupKey *asset.GroupKey, + db BatchedUniverseTree) *rapid.Generator[supplycommit.SupplyUpdateEvent] { //nolint:lll + + return rapid.Custom(func(t *rapid.T) supplycommit.SupplyUpdateEvent { + // Use the base genesis for burn events. + burnAsset := burnAssetGen(t) + burnAsset.Amount = uint64( + rapid.Int32Range(1, 1_000).Draw(t, "burn_amt"), + ) + + burnAsset.Genesis = baseGenesis + if groupKey != nil { + burnAsset.GroupKey = groupKey + } + burnProof := randProofGen(t, burnAsset) + burnProof.GenesisReveal = &baseGenesis + + burnLeaf := &universe.BurnLeaf{ + UniverseKey: universe.AssetLeafKey{ + BaseLeafKey: universe.BaseLeafKey{ + OutPoint: burnProof.OutPoint(), + ScriptKey: &burnProof.Asset.ScriptKey, + }, + AssetID: burnProof.Asset.ID(), + }, + BurnProof: burnProof, + } + + // 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) + + return &supplycommit.NewBurnEvent{ + BurnLeaf: *burnLeaf, + } + }) +} + +func randIgnoreTupleGen(t *rapid.T, + db BatchedUniverseTree) universe.SignedIgnoreTuple { + + scriptKey := asset.ScriptKeyGen.Draw(t, "script_key") + + ctx := context.Background() + + op := asset.OutPointGen.Draw(t, "outpoint") + + genesis := asset.GenesisGen.Draw(t, "genesis") + + assetID := genesis.ID() + + ignoreTuple := &universe.IgnoreTuple{ + PrevID: asset.PrevID{ + ID: assetID, + ScriptKey: asset.ToSerialized(scriptKey.PubKey), + OutPoint: op, + }, + Amount: 100, + } + + // Create a signature for the ignore tuple. + testSchnorrSigStr, err := hex.DecodeString( + "04e7f9037658a92afeb4f25bae5339e3ddca81a353493827d26f16d92308" + + "e49e2a25e92208678a2df86970da91b03a8af8815a8a60498b35" + + "8daf560b347aa557", + ) + require.NoError(t, err) + testSchnorrSig, _ := lnwire.NewSigFromSchnorrRawSignature( + testSchnorrSigStr, + ) + testSchnorrSig.ForceSchnorr() + sig, err := testSchnorrSig.ToSignature() + require.NoError(t, err) + + signature, ok := sig.(*schnorr.Signature) + require.True(t, ok) + + genesisOutpoint := genesis.FirstPrevOut + + genesisPointID, err := upsertGenesisPoint(ctx, db, genesisOutpoint) + require.NoError(t, err) + _, err = upsertGenesis(ctx, db, genesisPointID, genesis) + require.NoError(t, err) + + // Create a SignedIgnoreTuple. + return universe.NewSignedIgnoreTuple( + *ignoreTuple, universe.IgnoreSig{Signature: *signature}, + ) +} + +// randIgnoreEventGen generates a random NewIgnoreEvent. It requires the base +// asset ID and the DB interface to ensure the genesis exists. +func randIgnoreEventGen(baseAssetID asset.ID, + db BatchedUniverseTree) *rapid.Generator[supplycommit.SupplyUpdateEvent] { //nolint:lll + + return rapid.Custom(func(t *rapid.T) supplycommit.SupplyUpdateEvent { + signedTuple := randIgnoreTupleGen(t, db) + signedTuple.IgnoreTuple.Val.ID = baseAssetID + + return &supplycommit.NewIgnoreEvent{ + SignedIgnoreTuple: signedTuple, + } + }) +} + +// randSupplyUpdateEventGen creates a composite generator that randomly selects +// one of the specific event type generators. +func randSupplyUpdateEventGen(baseGenesis asset.Genesis, + groupKey *asset.GroupKey, + db BatchedUniverseTree) *rapid.Generator[supplycommit.SupplyUpdateEvent] { //nolint:lll + + var groupPubKey *btcec.PublicKey + if groupKey != nil { + groupPubKey = &groupKey.GroupPubKey + } + + return rapid.OneOf( + randMintEventGen(groupPubKey), + randBurnEventGen(baseGenesis, groupKey, db), + randIgnoreEventGen(baseGenesis.ID(), db), + ) +} + +// setupSupplyTreeTestForProps sets up a test environment for property-based +// testing of SupplyTreeStore. It returns the store, specifier, base genesis, +// group key, and the composite event generator. +func setupSupplyTreeTestForProps(t *testing.T) (*SupplyTreeStore, + asset.Specifier, *rapid.Generator[supplycommit.SupplyUpdateEvent]) { + + sqlDB := NewTestDB(t) + dbTxer := NewTransactionExecutor( + sqlDB, func(tx *sql.Tx) BaseUniverseStore { + return sqlDB.WithTx(tx) + }, + ) + supplyStore := NewSupplyTreeStore(dbTxer) + ctx := context.Background() + + // Generate a random group key for testing. + groupPrivKey := asset.PrivKeyGen.Example() + require.NotNil(t, groupPrivKey) + + groupPub := groupPrivKey.PubKey() + groupKey := &asset.GroupKey{ + GroupPubKey: *groupPub, + } + + baseGenesis := asset.GenesisGen.Example() + + // Create a base genesis and insert it. This will be referenced by + // burn/ignore events. + baseAssetID := baseGenesis.ID() + genesisOutpoint := baseGenesis.FirstPrevOut + genesisPointID, err := upsertGenesisPoint(ctx, dbTxer, genesisOutpoint) + require.NoError(t, err) + genAssetID, err := upsertGenesis( + ctx, dbTxer, genesisPointID, baseGenesis, + ) + require.NoError(t, err) + _, err = upsertGroupKey( + ctx, groupKey, dbTxer, genesisPointID, genAssetID, + ) + require.NoError(t, err) + + // Create an asset specifier with the group key. + spec, err := asset.NewSpecifier( + &baseAssetID, &groupKey.GroupPubKey, nil, true, + ) + require.NoError(t, err) + + // Create the composite generator. + eventGen := randSupplyUpdateEventGen(baseGenesis, groupKey, dbTxer) + + return supplyStore, spec, eventGen +} + +// TestSupplyTreeStoreApplySupplyUpdates tests that the ApplySupplyUpdates meets +// a series of key invariant via property based testing. +func TestSupplyTreeStoreApplySupplyUpdates(t *testing.T) { + t.Parallel() + + supplyStore, spec, eventGen := setupSupplyTreeTestForProps( + t, + ) + ctxb := context.Background() + + // Draw a random list of supply update events. Limit the number + // of events to keep test execution time reasonable. + updates := rapid.SliceOfN( + eventGen, 1, 20, + ).Example() + + // Apply the updates. + finalRootSupplyRoot, err := supplyStore.ApplySupplyUpdates( + ctxb, spec, updates, + ) + require.NoError(t, err) + + // First, we'll make a series of maps so we can easily verify + // the expected sub-tree roots and sums. + expectedSubRoots := make(map[supplycommit.SupplySubTree]mssmt.Node) //nolint:lll + expectedSubSums := make(map[supplycommit.SupplySubTree]uint64) + tempTrees := make(map[supplycommit.SupplySubTree]mssmt.Tree) + + // To do this, we'll first create temporary trees for each + // sub-tree. If we didn't have an update type for a given tree, + // it'll be the empty tree. + for _, treeType := range []supplycommit.SupplySubTree{ + supplycommit.MintTreeType, supplycommit.BurnTreeType, + supplycommit.IgnoreTreeType, + } { + tempTrees[treeType] = mssmt.NewCompactedTree( + mssmt.NewDefaultStore(), + ) + expectedSubRoots[treeType] = mssmt.NewComputedBranch( + mssmt.EmptyTreeRootHash, 0, + ) + } + + // Next, we'll apply each of the updates to the proper tree + // based on the sub-tree type. + for _, update := range updates { + treeType := update.SupplySubTreeType() + + tree, ok := tempTrees[treeType] + require.True(t, ok, "missing tree for %v", treeType) + + updateKey := update.UniverseLeafKey() + updateLeaf, err := update.UniverseLeafNode() + require.NoError(t, err) + + _, err = tree.Insert( + ctxb, updateKey.UniverseKey(), updateLeaf, + ) + require.NoError(t, err) + + // Update expected root and sum for this sub-tree. + root, err := tree.Root(ctxb) + require.NoError(t, err) + expectedSubRoots[treeType] = root + expectedSubSums[treeType] = root.NodeSum() + } + + // Now that the verification trees have been populated, we can + // start verifying the results. We'll start by verifying the + // sub-tree results. + var totalExpectedRootSum uint64 + for treeType, expectedRoot := range expectedSubRoots { + treeRes := supplyStore.FetchSubTree( + ctxb, spec, treeType, + ) + actualTree, err := treeRes.Unpack() + require.NoError(t, err) + + actualRoot, err := actualTree.Root(ctxb) + require.NoError(t, err) + + require.Equal( + t, expectedRoot.NodeHash(), + actualRoot.NodeHash(), + "sub-tree root hash mismatch for %v", treeType, + ) + require.Equal( + t, expectedRoot.NodeSum(), + actualRoot.NodeSum(), + "sub-tree root sum mismatch for %v", treeType, + ) + totalExpectedRootSum += actualRoot.NodeSum() + } + + // We know the sub-tree roots are correct, now we'll verify the + // root sum. + require.NotNil(t, finalRootSupplyRoot) + require.Equal( + t, int64(totalExpectedRootSum), + int64(finalRootSupplyRoot.NodeSum()), + "final root supply tree sum mismatch", + ) + + // With the checks above, we know that the sub-trees are + // correct, we'll now verify the root supply tree itself. + rootTreeRes := supplyStore.FetchRootSupplyTree(ctxb, spec) + rootTree, err := rootTreeRes.Unpack() + require.NoError(t, err) + + for treeType, expectedSubRoot := range expectedSubRoots { + // Only check inclusion if the sub-tree was actually + // modified. + if expectedSubRoot.NodeHash() == + mssmt.EmptyTreeRootHash { + + continue + } + + // We'll create a merkle proof for the sub-tree, based + // on the root tree we've read from the DB. + leafKey := treeType.UniverseKey() + dbProof, err := rootTree.MerkleProof(ctxb, leafKey) + require.NoError(t, err) + + // Fetch the actual sub-tree root *after* updates. + actualSubTreeRes := supplyStore.FetchSubTree( + ctxb, spec, treeType, + ) + actualSubTree, err := actualSubTreeRes.Unpack() + require.NoError(t, err) + actualSubTreeRoot, err := actualSubTree.Root(ctxb) + require.NoError(t, err) + + // Construct the expected leaf node for the root tree. + expectedLeafNode := mssmt.NewLeafNode( + lnutils.ByteSlice(actualSubTreeRoot.NodeHash()), + actualSubTreeRoot.NodeSum(), + ) + + // Verify the proof. + valid := mssmt.VerifyMerkleProof( + leafKey, expectedLeafNode, dbProof, + finalRootSupplyRoot, + ) + require.True( + t, valid, "root supply tree inclusion proof "+ + "invalid for %v", treeType, + ) + } + + // If we apply the same set of updates, we should get the same + // result. + idempotentRoot, err := supplyStore.ApplySupplyUpdates( + ctxb, spec, updates, + ) + require.NoError(t, err) + require.Equal( + t, finalRootSupplyRoot.NodeHash(), + idempotentRoot.NodeHash(), + "idempotency check failed: root hash mismatch", + ) + require.Equal( + t, finalRootSupplyRoot.NodeSum(), + idempotentRoot.NodeSum(), + "idempotency check failed: root sum mismatch", + ) + + // Read out an additional set of updates, we should be able to + // without any issues. + updates = rapid.SliceOfN( + eventGen, 1, 20, + ).Example() + _, err = supplyStore.ApplySupplyUpdates( + ctxb, spec, updates, + ) + require.NoError(t, err) +} diff --git a/tapdb/universe.go b/tapdb/universe.go index 2a4e92c48..93a2b178e 100644 --- a/tapdb/universe.go +++ b/tapdb/universe.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" + "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/btcec/v2/schnorr" "github.com/btcsuite/btcd/wire" "github.com/lightninglabs/taproot-assets/asset" @@ -51,6 +52,12 @@ type ( // DeleteMultiverseLeaf is used to delete a multiverse leaf. DeleteMultiverseLeaf = sqlc.DeleteMultiverseLeafParams + + // UpsertUniverseSupplyRoot is used to upsert a universe supply root. + UpsertUniverseSupplyRoot = sqlc.UpsertUniverseSupplyRootParams + + // UpsertUniverseSupplyLeaf is used to upsert a universe supply leaf. + UpsertUniverseSupplyLeaf = sqlc.UpsertUniverseSupplyLeafParams ) // BaseUniverseStore is the main interface for the Taproot Asset universe store. @@ -111,39 +118,23 @@ type BaseUniverseStore interface { // DeleteMultiverseLeaf deletes a multiverse leaf from the database. DeleteMultiverseLeaf(ctx context.Context, arg DeleteMultiverseLeaf) error -} - -// specifierToIdentifier converts an asset.Specifier into a universe.Identifier -// for a specific proof type. -// -// NOTE: This makes an assumption that only specifiers with a group key are -// valid for the ignore and burn proof types. -func specifierToIdentifier(spec asset.Specifier, - proofType universe.ProofType) (universe.Identifier, error) { - - var id universe.Identifier - - // The specifier must have a group key to be able to be used within the - // ignore or burn tree context. - requireGroupKey := proofType == universe.ProofTypeIgnore || - proofType == universe.ProofTypeBurn - if requireGroupKey && !spec.HasGroupPubKey() { - return id, fmt.Errorf("group key must be set for proof type %v", - proofType) - } - id.GroupKey = spec.UnwrapGroupKeyToPtr() - id.ProofType = proofType + // UpsertUniverseSupplyLeaf attempts to upsert a supply leaf into the + // supply tree for a given asset. + UpsertUniverseSupplyLeaf(ctx context.Context, + arg UpsertUniverseSupplyLeaf) (int64, error) - return id, nil + // UpsertUniverseSupplyRoot attempts to upsert a supply root into the + // supply tree for a given asset. + UpsertUniverseSupplyRoot(ctx context.Context, + arg UpsertUniverseSupplyRoot) (int64, error) } // getUniverseTreeSum retrieves the sum of a universe tree specified by its // identifier. func getUniverseTreeSum(ctx context.Context, db BatchedUniverseTree, - id universe.Identifier) universe.SumQueryResp { + namespace string) universe.SumQueryResp { - namespace := id.String() var sumOpt lfn.Option[uint64] readTx := NewBaseUniverseReadTx() @@ -211,12 +202,11 @@ type authProofBuilder[DecodedLeafType any, AuthProofType any] func( // info, and finally the QueryType is the type that is used to query the leaves. func queryUniverseLeavesAndProofs[LeafType any, AuthType any, QueryType any]( ctx context.Context, db BatchedUniverseTree, assetSpec asset.Specifier, - id universe.Identifier, leafQuery universeLeafQueryFunc[QueryType], + namespace string, leafQuery universeLeafQueryFunc[QueryType], leafDecode universeLeafDecodeFunc[LeafType], proofBuild authProofBuilder[LeafType, AuthType], queryParams ...QueryType) lfn.Result[lfn.Option[[]AuthType]] { - namespace := id.String() var ( resultAuths []AuthType foundAny bool @@ -306,10 +296,9 @@ func queryUniverseLeavesAndProofs[LeafType any, AuthType any, QueryType any]( // decodeFunc decodes the raw proof bytes from a UniverseLeaf into the // desired domain-specific type. func listUniverseLeaves[T any](ctx context.Context, db BatchedUniverseTree, - id universe.Identifier, decodeFunc func(UniverseLeaf) (T, error), + namespace string, decodeFunc func(UniverseLeaf) (T, error), ) lfn.Result[lfn.Option[[]T]] { - namespace := id.String() var results []T readTx := NewBaseUniverseReadTx() @@ -576,38 +565,144 @@ func (b *BaseUniverseTree) UpsertProofLeaf(ctx context.Context, metaReveal *proof.MetaReveal) (*universe.Proof, error) { var ( - writeTx BaseUniverseStoreOptions - - err error - issuanceProof *universe.Proof + writeTx BaseUniverseStoreOptions + uniProof *universe.Proof ) dbErr := b.db.ExecTx(ctx, &writeTx, func(dbTx BaseUniverseStore) error { - issuanceProof, err = universeUpsertProofLeaf( - ctx, dbTx, b.id, key, leaf, metaReveal, false, + namespace := b.id.String() + issuanceProof, err := universeUpsertProofLeaf( + ctx, dbTx, namespace, b.id.ProofType.String(), + b.id.GroupKey, key, leaf, metaReveal, ) + if err != nil { + return fmt.Errorf("failed universe upsert: %w", err) + } + + multiRoot, multiProof, err := upsertMultiverseLeafEntry( + ctx, dbTx, b.id, issuanceProof.UniverseRoot, + ) + if err != nil { + return fmt.Errorf("failed multiverse upsert: %w", err) + } + + issuanceProof.MultiverseRoot = multiRoot + issuanceProof.MultiverseInclusionProof = multiProof + + uniProof = issuanceProof return err }) if dbErr != nil { return nil, dbErr } - return issuanceProof, nil + return uniProof, nil +} + +// upsertMultiverseLeafEntry inserts the universe root into the main multiverse +// tree. This should be called *after* universeUpsertProofLeaf if the proof +// needs to be added to the main issuance/transfer multiverse. +// +// NOTE: This function accepts a db transaction, as it's used when making +// broader DB updates. +func upsertMultiverseLeafEntry(ctx context.Context, dbTx BaseUniverseStore, + id universe.Identifier, universeRoot mssmt.Node) ( + mssmt.Node, *mssmt.Proof, error) { + + // Determine the multiverse namespace based on the proof type. + multiverseNS, err := namespaceForProof(id.ProofType) + if err != nil { + return nil, nil, err + } + + // Retrieve a handle to the multiverse tree. + multiverseTree := mssmt.NewCompactedTree( + newTreeStoreWrapperTx(dbTx, multiverseNS), + ) + + // Construct a leaf node for insertion into the multiverse tree. + universeRootHash := universeRoot.NodeHash() + assetGroupSum := universeRoot.NodeSum() + + // For issuance proofs, the sum in the multiverse is always 1 (one asset + // or group). For transfers, it's the actual amount. + if id.ProofType == universe.ProofTypeIssuance { + assetGroupSum = 1 + } + + uniLeafNode := mssmt.NewLeafNode(universeRootHash[:], assetGroupSum) + + // Use asset ID (or asset group hash) as the upper tree leaf node key. + uniLeafNodeKey := id.Bytes() + + _, err = multiverseTree.Insert(ctx, uniLeafNodeKey, uniLeafNode) + if err != nil { + return nil, nil, fmt.Errorf("multiverse tree insert "+ + "failed: %w", err) + } + + // Ensure the corresponding multiverse roots and leaves DB entries + // exist. + multiverseRootID, err := dbTx.UpsertMultiverseRoot( + ctx, UpsertMultiverseRoot{ + NamespaceRoot: multiverseNS, + ProofType: id.ProofType.String(), + }, + ) + if err != nil { + return nil, nil, fmt.Errorf("unable to upsert multiverse "+ + "root: %w", err) + } + + var assetIDBytes, groupKeyBytes []byte + if id.GroupKey == nil { + assetIDBytes = id.AssetID[:] + } else { + groupKeyBytes = schnorr.SerializePubKey(id.GroupKey) + } + + _, err = dbTx.UpsertMultiverseLeaf(ctx, UpsertMultiverseLeaf{ + MultiverseRootID: multiverseRootID, + AssetID: assetIDBytes, + GroupKey: groupKeyBytes, + LeafNodeKey: uniLeafNodeKey[:], + LeafNodeNamespace: multiverseNS, + }) + if err != nil { + return nil, nil, fmt.Errorf("unable to upsert multiverse "+ + "leaf: %w", err) + } + + // Retrieve the multiverse root and inclusion proof. + finalMultiverseRoot, err := multiverseTree.Root(ctx) + if err != nil { + return nil, nil, fmt.Errorf("failed to get multiverse "+ + "root: %w", err) + } + + multiverseInclusionProof, err := multiverseTree.MerkleProof( + ctx, uniLeafNodeKey, + ) + if err != nil { + return nil, nil, fmt.Errorf("failed to get multiverse "+ + "proof: %w", err) + } + + return finalMultiverseRoot, multiverseInclusionProof, nil } // universeUpsertProofLeaf upserts a proof leaf within the universe tree (stored -// at the proof leaf key). +// at the proof leaf key). It handles the insertion into the specific universe's +// SMT and updates the relevant database tables for that universe. // // This function returns the inserted/updated proof leaf and the new universe -// root. +// root. It does NOT insert into the top-level multiverse tree. // // NOTE: This function accepts a db transaction, as it's used when making // broader DB updates. func universeUpsertProofLeaf(ctx context.Context, dbTx BaseUniverseStore, - id universe.Identifier, key universe.LeafKey, leaf *universe.Leaf, - metaReveal *proof.MetaReveal, - skipMultiverse bool) (*universe.Proof, error) { - - namespace := id.String() + namespace string, proofTypeStr string, groupKey *btcec.PublicKey, + key universe.LeafKey, leaf *universe.Leaf, + metaReveal *proof.MetaReveal) (*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. @@ -618,8 +713,8 @@ func universeUpsertProofLeaf(ctx context.Context, dbTx BaseUniverseStore, leafNode := leaf.SmtLeafNode() var groupKeyBytes []byte - if id.GroupKey != nil { - groupKeyBytes = schnorr.SerializePubKey(id.GroupKey) + if groupKey != nil { + groupKeyBytes = schnorr.SerializePubKey(groupKey) } mintingPointBytes, err := encodeOutpoint(key.LeafOutPoint()) @@ -651,7 +746,7 @@ func universeUpsertProofLeaf(ctx context.Context, dbTx BaseUniverseStore, NamespaceRoot: namespace, AssetID: fn.ByteSlice(leaf.ID()), GroupKey: groupKeyBytes, - ProofType: sqlStr(id.ProofType.String()), + ProofType: sqlStr(proofTypeStr), }) if err != nil { return nil, err @@ -706,105 +801,13 @@ func universeUpsertProofLeaf(ctx context.Context, dbTx BaseUniverseStore, return nil, err } - // If this Universe tree isn't part of the greater multi-verse tree, - // then we'll skip insertion for now. - // - // TODO(roasbeef): will go into a combined multi-verse tree for diff - // proof types later - if skipMultiverse { - return &universe.Proof{ - LeafKey: key, - UniverseRoot: universeRoot, - UniverseInclusionProof: leafInclusionProof, - Leaf: leaf, - }, nil - } - - // The next step is to insert the multiverse leaf, which is a leaf in - // the multiverse tree that points to the universe leaf we just created. - multiverseNS, err := namespaceForProof(id.ProofType) - if err != nil { - return nil, err - } - - // Retrieve a handle to the multiverse tree so that we can update the - // tree by inserting a new issuance. - multiverseTree := mssmt.NewCompactedTree( - newTreeStoreWrapperTx(dbTx, multiverseNS), - ) - - // Construct a leaf node for insertion into the multiverse tree. The - // leaf node includes a reference to the lower tree via the lower tree - // root hash. - universeRootHash := universeRoot.NodeHash() - assetGroupSum := universeRoot.NodeSum() - - if id.ProofType == universe.ProofTypeIssuance { - assetGroupSum = 1 - } - - uniLeafNode := mssmt.NewLeafNode(universeRootHash[:], assetGroupSum) - - // Use asset ID (or asset group hash) as the upper tree leaf node key. - // This is the same as the asset specific universe ID. - uniLeafNodeKey := id.Bytes() - - _, err = multiverseTree.Insert(ctx, uniLeafNodeKey, uniLeafNode) - if err != nil { - return nil, err - } - - // Now that we've inserted the leaf into the multiverse tree, we'll also - // make sure the corresponding multiverse roots and leaves are created. - multiverseRootID, err := dbTx.UpsertMultiverseRoot( - ctx, UpsertMultiverseRoot{ - NamespaceRoot: multiverseNS, - ProofType: id.ProofType.String(), - }, - ) - if err != nil { - return nil, fmt.Errorf("unable to upsert multiverse root: %w", - err) - } - - var assetIDBytes []byte - if id.GroupKey == nil { - assetIDBytes = id.AssetID[:] - } - - _, err = dbTx.UpsertMultiverseLeaf(ctx, UpsertMultiverseLeaf{ - MultiverseRootID: multiverseRootID, - AssetID: assetIDBytes, - GroupKey: groupKeyBytes, - LeafNodeKey: uniLeafNodeKey[:], - LeafNodeNamespace: multiverseNS, - }) - if err != nil { - return nil, fmt.Errorf("unable to upsert multiverse leaf: %w", - err) - } - - // Retrieve the multiverse root and asset specific inclusion proof for - // the leaf node. - multiverseRoot, err := multiverseTree.Root(ctx) - if err != nil { - return nil, err - } - - multiverseInclusionProof, err := multiverseTree.MerkleProof( - ctx, uniLeafNodeKey, - ) - if err != nil { - return nil, err - } - + // Return the proof containing only universe-level information. + // Multiverse details will be added by the caller if needed. return &universe.Proof{ - LeafKey: key, - UniverseRoot: universeRoot, - UniverseInclusionProof: leafInclusionProof, - MultiverseRoot: multiverseRoot, - MultiverseInclusionProof: multiverseInclusionProof, - Leaf: leaf, + LeafKey: key, + UniverseRoot: universeRoot, + UniverseInclusionProof: leafInclusionProof, + Leaf: leaf, }, nil } @@ -1175,7 +1178,7 @@ func deleteUniverseTree(ctx context.Context, LeafNodeKey: fn.ByteSlice(id.Bytes()), }) if err != nil { - return fmt.Errorf("unable to upsert multiverse leaf: %w", err) + return fmt.Errorf("unable to delete multiverse leaf: %w", err) } return nil diff --git a/universe/interface.go b/universe/interface.go index e16af705a..a999488f4 100644 --- a/universe/interface.go +++ b/universe/interface.go @@ -787,6 +787,10 @@ const ( // ProofTypeBurn corresponds to the burn proof type. ProofTypeBurn + + // ProofTypeMintSupply indicates a proof related to the mint supply + // sub-tree. + ProofTypeMintSupply ) // NewProofTypeFromAsset returns the proof type for the given asset proof. @@ -815,6 +819,8 @@ func (t ProofType) String() string { return "ignore" case ProofTypeBurn: return "burn" + case ProofTypeMintSupply: + return "mint_supply" } return fmt.Sprintf("unknown(%v)", int(t)) @@ -833,6 +839,8 @@ func ParseStrProofType(typeStr string) (ProofType, error) { return ProofTypeIgnore, nil case "burn": return ProofTypeBurn, nil + case "mint_supply": + return ProofTypeMintSupply, nil default: return 0, fmt.Errorf("unknown proof type: %v", typeStr) }