diff --git a/address/book.go b/address/book.go index b021d8bc8..1bfd70878 100644 --- a/address/book.go +++ b/address/book.go @@ -165,6 +165,12 @@ type Storage interface { // database. FetchAllAssetMeta( ctx context.Context) (map[asset.ID]*proof.MetaReveal, error) + + // FetchInternalKeyLocator attempts to fetch the key locator information + // for the given raw internal key. If the key cannot be found, then + // ErrInternalKeyNotFound is returned. + FetchInternalKeyLocator(ctx context.Context, + rawKey *btcec.PublicKey) (keychain.KeyLocator, error) } // KeyRing is used to create script and internal keys for Taproot Asset @@ -841,3 +847,73 @@ func (b *Book) RemoveSubscriber( return nil } + +// DelegationKeyChecker is used to verify that we control the delegation key +// for a given asset, which is required for creating supply commitments. +type DelegationKeyChecker interface { + // HasDelegationKey checks if we control the delegation key for the + // given asset ID. Returns true if we have the private key for the + // asset's delegation key, false otherwise. + HasDelegationKey(ctx context.Context, assetID asset.ID) (bool, error) +} + +// HasDelegationKey checks if we control the delegation key for the given +// asset ID. Returns true if we have the private key for the asset's +// delegation key, false otherwise. +// +// NOTE: This is part of the DelegationKeyChecker interface. +func (b *Book) HasDelegationKey(ctx context.Context, + assetID asset.ID) (bool, error) { + + assetGroup, err := b.cfg.Store.QueryAssetGroupByID(ctx, assetID) + if err != nil { + return false, fmt.Errorf("fail to find asset group given "+ + "asset ID: %w", err) + } + + // If the asset doesn't have a group, it can't have a delegation key. So + // we just return false here. + if assetGroup == nil || assetGroup.GroupKey == nil { + return false, nil + } + + // Retrieve asset meta reveal for the asset ID. This will be used to + // obtain the supply commitment delegation key. + metaReveal, err := b.cfg.Store.FetchAssetMetaForAsset(ctx, assetID) + if err != nil { + return false, fmt.Errorf("failed to fetch asset meta: %w", err) + } + + // If there's no meta reveal or delegation key, we can't control it. + if metaReveal == nil || metaReveal.DelegationKey.IsNone() { + return false, nil + } + + delegationPubKey, err := metaReveal.DelegationKey.UnwrapOrErr( + fmt.Errorf("delegation key not found for given asset"), + ) + if err != nil { + return false, err + } + + // Now that we have the delegation key, we'll see if we know of the + // internal key loactor. If we do, then this means that we were the ones + // that created it in the first place. + _, err = b.cfg.Store.FetchInternalKeyLocator(ctx, &delegationPubKey) + switch { + // If we get this error, then we don't control this delegation key. + case errors.Is(err, ErrInternalKeyNotFound): + return false, nil + case err != nil: + return false, fmt.Errorf("failed to fetch delegation key "+ + "locator: %w", err) + } + + // If we reached this point, then we know that we control the delegation + // key. + return true, nil +} + +// A compile-time assertion to ensure Book implements the DelegationKeyChecker +// interface. +var _ DelegationKeyChecker = (*Book)(nil) diff --git a/address/book_test.go b/address/book_test.go index ea63ae8f5..bd80d0faa 100644 --- a/address/book_test.go +++ b/address/book_test.go @@ -2,6 +2,7 @@ package address import ( "context" + "errors" "net/url" "testing" "time" @@ -9,6 +10,7 @@ import ( "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/wire" "github.com/lightninglabs/taproot-assets/asset" + "github.com/lightninglabs/taproot-assets/fn" "github.com/lightninglabs/taproot-assets/internal/test" "github.com/lightninglabs/taproot-assets/proof" "github.com/lightningnetwork/lnd/keychain" @@ -16,6 +18,13 @@ import ( "github.com/stretchr/testify/require" ) +// Test error constants used throughout the tests. +var ( + errDatabaseError = errors.New("database error") + errMetadataError = errors.New("metadata error") + errKeyFetchError = errors.New("key fetch error") +) + // TestBook_NewAddress tests that we can create a new address with the // `NewAddress` method of the Book type, while mocking the necessary // dependencies. @@ -68,6 +77,278 @@ func TestBook_NewAddress(t *testing.T) { mockSyncer.AssertExpectations(t) } +// addrMockHelper provides helper functions for setting up mock expectations. +type addrMockHelper struct { + ctx context.Context + storage *MockStorage +} + +// newAddrMockHelper creates a new addrMockHelper instance. +func newAddrMockHelper(ctx context.Context, + storage *MockStorage) *addrMockHelper { + + return &addrMockHelper{ + ctx: ctx, + storage: storage, + } +} + +// expectAssetGroup sets up mock expectation for QueryAssetGroup. +func (m *addrMockHelper) expectAssetGroup(assetID asset.ID, + group *asset.AssetGroup, err error) { + + m.storage.On("QueryAssetGroup", m.ctx, assetID).Return(group, err) +} + +// expectAssetMeta sets up mock expectation for FetchAssetMetaForAsset. +func (m *addrMockHelper) expectAssetMeta(assetID asset.ID, + meta *proof.MetaReveal, err error) { + + m.storage.On("FetchAssetMetaForAsset", m.ctx, assetID).Return(meta, err) +} + +// expectKeyLocator sets up mock expectation for FetchInternalKeyLocator. +func (m *addrMockHelper) expectKeyLocator(key *btcec.PublicKey, + locator keychain.KeyLocator, err error) { + + m.storage.On("FetchInternalKeyLocator", m.ctx, key).Return(locator, err) +} + +// expectFullDelegationFlow sets up all three mock expectations for a complete +// delegation check. +func (m *addrMockHelper) expectFullDelegationFlow(assetID asset.ID, + group *asset.AssetGroup, meta *proof.MetaReveal, + delegationKey *btcec.PublicKey, locator keychain.KeyLocator, + locatorErr error) { + + m.expectAssetGroup(assetID, group, nil) + m.expectAssetMeta(assetID, meta, nil) + + if delegationKey != nil { + m.expectKeyLocator(delegationKey, locator, locatorErr) + } +} + +// TestBookHasDelegationKey tests that the HasDelegationKey method correctly +// checks if we control the delegation key for a given asset. +func TestBookHasDelegationKey(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + // We'll start off by creating some test data to use in our tests. + assetWithDelegation := asset.RandID(t) + assetWithoutDelegation := asset.RandID(t) + assetNoGroup := asset.RandID(t) + assetNoMeta := asset.RandID(t) + + delegationKey, _ := btcec.NewPrivateKey() + delegationPubKey := delegationKey.PubKey() + + groupKey, _ := btcec.NewPrivateKey() + groupPubKey := groupKey.PubKey() + + assetGroup := &asset.AssetGroup{ + GroupKey: &asset.GroupKey{ + GroupPubKey: *groupPubKey, + }, + } + + metaWithDelegation := &proof.MetaReveal{ + DelegationKey: fn.Some(*delegationPubKey), + } + + metaWithoutDelegation := &proof.MetaReveal{ + DelegationKey: fn.None[btcec.PublicKey](), + } + + keyLocator := keychain.KeyLocator{ + Family: 1, + Index: 1, + } + + tests := []struct { + name string + assetID asset.ID + setupMocks func(*MockStorage) + expectedHas bool + expectedError string + }{ + // If we can fetch the asset group, the meta, and also the + // internal key, then we should conclude we made the asset. + { + name: "asset with controlled delegation key", + assetID: assetWithDelegation, + setupMocks: func(m *MockStorage) { + h := newAddrMockHelper(ctx, m) + + h.expectFullDelegationFlow( + assetWithDelegation, assetGroup, + metaWithDelegation, delegationPubKey, + keyLocator, nil, + ) + }, + expectedHas: true, + }, + + // Test that if we can't find the internal key, that we conclude + // it isn't our asset. + { + name: "asset with non-controlled delegation key", + assetID: assetWithDelegation, + setupMocks: func(m *MockStorage) { + h := newAddrMockHelper(ctx, m) + h.expectFullDelegationFlow( + assetWithDelegation, assetGroup, + metaWithDelegation, delegationPubKey, + keychain.KeyLocator{}, + ErrInternalKeyNotFound, + ) + }, + expectedHas: false, + }, + + // If an asset has no group, then it can't have a delegation + // key. + { + name: "asset without group", + assetID: assetNoGroup, + setupMocks: func(m *MockStorage) { + h := newAddrMockHelper(ctx, m) + h.expectAssetGroup(assetNoGroup, nil, nil) + }, + expectedHas: false, + }, + + // If we can't query for the group, we return an error. + { + name: "asset group query error", + assetID: assetWithDelegation, + setupMocks: func(m *MockStorage) { + h := newAddrMockHelper(ctx, m) + h.expectAssetGroup( + assetWithDelegation, nil, + errDatabaseError, + ) + }, + expectedHas: false, + expectedError: "fail to find asset group given asset " + + "ID", + }, + + // If an asset has no metadata, then we can't have a delegation + // key. + { + name: "asset without metadata", + assetID: assetNoMeta, + setupMocks: func(m *MockStorage) { + h := newAddrMockHelper(ctx, m) + h.expectAssetGroup(assetNoMeta, assetGroup, nil) + h.expectAssetMeta(assetNoMeta, nil, nil) + }, + expectedHas: false, + }, + + // If we can't fetch the asset metadata, we return an error. + { + name: "asset metadata fetch error", + assetID: assetWithDelegation, + setupMocks: func(m *MockStorage) { + h := newAddrMockHelper(ctx, m) + h.expectAssetGroup( + assetWithDelegation, assetGroup, nil, + ) + h.expectAssetMeta( + assetWithDelegation, nil, + errMetadataError, + ) + }, + expectedHas: false, + expectedError: "failed to fetch asset meta", + }, + + // If the asset metadata has no delegation key, we conclude we + // didn't make it. + { + name: "asset with no delegation key in metadata", + assetID: assetWithoutDelegation, + setupMocks: func(m *MockStorage) { + h := newAddrMockHelper(ctx, m) + h.expectAssetGroup( + assetWithoutDelegation, assetGroup, nil, + ) + h.expectAssetMeta( + assetWithoutDelegation, + metaWithoutDelegation, nil, + ) + }, + expectedHas: false, + }, + + // If we can't fetch the delegation key locator, we return an + // error. + { + name: "key locator fetch error", + assetID: assetWithDelegation, + setupMocks: func(m *MockStorage) { + h := newAddrMockHelper(ctx, m) + h.expectAssetGroup( + assetWithDelegation, assetGroup, nil, + ) + h.expectAssetMeta( + assetWithDelegation, metaWithDelegation, + nil, + ) + h.expectKeyLocator( + delegationPubKey, keychain.KeyLocator{}, + errKeyFetchError, + ) + }, + expectedHas: false, + expectedError: "failed to fetch delegation key locator", + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + mockStorage := &MockStorage{} + mockKeyRing := &MockKeyRing{} + + // For each test, we'll create a new Book instance, + // then set up our mock expectations. + book := NewBook(BookConfig{ + Store: mockStorage, + KeyRing: mockKeyRing, + Chain: TestNet3Tap, + StoreTimeout: time.Second, + }) + tt.setupMocks(mockStorage) + + // The bulk of the interaction is handled by our mocks, + // so we'll cal with the args, then assert our + // expectations. + hasDelegation, err := book.HasDelegationKey( + ctx, tt.assetID, + ) + if tt.expectedError != "" { + require.Error(t, err) + require.Contains( + t, err.Error(), tt.expectedError, + ) + } else { + require.NoError(t, err) + } + + require.Equal(t, tt.expectedHas, hasDelegation) + + // Verify expectations + mockStorage.AssertExpectations(t) + mockKeyRing.AssertExpectations(t) + }) + } +} + // TestBook_QueryAssetInfo tests that we can query asset info, while mocking the // necessary dependencies. func TestBook_QueryAssetInfo(t *testing.T) { @@ -248,6 +529,9 @@ func (m *MockStorage) QueryAssetGroupByID(ctx context.Context, id asset.ID) (*asset.AssetGroup, error) { args := m.Called(ctx, id) + if args.Get(0) == nil { + return nil, args.Error(1) + } return args.Get(0).(*asset.AssetGroup), args.Error(1) } @@ -269,6 +553,9 @@ func (m *MockStorage) FetchAssetMetaForAsset(ctx context.Context, assetID asset.ID) (*proof.MetaReveal, error) { args := m.Called(ctx, assetID) + if args.Get(0) == nil { + return nil, args.Error(1) + } return args.Get(0).(*proof.MetaReveal), args.Error(1) } @@ -300,6 +587,16 @@ func (m *MockStorage) InsertScriptKey(ctx context.Context, return args.Error(0) } +func (m *MockStorage) FetchInternalKeyLocator(ctx context.Context, + rawKey *btcec.PublicKey) (keychain.KeyLocator, error) { + + args := m.Called(ctx, rawKey) + if args.Get(0) == nil { + return keychain.KeyLocator{}, args.Error(1) + } + return args.Get(0).(keychain.KeyLocator), args.Error(1) +} + // MockAssetSyncer is a mock implementation of the AssetSyncer interface. type MockAssetSyncer struct { mock.Mock diff --git a/itest/assertions.go b/itest/assertions.go index 10762b8b0..4e2e59b05 100644 --- a/itest/assertions.go +++ b/itest/assertions.go @@ -2756,3 +2756,57 @@ func LargestUtxo(t *testing.T, client taprpc.TaprootAssetsClient, return outputs[0] } + +// UpdateAndMineSupplyCommit updates the on-chain supply commitment for an asset +// group and mines the commitment transaction. +func UpdateAndMineSupplyCommit(t *testing.T, ctx context.Context, + tapd unirpc.UniverseClient, miner *rpcclient.Client, + groupKeyBytes []byte, expectedTxsInBlock int) []*wire.MsgBlock { + + groupKeyUpdate := &unirpc.UpdateSupplyCommitRequest_GroupKeyBytes{ + GroupKeyBytes: groupKeyBytes, + } + + respUpdate, err := tapd.UpdateSupplyCommit( + ctx, &unirpc.UpdateSupplyCommitRequest{ + GroupKey: groupKeyUpdate, + }, + ) + require.NoError(t, err) + require.NotNil(t, respUpdate) + + // Mine the supply commitment transaction. + minedBlocks := MineBlocks(t, miner, 1, expectedTxsInBlock) + require.Len(t, minedBlocks, 1) + + return minedBlocks +} + +// WaitForSupplyCommit waits for a supply commitment to be available and returns +// it when the specified condition is met. +func WaitForSupplyCommit(t *testing.T, ctx context.Context, + tapd unirpc.UniverseClient, groupKeyBytes []byte, + condition func(*unirpc.FetchSupplyCommitResponse) bool) *unirpc.FetchSupplyCommitResponse { + + groupKeyReq := &unirpc.FetchSupplyCommitRequest_GroupKeyBytes{ + GroupKeyBytes: groupKeyBytes, + } + + var fetchResp *unirpc.FetchSupplyCommitResponse + var err error + + require.Eventually(t, func() bool { + fetchResp, err = tapd.FetchSupplyCommit( + ctx, &unirpc.FetchSupplyCommitRequest{ + GroupKey: groupKeyReq, + }, + ) + if err != nil { + return false + } + + return fetchResp != nil && condition(fetchResp) + }, defaultWaitTimeout, time.Second) + + return fetchResp +} diff --git a/itest/supply_commit_mint_burn_test.go b/itest/supply_commit_mint_burn_test.go new file mode 100644 index 000000000..eaf0612b5 --- /dev/null +++ b/itest/supply_commit_mint_burn_test.go @@ -0,0 +1,273 @@ +package itest + +import ( + "bytes" + "context" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/chaincfg/chainhash" + taprootassets "github.com/lightninglabs/taproot-assets" + "github.com/lightninglabs/taproot-assets/fn" + "github.com/lightninglabs/taproot-assets/taprpc" + "github.com/lightninglabs/taproot-assets/taprpc/mintrpc" + unirpc "github.com/lightninglabs/taproot-assets/taprpc/universerpc" + "github.com/lightninglabs/taproot-assets/universe/supplycommit" + "github.com/stretchr/testify/require" +) + +// testSupplyCommitMintBurn tests that supply commitment trees are correctly +// updated when minting assets with group keys and burning outputs. It verifies: +// +// 1. Minting assets with EnableSupplyCommitments creates proper pre-commitment +// outputs and updates the supply tree with mint leaves. +// 2. Re-issuing assets to the same group updates the supply tree correctly. +// 3. Burning assets creates burn leaves in the supply tree with negative amounts. +// 4. All operations produce valid inclusion proofs that can be verified. +func testSupplyCommitMintBurn(t *harnessTest) { + ctxb := context.Background() + + t.Log("Minting initial asset group with universe/supply " + + "commitments enabled") + + // Create a mint request for a grouped asset with supply commitments. + mintReq := CopyRequest(issuableAssets[0]) + mintReq.Asset.Amount = 5000 + + t.Log("Minting asset with supply commitments and verifying " + + "pre-commitment") + + rpcFirstAsset, delegationKey := MintAssetWithSupplyCommit( + t, mintReq, fn.None[btcec.PublicKey](), + ) + + // Parse out the group key from the minted asset, we'll use this later. + groupKeyBytes := rpcFirstAsset.AssetGroup.TweakedGroupKey + require.NotNil(t.t, groupKeyBytes) + + // Update the on-chain supply commitment for the asset group. + // + // TODO(roasbeef): still rely on the time based ticker here? + t.Log("Updating and mining supply commitment for asset group") + UpdateAndMineSupplyCommit( + t.t, ctxb, t.tapd, t.lndHarness.Miner().Client, + groupKeyBytes, 1, + ) + + // Fetch the latest supply commitment for the asset group. + t.Log("Fetching supply commitment to verify mint leaves") + fetchResp := WaitForSupplyCommit( + t.t, ctxb, t.tapd, groupKeyBytes, + func(resp *unirpc.FetchSupplyCommitResponse) bool { + return resp.BlockHeight > 0 && len(resp.BlockHash) > 0 + }, + ) + + // Verify the issuance subtree root exists and has the correct amount. + require.NotNil(t.t, fetchResp.IssuanceSubtreeRoot) + require.Equal( + t.t, int64(mintReq.Asset.Amount), + fetchResp.IssuanceSubtreeRoot.RootNode.RootSum, + ) + + // Verify the issuance leaf inclusion in the supply tree. + AssertSubtreeInclusionProof( + t, fetchResp.SupplyCommitmentRoot.RootHash, + fetchResp.IssuanceSubtreeRoot, + ) + + // Now we'll mint a second asset into the same group, this tests that + // we're able to properly update the supply commitment with new mints. + t.Log("Minting second tranche into the same asset group") + + secondMintReq := &mintrpc.MintAssetRequest{ + Asset: &mintrpc.MintAsset{ + AssetType: taprpc.AssetType_NORMAL, + Name: "itestbuxx-supply-commit-tranche-2", + AssetMeta: &taprpc.AssetMeta{ + Data: []byte("second tranche metadata"), + }, + Amount: 3000, + AssetVersion: taprpc.AssetVersion_ASSET_VERSION_V1, //nolint:lll + NewGroupedAsset: false, + GroupedAsset: true, + GroupKey: groupKeyBytes, + EnableSupplyCommitments: true, + }, + } + rpcSecondAsset, _ := MintAssetWithSupplyCommit( + t, secondMintReq, fn.Some(delegationKey), + ) + + // Ensure both assets are in the same group. + require.EqualValues( + t.t, groupKeyBytes, + rpcSecondAsset.AssetGroup.TweakedGroupKey, + ) + + t.Log("Updating supply commitment after second mint") + + // Update and mine the supply commitment after second mint. + UpdateAndMineSupplyCommit( + t.t, ctxb, t.tapd, t.lndHarness.Miner().Client, + groupKeyBytes, 1, + ) + + t.Log("Verifying supply tree includes both mint operations") + + // Fetch and verify the updated supply includes both mints. + expectedTotal := int64( + mintReq.Asset.Amount + secondMintReq.Asset.Amount, + ) + fetchResp = WaitForSupplyCommit(t.t, ctxb, t.tapd, groupKeyBytes, + func(resp *unirpc.FetchSupplyCommitResponse) bool { + return resp.IssuanceSubtreeRoot != nil && + resp.IssuanceSubtreeRoot.RootNode.RootSum == expectedTotal + }, + ) + + // Finally, we'll test burning assets from the group, and ensure that + // the supply tree is updated with this information. + t.Log("Burning assets from the group") + + const ( + burnAmt = 1000 + burnNote = "supply commit burn test" + ) + + burnResp, err := t.tapd.BurnAsset(ctxb, &taprpc.BurnAssetRequest{ + Asset: &taprpc.BurnAssetRequest_AssetId{ + AssetId: rpcFirstAsset.AssetGenesis.AssetId, + }, + AmountToBurn: burnAmt, + Note: burnNote, + ConfirmationText: taprootassets.AssetBurnConfirmationText, + }) + require.NoError(t.t, err) + require.NotNil(t.t, burnResp) + + t.Log("Confirming burn transaction") + + // Confirm the burn transaction, asserting that all the expected records + // on disk are in place. + AssertAssetOutboundTransferWithOutputs( + t.t, t.lndHarness.Miner().Client, t.tapd, burnResp.BurnTransfer, + [][]byte{rpcFirstAsset.AssetGenesis.AssetId}, + []uint64{mintReq.Asset.Amount - burnAmt, burnAmt}, + 0, 1, 2, true, + ) + + // Make sure that the burn is recognized in the burn records. + burns := AssertNumBurns(t.t, t.tapd, 1, nil) + burn := burns[0] + require.Equal(t.t, uint64(burnAmt), burn.Amount) + require.Equal(t.t, burnNote, burn.Note) + + t.Log("Updating supply commitment after burn") + + // Update and mine the supply commitment after burn. + finalMinedBlocks := UpdateAndMineSupplyCommit( + t.t, ctxb, t.tapd, t.lndHarness.Miner().Client, + groupKeyBytes, 1, + ) + + t.Log("Verifying supply tree includes burn leaves") + + // Fetch and verify the supply tree now includes burn leaves. + fetchResp = WaitForSupplyCommit(t.t, ctxb, t.tapd, groupKeyBytes, + func(resp *unirpc.FetchSupplyCommitResponse) bool { //nolint:lll + return resp.BurnSubtreeRoot != nil && + resp.BurnSubtreeRoot.RootNode.RootSum == int64(burnAmt) + }, + ) + + // Verify the burn subtree inclusion in the supply tree. + AssertSubtreeInclusionProof( + t, fetchResp.SupplyCommitmentRoot.RootHash, + fetchResp.BurnSubtreeRoot, + ) + + t.Log("Fetching supply leaves for detailed verification") + + // Fetch supply leaves to verify individual entries have all been + // properly committed. + respLeaves, err := t.tapd.FetchSupplyLeaves( + ctxb, &unirpc.FetchSupplyLeavesRequest{ + GroupKey: &unirpc.FetchSupplyLeavesRequest_GroupKeyBytes{ + GroupKeyBytes: groupKeyBytes, + }, + }, + ) + require.NoError(t.t, err) + require.NotNil(t.t, respLeaves) + + // Verify we have the expected issuance leaves (2 mints), and a single + // burn leaf. + require.Equal( + t.t, len(respLeaves.IssuanceLeaves), 2, + "expected at least 2 issuance leaves", + ) + require.Equal( + t.t, len(respLeaves.BurnLeaves), 1, + "expected at least 1 burn leaf", + ) + + // Make sure that the burn leaf has the proper amount. + foundBurn := false + for _, burnLeaf := range respLeaves.BurnLeaves { + if burnLeaf.LeafNode.RootSum == int64(burnAmt) { + + foundBurn = true + + require.True(t.t, bytes.Equal( + rpcFirstAsset.AssetGenesis.AssetId, + burnLeaf.LeafKey.AssetId, + ), "burn leaf asset ID mismatch") + break + } + } + require.True(t.t, foundBurn, "expected burn leaf not found") + + // Finally, we'll verify that the final supply commitment has the + // pkScript that we expect. + require.Len(t.t, finalMinedBlocks, 1, "expected one mined block") + block := finalMinedBlocks[0] + blockHash, _ := t.lndHarness.Miner().GetBestBlock() + + fetchBlockHash, err := chainhash.NewHash(fetchResp.BlockHash) + require.NoError(t.t, err) + require.True(t.t, fetchBlockHash.IsEqual(blockHash)) + + // Re-compute the supply commitment root hash from the latest fetch, + // then use that to derive the expected commitment output. + supplyCommitRootHash := fn.ToArray[[32]byte]( + fetchResp.SupplyCommitmentRoot.RootHash, + ) + internalKey, err := btcec.ParsePubKey(fetchResp.AnchorTxOutInternalKey) + require.NoError(t.t, err) + expectedTxOut, _, err := supplycommit.RootCommitTxOut( + internalKey, nil, supplyCommitRootHash, + ) + require.NoError(t.t, err) + + foundCommitTxOut := false + for _, tx := range block.Transactions { + for _, txOut := range tx.TxOut { + pkScriptMatch := bytes.Equal( + txOut.PkScript, expectedTxOut.PkScript, + ) + if txOut.Value == expectedTxOut.Value && pkScriptMatch { + foundCommitTxOut = true + break + } + } + if foundCommitTxOut { + break + } + } + require.True( + t.t, foundCommitTxOut, + "supply commitment tx output not found in block", + ) + + t.Log("Supply commit mint and burn test completed successfully") +} diff --git a/itest/supply_commit_test.go b/itest/supply_commit_test.go index 1d2372f1a..744e89f2c 100644 --- a/itest/supply_commit_test.go +++ b/itest/supply_commit_test.go @@ -450,6 +450,8 @@ func testSupplyCommitIgnoreAsset(t *harnessTest) { func AssertInclusionProof(t *harnessTest, expectedRootHash [32]byte, inclusionProofBytes []byte, leafKey [32]byte, leafNode mssmt.Node) { + t.t.Helper() + // Decode the inclusion proof bytes into a compressed proof. var compressedProof mssmt.CompressedProof err := compressedProof.Decode(bytes.NewReader(inclusionProofBytes)) @@ -469,3 +471,53 @@ func AssertInclusionProof(t *harnessTest, expectedRootHash [32]byte, expectedRootHash[:], derivedRootHash) } } + +// AssertSubtreeInclusionProof verifies that a subtree is properly included in +// the supply commitment tree by checking the inclusion proof. +func AssertSubtreeInclusionProof(t *harnessTest, + supplyRootHash []byte, subtreeRoot *unirpc.SupplyCommitSubtreeRoot) { + + require.NotNil(t.t, subtreeRoot) + + // Convert to fixed-size arrays for verification. + rootHash := fn.ToArray[[32]byte](supplyRootHash) + leafKey := fn.ToArray[[32]byte](subtreeRoot.SupplyTreeLeafKey) + + // Create the leaf node for the subtree. + leafNode := mssmt.NewLeafNode( + subtreeRoot.RootNode.RootHash, + uint64(subtreeRoot.RootNode.RootSum), + ) + + // Verify the inclusion proof. + AssertInclusionProof( + t, rootHash, + subtreeRoot.SupplyTreeInclusionProof, + leafKey, leafNode, + ) +} + +// MintAssetWithSupplyCommit mints an asset with supply commitments enabled +// and verifies the pre-commitment output. +func MintAssetWithSupplyCommit(t *harnessTest, + mintReq *mintrpc.MintAssetRequest, + expectedDelegationKey fn.Option[btcec.PublicKey]) (*taprpc.Asset, btcec.PublicKey) { + + // Ensure supply commitments are enabled. + mintReq.Asset.EnableSupplyCommitments = true + + // Mint the asset. + rpcAssets := MintAssetsConfirmBatch( + t.t, t.lndHarness.Miner().Client, t.tapd, + []*mintrpc.MintAssetRequest{mintReq}, + ) + require.Len(t.t, rpcAssets, 1, "expected one minted asset") + rpcAsset := rpcAssets[0] + + // Verify the pre-commitment output. + delegationKey := assertAnchorTxPreCommitOut( + t, t.tapd, rpcAsset, expectedDelegationKey, + ) + + return rpcAsset, delegationKey +} diff --git a/itest/test_list_on_test.go b/itest/test_list_on_test.go index d3f32bdb7..3707e3167 100644 --- a/itest/test_list_on_test.go +++ b/itest/test_list_on_test.go @@ -355,6 +355,10 @@ var allTestCases = []*testCase{ name: "supply commit ignore asset", test: testSupplyCommitIgnoreAsset, }, + { + name: "supply commit mint burn", + test: testSupplyCommitMintBurn, + }, { name: "auth mailbox message store and fetch", test: testAuthMailboxStoreAndFetchMessage, diff --git a/tapcfg/server.go b/tapcfg/server.go index 8469806fe..ba8fe92e1 100644 --- a/tapcfg/server.go +++ b/tapcfg/server.go @@ -476,6 +476,57 @@ func genServerConfig(cfg *Config, cfgLogger btclog.Logger, return nil, err } + auxLeafSigner := tapchannel.NewAuxLeafSigner( + &tapchannel.LeafSignerConfig{ + ChainParams: &tapChainParams, + Signer: assetWallet, + }, + ) + channelFunder := lndservices.NewLndPbstChannelFunder(lndServices) + + // Parse the universe public access status. + universePublicAccess, err := tap.ParseUniversePublicAccessStatus( + cfg.Universe.PublicAccess, + ) + if err != nil { + return nil, fmt.Errorf("unable to parse universe public "+ + "access status: %w", err) + } + + // Construct the supply commit manager, which is used to + // formulate universe supply commitment transactions. + // + // Construct database backends for the supply commitment state machines. + supplyCommitDb := tapdb.NewTransactionExecutor( + db, func(tx *sql.Tx) tapdb.SupplyCommitStore { + return db.WithTx(tx) + }, + ) + supplyCommitStore := tapdb.NewSupplyCommitMachine(supplyCommitDb) + + // Construct the supply tree database backend. + supplyTreeDb := tapdb.NewTransactionExecutor( + db, func(tx *sql.Tx) tapdb.BaseUniverseStore { + return db.WithTx(tx) + }, + ) + supplyTreeStore := tapdb.NewSupplyTreeStore(supplyTreeDb) + + // Create the supply commitment state machine manager, which is used to + // manage the supply commitment state machines for each asset group. + supplyCommitManager := supplycommit.NewMultiStateMachineManager( + supplycommit.MultiStateMachineManagerCfg{ + TreeView: supplyTreeStore, + Commitments: supplyCommitStore, + Wallet: walletAnchor, + KeyRing: keyRing, + Chain: chainBridge, + DaemonAdapters: lndFsmDaemonAdapters, + StateLog: supplyCommitStore, + ChainParams: *tapChainParams.Params, + }, + ) + // For the porter, we'll make a multi-notifier comprised of all the // possible proof file sources to ensure it can always fetch input // proofs. @@ -500,16 +551,11 @@ func genServerConfig(cfg *Config, cfgLogger btclog.Logger, ProofCourierDispatcher: proofCourierDispatcher, ProofWatcher: reOrgWatcher, ErrChan: mainErrChan, + BurnCommitter: supplyCommitManager, + DelegationKeyChecker: addrBook, }, ) - auxLeafSigner := tapchannel.NewAuxLeafSigner( - &tapchannel.LeafSignerConfig{ - ChainParams: &tapChainParams, - Signer: assetWallet, - }, - ) - channelFunder := lndservices.NewLndPbstChannelFunder(lndServices) auxFundingController := tapchannel.NewFundingController( tapchannel.FundingControllerCfg{ HeaderVerifier: headerVerifier, @@ -581,49 +627,6 @@ func genServerConfig(cfg *Config, cfgLogger btclog.Logger, }, ) - // Parse the universe public access status. - universePublicAccess, err := tap.ParseUniversePublicAccessStatus( - cfg.Universe.PublicAccess, - ) - if err != nil { - return nil, fmt.Errorf("unable to parse universe public "+ - "access status: %w", err) - } - - // Construct the supply commit manager, which is used to - // formulate universe supply commitment transactions. - // - // Construct database backends for the supply commitment state machines. - supplyCommitDb := tapdb.NewTransactionExecutor( - db, func(tx *sql.Tx) tapdb.SupplyCommitStore { - return db.WithTx(tx) - }, - ) - supplyCommitStore := tapdb.NewSupplyCommitMachine(supplyCommitDb) - - // Construct the supply tree database backend. - supplyTreeDb := tapdb.NewTransactionExecutor( - db, func(tx *sql.Tx) tapdb.BaseUniverseStore { - return db.WithTx(tx) - }, - ) - supplyTreeStore := tapdb.NewSupplyTreeStore(supplyTreeDb) - - // Create the supply commitment state machine manager, which is used to - // manage the supply commitment state machines for each asset group. - supplyCommitManager := supplycommit.NewMultiStateMachineManager( - supplycommit.MultiStateMachineManagerCfg{ - TreeView: supplyTreeStore, - Commitments: supplyCommitStore, - Wallet: walletAnchor, - KeyRing: keyRing, - Chain: chainBridge, - DaemonAdapters: lndFsmDaemonAdapters, - StateLog: supplyCommitStore, - ChainParams: *tapChainParams.Params, - }, - ) - return &tap.Config{ DebugLevel: cfg.DebugLevel, RuntimeID: runtimeID, @@ -645,6 +648,8 @@ func genServerConfig(cfg *Config, cfgLogger btclog.Logger, Universe: universeFederation, ProofWatcher: reOrgWatcher, UniversePushBatchSize: defaultUniverseSyncBatchSize, + MintSupplyCommitter: supplyCommitManager, + DelegationKeyChecker: addrBook, }, ChainParams: tapChainParams, ProofUpdates: proofArchive, diff --git a/tapdb/asset_minting.go b/tapdb/asset_minting.go index 2e830978f..d394d5ca1 100644 --- a/tapdb/asset_minting.go +++ b/tapdb/asset_minting.go @@ -439,12 +439,22 @@ func insertMintAnchorTx(ctx context.Context, q PendingAssetStore, }, ) + outPoint := wire.OutPoint{ + Hash: anchorPackage.Pkt.UnsignedTx.TxHash(), + Index: uint32(preCommitOut.OutIdx), + } + outPointBytes, err := encodeOutpoint(outPoint) + if err != nil { + return fmt.Errorf("unable to encode outpoint: %w", err) + } + _, err = q.UpsertMintAnchorUniCommitment( ctx, MintAnchorUniCommitParams{ BatchKey: rawBatchKey, TxOutputIndex: int32(preCommitOut.OutIdx), TaprootInternalKeyID: internalKeyID, GroupKey: groupPubKey, + Outpoint: outPointBytes, }, ) if err != nil { @@ -1625,12 +1635,22 @@ func upsertPreCommit(ctx context.Context, q PendingAssetStore, }, ) + outPoint := wire.OutPoint{ + Hash: genesisPkt.Pkt.UnsignedTx.TxHash(), + Index: uint32(preCommit.OutIdx), + } + outPointBytes, err := encodeOutpoint(outPoint) + if err != nil { + return fmt.Errorf("unable to encode outpoint: %w", err) + } + _, err = q.UpsertMintAnchorUniCommitment( ctx, MintAnchorUniCommitParams{ BatchKey: batchKey, TxOutputIndex: int32(preCommit.OutIdx), TaprootInternalKeyID: internalKeyID, GroupKey: groupPubKeyBytes, + Outpoint: outPointBytes, }, ) if err != nil { diff --git a/tapdb/migrations.go b/tapdb/migrations.go index 51c3d523f..74fcc4187 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 = 43 + LatestMigrationVersion = 44 ) // DatabaseBackend is an interface that contains all methods our different diff --git a/tapdb/sqlc/assets.sql.go b/tapdb/sqlc/assets.sql.go index bf1d924e3..20e0e1697 100644 --- a/tapdb/sqlc/assets.sql.go +++ b/tapdb/sqlc/assets.sql.go @@ -3238,19 +3238,20 @@ WITH target_batch AS ( -- internal key associated with the batch. SELECT keys.key_id AS batch_id FROM internal_keys keys - WHERE keys.raw_key = $5 + WHERE keys.raw_key = $6 ) INSERT INTO mint_anchor_uni_commitments ( - batch_id, tx_output_index, taproot_internal_key_id, group_key, spent_by + batch_id, tx_output_index, taproot_internal_key_id, group_key, spent_by, outpoint ) VALUES ( (SELECT batch_id FROM target_batch), $1, - $2, $3, $4 + $2, $3, $4, $5 ) ON CONFLICT(batch_id, tx_output_index) DO UPDATE SET -- The following fields are updated if a conflict occurs. taproot_internal_key_id = EXCLUDED.taproot_internal_key_id, - group_key = EXCLUDED.group_key + group_key = EXCLUDED.group_key, + outpoint = EXCLUDED.outpoint RETURNING id ` @@ -3259,6 +3260,7 @@ type UpsertMintAnchorUniCommitmentParams struct { TaprootInternalKeyID int64 GroupKey []byte SpentBy sql.NullInt64 + Outpoint []byte BatchKey []byte } @@ -3271,6 +3273,7 @@ func (q *Queries) UpsertMintAnchorUniCommitment(ctx context.Context, arg UpsertM arg.TaprootInternalKeyID, arg.GroupKey, arg.SpentBy, + arg.Outpoint, arg.BatchKey, ) var id int64 diff --git a/tapdb/sqlc/migrations/000044_mint_anchor_outpoint.down.sql b/tapdb/sqlc/migrations/000044_mint_anchor_outpoint.down.sql new file mode 100644 index 000000000..29b2b6640 --- /dev/null +++ b/tapdb/sqlc/migrations/000044_mint_anchor_outpoint.down.sql @@ -0,0 +1,3 @@ +-- Remove the outpoint column and its index +DROP INDEX IF EXISTS mint_anchor_uni_commitments_outpoint_idx; +ALTER TABLE mint_anchor_uni_commitments DROP COLUMN outpoint; \ No newline at end of file diff --git a/tapdb/sqlc/migrations/000044_mint_anchor_outpoint.up.sql b/tapdb/sqlc/migrations/000044_mint_anchor_outpoint.up.sql new file mode 100644 index 000000000..a9893ae82 --- /dev/null +++ b/tapdb/sqlc/migrations/000044_mint_anchor_outpoint.up.sql @@ -0,0 +1,9 @@ +-- Add outpoint field to mint_anchor_uni_commitments to track the exact UTXO +-- This allows precise marking of spent pre-commitments using the transaction inputs +ALTER TABLE mint_anchor_uni_commitments + ADD COLUMN outpoint BLOB; + +-- Create an index for efficient lookups by outpoint +CREATE INDEX mint_anchor_uni_commitments_outpoint_idx + ON mint_anchor_uni_commitments(outpoint) + WHERE outpoint IS NOT NULL; \ No newline at end of file diff --git a/tapdb/sqlc/models.go b/tapdb/sqlc/models.go index 14a3ad37d..97d4f3c89 100644 --- a/tapdb/sqlc/models.go +++ b/tapdb/sqlc/models.go @@ -311,6 +311,7 @@ type MintAnchorUniCommitment struct { GroupKey []byte TaprootInternalKeyID int64 SpentBy sql.NullInt64 + Outpoint []byte } type MssmtNode struct { diff --git a/tapdb/sqlc/querier.go b/tapdb/sqlc/querier.go index 6a500c18c..08c71b1b3 100644 --- a/tapdb/sqlc/querier.go +++ b/tapdb/sqlc/querier.go @@ -142,6 +142,8 @@ type Querier interface { LinkDanglingSupplyUpdateEvents(ctx context.Context, arg LinkDanglingSupplyUpdateEventsParams) error LogProofTransferAttempt(ctx context.Context, arg LogProofTransferAttemptParams) error LogServerSync(ctx context.Context, arg LogServerSyncParams) error + // Mark a specific pre-commitment output as spent by its outpoint. + MarkPreCommitmentSpentByOutpoint(ctx context.Context, arg MarkPreCommitmentSpentByOutpointParams) error NewMintingBatch(ctx context.Context, arg NewMintingBatchParams) error QueryAddr(ctx context.Context, arg QueryAddrParams) (QueryAddrRow, error) // We use a LEFT JOIN here as not every asset has a group key, so this'll diff --git a/tapdb/sqlc/queries/assets.sql b/tapdb/sqlc/queries/assets.sql index 08bd5f368..c1eaef847 100644 --- a/tapdb/sqlc/queries/assets.sql +++ b/tapdb/sqlc/queries/assets.sql @@ -1073,16 +1073,17 @@ WITH target_batch AS ( WHERE keys.raw_key = @batch_key ) INSERT INTO mint_anchor_uni_commitments ( - batch_id, tx_output_index, taproot_internal_key_id, group_key, spent_by + batch_id, tx_output_index, taproot_internal_key_id, group_key, spent_by, outpoint ) VALUES ( (SELECT batch_id FROM target_batch), @tx_output_index, - @taproot_internal_key_id, @group_key, sqlc.narg('spent_by') + @taproot_internal_key_id, @group_key, sqlc.narg('spent_by'), sqlc.narg('outpoint') ) ON CONFLICT(batch_id, tx_output_index) DO UPDATE SET -- The following fields are updated if a conflict occurs. taproot_internal_key_id = EXCLUDED.taproot_internal_key_id, - group_key = EXCLUDED.group_key + group_key = EXCLUDED.group_key, + outpoint = EXCLUDED.outpoint RETURNING id; -- name: FetchMintAnchorUniCommitment :many diff --git a/tapdb/sqlc/queries/supply_commit.sql b/tapdb/sqlc/queries/supply_commit.sql index 102cfcfc8..bb6291e7a 100644 --- a/tapdb/sqlc/queries/supply_commit.sql +++ b/tapdb/sqlc/queries/supply_commit.sql @@ -172,6 +172,13 @@ WHERE mac.group_key = @group_key AND (mac.spent_by IS NULL OR commit_txn.block_hash IS NULL); +-- name: MarkPreCommitmentSpentByOutpoint :exec +-- Mark a specific pre-commitment output as spent by its outpoint. +UPDATE mint_anchor_uni_commitments +SET spent_by = @spent_by_commit_id +WHERE outpoint = @outpoint + AND spent_by IS NULL; + -- name: FetchSupplyCommit :one SELECT sc.commit_id, diff --git a/tapdb/sqlc/schemas/generated_schema.sql b/tapdb/sqlc/schemas/generated_schema.sql index 64af43c8b..d61be7eb6 100644 --- a/tapdb/sqlc/schemas/generated_schema.sql +++ b/tapdb/sqlc/schemas/generated_schema.sql @@ -637,7 +637,11 @@ CREATE TABLE mint_anchor_uni_commitments ( group_key BLOB , taproot_internal_key_id BIGINT REFERENCES internal_keys(key_id) -NOT NULL, spent_by BIGINT REFERENCES supply_commitments(commit_id)); +NOT NULL, spent_by BIGINT REFERENCES supply_commitments(commit_id), outpoint BLOB); + +CREATE INDEX mint_anchor_uni_commitments_outpoint_idx + ON mint_anchor_uni_commitments(outpoint) + WHERE outpoint IS NOT NULL; CREATE UNIQUE INDEX mint_anchor_uni_commitments_unique ON mint_anchor_uni_commitments (batch_id, tx_output_index); diff --git a/tapdb/sqlc/supply_commit.sql.go b/tapdb/sqlc/supply_commit.sql.go index a7213954a..efbac58f3 100644 --- a/tapdb/sqlc/supply_commit.sql.go +++ b/tapdb/sqlc/supply_commit.sql.go @@ -328,6 +328,24 @@ func (q *Queries) LinkDanglingSupplyUpdateEvents(ctx context.Context, arg LinkDa return err } +const MarkPreCommitmentSpentByOutpoint = `-- name: MarkPreCommitmentSpentByOutpoint :exec +UPDATE mint_anchor_uni_commitments +SET spent_by = $1 +WHERE outpoint = $2 + AND spent_by IS NULL +` + +type MarkPreCommitmentSpentByOutpointParams struct { + SpentByCommitID sql.NullInt64 + Outpoint []byte +} + +// Mark a specific pre-commitment output as spent by its outpoint. +func (q *Queries) MarkPreCommitmentSpentByOutpoint(ctx context.Context, arg MarkPreCommitmentSpentByOutpointParams) error { + _, err := q.db.ExecContext(ctx, MarkPreCommitmentSpentByOutpoint, arg.SpentByCommitID, arg.Outpoint) + return err +} + const QueryDanglingSupplyUpdateEvents = `-- name: QueryDanglingSupplyUpdateEvents :many SELECT ue.event_id, diff --git a/tapdb/supply_commit.go b/tapdb/supply_commit.go index 9ad06169b..0bccc65ee 100644 --- a/tapdb/supply_commit.go +++ b/tapdb/supply_commit.go @@ -198,6 +198,11 @@ type SupplyCommitStore interface { FinalizeSupplyCommitTransition(ctx context.Context, transitionID int64) error + // MarkPreCommitmentSpentByOutpoint marks a pre-commitment as spent + // by its outpoint. + MarkPreCommitmentSpentByOutpoint(ctx context.Context, + arg sqlc.MarkPreCommitmentSpentByOutpointParams) error + // QueryExistingPendingTransition fetches the ID of an existing // non-finalized transition for a group key. Returns sql.ErrNoRows if // none exists. @@ -850,7 +855,7 @@ func (s *SupplyCommitMachine) InsertSignedCommitTx(ctx context.Context, groupKeyBytes := groupKey.SerializeCompressed() commitTx := commitDetails.Txn - internalKey := commitDetails.InternalKey + internalKeyDesc := commitDetails.InternalKey outputKey := commitDetails.OutputKey outputIndex := commitDetails.OutputIndex @@ -892,15 +897,17 @@ func (s *SupplyCommitMachine) InsertSignedCommitTx(ctx context.Context, "%w", err) } - // Upsert the internal key to get its ID. We assume key family - // and index 0 for now, as this key is likely externally. + // Upsert the internal key to get its ID, preserving the full + // key derivation information for proper PSBT signing later. internalKeyID, err := db.UpsertInternalKey(ctx, InternalKey{ - RawKey: internalKey.SerializeCompressed(), + RawKey: internalKeyDesc.PubKey.SerializeCompressed(), + KeyFamily: int32(internalKeyDesc.Family), + KeyIndex: int32(internalKeyDesc.Index), }) if err != nil { return fmt.Errorf("failed to upsert internal key %x: "+ "%w", - internalKey.SerializeCompressed(), err) + internalKeyDesc.PubKey.SerializeCompressed(), err) } // Insert the new commitment record. Chain details (block @@ -1321,16 +1328,6 @@ func (s *SupplyCommitMachine) ApplyStateTransition( dbTransition := dbTransitionRow.SupplyCommitTransition transitionID := dbTransition.TransitionID - // Next, we'll apply all the pending updates to the supply - // sub-trees, then use that to update the root tree. - _, err = applySupplyUpdatesInternal( - ctx, db, assetSpec, transition.PendingUpdates, - ) - if err != nil { - return fmt.Errorf("failed to apply SMT updates: "+ - "%w", err) - } - // Next, we'll update the supply commitment data, before we do // that, perform some basic sanity checks. if !dbTransition.NewCommitmentID.Valid { @@ -1344,8 +1341,9 @@ func (s *SupplyCommitMachine) ApplyStateTransition( } chainTxnID := dbTransition.PendingCommitTxnID.Int64 - // Update the commitment record with the calculated root hash - // and sum. + // Next, we'll apply all the pending updates to the supply + // sub-trees, then use that to update the root tree. + // finalRootSupplyRoot, err := applySupplyUpdatesInternal( ctx, db, assetSpec, transition.PendingUpdates, ) @@ -1353,6 +1351,9 @@ func (s *SupplyCommitMachine) ApplyStateTransition( return fmt.Errorf("failed to apply SMT updates: "+ "%w", err) } + + // Update the commitment record with the calculated root hash + // and sum. finalRootHash := finalRootSupplyRoot.NodeHash() finalRootSum := finalRootSupplyRoot.NodeSum() err = db.UpdateSupplyCommitmentRoot( @@ -1436,6 +1437,51 @@ func (s *SupplyCommitMachine) ApplyStateTransition( "confirmation: %w", err) } + // Mark the specific pre-commitments that were spent in this + // transaction as spent by the new commitment. We identify them + // by looking at the transaction inputs. + for _, txIn := range newCommitment.Txn.TxIn { + // Encode the outpoint to match the format stored in the DB. + outpointBytes, err := encodeOutpoint( + txIn.PreviousOutPoint, + ) + if err != nil { + return fmt.Errorf("failed to encode "+ + "outpoint %v: %w", + txIn.PreviousOutPoint, err) + } + + log.Infof("Attempting to mark outpoint as "+ + "spent: %v (hash=%x, index=%d)", + txIn.PreviousOutPoint, + txIn.PreviousOutPoint.Hash[:], + txIn.PreviousOutPoint.Index) + + // Mark this specific pre-commitment as spent. + err = db.MarkPreCommitmentSpentByOutpoint(ctx, + sqlc.MarkPreCommitmentSpentByOutpointParams{ + SpentByCommitID: sqlInt64( + newCommitmentID, + ), + Outpoint: outpointBytes, + }, + ) + if err != nil { + // It's OK if this outpoint doesn't exist in our + // table - it might be an old commitment output + // or a wallet input for fees. We only care + // about marking actual pre-commitments as + // spent. + log.Debugf("Could not mark outpoint %v as "+ + "spent (may not be a "+ + "pre-commitment): %v", + txIn.PreviousOutPoint, err) + } else { + log.Infof("Successfully marked outpoint "+ + "as spent: %v", txIn.PreviousOutPoint) + } + } + // To finish up our book keeping, we'll now finalize the state // transition on disk. err = db.FinalizeSupplyCommitTransition(ctx, transitionID) diff --git a/tapdb/supply_commit_test.go b/tapdb/supply_commit_test.go index 78111c6d0..093e28e93 100644 --- a/tapdb/supply_commit_test.go +++ b/tapdb/supply_commit_test.go @@ -20,6 +20,7 @@ import ( "github.com/lightninglabs/taproot-assets/tapdb/sqlc" "github.com/lightninglabs/taproot-assets/universe/supplycommit" lfn "github.com/lightningnetwork/lnd/fn/v2" + "github.com/lightningnetwork/lnd/keychain" "github.com/lightningnetwork/lnd/lnutils" "github.com/stretchr/testify/require" ) @@ -126,8 +127,8 @@ func (h *supplyCommitTestHarness) addTestMintingBatch() ([]byte, int64, Txid: mintTxID[:], RawTx: mintTxBytes, }) - require.NoError(h.t, err) + require.NoError(h.t, err) genesisPointBytes, err := encodeOutpoint(genesisPoint) require.NoError(h.t, err) err = db.AnchorGenesisPoint(ctx, sqlc.AnchorGenesisPointParams{ @@ -158,7 +159,7 @@ func (h *supplyCommitTestHarness) addTestMintingBatch() ([]byte, int64, // transition performed by performSingleTransition. type stateTransitionOutput struct { appliedUpdates []supplycommit.SupplyUpdateEvent - internalKey *btcec.PublicKey + internalKey keychain.KeyDescriptor outputKey *btcec.PublicKey commitTx *wire.MsgTx chainProof supplycommit.ChainProof @@ -212,9 +213,9 @@ func newSupplyCommitTestHarness(t *testing.T) *supplyCommitTestHarness { } // addTestMintAnchorUniCommitment inserts a mint_anchor_uni_commitments record -// using harness data. +// using harness data and returns both the commitment ID and the outpoint. func (h *supplyCommitTestHarness) addTestMintAnchorUniCommitment( - batchKeyBytes []byte, spentBy sql.NullInt64) int64 { + batchKeyBytes []byte, spentBy sql.NullInt64, mintTxid chainhash.Hash) (int64, wire.OutPoint) { h.t.Helper() @@ -228,7 +229,16 @@ func (h *supplyCommitTestHarness) addTestMintAnchorUniCommitment( ) require.NoError(h.t, err) - txOutputIndex := int32(test.RandInt[uint32]() % 100) + txOutputIndex := int32(test.RandInt[uint32]()) + + outpoint := wire.OutPoint{ + Hash: mintTxid, + Index: uint32(txOutputIndex), + } + + var outpointBuf bytes.Buffer + err = wire.WriteOutPoint(&outpointBuf, 0, 0, &outpoint) + require.NoError(h.t, err) anchorCommitID, err := h.db.UpsertMintAnchorUniCommitment( h.ctx, sqlc.UpsertMintAnchorUniCommitmentParams{ @@ -237,11 +247,12 @@ func (h *supplyCommitTestHarness) addTestMintAnchorUniCommitment( TaprootInternalKeyID: internalKeyID, GroupKey: h.groupKeyBytes, SpentBy: spentBy, + Outpoint: outpointBuf.Bytes(), }, ) require.NoError(h.t, err) - return anchorCommitID + return anchorCommitID, outpoint } // currentState fetches the current state of the state machine via FetchState. @@ -687,7 +698,8 @@ func (h *supplyCommitTestHarness) confirmChainTx(txID int64, txidBytes, // list of updates applied, the generated keys, the commit TX, and the simulated // chain proof details for assertion purposes. func (h *supplyCommitTestHarness) performSingleTransition( - updates []supplycommit.SupplyUpdateEvent) stateTransitionOutput { + updates []supplycommit.SupplyUpdateEvent, + preCommitOutpoints []wire.OutPoint) stateTransitionOutput { h.t.Helper() @@ -709,6 +721,14 @@ func (h *supplyCommitTestHarness) performSingleTransition( // Next, we'll generate a new "fake" commitment transaction along with // sample internal and output keys. commitTx := randTx(h.t, 1) + + // Add inputs to the transaction that spend the pre-commitment outputs. + for _, outpoint := range preCommitOutpoints { + commitTx.TxIn = append(commitTx.TxIn, &wire.TxIn{ + PreviousOutPoint: outpoint, + }) + } + internalKey, _ := test.RandKeyDesc(h.t) outputKey := test.RandPubKey(h.t) @@ -717,7 +737,7 @@ func (h *supplyCommitTestHarness) performSingleTransition( // and commit that. commitDetails := supplycommit.SupplyCommitTxn{ Txn: commitTx, - InternalKey: internalKey.PubKey, + InternalKey: internalKey, OutputKey: outputKey, OutputIndex: 1, } @@ -792,7 +812,7 @@ func (h *supplyCommitTestHarness) performSingleTransition( return stateTransitionOutput{ appliedUpdates: updates, - internalKey: internalKey.PubKey, + internalKey: internalKey, outputKey: outputKey, commitTx: commitTx, chainProof: chainProof, @@ -999,7 +1019,7 @@ func (h *supplyCommitTestHarness) assertTransitionApplied( // The keys should also be inserted, and the db commitment should match // what we inserted. require.Equal( - h.t, internalKey.SerializeCompressed(), + h.t, internalKey.PubKey.SerializeCompressed(), h.fetchInternalKeyByID(dbCommitment.InternalKeyID).RawKey, "internalKey mismatch", ) @@ -1143,7 +1163,7 @@ func (h *supplyCommitTestHarness) assertTransitionApplied( "SupplyCommit returned wrong Txn hash", ) require.Equal( - h.t, output.internalKey.SerializeCompressed(), + h.t, output.internalKey.PubKey.SerializeCompressed(), fetchedCommit.InternalKey.PubKey.SerializeCompressed(), "SupplyCommit returned wrong InternalKey", ) @@ -1255,7 +1275,8 @@ func TestBindDanglingUpdatesToTransition(t *testing.T) { // To create dangling updates, we first need a state machine and a // finalized transition. updates1 := []supplycommit.SupplyUpdateEvent{h.randMintEvent()} - stateTransition1 := h.performSingleTransition(updates1) + // Pass empty outpoints since this test doesn't need pre-commitments + stateTransition1 := h.performSingleTransition(updates1, []wire.OutPoint{}) h.assertTransitionApplied(stateTransition1) // Now, with the machine in DefaultState, we'll manually insert some @@ -1382,7 +1403,7 @@ func TestSupplyCommitInsertSignedCommitTx(t *testing.T) { commitTxid2 := commitTx2.TxHash() // Insert the signed commitment with the updated transaction. - internalKey := test.RandPubKey(t) + internalKey, _ := test.RandKeyDesc(t) outputKey := test.RandPubKey(t) commitDetails := supplycommit.SupplyCommitTxn{ Txn: commitTx2, @@ -1421,7 +1442,7 @@ func TestSupplyCommitInsertSignedCommitTx(t *testing.T) { require.NoError(t, err) require.Equal(t, chainTx2Record.TxnID, newDbCommitment.ChainTxnID) require.Equal(t, - internalKey.SerializeCompressed(), + internalKey.PubKey.SerializeCompressed(), h.fetchInternalKeyByID(newDbCommitment.InternalKeyID).RawKey, ) require.Equal( @@ -1444,7 +1465,7 @@ func TestSupplyCommitInsertSignedCommitTx(t *testing.T) { t, commitTxid2, fetchedTransition.NewCommitment.Txn.TxHash(), ) require.Equal( - t, internalKey.SerializeCompressed(), + t, internalKey.PubKey.SerializeCompressed(), fetchedTransition.NewCommitment.InternalKey.PubKey.SerializeCompressed(), //nolint:lll ) require.Equal( @@ -1558,7 +1579,7 @@ func TestSupplyCommitFetchState(t *testing.T) { // Next, we'll insert a signed commitment transaction. commitTx := randTx(t, 1) - internalKey := test.RandPubKey(t) + internalKey, _ := test.RandKeyDesc(t) outputKey := test.RandPubKey(t) commitDetails := supplycommit.SupplyCommitTxn{ @@ -1640,6 +1661,46 @@ func TestSupplyCommitApplyStateTransition(t *testing.T) { h := newSupplyCommitTestHarness(t) + // First, let's create some pre-commitments that should be spent + // when we apply the state transition. We'll create mint transactions + // and track their outpoints. + batchKeyBytes1, _, _, mintTxidBytes1, _ := h.addTestMintingBatch() + var mintTxid1 chainhash.Hash + copy(mintTxid1[:], mintTxidBytes1) + _, outpoint1 := h.addTestMintAnchorUniCommitment( + batchKeyBytes1, sql.NullInt64{}, mintTxid1, + ) + batchKeyBytes2, _, _, mintTxidBytes2, _ := h.addTestMintingBatch() + var mintTxid2 chainhash.Hash + copy(mintTxid2[:], mintTxidBytes2) + _, outpoint2 := h.addTestMintAnchorUniCommitment( + batchKeyBytes2, sql.NullInt64{}, mintTxid2, + ) + + // Create an additional pre-commitment that should NOT be spent + // This tests that we're only marking the specific pre-commitments + // referenced in the transaction inputs as spent. + batchKeyBytesExtra, _, _, mintTxidBytesExtra, _ := h.addTestMintingBatch() + var mintTxidExtra chainhash.Hash + copy(mintTxidExtra[:], mintTxidBytesExtra) + _, outpointExtra := h.addTestMintAnchorUniCommitment( + batchKeyBytesExtra, sql.NullInt64{}, mintTxidExtra, + ) + + // Collect only the first two outpoints for the transaction inputs + // The extra one should remain unspent + preCommitOutpoints := []wire.OutPoint{outpoint1, outpoint2} + + // Verify we have all three unspent pre-commitments before the + // transition. + precommitsRes := h.commitMachine.UnspentPrecommits(h.ctx, h.assetSpec) + precommits, err := precommitsRes.Unpack() + require.NoError(t, err) + require.Len( + t, precommits, 3, "should have 3 unspent pre-commitments "+ + "before transition", + ) + // To kick off our test, we'll perform a single state transition. This // entails: adding a set of pending updates, committing the signed // commit tx, and finally applying the state transition. After @@ -1649,17 +1710,70 @@ func TestSupplyCommitApplyStateTransition(t *testing.T) { updates1 := []supplycommit.SupplyUpdateEvent{ h.randMintEvent(), h.randBurnEvent(), } - stateTransition1 := h.performSingleTransition(updates1) + stateTransition1 := h.performSingleTransition(updates1, preCommitOutpoints) h.assertTransitionApplied(stateTransition1) + // After the first transition, only the two pre-commitments that were + // included in the transaction inputs should be marked as spent. + // The extra pre-commitment should remain unspent. + precommitsRes = h.commitMachine.UnspentPrecommits(h.ctx, h.assetSpec) + precommits, err = precommitsRes.Unpack() + require.NoError(t, err) + require.Len( + t, precommits, 1, "should have 1 unspent pre-commitment after "+ + "first transition (the one not included in tx inputs)", + ) + + // Verify that the remaining unspent pre-commitment is the extra one + // by checking its outpoint + remainingPrecommit := precommits[0] + remainingOutpoint := wire.OutPoint{ + Hash: remainingPrecommit.MintingTxn.TxHash(), + Index: remainingPrecommit.OutIdx, + } + + require.Equal( + t, outpointExtra, remainingOutpoint, + "the remaining unspent pre-commitment should be the extra one", + ) + + // Now create new pre-commitments for the second transition. + batchKeyBytes3, _, _, mintTxidBytes3, _ := h.addTestMintingBatch() + var mintTxid3 chainhash.Hash + copy(mintTxid3[:], mintTxidBytes3) + _, outpoint3 := h.addTestMintAnchorUniCommitment( + batchKeyBytes3, sql.NullInt64{}, mintTxid3, + ) + + // Verify we have the extra one from before plus the new one. + precommitsRes = h.commitMachine.UnspentPrecommits(h.ctx, h.assetSpec) + precommits, err = precommitsRes.Unpack() + require.NoError(t, err) + require.Len( + t, precommits, 2, "should have 2 unspent pre-commitments "+ + "before second transition (extra from first + new one)", + ) + // To ensure that we can perform multiple transitions, we'll now do // another one, with a new set of events, and then assert that it's been - // applied properly. + // applied properly. This time we'll spend both the extra pre-commitment + // from the first round and the new one. updates2 := []supplycommit.SupplyUpdateEvent{ h.randMintEvent(), h.randIgnoreEvent(), } - stateTransition2 := h.performSingleTransition(updates2) + preCommitOutpoints2 := []wire.OutPoint{outpointExtra, outpoint3} + stateTransition2 := h.performSingleTransition(updates2, preCommitOutpoints2) h.assertTransitionApplied(stateTransition2) + + // After the second transition, the new pre-commitment should also be spent. + // Finally, verify that no unspent pre-commitments remain. + precommitsRes = h.commitMachine.UnspentPrecommits(h.ctx, h.assetSpec) + precommits, err = precommitsRes.Unpack() + require.NoError(t, err) + require.Empty( + t, precommits, "should have no unspent pre-commitments after "+ + "all transitions", + ) } // TestSupplyCommitUnspentPrecommits tests the UnspentPrecommits method. @@ -1681,8 +1795,10 @@ func TestSupplyCommitUnspentPrecommits(t *testing.T) { require.Empty(t, precommits) // Next, we'll add a new minting batch, and a pre-commit along with it. - batchKeyBytes, _, mintTx1, _, _ := h.addTestMintingBatch() - _ = h.addTestMintAnchorUniCommitment(batchKeyBytes, sql.NullInt64{}) + batchKeyBytes, _, mintTx1, mintTxidBytes, _ := h.addTestMintingBatch() + var mintTxid chainhash.Hash + copy(mintTxid[:], mintTxidBytes) + _, _ = h.addTestMintAnchorUniCommitment(batchKeyBytes, sql.NullInt64{}, mintTxid) // At this point, we should find a single pre commitment on disk. precommitsRes = h.commitMachine.UnspentPrecommits(h.ctx, spec) @@ -1694,12 +1810,14 @@ func TestSupplyCommitUnspentPrecommits(t *testing.T) { // Next, we'll add another pre commitment, and this time associate it // (spend it) by a supply commitment. //nolint:lll - batchKeyBytes, commitTxDbID2, _, commitTxid2, commitRawTx2 := + batchKeyBytes, commitTxDbID2, _, commitTxidBytes2, commitRawTx2 := h.addTestMintingBatch() + var commitTxid2 chainhash.Hash + copy(commitTxid2[:], commitTxidBytes2) commitID2 := h.addTestSupplyCommitment( - commitTxDbID2, commitTxid2, commitRawTx2, false, + commitTxDbID2, commitTxidBytes2, commitRawTx2, false, ) - _ = h.addTestMintAnchorUniCommitment(batchKeyBytes, sqlInt64(commitID2)) + _, _ = h.addTestMintAnchorUniCommitment(batchKeyBytes, sqlInt64(commitID2), commitTxid2) // We should now find two pre-commitments. precommitsRes = h.commitMachine.UnspentPrecommits(h.ctx, spec) @@ -1713,7 +1831,7 @@ func TestSupplyCommitUnspentPrecommits(t *testing.T) { blockHeight := sqlInt32(123) txIndex := sqlInt32(1) _, err = h.db.UpsertChainTx(h.ctx, sqlc.UpsertChainTxParams{ - Txid: commitTxid2, + Txid: commitTxid2[:], RawTx: commitRawTx2, ChainFees: 0, BlockHash: blockHash, diff --git a/tapfreighter/chain_porter.go b/tapfreighter/chain_porter.go index 38f17d8e9..6af398df9 100644 --- a/tapfreighter/chain_porter.go +++ b/tapfreighter/chain_porter.go @@ -24,6 +24,7 @@ import ( "github.com/lightninglabs/taproot-assets/tappsbt" "github.com/lightninglabs/taproot-assets/tapscript" "github.com/lightninglabs/taproot-assets/tapsend" + "github.com/lightninglabs/taproot-assets/universe" "github.com/lightningnetwork/lnd/chainntnfs" "github.com/lightningnetwork/lnd/lnwallet" "github.com/lightningnetwork/lnd/lnwallet/chainfee" @@ -49,6 +50,15 @@ type ProofImporter interface { replace bool, proofs ...*proof.AnnotatedProof) error } +// BurnSupplyCommitter is used by the chain porter to update the on-chain supply +// commitment when burns new 1st party burns are confirmed. +type BurnSupplyCommitter interface { + // SendBurnEvent sends a burn event to the supply commitment state + // machine. + SendBurnEvent(ctx context.Context, assetSpec asset.Specifier, + burnLeaf universe.BurnLeaf) error +} + // ChainPorterConfig is the main config for the chain porter. type ChainPorterConfig struct { // ChainParams are the chain parameters for the chain porter. @@ -100,6 +110,15 @@ type ChainPorterConfig struct { // ErrChan is the main error channel the custodian will report back // critical errors to the main server. ErrChan chan<- error + + // BurnSupplyCommitter is used to track supply changes (burns) and + // create periodic on-chain supply commitments. + BurnCommitter BurnSupplyCommitter + + // DelegationKeyChecker is used to verify that we control the delegation + // key for a given asset, which is required for creating supply + // commitments. + DelegationKeyChecker address.DelegationKeyChecker } // ChainPorter is the main sub-system of the tapfreighter package. The porter @@ -677,6 +696,94 @@ func (p *ChainPorter) storeProofs(sendPkg *sendPackage) error { return nil } +// sendBurnSupplyCommitEvents sends supply commitment events for all burned +// assets to track them in the supply commitment state machine. +func (p *ChainPorter) sendBurnSupplyCommitEvents(ctx context.Context, + burns []*AssetBurn) error { + + // If no supply commit manager is configured, skip this step. + if p.cfg.BurnCommitter == nil { + return nil + } + + // If no delegation key checker is configured, skip this step. We need + // it to figure out if this is an asset we created or not. + if p.cfg.DelegationKeyChecker == nil { + return nil + } + + delChecker := p.cfg.DelegationKeyChecker + + // We'll use a filter predicate to filter out the burns that we didn't + // do ourselves, i.e., those that don't have a delegation key. + burnsWithDelegation := fn.Filter(burns, func(burn *AssetBurn) bool { + var assetID asset.ID + copy(assetID[:], burn.AssetID) + + // If the asset doesn't have a group, then this will return + // false. + hasDelegationKey, err := delChecker.HasDelegationKey( + ctx, assetID, + ) + if err != nil { + log.Debugf("Error checking delegation key for "+ + "asset %x: %v", assetID, err) + return false + } + + if !hasDelegationKey { + log.Debugf("Skipping supply commit burn event "+ + "for asset %x: delegation key not controlled "+ + "locally", + assetID) + } + + return hasDelegationKey + }) + + for _, burn := range burnsWithDelegation { + var assetID asset.ID + copy(assetID[:], burn.AssetID) + + groupKeyBytes := burn.GroupKey + groupKey, err := btcec.ParsePubKey(groupKeyBytes) + if err != nil { + return fmt.Errorf("unable to parse group key: %w", err) + } + + assetSpec := asset.NewSpecifierOptionalGroupPubKey( + assetID, groupKey, + ) + + // Create a NewBurnEvent for the supply commitment state machine. + // We need to create a universe.BurnLeaf for this. + burnLeaf := universe.BurnLeaf{ + UniverseKey: universe.AssetLeafKey{ + BaseLeafKey: universe.BaseLeafKey{ + ScriptKey: burn.ScriptKey, + OutPoint: burn.OutPoint, + }, + AssetID: assetID, + }, + BurnProof: burn.Proof, + } + + // Send the burn event to the supply commit manager. + err = p.cfg.BurnCommitter.SendBurnEvent( + ctx, assetSpec, burnLeaf, + ) + if err != nil { + return fmt.Errorf("unable to send burn event for "+ + "asset %x: %w", assetID, err) + } + + log.Infof("Sent supply commit burn event for asset %v", + assetID) + } + + return nil +} + // storePackageAnchorTxConf logs the on-chain confirmation of the transfer // anchor transaction for the given package. func (p *ChainPorter) storePackageAnchorTxConf(pkg *sendPackage) error { @@ -728,6 +835,12 @@ func (p *ChainPorter) storePackageAnchorTxConf(pkg *sendPackage) error { Amount: o.Amount, AnchorTxid: pkg.OutboundPkg.AnchorTx.TxHash(), Note: pkg.Note, + ScriptKey: &o.Asset.ScriptKey, + Proof: o.ProofSuffix, + OutPoint: wire.OutPoint{ + Hash: pkg.OutboundPkg.AnchorTx.TxHash(), + Index: o.AnchorOutputIndex, + }, } if o.Asset.GroupKey != nil { @@ -739,11 +852,20 @@ func (p *ChainPorter) storePackageAnchorTxConf(pkg *sendPackage) error { } } + // Send supply commitment events for all burned assets before confirming + // the transaction. This ensures that supply commitments are tracked + // before the burn is considered complete. + err := p.sendBurnSupplyCommitEvents(ctx, burns) + if err != nil { + return fmt.Errorf("unable to send burn supply commit "+ + "events: %w", err) + } + // At this point we have the confirmation signal, so we can mark the // parcel delivery as completed in the database. anchorTXID := pkg.OutboundPkg.AnchorTx.TxHash() anchorTxBlockHeight := int32(pkg.TransferTxConfEvent.BlockHeight) - err := p.cfg.ExportLog.LogAnchorTxConfirm(ctx, &AssetConfirmEvent{ + err = p.cfg.ExportLog.LogAnchorTxConfirm(ctx, &AssetConfirmEvent{ AnchorTXID: anchorTXID, BlockHash: *pkg.TransferTxConfEvent.BlockHash, BlockHeight: anchorTxBlockHeight, diff --git a/tapfreighter/interface.go b/tapfreighter/interface.go index 0dee00a87..70179cb9c 100644 --- a/tapfreighter/interface.go +++ b/tapfreighter/interface.go @@ -78,6 +78,15 @@ type AssetBurn struct { // AnchorTxid is the txid of the transaction this burn is anchored to. AnchorTxid chainhash.Hash + + // ScriptKey is the script key of the asset that got burnt. + ScriptKey *asset.ScriptKey + + // OutPoint is the outpoint of the asset that got burnt. + OutPoint wire.OutPoint + + // Proof is the proof that the asset was burnt. + Proof *proof.Proof } // String returns the string representation of the commitment constraints. diff --git a/tapfreighter/supply_commit_test.go b/tapfreighter/supply_commit_test.go new file mode 100644 index 000000000..74d2cd029 --- /dev/null +++ b/tapfreighter/supply_commit_test.go @@ -0,0 +1,302 @@ +package tapfreighter + +import ( + "context" + "testing" + + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/lightninglabs/taproot-assets/asset" + "github.com/lightninglabs/taproot-assets/internal/test" + "github.com/lightninglabs/taproot-assets/universe" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +// MockBurnSupplyCommitter is a mock implementation of the BurnSupplyCommitter +// interface for testing. +type MockBurnSupplyCommitter struct { + mock.Mock +} + +// MockDelegationKeyChecker is a mock implementation of the DelegationKeyChecker +// interface for testing. +type MockDelegationKeyChecker struct { + mock.Mock +} + +// HasDelegationKey implements the DelegationKeyChecker interface. +func (m *MockDelegationKeyChecker) HasDelegationKey(ctx context.Context, + assetID asset.ID) (bool, error) { + + args := m.Called(ctx, assetID) + return args.Bool(0), args.Error(1) +} + +// SendEvent implements the BurnSupplyCommitter interface. +func (m *MockBurnSupplyCommitter) SendEvent(ctx context.Context, + assetSpec asset.Specifier, event interface{}) error { + + args := m.Called(ctx, assetSpec, event) + return args.Error(0) +} + +// SendBurnEvent implements the BurnSupplyCommitter interface. +func (m *MockBurnSupplyCommitter) SendBurnEvent(ctx context.Context, + assetSpec asset.Specifier, burnLeaf universe.BurnLeaf) error { + + args := m.Called(ctx, assetSpec, burnLeaf) + return args.Error(0) +} + +// delegationKeyResult represents the result of a delegation key check. +type delegationKeyResult struct { + hasKey bool + err error +} + +// chainPorterTestSetup holds the configuration for a chain porter test. +type chainPorterTestSetup struct { + burns []*AssetBurn + delegationKeyResponses map[asset.ID]delegationKeyResult + expectedBurnCalls int + expectError bool + expectNoManager bool + managerError error + invalidGroupKey bool +} + +// setupChainPorterTest creates a configured ChainPorter with mocks based on the +// test setup. +func setupChainPorterTest(t *testing.T, + ctx context.Context, setup chainPorterTestSetup, +) (*ChainPorter, *MockBurnSupplyCommitter, *MockDelegationKeyChecker) { + + mockDelegationChecker := &MockDelegationKeyChecker{} + + // Only set up delegation key expectations if we have a manager. + if !setup.expectNoManager { + for assetID, result := range setup.delegationKeyResponses { + mockDelegationChecker.On( + "HasDelegationKey", ctx, assetID, + ).Return( + result.hasKey, result.err, + ) + } + } + + var ( + mockManager *MockBurnSupplyCommitter + manager BurnSupplyCommitter + ) + + // If we expect a manager, set it up with the expected burn calls. + if !setup.expectNoManager { + mockManager = &MockBurnSupplyCommitter{} + + if setup.expectedBurnCalls > 0 { + call := mockManager.On("SendBurnEvent", + ctx, + mock.AnythingOfType("asset.Specifier"), + mock.AnythingOfType("universe.BurnLeaf")) + + if setup.managerError != nil { + call.Return(setup.managerError) + } else { + call.Return(nil) + } + call.Times(setup.expectedBurnCalls) + } + + manager = mockManager + } + + porter := &ChainPorter{ + cfg: &ChainPorterConfig{ + BurnCommitter: manager, + DelegationKeyChecker: mockDelegationChecker, + }, + } + + return porter, mockManager, mockDelegationChecker +} + +// TestChainPorterSupplyCommitEvents tests the comprehensive functionality of +// supply commit burn event processing including delegation key filtering, error +// handling, and various burn scenarios. +func TestChainPorterSupplyCommitEvents(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + // We'll make some test data, including asset IDs, anchor transaction + // ID, and a series of burns. + assetID1 := asset.RandID(t) + assetID2 := asset.RandID(t) + assetID3 := asset.RandID(t) + anchorTxid := chainhash.Hash{1, 2, 3, 4} + groupKey := test.RandPubKey(t) + + // Create various burn scenarios + burnWithoutGroup := &AssetBurn{ + AssetID: assetID1[:], + Amount: 1000, + AnchorTxid: anchorTxid, + GroupKey: nil, + Note: "burn without group", + } + + burnWithGroup := &AssetBurn{ + AssetID: assetID2[:], + Amount: 500, + AnchorTxid: anchorTxid, + GroupKey: groupKey.SerializeCompressed(), + Note: "burn with group", + } + + burnForFiltering := &AssetBurn{ + AssetID: assetID3[:], + Amount: 250, + AnchorTxid: anchorTxid, + GroupKey: nil, + Note: "burn for filtering test", + } + + burnWithInvalidGroup := &AssetBurn{ + AssetID: assetID1[:], + Amount: 100, + AnchorTxid: anchorTxid, + GroupKey: []byte{0xFF, 0xFF}, + Note: "burn with invalid group", + } + + tests := []struct { + name string + setup chainPorterTestSetup + }{ //nolint:lll + { + name: "successful burn events with mixed group keys", + setup: chainPorterTestSetup{ + burns: []*AssetBurn{ + burnWithoutGroup, burnWithGroup, + }, + delegationKeyResponses: map[asset.ID]delegationKeyResult{ + assetID1: {hasKey: true, err: nil}, + assetID2: {hasKey: true, err: nil}, + }, + expectedBurnCalls: 2, + expectError: false, + }, + }, + { + name: "delegation key filtering - only some have " + + "delegation keys", + setup: chainPorterTestSetup{ + burns: []*AssetBurn{ + burnWithoutGroup, burnWithGroup, burnForFiltering, + }, + delegationKeyResponses: map[asset.ID]delegationKeyResult{ + assetID1: {hasKey: true, err: nil}, + assetID2: {hasKey: false, err: nil}, + assetID3: {hasKey: true, err: nil}, + }, + expectedBurnCalls: 2, + expectError: false, + }, + }, + { + name: "delegation key filtering - no assets have " + + "delegation keys", + setup: chainPorterTestSetup{ + burns: []*AssetBurn{ + burnWithoutGroup, burnWithGroup, + }, + delegationKeyResponses: map[asset.ID]delegationKeyResult{ + assetID1: {hasKey: false, err: nil}, + assetID2: {hasKey: false, err: nil}, + }, + expectedBurnCalls: 0, + expectError: false, + }, + }, + { + name: "delegation key checker error - filtered out " + + "gracefully", + setup: chainPorterTestSetup{ + burns: []*AssetBurn{ + burnWithoutGroup, burnWithGroup, + }, + delegationKeyResponses: map[asset.ID]delegationKeyResult{ + assetID1: {hasKey: false, err: assert.AnError}, + assetID2: {hasKey: true, err: nil}, + }, + expectedBurnCalls: 1, + expectError: false, + }, + }, + { + name: "burn committer error", + setup: chainPorterTestSetup{ + burns: []*AssetBurn{burnWithoutGroup}, + delegationKeyResponses: map[asset.ID]delegationKeyResult{ + assetID1: {hasKey: true, err: nil}, + }, + expectedBurnCalls: 1, + managerError: assert.AnError, + expectError: true, + }, + }, + { + name: "no burn committer configured", + setup: chainPorterTestSetup{ + burns: []*AssetBurn{burnWithoutGroup, burnWithGroup}, + delegationKeyResponses: map[asset.ID]delegationKeyResult{ + assetID1: {hasKey: true, err: nil}, + assetID2: {hasKey: true, err: nil}, + }, + expectNoManager: true, + expectError: false, + }, + }, + { + name: "invalid group key bytes", + setup: chainPorterTestSetup{ + burns: []*AssetBurn{burnWithInvalidGroup}, + delegationKeyResponses: map[asset.ID]delegationKeyResult{ + assetID1: {hasKey: true, err: nil}, + }, + expectedBurnCalls: 0, + expectError: true, + }, + }, + } + + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // We'll set up the test, call the method with the + // specified args, then assert the results. + //nolint:lll + porter, mockManager, mockDelegationChecker := setupChainPorterTest( + t, ctx, tc.setup, + ) + + err := porter.sendBurnSupplyCommitEvents(ctx, tc.setup.burns) + + if tc.setup.expectError { + require.Error(t, err) + } else { + require.NoError(t, err) + } + + if mockManager != nil { + mockManager.AssertExpectations(t) + } + if !tc.setup.expectNoManager { + mockDelegationChecker.AssertExpectations(t) + } + }) + } +} diff --git a/tapgarden/caretaker.go b/tapgarden/caretaker.go index e84ef2a70..b2c8e0067 100644 --- a/tapgarden/caretaker.go +++ b/tapgarden/caretaker.go @@ -192,7 +192,7 @@ func (b *BatchCaretaker) Cancel() error { // function, so the batch state read is the next state that has not yet // been executed. Seedlings are converted to asset sprouts in the Frozen // state, and broadcast in the Broadast state. - log.Debugf("BatchCaretaker(%x): Trying to cancel", batchKey) + log.Debugf("BatchCaretaker(%x): Trying to cancel", batchKey[:]) switch batchState { // In the pending state, the batch seedlings have not sprouted yet. case BatchStatePending, BatchStateFrozen: @@ -202,7 +202,7 @@ func (b *BatchCaretaker) Cancel() error { ) if err != nil { err = fmt.Errorf("BatchCaretaker(%x), batch state(%v), "+ - "cancel failed: %w", batchKey, batchState, err) + "cancel failed: %w", batchKey[:], batchState, err) b.cfg.PublishMintEvent(newAssetMintErrorEvent( err, BatchStateSeedlingCancelled, b.cfg.Batch, @@ -222,7 +222,7 @@ func (b *BatchCaretaker) Cancel() error { ) if err != nil { err = fmt.Errorf("BatchCaretaker(%x), batch state(%v), "+ - "cancel failed: %w", batchKey, batchState, err) + "cancel failed: %w", batchKey[:], batchState, err) b.cfg.PublishMintEvent(newAssetMintErrorEvent( err, BatchStateSproutCancelled, b.cfg.Batch, @@ -552,7 +552,7 @@ func (b *BatchCaretaker) stateStep(currentState BatchState) (BatchState, error) } log.Infof("BatchCaretaker(%x): transition states: %v -> %v", - b.batchKey, BatchStatePending, BatchStateFrozen) + b.batchKey[:], BatchStatePending, BatchStateFrozen) return BatchStateFrozen, nil @@ -690,7 +690,7 @@ func (b *BatchCaretaker) stateStep(currentState BatchState) (BatchState, error) } log.Infof("BatchCaretaker(%x): transition states: %v -> %v", - b.batchKey, BatchStateFrozen, BatchStateCommitted) + b.batchKey[:], BatchStateFrozen, BatchStateCommitted) return BatchStateCommitted, nil @@ -819,7 +819,7 @@ func (b *BatchCaretaker) stateStep(currentState BatchState) (BatchState, error) } log.Infof("BatchCaretaker(%x): transition states: %v -> %v", - b.batchKey, BatchStateCommitted, BatchStateBroadcast) + b.batchKey[:], BatchStateCommitted, BatchStateBroadcast) return BatchStateBroadcast, nil @@ -963,7 +963,7 @@ func (b *BatchCaretaker) stateStep(currentState BatchState) (BatchState, error) }() log.Infof("BatchCaretaker(%x): transition states: %v -> %v", - b.batchKey, BatchStateBroadcast, BatchStateBroadcast) + b.batchKey[:], BatchStateBroadcast, BatchStateBroadcast) return BatchStateBroadcast, nil @@ -1139,6 +1139,17 @@ func (b *BatchCaretaker) stateStep(currentState BatchState) (BatchState, error) } } + // Send supply commitment events for all minted assets before + // finalizing the batch. This ensures that supply commitments + // are tracked before the batch is considered complete. + err = b.sendSupplyCommitEvents( + ctx, anchorAssets, nonAnchorAssets, mintingProofs, + ) + if err != nil { + return 0, fmt.Errorf("unable to send supply commit "+ + "events: %w", err) + } + err = b.cfg.Log.MarkBatchConfirmed( ctx, b.cfg.Batch.BatchKey.PubKey, confInfo.BlockHash, confInfo.BlockHeight, confInfo.TxIndex, @@ -1157,7 +1168,7 @@ func (b *BatchCaretaker) stateStep(currentState BatchState) (BatchState, error) } log.Infof("BatchCaretaker(%x): transition states: %v -> %v", - b.batchKey, BatchStateConfirmed, BatchStateFinalized) + b.batchKey[:], BatchStateConfirmed, BatchStateFinalized) return BatchStateFinalized, nil @@ -1165,7 +1176,7 @@ func (b *BatchCaretaker) stateStep(currentState BatchState) (BatchState, error) // so we just go back to batch finalized. case BatchStateFinalized: log.Infof("BatchCaretaker(%x): transition states: %v -> %v", - b.batchKey, BatchStateFinalized, BatchStateFinalized) + b.batchKey[:], BatchStateFinalized, BatchStateFinalized) // TODO(roasbeef): confirmed should just be the final state? ctx, cancel := b.WithCtxQuit() @@ -1432,6 +1443,123 @@ func GenHeaderVerifier(ctx context.Context, } } +// sendSupplyCommitEvents sends supply commitment events for all minted assets +// in the batch to track them in the supply commitment state machine. +func (b *BatchCaretaker) sendSupplyCommitEvents(ctx context.Context, + anchorAssets, nonAnchorAssets []*asset.Asset, + mintingProofs proof.AssetProofs) error { + + // If no supply commit manager is configured, skip this step. + if b.cfg.MintSupplyCommitter == nil { + return nil + } + + // If no delegation key checker is configured, skip this step. As if we + // need this to figure out if we made the asset or not. + if b.cfg.DelegationKeyChecker == nil { + return nil + } + + // We'll combine the anchor and non-anchor assets into a single slice + // that we'll run through below. + allAssets := append(anchorAssets, nonAnchorAssets...) + + delChecker := b.cfg.DelegationKeyChecker + + // We filter the assets to only include those that have a delegation + // key. As only those have a supply commitment maintained. + assetsWithDelegation := fn.Filter(allAssets, func(a *asset.Asset) bool { + hasDelegationKey, err := delChecker.HasDelegationKey( + ctx, a.ID(), + ) + if err != nil { + log.Debugf("Error checking delegation key for "+ + "asset %x: %v", a.ID(), err) + return false + } + if !hasDelegationKey { + log.Debugf("Skipping supply commit event for "+ + "asset %x: delegation key not controlled "+ + "locally", + a.ID()) + } + return hasDelegationKey + }) + + // For each of the assets that we just created with a delegation key, + // we'll create then send a supply commit event so the committer can + // take care of it. + for _, mintedAsset := range assetsWithDelegation { + // First, we'll extract the minting proof based on the srcipt + // key, and extract the very last proof from that. + scriptKey := asset.ToSerialized(mintedAsset.ScriptKey.PubKey) + mintingProof, ok := mintingProofs[scriptKey] + if !ok { + return fmt.Errorf("missing minting proof for asset "+ + "with script key %x", scriptKey[:]) + } + + proofBlob, err := proof.EncodeAsProofFile(mintingProof) + if err != nil { + return fmt.Errorf("unable to encode proof as "+ + "file: %w", err) + } + proofFile, err := proof.DecodeFile(proofBlob) + if err != nil { + return fmt.Errorf("unable to decode proof "+ + "file: %w", err) + } + leafProof, err := proofFile.LastProof() + if err != nil { + return fmt.Errorf("unable to get leaf proof: %w", err) + } + + // Encode just the leaf proof, not the entire file. + var leafProofBuf bytes.Buffer + if err := leafProof.Encode(&leafProofBuf); err != nil { + return fmt.Errorf("unable to encode leaf proof: %w", err) + } + leafProofBytes := leafProofBuf.Bytes() + + // With the proof extracted, we can now create the universe + // key and leaf. + universeKey := universe.BaseLeafKey{ + OutPoint: mintedAsset.Genesis.FirstPrevOut, + ScriptKey: &mintedAsset.ScriptKey, + } + uniqueLeafKey := universe.AssetLeafKey{ + BaseLeafKey: universeKey, + AssetID: mintedAsset.ID(), + } + universeLeaf := universe.Leaf{ + GenesisWithGroup: universe.GenesisWithGroup{ + Genesis: mintedAsset.Genesis, + GroupKey: mintedAsset.GroupKey, + }, + RawProof: leafProofBytes, + Asset: &leafProof.Asset, + Amt: mintedAsset.Amount, + } + assetSpec := asset.NewSpecifierOptionalGroupKey( + mintedAsset.ID(), mintedAsset.GroupKey, + ) + + // Finally we send all of the above to the supply commiter. + err = b.cfg.MintSupplyCommitter.SendMintEvent( + ctx, assetSpec, uniqueLeafKey, universeLeaf, + ) + if err != nil { + return fmt.Errorf("unable to send mint event for "+ + "asset %x: %w", mintedAsset.ID(), err) + } + + log.Infof("Sent supply commit mint event for asset %v", + mintedAsset.ID()) + } + + return nil +} + // assetGroupCacheSize is the size of the cache for group keys. const assetGroupCacheSize = 10000 diff --git a/tapgarden/planter.go b/tapgarden/planter.go index e8305bbfd..30501d848 100644 --- a/tapgarden/planter.go +++ b/tapgarden/planter.go @@ -31,6 +31,16 @@ import ( "golang.org/x/exp/maps" ) +// MintSupplyCommitter is used during minting to update the on-chain supply +// commitment of a new minted asset. +type MintSupplyCommitter interface { + // SendMintEvent sends a mint event to the supply commitment state + // machine. + SendMintEvent(ctx context.Context, assetSpec asset.Specifier, + leafKey universe.UniqueLeafKey, + issuanceProof universe.Leaf) error +} + // GardenKit holds the set of shared fundamental interfaces all sub-systems of // the tapgarden need to function. type GardenKit struct { @@ -81,6 +91,15 @@ type GardenKit struct { // UniversePushBatchSize is the number of minted items to push to the // local universe in a single batch. UniversePushBatchSize int + + // MintSupplyCommitter is used to commit the minting of new assets to + // the supply commitment state machine. + MintSupplyCommitter MintSupplyCommitter + + // DelegationKeyChecker is used to verify that we control the delegation + // key for a given asset, which is required for creating supply + // commitments. + DelegationKeyChecker address.DelegationKeyChecker } // PlanterConfig is the main config for the ChainPlanter. diff --git a/tapgarden/supply_commit_test.go b/tapgarden/supply_commit_test.go new file mode 100644 index 000000000..db206b882 --- /dev/null +++ b/tapgarden/supply_commit_test.go @@ -0,0 +1,191 @@ +package tapgarden + +import ( + "context" + "testing" + "time" + + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/taproot-assets/asset" + "github.com/lightninglabs/taproot-assets/proof" + "github.com/lightninglabs/taproot-assets/universe" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +// MockSupplyCommitManager is a mock implementation of the SupplyCommitManager +// interface for testing. +type MockSupplyCommitManager struct { + mock.Mock +} + +// MockDelegationKeyChecker is a mock implementation of the DelegationKeyChecker +// interface for testing. +type MockDelegationKeyChecker struct { + mock.Mock +} + +// HasDelegationKey implements the DelegationKeyChecker interface. +func (m *MockDelegationKeyChecker) HasDelegationKey(ctx context.Context, + assetID asset.ID) (bool, error) { + + args := m.Called(ctx, assetID) + return args.Bool(0), args.Error(1) +} + +// SendEvent implements the SupplyCommitManager interface. +func (m *MockSupplyCommitManager) SendEvent(ctx context.Context, + assetSpec asset.Specifier, event interface{}) error { + + args := m.Called(ctx, assetSpec, event) + return args.Error(0) +} + +// SendMintEvent implements the SupplyCommitManager interface. +func (m *MockSupplyCommitManager) SendMintEvent(ctx context.Context, + assetSpec asset.Specifier, leafKey universe.UniqueLeafKey, + issuanceProof universe.Leaf) error { + + args := m.Called(ctx, assetSpec, leafKey, issuanceProof) + return args.Error(0) +} + +// SendBurnEvent implements the SupplyCommitManager interface. +func (m *MockSupplyCommitManager) SendBurnEvent(ctx context.Context, + assetSpec asset.Specifier, burnLeaf universe.BurnLeaf) error { + + args := m.Called(ctx, assetSpec, burnLeaf) + return args.Error(0) +} + +// TestSupplyCommitDelegationKeyFiltering tests that supply commit events +// are only sent for assets where we control the delegation key. +func TestSupplyCommitDelegationKeyFiltering(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + asset1 := asset.RandAsset(t, asset.Normal) + asset2 := asset.RandAsset(t, asset.Normal) + asset3 := asset.RandAsset(t, asset.Normal) + + tests := []struct { + name string + assets []*asset.Asset + delegationKeyResponses map[asset.ID]bool + expectedCallCount int + }{ + { + name: "all assets have delegation key", + assets: []*asset.Asset{asset1, asset2}, + delegationKeyResponses: map[asset.ID]bool{ + asset1.ID(): true, + asset2.ID(): true, + }, + expectedCallCount: 2, + }, + { + name: "only one asset has delegation key", + assets: []*asset.Asset{asset1, asset2, asset3}, + delegationKeyResponses: map[asset.ID]bool{ + asset1.ID(): true, + asset2.ID(): false, + asset3.ID(): true, + }, + expectedCallCount: 2, + }, + { + name: "no assets have delegation key", + assets: []*asset.Asset{asset1, asset2}, + delegationKeyResponses: map[asset.ID]bool{ + asset1.ID(): false, + asset2.ID(): false, + }, + expectedCallCount: 0, + }, + } + + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // First, we'll set up our series of mocks, and then + // record the intended responses for each of them. + mockCommitter := &MockSupplyCommitManager{} + mockDelegationChecker := &MockDelegationKeyChecker{} + + // Set up delegation key checker responses + for assetID, hasKey := range tc.delegationKeyResponses { + mockDelegationChecker.On( + "HasDelegationKey", ctx, assetID, + ).Return(hasKey, nil) + } + + // If we're expecting any calls, then we'll make sure to + // register that here. + if tc.expectedCallCount > 0 { + //nolint:lll + mockCommitter.On("SendMintEvent", + ctx, + mock.AnythingOfType("asset.Specifier"), + mock.AnythingOfType( + "universe.AssetLeafKey", + ), + mock.AnythingOfType("universe.Leaf")). + Return(nil). + Times(tc.expectedCallCount) + } + + // With the mocks registered above, we'll create a new + // care taker instance that uses them. + caretaker := &BatchCaretaker{ + cfg: &BatchCaretakerConfig{ //nolint:lll + GardenKit: GardenKit{ + MintSupplyCommitter: mockCommitter, + DelegationKeyChecker: mockDelegationChecker, + }, + }, + } + + // Next, we'll create a series of proofs for each of the + // assets. + proofs := make(proof.AssetProofs) + dummyTx := &wire.MsgTx{ + Version: 2, + TxIn: []*wire.TxIn{{ + PreviousOutPoint: wire.OutPoint{}, + SignatureScript: []byte{}, + Sequence: 0xffffffff, + }}, + } + block := wire.MsgBlock{ + Header: wire.BlockHeader{ + Version: 1, + Timestamp: time.Now(), + Bits: 0x207fffff, + }, + Transactions: []*wire.MsgTx{dummyTx}, + } + for i, a := range tc.assets { + scriptKey := asset.ToSerialized( + a.ScriptKey.PubKey, + ) + testProof := proof.RandProof( + t, a.Genesis, a.ScriptKey.PubKey, block, + 0, uint32(i), + ) + proofs[scriptKey] = &testProof + } + + // Call the internal method, then verify the expected + // calls were made. + err := caretaker.sendSupplyCommitEvents( + ctx, tc.assets, nil, proofs, + ) + require.NoError(t, err) + mockCommitter.AssertExpectations(t) + mockDelegationChecker.AssertExpectations(t) + }) + } +} diff --git a/universe/supplycommit/env.go b/universe/supplycommit/env.go index d72339e1f..dcd870f48 100644 --- a/universe/supplycommit/env.go +++ b/universe/supplycommit/env.go @@ -35,6 +35,7 @@ const ( type SupplySubTree uint8 const ( + // MintTreeType is the sub tree that tracks mints. MintTreeType SupplySubTree = iota @@ -235,6 +236,39 @@ func (r *RootCommitment) TxOut() (*wire.TxOut, error) { return txOut, err } +// CommitPoint returns the outpoint that corresponds to the root commitment. +func (r *RootCommitment) CommitPoint() wire.OutPoint { + return wire.OutPoint{ + Hash: r.Txn.TxHash(), + Index: r.TxOutIdx, + } +} + +// computeSupplyCommitTapscriptRoot creates the tapscript root hash for a supply +// commitment with the given supply root hash. +func computeSupplyCommitTapscriptRoot(supplyRootHash mssmt.NodeHash, +) ([]byte, error) { + + // Create a non-spendable script leaf that commits to the supply root. + tapLeaf, err := asset.NewNonSpendableScriptLeaf( + asset.PedersenVersion, supplyRootHash[:], + ) + if err != nil { + return nil, fmt.Errorf("unable to create leaf: %w", err) + } + + tapscriptTree := txscript.AssembleTaprootScriptTree(tapLeaf) + rootHash := tapscriptTree.RootNode.TapHash() + return rootHash[:], nil +} + +// TapscriptRoot returns the tapscript root hash that commits to the supply +// root. This is tweaked with the internal key to derive the output key. +func (r *RootCommitment) TapscriptRoot() ([]byte, error) { + supplyRootHash := r.SupplyRoot.NodeHash() + return computeSupplyCommitTapscriptRoot(supplyRootHash) +} + // RootCommitTxOut returns the transaction output that corresponds to the root // commitment. This is used to create a new commitment output. func RootCommitTxOut(internalKey *btcec.PublicKey, @@ -245,21 +279,13 @@ func RootCommitTxOut(internalKey *btcec.PublicKey, if tapOutKey == nil { // We'll create a new unspendable output that contains a // commitment to the root. - // - // TODO(roasbeef): need other version info here/ - tapLeaf, err := asset.NewNonSpendableScriptLeaf( - asset.PedersenVersion, supplyRootHash[:], - ) + rootHash, err := computeSupplyCommitTapscriptRoot(supplyRootHash) if err != nil { - return nil, nil, fmt.Errorf("unable to create leaf: %w", - err) + return nil, nil, err } - tapscriptTree := txscript.AssembleTaprootScriptTree(tapLeaf) - - rootHash := tapscriptTree.RootNode.TapHash() taprootOutputKey = txscript.ComputeTaprootOutputKey( - internalKey, rootHash[:], + internalKey, rootHash, ) } else { taprootOutputKey = tapOutKey @@ -491,8 +517,9 @@ type SupplyCommitTxn struct { // Txn is the transaction that creates the supply commitment. Txn *wire.MsgTx - // InternalKey is the internal key used for the commitment output. - InternalKey *btcec.PublicKey + // InternalKey is the internal key descriptor used for the commitment + // output. This preserves the full key derivation information. + InternalKey keychain.KeyDescriptor // OutputKey is the taproot output key used for the commitment output. OutputKey *btcec.PublicKey diff --git a/universe/supplycommit/log.go b/universe/supplycommit/log.go index da4baae7c..6084f21ae 100644 --- a/universe/supplycommit/log.go +++ b/universe/supplycommit/log.go @@ -2,6 +2,7 @@ package supplycommit import ( "github.com/btcsuite/btclog/v2" + "github.com/davecgh/go-spew/spew" ) // Subsystem defines the logging code for this subsystem. @@ -18,6 +19,13 @@ func DisableLog() { UseLogger(btclog.Disabled) } +// limitSpewer is a spew.ConfigState that limits the depth of the output to 4 +// levels, so it can safely be used for things that contain an MS-SMT tree. +var limitSpewer = &spew.ConfigState{ + Indent: " ", + MaxDepth: 4, +} + // UseLogger uses a specified Logger to output package logging info. // This should be used in preference to SetLogWriter if the caller is also // using btclog. diff --git a/universe/supplycommit/multi_sm_manager.go b/universe/supplycommit/multi_sm_manager.go index 6dcbd5104..c7d193635 100644 --- a/universe/supplycommit/multi_sm_manager.go +++ b/universe/supplycommit/multi_sm_manager.go @@ -12,6 +12,7 @@ import ( "github.com/lightninglabs/taproot-assets/fn" "github.com/lightninglabs/taproot-assets/mssmt" "github.com/lightninglabs/taproot-assets/tapgarden" + "github.com/lightninglabs/taproot-assets/universe" "github.com/lightningnetwork/lnd/msgmux" "github.com/lightningnetwork/lnd/protofsm" ) @@ -225,6 +226,30 @@ func (m *MultiStateMachineManager) SendEvent(ctx context.Context, return nil } +// SendMintEvent sends a mint event to the supply commitment state machine. +func (m *MultiStateMachineManager) SendMintEvent(ctx context.Context, + assetSpec asset.Specifier, leafKey universe.UniqueLeafKey, + issuanceProof universe.Leaf) error { + + mintEvent := &NewMintEvent{ + LeafKey: leafKey, + IssuanceProof: issuanceProof, + } + + return m.SendEvent(ctx, assetSpec, mintEvent) +} + +// SendBurnEvent sends a burn event to the supply commitment state machine. +func (m *MultiStateMachineManager) SendBurnEvent(ctx context.Context, + assetSpec asset.Specifier, burnLeaf universe.BurnLeaf) error { + + burnEvent := &NewBurnEvent{ + BurnLeaf: burnLeaf, + } + + return m.SendEvent(ctx, assetSpec, burnEvent) +} + // CanHandle determines if the state machine associated with the given asset // specifier can handle the given message. If a state machine for the asset // group does not exist, it will be created and started. diff --git a/universe/supplycommit/transitions.go b/universe/supplycommit/transitions.go index b6abe60fa..cd90f1f39 100644 --- a/universe/supplycommit/transitions.go +++ b/universe/supplycommit/transitions.go @@ -8,6 +8,7 @@ import ( "github.com/btcsuite/btcd/btcutil/psbt" "github.com/btcsuite/btcd/chaincfg" "github.com/btcsuite/btcd/wire" + "github.com/btcsuite/btclog/v2" "github.com/lightninglabs/taproot-assets/asset" "github.com/lightninglabs/taproot-assets/mssmt" "github.com/lightninglabs/taproot-assets/proof" @@ -66,12 +67,14 @@ func createCommitmentTxLabel(assetSpec asset.Specifier, func (d *DefaultState) ProcessEvent(event Event, env *Environment) (*StateTransition, error) { + // Create a prefixed logger for this supply commit. + prefixedLog := log.WithPrefix(fmt.Sprintf("SupplyCommit(%v): ", env.AssetSpec.String())) + // In this state, we'll receive one of three events: a new burn, a new // mint, or a new ignore. For all three types, we'll emit this as an // internal event as we transition to the UpdatePendingState. switch supplyEvent := event.(type) { case SupplyUpdateEvent: - // Before we transition to the next state, we'll add this event // to our update log. This ensures that we'll remember to // process from this state after a restart. @@ -86,6 +89,8 @@ func (d *DefaultState) ProcessEvent(event Event, "pending update: %w", err) } + prefixedLog.Infof("Received new supply update event: %T", supplyEvent) + // We'll go to the next state, caching the pendingUpdate we just // received. return &StateTransition{ @@ -117,11 +122,13 @@ func (d *DefaultState) ProcessEvent(event Event, func (u *UpdatesPendingState) ProcessEvent(event Event, env *Environment) ( *StateTransition, error) { + // Create a prefixed logger for this supply commit. + prefixedLog := log.WithPrefix(fmt.Sprintf("SupplyCommit(%v): ", env.AssetSpec.String())) + switch newEvent := event.(type) { // We've received a new update event, we'll stage this in our local // state, and do a self transition. case SupplyUpdateEvent: - // We just got a new pending update in addition to the one that // made us transition to this state. This ensures that we'll // remember to process from this state after a restart. @@ -134,6 +141,8 @@ func (u *UpdatesPendingState) ProcessEvent(event Event, env *Environment) ( "pending update: %w", err) } + prefixedLog.Infof("Received new supply update event: %T", newEvent) + // We'll go to the next state, caching the pendingUpdate we just // received. currentUpdates := append(u.pendingUpdates, newEvent) @@ -157,6 +166,9 @@ func (u *UpdatesPendingState) ProcessEvent(event Event, env *Environment) ( "pending transition: %w", err) } + prefixedLog.Infof("Received tick event, committing %d supply updates", + len(u.pendingUpdates)) + return &StateTransition{ NextState: &CommitTreeCreateState{}, NewEvents: lfn.Some(FsmEvent{ @@ -248,12 +260,17 @@ func applyTreeUpdates(supplyTrees SupplyTrees, func (c *CommitTreeCreateState) ProcessEvent(event Event, env *Environment) (*StateTransition, error) { + // Create a prefixed logger for this supply commit. + // + // TODO(roasbeef): put this in the env? + prefixedLog := log.WithPrefix(fmt.Sprintf("SupplyCommit(%v): ", env.AssetSpec.String())) + // TODO(roasbeef): cache attemps to add new elements? or main state // driver still single threaded? switch newEvent := event.(type) { - // If we get a supply update event while we're creating the tree, - // we'll just insert it as a dangling update and do a self-transition. + // If we get a supply update event while we're creating the tree, we'll + // just insert it as a dangling update and do a self-transition. case SupplyUpdateEvent: ctx := context.Background() err := env.StateLog.InsertPendingUpdate( @@ -264,6 +281,8 @@ func (c *CommitTreeCreateState) ProcessEvent(event Event, "pending update: %w", err) } + prefixedLog.Infof("Received new supply update event: %T", newEvent) + return &StateTransition{ NextState: c, }, nil @@ -281,6 +300,10 @@ func (c *CommitTreeCreateState) ProcessEvent(event Event, case *CreateTreeEvent: pendingUpdates := newEvent.updatesToCommit + prefixedLog.Infof("Creating new supply trees "+ + "with %d pending updates", + len(pendingUpdates)) + // TODO(ffranr): Pass in context? ctx := context.Background() @@ -386,7 +409,13 @@ func newRootCommitment(ctx context.Context, oldCommitment lfn.Option[RootCommitment], unspentPreCommits []PreCommitment, newSupplyRoot *mssmt.BranchNode, wallet Wallet, keyRing KeyRing, - chainParams chaincfg.Params) (*RootCommitment, *psbt.Packet, error) { + chainParams chaincfg.Params, + logger lfn.Option[btclog.Logger]) (*RootCommitment, *psbt.Packet, error) { + + logger.WhenSome(func(l btclog.Logger) { + l.Infof("Creating new root commitment, spending %v "+ + "pre-commits", len(unspentPreCommits)) + }) newCommitTx := wire.NewMsgTx(2) @@ -425,6 +454,11 @@ func newRootCommitment(ctx context.Context, // on mint transactions where as the old commitment is the last // commitment that was broadcast. oldCommitment.WhenSome(func(r RootCommitment) { + logger.WhenSome(func(l btclog.Logger) { + l.Infof("Re-using prior commitment as outpoint=%v: %v", + r.CommitPoint(), limitSpewer.Sdump(r)) + }) + newCommitTx.AddTxIn(r.TxIn()) bip32Derivation, trBip32Derivation := @@ -434,6 +468,8 @@ func newRootCommitment(ctx context.Context, witnessUtxo := r.Txn.TxOut[r.TxOutIdx] + commitTapscriptRoot, _ := r.TapscriptRoot() + packetPInputs = append(packetPInputs, psbt.PInput{ WitnessUtxo: witnessUtxo, Bip32Derivation: []*psbt.Bip32Derivation{ @@ -443,14 +479,16 @@ func newRootCommitment(ctx context.Context, trBip32Derivation, }, TaprootInternalKey: trBip32Derivation.XOnlyPubKey, + TaprootMerkleRoot: commitTapscriptRoot, }) }) + // TODO(roasbef): do CreateTaprootSignature instead? // With the inputs available, derive the supply commitment output. // - // Determine the internal key to use for this output. - // If a prior root commitment exists, reuse its internal key; - // otherwise, generate a new one. + // Determine the internal key to use for this output. If a prior root + // commitment exists, reuse its internal key; otherwise, generate a new + // one. iKeyOpt := lfn.MapOption(func(r RootCommitment) keychain.KeyDescriptor { return r.InternalKey })(oldCommitment) @@ -521,6 +559,11 @@ func newRootCommitment(ctx context.Context, SupplyRoot: newSupplyRoot, } + logger.WhenSome(func(l btclog.Logger) { + l.Infof("Created new root commitment: %v", + limitSpewer.Sdump(newSupplyCommit)) + }) + commitPkt, err := psbt.NewFromUnsignedTx(newCommitTx) if err != nil { return nil, nil, fmt.Errorf("unable to create PSBT: %w", err) @@ -595,6 +638,11 @@ func fundSupplyCommitTx(ctx context.Context, supplyCommit *RootCommitment, func (c *CommitTxCreateState) ProcessEvent(event Event, env *Environment) (*StateTransition, error) { + // Create a prefixed logger for this supply commit. + prefixedLog := log.WithPrefix(fmt.Sprintf( + "SupplyCommit(%v): ", env.AssetSpec.String()), + ) + switch newEvent := event.(type) { // If we get a supply update event while we're creating the commit tx, // we'll just insert it as a dangling update and do a self-transition. @@ -608,6 +656,8 @@ func (c *CommitTxCreateState) ProcessEvent(event Event, "pending update: %w", err) } + prefixedLog.Infof("Received new supply update event: %T", newEvent) + return &StateTransition{ NextState: c, }, nil @@ -618,6 +668,8 @@ func (c *CommitTxCreateState) ProcessEvent(event Event, case *CreateTxEvent: ctx := context.Background() + prefixedLog.Infof("Creating new supply commitment tx") + // To create the new commitment, we'll fetch the unspent pre // commitments, the current supply root (which may not exist), // and the new supply root. @@ -642,6 +694,7 @@ func (c *CommitTxCreateState) ProcessEvent(event Event, newSupplyCommit, commitPkt, err := newRootCommitment( ctx, oldCommitment, preCommits, newSupplyRoot, env.Wallet, env.KeyRing, env.ChainParams, + lfn.Some(prefixedLog), ) if err != nil { return nil, fmt.Errorf("unable to create "+ @@ -694,6 +747,9 @@ func (c *CommitTxCreateState) ProcessEvent(event Event, func (s *CommitTxSignState) ProcessEvent(event Event, env *Environment) (*StateTransition, error) { + // Create a prefixed logger for this supply commit. + prefixedLog := log.WithPrefix(fmt.Sprintf("SupplyCommit(%v): ", env.AssetSpec.String())) + switch newEvent := event.(type) { // If we get a supply update event while we're signing the commit tx, // we'll just insert it as a dangling update and do a self-transition. @@ -707,6 +763,9 @@ func (s *CommitTxSignState) ProcessEvent(event Event, "pending update: %w", err) } + prefixedLog.Infof("Received new supply update "+ + "event: %T", newEvent) + return &StateTransition{ NextState: s, }, nil @@ -720,7 +779,7 @@ func (s *CommitTxSignState) ProcessEvent(event Event, stateTransition := s.SupplyTransition // After some initial validation, we'll now sign the PSBT. - log.Debug("Signing supply commitment PSBT") + prefixedLog.Debug("Signing supply commitment PSBT") signedPsbt, err := env.Wallet.SignPsbt( ctx, newEvent.CommitPkt.Pkt, ) @@ -729,6 +788,9 @@ func (s *CommitTxSignState) ProcessEvent(event Event, "commitment tx: %w", err) } + prefixedLog.Infof("Signed supply "+ + "commitment txn: %v", limitSpewer.Sdump(signedPsbt)) + err = psbt.MaybeFinalizeAll(signedPsbt) if err != nil { return nil, fmt.Errorf("unable to finalize psbt: %w", @@ -750,7 +812,7 @@ func (s *CommitTxSignState) ProcessEvent(event Event, newCommit := &stateTransition.NewCommitment commitTxnDetails := SupplyCommitTxn{ Txn: commitTx, - InternalKey: newCommit.InternalKey.PubKey, + InternalKey: newCommit.InternalKey, OutputKey: newCommit.OutputKey, OutputIndex: newCommit.TxOutIdx, } @@ -784,11 +846,18 @@ func (s *CommitTxSignState) ProcessEvent(event Event, func (c *CommitBroadcastState) ProcessEvent(event Event, env *Environment) (*StateTransition, error) { + // Create a prefixed logger for this supply commit. + prefixedLog := log.WithPrefix(fmt.Sprintf("SupplyCommit(%v): ", env.AssetSpec.String())) + switch newEvent := event.(type) { // If we get a supply update event while we're broadcasting the commit // tx, we'll just insert it as a dangling update and do a // self-transition. case SupplyUpdateEvent: + prefixedLog.Infof("Received new supply update %T while "+ + "finalizing prior commitment, inserting as dangling update", + newEvent) + ctx := context.Background() err := env.StateLog.InsertPendingUpdate( ctx, env.AssetSpec, newEvent, @@ -810,6 +879,10 @@ func (c *CommitBroadcastState) ProcessEvent(event Event, return nil, fmt.Errorf("commitment transaction is nil") } + commitTxid := c.SupplyTransition.NewCommitment.Txn.TxHash() + prefixedLog.Infof("Broadcasting supply commitment txn (txid=%v): %v", + commitTxid, limitSpewer.Sdump(c.SupplyTransition.NewCommitment.Txn)) + commitTx := c.SupplyTransition.NewCommitment.Txn // Construct a detailed label for the broadcast request using @@ -887,6 +960,9 @@ func (c *CommitBroadcastState) ProcessEvent(event Event, "proof: %w", err) } + // Now that the transaction has been confirmed, we'll construct + // a merkle proof for the commitment transaction. This'll be + // used to prove that the supply commit is canonical. stateTransition.ChainProof = lfn.Some(ChainProof{ Header: newEvent.Block.Header, BlockHeight: newEvent.BlockHeight, @@ -894,11 +970,9 @@ func (c *CommitBroadcastState) ProcessEvent(event Event, TxIndex: newEvent.TxIndex, }) - // Now that the transaction has been confirmed, we'll construct - // a merkle proof for the commitment transaction. This'll be - // used to prove that the supply commit is canonical. - // - // TODO(roasbeef): need header and txindex, etc to construct + prefixedLog.Infof("Supply commitment txn confirmed in block %d (hash=%v): %v", + newEvent.BlockHeight, newEvent.Block.Header.BlockHash(), + limitSpewer.Sdump(c.SupplyTransition.NewCommitment.Txn)) // The commitment has been confirmed, so we'll transition to the // finalize state, but also log on disk that we no longer need @@ -935,10 +1009,17 @@ func (c *CommitBroadcastState) ProcessEvent(event Event, func (c *CommitFinalizeState) ProcessEvent(event Event, env *Environment) (*StateTransition, error) { + // Create a prefixed logger for this supply commit. + prefixedLog := log.WithPrefix(fmt.Sprintf("SupplyCommit(%v): ", env.AssetSpec.String())) + switch newEvent := event.(type) { // If we get a supply update event while we're finalizing the commit, // we'll just insert it as a dangling update and do a self-transition. case SupplyUpdateEvent: + prefixedLog.Infof("Received new supply update %T while "+ + "finalizing prior commitment, inserting as dangling update", + newEvent) + ctx := context.Background() err := env.StateLog.InsertPendingUpdate( ctx, env.AssetSpec, newEvent, @@ -958,6 +1039,8 @@ func (c *CommitFinalizeState) ProcessEvent(event Event, case *FinalizeEvent: ctx := context.Background() + prefixedLog.Infof("Finalizing supply commitment transition") + // At this point, the commitment has been confirmed on disk, so // we can update: the state machine state on disk, and swap in // all the new supply tree information. @@ -992,6 +1075,8 @@ func (c *CommitFinalizeState) ProcessEvent(event Event, }, nil } + prefixedLog.Infof("Dangling updates found: %d", len(danglingUpdates)) + // Otherwise, we have more work to do! We'll kick off a new // commitment cycle right away by transitioning to the tree // creation state.