From cda2a9e5b59807a081fb76e51c80ead715a6c833 Mon Sep 17 00:00:00 2001 From: Jonathan Harvey-Buschel Date: Wed, 3 Jul 2024 17:19:13 -0400 Subject: [PATCH 1/7] tapgarden: add helpers for reading finalized batch --- tapgarden/batch.go | 61 ++++++++++++++++++++++++++++++++++++++++++ tapgarden/caretaker.go | 35 +++++++++++++----------- tapgarden/planter.go | 8 +----- tapgarden/seedling.go | 19 +++++++++++++ 4 files changed, 101 insertions(+), 22 deletions(-) diff --git a/tapgarden/batch.go b/tapgarden/batch.go index de6103b36..635e2bce6 100644 --- a/tapgarden/batch.go +++ b/tapgarden/batch.go @@ -1,6 +1,7 @@ package tapgarden import ( + "bytes" "fmt" "sync/atomic" "time" @@ -193,6 +194,66 @@ func (m *MintingBatch) MintingOutputKey(sibling *commitment.TapscriptPreimage) ( return m.mintingPubKey, m.taprootAssetScriptRoot, nil } +// VerifyOutputScript recomputes a batch genesis output script from a batch key, +// tapscript sibling, and set of assets. It checks multiple tap commitment +// versions to account for legacy batches. +func VerifyOutputScript(batchKey *btcec.PublicKey, tapSibling *chainhash.Hash, + genesisScript []byte, assets []*asset.Asset) (*commitment.TapCommitment, + error) { + + // Construct a TapCommitment from the batch sprouts, and verify that the + // version is correct by recomputing the genesis output script. + buildTrimmedCommitment := func(vers *commitment.TapCommitmentVersion, + assets ...*asset.Asset) (*commitment.TapCommitment, error) { + + tapCommitment, err := commitment.FromAssets(vers, assets...) + if err != nil { + return nil, err + } + + return commitment.TrimSplitWitnesses(vers, tapCommitment) + } + + tapCommitment, err := buildTrimmedCommitment( + fn.Ptr(commitment.TapCommitmentV2), assets..., + ) + if err != nil { + return nil, err + } + + computedScript, err := tapscript.PayToAddrScript( + *batchKey, tapSibling, *tapCommitment, + ) + if err != nil { + return nil, err + } + + if !bytes.Equal(genesisScript, computedScript) { + // The batch may have used a non-V2 commitment; check against a + // non-V2 commitment. + tapCommitment, err = buildTrimmedCommitment(nil, assets...) + if err != nil { + return nil, err + } + + computedScriptV0, err := tapscript.PayToAddrScript( + *batchKey, tapSibling, *tapCommitment, + ) + if err != nil { + return nil, err + } + + if !bytes.Equal(genesisScript, computedScriptV0) { + return nil, fmt.Errorf("invalid commitment to asset "+ + "sprouts: batch %x", + batchKey.SerializeCompressed(), + ) + } + } + + return tapCommitment, nil +} + // genesisScript returns the script that should be placed in the minting output // within the genesis transaction. func (m *MintingBatch) genesisScript(sibling *commitment.TapscriptPreimage) ( diff --git a/tapgarden/caretaker.go b/tapgarden/caretaker.go index 23fe71eed..a3434f3a1 100644 --- a/tapgarden/caretaker.go +++ b/tapgarden/caretaker.go @@ -412,7 +412,21 @@ func (b *BatchCaretaker) assetCultivator() { } } -// extractGenesisOutpoint extracts the genesis point (the first output from the +// extractAnchorOutputIndex extracts the anchor output index from a funded +// genesis packet. +func extractAnchorOutputIndex(genesisPkt *tapsend.FundedPsbt) uint32 { + anchorOutputIndex := uint32(0) + + // TODO(jhb): Does funding guarantee that minting TXs always have + // exactly two outputs? If not this func should be fallible. + if genesisPkt.ChangeOutputIndex == 0 { + anchorOutputIndex = 1 + } + + return anchorOutputIndex +} + +// extractGenesisOutpoint extracts the genesis point (the first input from the // genesis transaction). func extractGenesisOutpoint(tx *wire.MsgTx) wire.OutPoint { return tx.TxIn[0].PreviousOutPoint @@ -436,7 +450,6 @@ func (b *BatchCaretaker) seedlingsToAssetSprouts(ctx context.Context, b.cfg.Batch.Seedlings, ) groupedSeedlingCount := len(groupedSeedlings) - // load seedling asset groups and check for correct group count seedlingGroups, err := b.cfg.Log.FetchSeedlingGroups( ctx, genesisPoint, assetOutputIndex, @@ -453,10 +466,9 @@ func (b *BatchCaretaker) seedlingsToAssetSprouts(ctx context.Context, seedlingGroupCount) } - for i := range seedlingGroups { + for _, seedlingGroup := range seedlingGroups { // check that asset group has a witness, and that the group // has a matching seedling - seedlingGroup := seedlingGroups[i] if len(seedlingGroup.GroupKey.Witness) == 0 { return nil, fmt.Errorf("not all seedling groups have " + "witnesses") @@ -496,12 +508,7 @@ func (b *BatchCaretaker) seedlingsToAssetSprouts(ctx context.Context, // build assets for ungrouped seedlings for seedlingName := range ungroupedSeedlings { seedling := ungroupedSeedlings[seedlingName] - assetGen := asset.Genesis{ - FirstPrevOut: genesisPoint, - Tag: seedling.AssetName, - OutputIndex: assetOutputIndex, - Type: seedling.AssetType, - } + assetGen := seedling.Genesis(genesisPoint, assetOutputIndex) // If the seedling has a meta data reveal set, then we'll bind // that by including the hash of the meta data in the asset @@ -607,11 +614,9 @@ func (b *BatchCaretaker) stateStep(currentState BatchState) (BatchState, error) // and vice versa. // TODO(jhb): return the anchor index instead of change? or both // so this works for N outputs - b.anchorOutputIndex = 0 - if changeOutputIndex == 0 { - b.anchorOutputIndex = 1 - } - + b.anchorOutputIndex = extractAnchorOutputIndex( + b.cfg.Batch.GenesisPacket, + ) genesisPoint := extractGenesisOutpoint(genesisTxPkt.UnsignedTx) // First, we'll turn all the seedlings into actual taproot assets. diff --git a/tapgarden/planter.go b/tapgarden/planter.go index 71db92d07..c89deb668 100644 --- a/tapgarden/planter.go +++ b/tapgarden/planter.go @@ -587,13 +587,7 @@ func buildGroupReqs(genesisPoint wire.OutPoint, assetOutputIndex uint32, for _, seedlingName := range orderedSeedlings { seedling := groupSeedlings[seedlingName] - - assetGen := asset.Genesis{ - FirstPrevOut: genesisPoint, - Tag: seedling.AssetName, - OutputIndex: assetOutputIndex, - Type: seedling.AssetType, - } + assetGen := seedling.Genesis(genesisPoint, assetOutputIndex) // If the seedling has a meta data reveal set, then we'll bind // that by including the hash of the meta data in the asset diff --git a/tapgarden/seedling.go b/tapgarden/seedling.go index 4829605f4..974beb3d5 100644 --- a/tapgarden/seedling.go +++ b/tapgarden/seedling.go @@ -4,6 +4,7 @@ import ( "crypto/sha256" "fmt" + "github.com/btcsuite/btcd/wire" "github.com/lightninglabs/taproot-assets/asset" "github.com/lightninglabs/taproot-assets/proof" "github.com/lightningnetwork/lnd/keychain" @@ -199,6 +200,24 @@ func (c Seedling) validateGroupKey(group asset.AssetGroup, return nil } +// Genesis reconstructs the asset genesis for a seedling. +func (c Seedling) Genesis(genOutpoint wire.OutPoint, + genIndex uint32) asset.Genesis { + + gen := asset.Genesis{ + FirstPrevOut: genOutpoint, + Tag: c.AssetName, + OutputIndex: genIndex, + Type: c.AssetType, + } + + if c.Meta != nil { + gen.MetaHash = c.Meta.MetaHash() + } + + return gen +} + // HasGroupKey checks if a seedling specifies a particular group key. func (c Seedling) HasGroupKey() bool { return c.GroupInfo != nil && c.GroupInfo.GroupKey != nil From b06eba193b53cc53880fc5edac6dff468eb0f205 Mon Sep 17 00:00:00 2001 From: Jonathan Harvey-Buschel Date: Wed, 3 Jul 2024 21:05:28 -0400 Subject: [PATCH 2/7] tapgarden+proof: complete mock proof archiver --- proof/courier_test.go | 50 +++------------------------ proof/mock.go | 73 +++++++++++++++++++++++++++++++++++++++ tapgarden/planter_test.go | 5 +-- 3 files changed, 81 insertions(+), 47 deletions(-) diff --git a/proof/courier_test.go b/proof/courier_test.go index 079346bab..8af08fadb 100644 --- a/proof/courier_test.go +++ b/proof/courier_test.go @@ -3,7 +3,6 @@ package proof import ( "bytes" "context" - "fmt" "testing" "github.com/lightninglabs/taproot-assets/asset" @@ -12,52 +11,10 @@ import ( "github.com/stretchr/testify/require" ) -type mockProofArchive struct { - proofs map[Locator]Blob -} - -func newMockProofArchive() *mockProofArchive { - return &mockProofArchive{ - proofs: make(map[Locator]Blob), - } -} - -func (m *mockProofArchive) FetchProof(ctx context.Context, - id Locator) (Blob, error) { - - proof, ok := m.proofs[id] - if !ok { - return nil, ErrProofNotFound - } - - return proof, nil -} - -func (m *mockProofArchive) HasProof(ctx context.Context, - id Locator) (bool, error) { - - _, ok := m.proofs[id] - - return ok, nil -} - -func (m *mockProofArchive) FetchProofs(ctx context.Context, - id asset.ID) ([]*AnnotatedProof, error) { - - return nil, fmt.Errorf("not implemented") -} - -func (m *mockProofArchive) ImportProofs(context.Context, HeaderVerifier, - MerkleVerifier, GroupVerifier, ChainLookupGenerator, bool, - ...*AnnotatedProof) error { - - return fmt.Errorf("not implemented") -} - // TestUniverseRpcCourierLocalArchiveShortCut tests that the local archive is // used as a shortcut to fetch a proof if it's available. func TestUniverseRpcCourierLocalArchiveShortCut(t *testing.T) { - localArchive := newMockProofArchive() + localArchive := NewMockProofArchive() testBlocks := readTestData(t) oddTxBlock := testBlocks[0] @@ -79,7 +36,10 @@ func TestUniverseRpcCourierLocalArchiveShortCut(t *testing.T) { ScriptKey: *proof.Asset.ScriptKey.PubKey, OutPoint: fn.Ptr(proof.OutPoint()), } - localArchive.proofs[locator] = proofBlob + locHash, err := locator.Hash() + require.NoError(t, err) + + localArchive.proofs.Store(locHash, proofBlob) courier := &UniverseRpcCourier{ recipient: Recipient{}, diff --git a/proof/mock.go b/proof/mock.go index b355220f1..f19272eae 100644 --- a/proof/mock.go +++ b/proof/mock.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/hex" + "fmt" "io" "net/url" "sync" @@ -20,6 +21,7 @@ import ( "github.com/lightninglabs/taproot-assets/fn" "github.com/lightninglabs/taproot-assets/internal/test" "github.com/lightningnetwork/lnd/keychain" + "github.com/lightningnetwork/lnd/lnutils" "github.com/stretchr/testify/require" ) @@ -254,6 +256,77 @@ func (m *mockChainLookup) GenProofChainLookup(*Proof) (asset.ChainLookup, var _ asset.ChainLookup = (*mockChainLookup)(nil) var _ ChainLookupGenerator = (*mockChainLookup)(nil) +// MockProofArchive is a map that implements the Archiver interface. +type MockProofArchive struct { + proofs lnutils.SyncMap[[32]byte, Blob] +} + +// NewMockProofArchive creates a new mock proof archive. +func NewMockProofArchive() *MockProofArchive { + return &MockProofArchive{ + proofs: lnutils.SyncMap[[32]byte, Blob]{}, + } +} + +// FetchProof fetches a proof for an asset uniquely identified by the passed +// Locator. If a proof cannot be found, then ErrProofNotFound is returned. +func (m *MockProofArchive) FetchProof(_ context.Context, + id Locator) (Blob, error) { + + idHash, err := id.Hash() + if err != nil { + return nil, err + } + + proof, ok := m.proofs.Load(idHash) + if !ok { + return nil, ErrProofNotFound + } + + return proof, nil +} + +// HasProof returns true if the proof for the given locator exists. +func (m *MockProofArchive) HasProof(_ context.Context, + id Locator) (bool, error) { + + idHash, err := id.Hash() + if err != nil { + return false, err + } + + _, ok := m.proofs.Load(idHash) + + return ok, nil +} + +// FetchProofs would fetch all proofs for a specific asset ID, but will always +// err for the mock proof archive. +func (m *MockProofArchive) FetchProofs(_ context.Context, + id asset.ID) ([]*AnnotatedProof, error) { + + return nil, fmt.Errorf("not implemented") +} + +// ImportProofs will store the given proofs, without performing any validation. +func (m *MockProofArchive) ImportProofs(_ context.Context, _ HeaderVerifier, + _ MerkleVerifier, _ GroupVerifier, _ ChainLookupGenerator, _ bool, + proofs ...*AnnotatedProof) error { + + for _, proof := range proofs { + locHash, err := proof.Locator.Hash() + if err != nil { + return err + } + + m.proofs.Store(locHash, proof.Blob) + } + + return nil +} + +var _ Archiver = (*MockProofArchive)(nil) + // MockProofCourierDispatcher is a mock proof courier dispatcher which returns // the same courier for all requests. type MockProofCourierDispatcher struct { diff --git a/tapgarden/planter_test.go b/tapgarden/planter_test.go index 3bb98ae17..c9da40d2b 100644 --- a/tapgarden/planter_test.go +++ b/tapgarden/planter_test.go @@ -99,7 +99,7 @@ type mintingTestHarness struct { planter *tapgarden.ChainPlanter - proofFiles *tapgarden.MockProofArchive + proofFiles *proof.MockProofArchive proofWatcher *tapgarden.MockProofWatcher @@ -116,6 +116,7 @@ func newMintingTestHarness(t *testing.T, keyRing := tapgarden.NewMockKeyRing() genSigner := tapgarden.NewMockGenSigner(keyRing) treeMgr := tapgarden.NewFallibleTapscriptTreeMgr(store) + archiver := proof.NewMockProofArchive() return &mintingTestHarness{ T: t, @@ -123,7 +124,7 @@ func newMintingTestHarness(t *testing.T, treeStore: &treeMgr, wallet: tapgarden.NewMockWalletAnchor(), chain: tapgarden.NewMockChainBridge(), - proofFiles: &tapgarden.MockProofArchive{}, + proofFiles: archiver, proofWatcher: &tapgarden.MockProofWatcher{}, keyRing: keyRing, genSigner: genSigner, From 56169acd9f374d04ab267f10a036541c7b56e0be Mon Sep 17 00:00:00 2001 From: Jonathan Harvey-Buschel Date: Wed, 3 Jul 2024 21:20:53 -0400 Subject: [PATCH 3/7] tapdb: return seedlings for a finalized batch In this commit, we change the batch marshalling behavior for finalized batches. We return the batch seedlings, as these are not mutated once an asset from a batch is spent. The next commit adds the means to fetch fully populated assets for a finalized batch. --- tapdb/asset_minting.go | 178 +++++++++++++++++------------------------ 1 file changed, 74 insertions(+), 104 deletions(-) diff --git a/tapdb/asset_minting.go b/tapdb/asset_minting.go index e7f5fbd41..ef052b6f5 100644 --- a/tapdb/asset_minting.go +++ b/tapdb/asset_minting.go @@ -18,7 +18,6 @@ import ( "github.com/lightninglabs/taproot-assets/proof" "github.com/lightninglabs/taproot-assets/tapdb/sqlc" "github.com/lightninglabs/taproot-assets/tapgarden" - "github.com/lightninglabs/taproot-assets/tapscript" "github.com/lightninglabs/taproot-assets/tapsend" "github.com/lightningnetwork/lnd/keychain" "golang.org/x/exp/maps" @@ -772,7 +771,7 @@ func fetchAssetSprouts(ctx context.Context, q PendingAssetStore, // We collect all the sprouts into fully grown assets, from which we'll // then create asset and tap level commitments. - assetSprouts := make([]*asset.Asset, len(dbSprout)) + sprouts := make([]*asset.Asset, len(dbSprout)) for i, sprout := range dbSprout { // First, we'll decode the script key which very asset must // specify, and populate the key locator information @@ -897,64 +896,27 @@ func fetchAssetSprouts(ctx context.Context, q PendingAssetStore, // TODO(roasbeef): need to update the above to set the // witnesses of a valid asset - assetSprouts[i] = assetSprout + sprouts[i] = assetSprout } - // Construct a TapCommitment from the batch sprouts, and verify that the - // version is correct by recomputing the genesis output script. - tapCommitment, err := commitment.FromAssets( - fn.Ptr(commitment.TapCommitmentV2), assetSprouts..., - ) - if err != nil { - return nil, err - } - - // If there are sprouts, let's find out whether they were created with - // a V2 commitment or not. + // Verify that we can reconstruct the genesis output script used in the + // anchor TX. batchKey, err := btcec.ParsePubKey(rawBatchKey) if err != nil { return nil, err } - var tapscriptSibling *chainhash.Hash + var tapSibling *chainhash.Hash if len(batchSibling) != 0 { - tapscriptSibling, err = chainhash.NewHash(batchSibling) + tapSibling, err = chainhash.NewHash(batchSibling) if err != nil { return nil, err } } - computedScript, err := tapscript.PayToAddrScript( - *batchKey, tapscriptSibling, *tapCommitment, + return tapgarden.VerifyOutputScript( + batchKey, tapSibling, genScript, sprouts, ) - if err != nil { - return nil, err - } - - if !bytes.Equal(genScript, computedScript) { - // The batch may have used a non-V2 commitment; check against a - // non-V2 commitment. - tapCommitment, err = commitment.FromAssets( - nil, assetSprouts..., - ) - if err != nil { - return nil, err - } - - computedScriptV0, err := tapscript.PayToAddrScript( - *batchKey, tapscriptSibling, *tapCommitment, - ) - if err != nil { - return nil, err - } - - if !bytes.Equal(genScript, computedScriptV0) { - return nil, fmt.Errorf("invalid commitment to asset "+ - "sprouts: batch %x", rawBatchKey) - } - } - - return tapCommitment, nil } // fetchAssetMetas attempts to fetch the asset meta reveal for each of the @@ -1190,20 +1152,36 @@ func marshalMintingBatch(ctx context.Context, q PendingAssetStore, } } - // Depending on what state this batch is in, we'll - // either fetch the set of seedlings (asset - // descriptions w/ no real assets), or the set of - // sprouts (full defined assets, but not yet mined). + // Depending on what state this batch is in, we'll either return the set + // of seedlings (asset descriptions w/ no real assets), or the set of + // sprouts (full defined assets, but not yet mined). In all cases, we + // start by fetching the batch seedlings. + batchSeedlings, err := fetchAssetSeedlings( + ctx, q, dbBatch.RawKey, + ) + if err != nil { + return nil, err + } + switch batchState { + // A batch in these states will only have seedlings. case tapgarden.BatchStatePending, tapgarden.BatchStateFrozen, tapgarden.BatchStateSeedlingCancelled: - // In this case we can just fetch the set of - // descriptions of future assets to be. - batch.Seedlings, err = fetchAssetSeedlings( - ctx, q, dbBatch.RawKey, - ) + batch.Seedlings = batchSeedlings + + // For finalized batches, we need to fetch the assets from the proof + // archiver and not the DB. Set the batch seedlings here so they can be + // used later to fetch those proofs. + case tapgarden.BatchStateFinalized: + // A finalized batch must have a populated genesis packet. + if batch.GenesisPacket == nil { + return nil, fmt.Errorf("sprouted batch missing " + + "genesis packet") + } + + batch.Seedlings = batchSeedlings default: if batch.GenesisPacket == nil { @@ -1217,18 +1195,10 @@ func marshalMintingBatch(ctx context.Context, q PendingAssetStore, } genesisTx := batch.GenesisPacket.Pkt.UnsignedTx genesisScript := genesisTx.TxOut[anchorOutputIndex].PkScript - - var tapscriptSibling []byte - if len(batch.TapSibling()) != 0 { - tapscriptSibling = batch.TapSibling() - } - + tapscriptSibling := batch.TapSibling() batch.RootAssetCommitment, err = fetchAssetSprouts( ctx, q, dbBatch.RawKey, tapscriptSibling, genesisScript, ) - if err != nil { - return nil, err - } // Finally, for each asset contained in the root // commitment above, we'll fetch the meta reveal for @@ -1238,9 +1208,9 @@ func marshalMintingBatch(ctx context.Context, q PendingAssetStore, batch.AssetMetas, err = fetchAssetMetas( ctx, q, assetsInBatch, ) - } - if err != nil { - return nil, err + if err != nil { + return nil, err + } } return batch, nil @@ -1363,57 +1333,57 @@ func (a *AssetMintingStore) AddSeedlingGroups(ctx context.Context, // FetchSeedlingGroups is used to fetch the asset groups for seedlings // associated with a funded batch. func (a *AssetMintingStore) FetchSeedlingGroups(ctx context.Context, - genesisPoint wire.OutPoint, anchorOutputIndex uint32, + genPoint wire.OutPoint, anchorOutputIndex uint32, seedlings []*tapgarden.Seedling) ([]*asset.AssetGroup, error) { - seedlingGroups := make([]*asset.AssetGroup, 0, len(seedlings)) - seedlingGens := make([]*asset.Genesis, 0, len(seedlings)) - - // Compute meta hashes and geneses before reading from the DB. - fn.ForEach(seedlings, func(seedling *tapgarden.Seedling) { - gen := &asset.Genesis{ - FirstPrevOut: genesisPoint, - Tag: seedling.AssetName, - OutputIndex: anchorOutputIndex, - Type: seedling.AssetType, - } - - if seedling.Meta != nil { - gen.MetaHash = seedling.Meta.MetaHash() - } + var ( + seedlingGroups []*asset.AssetGroup + err error + ) - seedlingGens = append(seedlingGens, gen) - }) + seedlingGens := fn.Map(seedlings, + func(s *tapgarden.Seedling) *asset.Genesis { + return fn.Ptr(s.Genesis(genPoint, anchorOutputIndex)) + }, + ) // Read geneses and asset groups. readOpts := NewAssetStoreReadTx() dbErr := a.db.ExecTx(ctx, &readOpts, func(q PendingAssetStore) error { - for i := range seedlingGens { - genID, err := fetchGenesisID(ctx, q, *seedlingGens[i]) - if err != nil { - // Re-map the error about a missing asset - // genesis so it can be better handled in the - // planter. - if errors.Is(err, ErrFetchGenesisID) { - return tapgarden.ErrNoGenesis - } + seedlingGroups, err = fetchSeedlingGroups(ctx, q, seedlingGens) + return err + }) + if dbErr != nil { + return nil, dbErr + } - return err - } + return seedlingGroups, nil +} - groupKey, err := fetchGroupByGenesis(ctx, q, genID) - if err != nil { - return err +// fetchSeedlingGroups fetches the asset groups for multiple geneses. +func fetchSeedlingGroups(ctx context.Context, q PendingAssetStore, + gens []*asset.Genesis) ([]*asset.AssetGroup, error) { + + seedlingGroups := make([]*asset.AssetGroup, 0, len(gens)) + for _, gen := range gens { + genID, err := fetchGenesisID(ctx, q, *gen) + if err != nil { + // Re-map the error about a missing asset + // genesis so it can be better handled in the + // planter. + if errors.Is(err, ErrFetchGenesisID) { + return nil, tapgarden.ErrNoGenesis } - seedlingGroups = append(seedlingGroups, groupKey) + return nil, err } - return nil - }) + groupKey, err := fetchGroupByGenesis(ctx, q, genID) + if err != nil { + return nil, err + } - if dbErr != nil { - return nil, dbErr + seedlingGroups = append(seedlingGroups, groupKey) } return seedlingGroups, nil From 154644fc5b5c8525ab0a25f8abe4bfdff573859a Mon Sep 17 00:00:00 2001 From: Jonathan Harvey-Buschel Date: Mon, 8 Jul 2024 20:19:26 -0400 Subject: [PATCH 4/7] proof+tapdb: add FetchIssuanceProof to Archiver In this commit, we add a new method to proof.Archiver to support querying for proofs with only the asset ID and anchor outpoint. This is useful when fetching issuance proofs in order to display genesis assets, as assets in the DB can be mutated if they are reanchored as passive assets (participate in a transfer as change). This is only implemented for the FileArchiver, as we can use proof file paths to efficiently fetch only the issuance proofs. --- proof/archive.go | 112 +++++++++++++++++++++++++++++++++++++++++- proof/mock.go | 112 +++++++++++++++++++++++++++++++++++++++++- tapdb/assets_store.go | 12 +++++ 3 files changed, 233 insertions(+), 3 deletions(-) diff --git a/proof/archive.go b/proof/archive.go index 3cd90e29c..8431117a8 100644 --- a/proof/archive.go +++ b/proof/archive.go @@ -145,6 +145,13 @@ type Archiver interface { // specific fields need to be set in the Locator (e.g. the OutPoint). FetchProof(ctx context.Context, id Locator) (Blob, error) + // FetchIssuanceProof fetches the issuance proof for an asset, given the + // anchor point of the issuance (NOT the genesis point for the asset). + // + // If a proof cannot be found, then ErrProofNotFound should be returned. + FetchIssuanceProof(ctx context.Context, id asset.ID, + anchorOutpoint wire.OutPoint) (Blob, error) + // HasProof returns true if the proof for the given locator exists. This // is intended to be a performance optimized lookup compared to fetching // a proof and checking for ErrProofNotFound. @@ -385,6 +392,7 @@ func lookupProofFilePath(rootPath string, loc Locator) (string, error) { assetID := hex.EncodeToString(loc.AssetID[:]) scriptKey := hex.EncodeToString(loc.ScriptKey.SerializeCompressed()) + // TODO(jhb): Check for correct file suffix and truncated outpoint? searchPattern := filepath.Join(rootPath, assetID, scriptKey+"*") matches, err := filepath.Glob(searchPattern) if err != nil { @@ -529,6 +537,78 @@ func (f *FileArchiver) FetchProof(_ context.Context, id Locator) (Blob, error) { return proofFile, nil } +// FetchIssuanceProof fetches the issuance proof for an asset, given the +// anchor point of the issuance (NOT the genesis point for the asset). +// +// If a proof cannot be found, then ErrProofNotFound should be returned. +// +// NOTE: This implements the Archiver interface. +func (f *FileArchiver) FetchIssuanceProof(ctx context.Context, id asset.ID, + anchorOutpoint wire.OutPoint) (Blob, error) { + + // Construct a pattern to search for the issuance proof file. We'll + // leave the script key unspecified, as we don't know what the script + // key was at genesis. + assetID := hex.EncodeToString(id[:]) + scriptKeyGlob := strings.Repeat("?", 2*btcec.PubKeyBytesLenCompressed) + truncatedHash := anchorOutpoint.Hash.String()[:outpointTruncateLength] + + fileName := fmt.Sprintf("%s-%s-%d.%s", + scriptKeyGlob, truncatedHash, anchorOutpoint.Index, + TaprootAssetsFileEnding) + + searchPattern := filepath.Join(f.proofPath, assetID, fileName) + matches, err := filepath.Glob(searchPattern) + if err != nil { + return nil, fmt.Errorf("error listing proof files: %w", err) + } + if len(matches) == 0 { + return nil, ErrProofNotFound + } + + // We expect exactly one matching proof for a specific asset ID and + // outpoint. However, the proof file path uses the truncated outpoint, + // so an asset transfer with a collision in the first half of the TXID + // could also match. We can filter out such proof files by size. + proofFiles := make([]Blob, 0, len(matches)) + for _, path := range matches { + proofFile, err := os.ReadFile(path) + + switch { + case os.IsNotExist(err): + return nil, ErrProofNotFound + + case err != nil: + return nil, fmt.Errorf("unable to find proof: %w", err) + } + + proofFiles = append(proofFiles, proofFile) + } + + switch { + // No proofs were read. + case len(proofFiles) == 0: + return nil, ErrProofNotFound + + // Exactly one proof, we'll return it. + case len(proofFiles) == 1: + return proofFiles[0], nil + + // Multiple proofs, return the smallest one. + default: + minProofIdx := 0 + minProofSize := len(proofFiles[minProofIdx]) + for idx, proof := range proofFiles { + if len(proof) < minProofSize { + minProofSize = len(proof) + minProofIdx = idx + } + } + + return proofFiles[minProofIdx], nil + } +} + // HasProof returns true if the proof for the given locator exists. This is // intended to be a performance optimized lookup compared to fetching a proof // and checking for ErrProofNotFound. @@ -704,10 +784,13 @@ func (f *FileArchiver) RemoveSubscriber( return f.eventDistributor.RemoveSubscriber(subscriber) } -// A compile-time interface to ensure FileArchiver meets the NotifyArchiver +// A compile-time assertion to ensure FileArchiver meets the NotifyArchiver // interface. var _ NotifyArchiver = (*FileArchiver)(nil) +// A compile-time assertion to ensure FileArchiver meets the Archiver interface. +var _ Archiver = (*FileArchiver)(nil) + // MultiArchiver is an archive of archives. It contains several archives and // attempts to use them either as a look-aside cache, or a write through cache // for all incoming requests. @@ -763,6 +846,33 @@ func (m *MultiArchiver) FetchProof(ctx context.Context, return nil, ErrProofNotFound } +// FetchIssuanceProof fetches the issuance proof for an asset, given the +// anchor point of the issuance (NOT the genesis point for the asset). +func (m *MultiArchiver) FetchIssuanceProof(ctx context.Context, + id asset.ID, anchorOutpoint wire.OutPoint) (Blob, error) { + + // Iterate through all our active backends and try to see if at least + // one of them contains the proof. Either one of them will have the + // proof, or we'll return an error back to the user. + for _, archive := range m.backends { + proof, err := archive.FetchIssuanceProof( + ctx, id, anchorOutpoint, + ) + + switch { + case errors.Is(err, ErrProofNotFound): + continue + + case err != nil: + return nil, err + } + + return proof, nil + } + + return nil, ErrProofNotFound +} + // HasProof returns true if the proof for the given locator exists. This is // intended to be a performance optimized lookup compared to fetching a proof // and checking for ErrProofNotFound. The multi archiver only considers a proof diff --git a/proof/mock.go b/proof/mock.go index f19272eae..081f6d6e3 100644 --- a/proof/mock.go +++ b/proof/mock.go @@ -22,6 +22,7 @@ import ( "github.com/lightninglabs/taproot-assets/internal/test" "github.com/lightningnetwork/lnd/keychain" "github.com/lightningnetwork/lnd/lnutils" + "github.com/lightningnetwork/lnd/lnwire" "github.com/stretchr/testify/require" ) @@ -258,16 +259,58 @@ var _ ChainLookupGenerator = (*mockChainLookup)(nil) // MockProofArchive is a map that implements the Archiver interface. type MockProofArchive struct { - proofs lnutils.SyncMap[[32]byte, Blob] + proofs lnutils.SyncMap[[32]byte, Blob] + locators lnutils.SyncMap[[132]byte, [32]byte] } // NewMockProofArchive creates a new mock proof archive. func NewMockProofArchive() *MockProofArchive { return &MockProofArchive{ - proofs: lnutils.SyncMap[[32]byte, Blob]{}, + proofs: lnutils.SyncMap[[32]byte, Blob]{}, + locators: lnutils.SyncMap[[132]byte, [32]byte]{}, } } +// storeLocator stores the locator as a byte array to allow for pattern matching +// over the locators for the stored proofs, similar to the FileArchiver +// implementation of FetchIssuanceProof. +func (m *MockProofArchive) storeLocator(id Locator) error { + var locBuf bytes.Buffer + + if id.AssetID == nil { + return fmt.Errorf("missing asset ID") + } + + locBuf.Write(id.AssetID[:]) + if id.GroupKey != nil { + locBuf.Write(id.GroupKey.SerializeCompressed()) + } else { + locBuf.Write(bytes.Repeat([]byte{0x00}, 33)) + } + + locBuf.Write(id.ScriptKey.SerializeCompressed()) + if id.OutPoint != nil { + err := lnwire.WriteOutPoint(&locBuf, *id.OutPoint) + if err != nil { + return err + } + } else { + locBuf.Write(bytes.Repeat([]byte{0x00}, 34)) + } + + var locArray [132]byte + copy(locArray[:], locBuf.Bytes()) + + locHash, err := id.Hash() + if err != nil { + return err + } + + m.locators.Store(locArray, locHash) + + return nil +} + // FetchProof fetches a proof for an asset uniquely identified by the passed // Locator. If a proof cannot be found, then ErrProofNotFound is returned. func (m *MockProofArchive) FetchProof(_ context.Context, @@ -286,6 +329,66 @@ func (m *MockProofArchive) FetchProof(_ context.Context, return proof, nil } +// FetchIssuanceProof fetches the issuance proof for an asset, given the +// anchor point of the issuance (NOT the genesis point for the asset). +// +// If a proof cannot be found, then ErrProofNotFound should be returned. +func (m *MockProofArchive) FetchIssuanceProof(_ context.Context, + id asset.ID, anchorOutpoint wire.OutPoint) (Blob, error) { + + var outpointBuf bytes.Buffer + err := lnwire.WriteOutPoint(&outpointBuf, anchorOutpoint) + if err != nil { + return nil, err + } + + // Mimic the pattern matching done with proof file paths in + // FileArchiver.FetchIssuanceProof(). + matchingHashes := make([][32]byte, 0) + locMatcher := func(locBytes [132]byte, locHash [32]byte) error { + if bytes.Equal(locBytes[:32], id[:]) && + bytes.Equal(locBytes[98:], outpointBuf.Bytes()) { + + matchingHashes = append(matchingHashes, locHash) + } + + return nil + } + + m.locators.ForEach(locMatcher) + if len(matchingHashes) == 0 { + return nil, ErrProofNotFound + } + + matchingProofs := make([]Blob, 0) + for _, locHash := range matchingHashes { + proof, ok := m.proofs.Load(locHash) + if !ok { + return nil, ErrProofNotFound + } + + matchingProofs = append(matchingProofs, proof) + } + + switch { + case len(matchingProofs) == 1: + return matchingProofs[0], nil + + // Multiple proofs, return the smallest one. + default: + minProofIdx := 0 + minProofSize := len(matchingProofs[minProofIdx]) + for idx, proof := range matchingProofs { + if len(proof) < minProofSize { + minProofSize = len(proof) + minProofIdx = idx + } + } + + return matchingProofs[minProofIdx], nil + } +} + // HasProof returns true if the proof for the given locator exists. func (m *MockProofArchive) HasProof(_ context.Context, id Locator) (bool, error) { @@ -314,6 +417,11 @@ func (m *MockProofArchive) ImportProofs(_ context.Context, _ HeaderVerifier, proofs ...*AnnotatedProof) error { for _, proof := range proofs { + err := m.storeLocator(proof.Locator) + if err != nil { + return fmt.Errorf("mock archive failed: %w", err) + } + locHash, err := proof.Locator.Hash() if err != nil { return err diff --git a/tapdb/assets_store.go b/tapdb/assets_store.go index bc86fd3a2..78c29bf86 100644 --- a/tapdb/assets_store.go +++ b/tapdb/assets_store.go @@ -1299,6 +1299,18 @@ func locatorToProofQuery(locator proof.Locator) (FetchAssetProof, error) { return args, nil } +// FetchIssuanceProof fetches the issuance proof for an asset, given the +// anchor point of the issuance (NOT the genesis point for the asset). For the +// AssetStore, we leave this unimplemented as we will only use this feature from +// the FileArchiver. +// +// NOTE: This implements the proof.Archiver interface. +func (a *AssetStore) FetchIssuanceProof(ctx context.Context, id asset.ID, + anchorOutpoint wire.OutPoint) (proof.Blob, error) { + + return nil, proof.ErrProofNotFound +} + // HasProof returns true if the proof for the given locator exists. This is // intended to be a performance optimized lookup compared to fetching a proof // and checking for ErrProofNotFound. From 660940e55aedd38bd0c42268eb59e10d3ea94bb8 Mon Sep 17 00:00:00 2001 From: Jonathan Harvey-Buschel Date: Tue, 9 Jul 2024 20:22:09 -0400 Subject: [PATCH 5/7] tapdb: add FetchScriptKeyStore interface --- tapdb/addrs.go | 47 +++++++++++------------------------------- tapdb/asset_minting.go | 31 ++++++++++++++++++++++++++++ tapdb/assets_common.go | 43 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 86 insertions(+), 35 deletions(-) diff --git a/tapdb/addrs.go b/tapdb/addrs.go index 7b03e4790..1270f2d7b 100644 --- a/tapdb/addrs.go +++ b/tapdb/addrs.go @@ -92,6 +92,10 @@ type AddrBook interface { // asset groups related to them. GroupStore + // FetchScriptKeyStore houses the methods related to fetching all + // information about a script key. + FetchScriptKeyStore + // FetchAddrs returns all the addresses based on the constraints of the // passed AddrQuery. FetchAddrs(ctx context.Context, arg AddrQuery) ([]Addresses, error) @@ -153,11 +157,6 @@ type AddrBook interface { FetchGenesisByAssetID(ctx context.Context, assetID []byte) (sqlc.GenesisInfoView, error) - // FetchScriptKeyByTweakedKey attempts to fetch the script key and - // corresponding internal key from the database. - FetchScriptKeyByTweakedKey(ctx context.Context, - tweakedScriptKey []byte) (ScriptKey, error) - // FetchInternalKeyLocator fetches the key locator for an internal key. FetchInternalKeyLocator(ctx context.Context, rawKey []byte) (KeyLocator, error) @@ -1158,43 +1157,21 @@ func (t *TapAddressBook) FetchScriptKey(ctx context.Context, tweakedScriptKey *btcec.PublicKey) (*asset.TweakedScriptKey, error) { var ( - readOpts = NewAddrBookReadTx() scriptKey *asset.TweakedScriptKey + err error ) - err := t.db.ExecTx(ctx, &readOpts, func(db AddrBook) error { - dbKey, err := db.FetchScriptKeyByTweakedKey( - ctx, tweakedScriptKey.SerializeCompressed(), - ) - if err != nil { - return err - } - rawKey, err := btcec.ParsePubKey(dbKey.RawKey) - if err != nil { - return fmt.Errorf("unable to parse raw key: %w", err) - } - - scriptKey = &asset.TweakedScriptKey{ - Tweak: dbKey.Tweak, - RawKey: keychain.KeyDescriptor{ - PubKey: rawKey, - KeyLocator: keychain.KeyLocator{ - Family: keychain.KeyFamily( - dbKey.KeyFamily, - ), - Index: uint32(dbKey.KeyIndex), - }, - }, - DeclaredKnown: dbKey.DeclaredKnown.Valid, - } - - return nil + readOpts := NewAddrBookReadTx() + dbErr := t.db.ExecTx(ctx, &readOpts, func(db AddrBook) error { + scriptKey, err = fetchScriptKey(ctx, db, tweakedScriptKey) + return err }) + switch { - case errors.Is(err, sql.ErrNoRows): + case errors.Is(dbErr, sql.ErrNoRows): return nil, address.ErrScriptKeyNotFound - case err != nil: + case dbErr != nil: return nil, err } diff --git a/tapdb/asset_minting.go b/tapdb/asset_minting.go index ef052b6f5..5c22a36dc 100644 --- a/tapdb/asset_minting.go +++ b/tapdb/asset_minting.go @@ -140,6 +140,10 @@ type PendingAssetStore interface { // GroupStore houses the methods related to querying asset groups. GroupStore + // FetchScriptKeyStore houses the methods related to fetching all + // information about a script key. + FetchScriptKeyStore + // TapscriptTreeStore houses the methods related to storing, fetching, // and deleting tapscript trees. TapscriptTreeStore @@ -1695,6 +1699,33 @@ func (a *AssetMintingStore) FetchGroupByGroupKey(ctx context.Context, return dbGroup, nil } +// FetchScriptKeyByTweakedKey fetches the populated script key given the tweaked +// script key. +func (a *AssetMintingStore) FetchScriptKeyByTweakedKey(ctx context.Context, + tweakedKey *btcec.PublicKey) (*asset.TweakedScriptKey, error) { + + var ( + scriptKey *asset.TweakedScriptKey + err error + ) + + readOpts := NewAssetStoreReadTx() + dbErr := a.db.ExecTx(ctx, &readOpts, func(q PendingAssetStore) error { + scriptKey, err = fetchScriptKey(ctx, q, tweakedKey) + return err + }) + + switch { + case errors.Is(dbErr, sql.ErrNoRows): + return nil, fmt.Errorf("script key not found") + + case dbErr != nil: + return nil, err + } + + return scriptKey, nil +} + // StoreTapscriptTree persists a Tapscript tree given a validated set of // TapLeafs or a TapBranch. If the store succeeds, the root hash of the // Tapscript tree is returned. diff --git a/tapdb/assets_common.go b/tapdb/assets_common.go index f84a27d6f..0e66b363b 100644 --- a/tapdb/assets_common.go +++ b/tapdb/assets_common.go @@ -422,6 +422,49 @@ func upsertScriptKey(ctx context.Context, scriptKey asset.ScriptKey, return scriptKeyID, nil } +// FetchScriptKeyStore houses the methods related to fetching all information +// about a script key. +type FetchScriptKeyStore interface { + // FetchScriptKeyByTweakedKey attempts to fetch the script key and + // corresponding internal key from the database. + FetchScriptKeyByTweakedKey(ctx context.Context, + tweakedScriptKey []byte) (ScriptKey, error) +} + +// fetchScriptKey attempts to fetch the full tweaked script key struct +// (including the key descriptor) for the given tweaked script key. +func fetchScriptKey(ctx context.Context, q FetchScriptKeyStore, + tweakedScriptKey *btcec.PublicKey) (*asset.TweakedScriptKey, error) { + + dbKey, err := q.FetchScriptKeyByTweakedKey( + ctx, tweakedScriptKey.SerializeCompressed(), + ) + if err != nil { + return nil, err + } + + rawKey, err := btcec.ParsePubKey(dbKey.RawKey) + if err != nil { + return nil, fmt.Errorf("unable to parse raw key: %w", err) + } + + scriptKey := &asset.TweakedScriptKey{ + Tweak: dbKey.Tweak, + RawKey: keychain.KeyDescriptor{ + PubKey: rawKey, + KeyLocator: keychain.KeyLocator{ + Family: keychain.KeyFamily( + dbKey.KeyFamily, + ), + Index: uint32(dbKey.KeyIndex), + }, + }, + DeclaredKnown: dbKey.DeclaredKnown.Valid, + } + + return scriptKey, nil +} + // FetchGenesisStore houses the methods related to fetching genesis assets. type FetchGenesisStore interface { // FetchGenesisByID returns a single genesis asset by its primary key From 4f16d2d729788642519ff5c4737c400b5b698879 Mon Sep 17 00:00:00 2001 From: Jonathan Harvey-Buschel Date: Wed, 3 Jul 2024 21:27:33 -0400 Subject: [PATCH 6/7] tapgarden: fetch finalized batch via file archiver In this commit, we update listBatches to fetch assets from finalized batches via the proof file archiver instead of the DB. Given the batch seedlings, we fetch issuance proofs and decode the asset in its genesis state. We also verify that we can recompute the genesis output script with the fetched assets. --- tapgarden/interface.go | 5 + tapgarden/planter.go | 215 +++++++++++++++++++++++++++++++++++++---- 2 files changed, 202 insertions(+), 18 deletions(-) diff --git a/tapgarden/interface.go b/tapgarden/interface.go index 266389db5..cfe9b71ff 100644 --- a/tapgarden/interface.go +++ b/tapgarden/interface.go @@ -262,6 +262,11 @@ type MintingStore interface { FetchGroupByGroupKey(ctx context.Context, groupKey *btcec.PublicKey) (*asset.AssetGroup, error) + // FetchScriptKeyByTweakedKey fetches the populated script key given the + // tweaked script key. + FetchScriptKeyByTweakedKey(ctx context.Context, + tweakedKey *btcec.PublicKey) (*asset.TweakedScriptKey, error) + // FetchAssetMeta fetches the meta reveal for an asset genesis. FetchAssetMeta(ctx context.Context, ID asset.ID) (*proof.MetaReveal, error) diff --git a/tapgarden/planter.go b/tapgarden/planter.go index c89deb668..59cde5c85 100644 --- a/tapgarden/planter.go +++ b/tapgarden/planter.go @@ -5,6 +5,7 @@ import ( "context" "errors" "fmt" + "slices" "sync" "time" @@ -724,10 +725,150 @@ func freezeMintingBatch(ctx context.Context, batchStore MintingStore, ) } +// filterFinalizedBatches separates a set of batches into two sets based on +// their batch state. +func filterFinalizedBatches(batches []*MintingBatch) ([]*MintingBatch, + []*MintingBatch) { + + finalized := []*MintingBatch{} + nonFinalized := []*MintingBatch{} + + fn.ForEach(batches, func(batch *MintingBatch) { + switch batch.State() { + case BatchStateFinalized: + finalized = append(finalized, batch) + default: + nonFinalized = append(nonFinalized, batch) + } + }) + + return finalized, nonFinalized +} + +// fetchFinalizedBatch fetches the assets of a batch in their genesis state, +// given a batch populated with seedlings. +func fetchFinalizedBatch(ctx context.Context, batchStore MintingStore, + archiver proof.Archiver, batch *MintingBatch) (*MintingBatch, error) { + + // Collect genesis TX information from the batch to build the proof + // locators. + anchorOutputIndex := extractAnchorOutputIndex(batch.GenesisPacket) + signedTx, err := psbt.Extract(batch.GenesisPacket.Pkt) + if err != nil { + return nil, err + } + + genOutpoint := extractGenesisOutpoint(signedTx) + genScript := signedTx.TxOut[anchorOutputIndex].PkScript + anchorOutpoint := wire.OutPoint{ + Hash: signedTx.TxHash(), + Index: anchorOutputIndex, + } + + batchAssets := make([]*asset.Asset, 0, len(batch.Seedlings)) + assetMetas := make(AssetMetas) + for _, seedling := range batch.Seedlings { + gen := seedling.Genesis(genOutpoint, anchorOutputIndex) + issuanceProof, err := archiver.FetchIssuanceProof( + ctx, gen.ID(), anchorOutpoint, + ) + if err != nil { + return nil, err + } + + proofFile, err := issuanceProof.AsFile() + if err != nil { + return nil, err + } + + if proofFile.NumProofs() != 1 { + return nil, fmt.Errorf("expected single proof for " + + "issuance proof") + } + + rawProof, err := proofFile.RawLastProof() + if err != nil { + return nil, err + } + + // Decode the sprouted asset from the issuance proof. + var sproutedAsset asset.Asset + assetRecord := proof.AssetLeafRecord(&sproutedAsset) + err = proof.SparseDecode(bytes.NewReader(rawProof), assetRecord) + if err != nil { + return nil, fmt.Errorf("unable to decode issuance "+ + "proof: %w", err) + } + + if !sproutedAsset.IsGenesisAsset() { + return nil, fmt.Errorf("decoded asset is not a " + + "genesis asset") + } + + // Populate the key info for the script key and group key. + if sproutedAsset.ScriptKey.PubKey == nil { + return nil, fmt.Errorf("decoded asset is missing " + + "script key") + } + + tweakedScriptKey, err := batchStore.FetchScriptKeyByTweakedKey( + ctx, sproutedAsset.ScriptKey.PubKey, + ) + if err != nil { + return nil, err + } + + sproutedAsset.ScriptKey.TweakedScriptKey = tweakedScriptKey + if sproutedAsset.GroupKey != nil { + assetGroup, err := batchStore.FetchGroupByGroupKey( + ctx, &sproutedAsset.GroupKey.GroupPubKey, + ) + if err != nil { + return nil, err + } + + sproutedAsset.GroupKey = assetGroup.GroupKey + } + + batchAssets = append(batchAssets, &sproutedAsset) + scriptKey := asset.ToSerialized(sproutedAsset.ScriptKey.PubKey) + assetMetas[scriptKey] = seedling.Meta + } + + // Verify that we can reconstruct the genesis output script used in the + // anchor TX. + batchSibling := batch.TapSibling() + var tapSibling *chainhash.Hash + if len(batchSibling) != 0 { + var err error + tapSibling, err = chainhash.NewHash(batchSibling) + if err != nil { + return nil, err + } + } + + tapCommitment, err := VerifyOutputScript( + batch.BatchKey.PubKey, tapSibling, genScript, batchAssets, + ) + + if err != nil { + return nil, err + } + + // With the batch assets validated, construct the populated finalized + // batch. + batch.Seedlings = nil + finalizedBatch := batch.Copy() + finalizedBatch.RootAssetCommitment = tapCommitment + finalizedBatch.AssetMetas = assetMetas + + return finalizedBatch, nil +} + // ListBatches returns the single batch specified by the batch key, or the set // of batches not yet finalized on disk. func listBatches(ctx context.Context, batchStore MintingStore, - genBuilder asset.GenesisTxBuilder, + archiver proof.Archiver, genBuilder asset.GenesisTxBuilder, params ListBatchesParams) ([]*VerboseBatch, error) { var ( @@ -747,12 +888,54 @@ func listBatches(ctx context.Context, batchStore MintingStore, return nil, err } - verboseBatches := fn.Map(batches, func(b *MintingBatch) *VerboseBatch { - return &VerboseBatch{ - MintingBatch: b, - UnsealedSeedlings: nil, + var ( + finalBatches, nonFinalBatches = filterFinalizedBatches(batches) + verboseBatches []*VerboseBatch + ) + + switch { + case len(finalBatches) == 0: + verboseBatches = fn.Map(batches, + func(b *MintingBatch) *VerboseBatch { + return &VerboseBatch{ + MintingBatch: b, + UnsealedSeedlings: nil, + } + }, + ) + + // For finalized batches, we need to fetch the assets from the proof + // archiver, not the DB. + default: + finalizedBatches := make([]*MintingBatch, 0, len(finalBatches)) + for _, batch := range finalBatches { + finalizedBatch, err := fetchFinalizedBatch( + ctx, batchStore, archiver, batch, + ) + if err != nil { + return nil, err + } + + finalizedBatches = append( + finalizedBatches, finalizedBatch, + ) } - }) + + // Re-sort the batches by creation time for consistent display. + allBatches := append(nonFinalBatches, finalizedBatches...) + slices.SortFunc(allBatches, func(a, b *MintingBatch) int { + return a.CreationTime.Compare(b.CreationTime) + }) + + verboseBatches = fn.Map(allBatches, + func(b *MintingBatch) *VerboseBatch { + return &VerboseBatch{ + MintingBatch: b, + UnsealedSeedlings: nil, + } + }, + ) + } // Return the batches without any extra asset group info. if !params.Verbose { @@ -787,11 +970,9 @@ func listBatches(ctx context.Context, batchStore MintingStore, // Before we can build the group key requests for each seedling, // we must fetch the genesis point and anchor index for the // batch. - anchorOutputIndex := uint32(0) - if currentBatch.GenesisPacket.ChangeOutputIndex == 0 { - anchorOutputIndex = 1 - } - + anchorOutputIndex := extractAnchorOutputIndex( + currentBatch.GenesisPacket, + ) genesisPoint := extractGenesisOutpoint( currentBatch.GenesisPacket.Pkt.UnsignedTx, ) @@ -1030,8 +1211,8 @@ func (c *ChainPlanter) gardener() { ctx, cancel := c.WithCtxQuit() batches, err := listBatches( - ctx, c.cfg.Log, c.cfg.GenTxBuilder, - *listBatchesParams, + ctx, c.cfg.Log, c.cfg.ProofFiles, + c.cfg.GenTxBuilder, *listBatchesParams, ) cancel() if err != nil { @@ -1311,11 +1492,9 @@ func (c *ChainPlanter) sealBatch(ctx context.Context, params SealParams, // Before we can build the group key requests for each seedling, we must // fetch the genesis point and anchor index for the batch. - anchorOutputIndex := uint32(0) - if workingBatch.GenesisPacket.ChangeOutputIndex == 0 { - anchorOutputIndex = 1 - } - + anchorOutputIndex := extractAnchorOutputIndex( + workingBatch.GenesisPacket, + ) genesisPoint := extractGenesisOutpoint( workingBatch.GenesisPacket.Pkt.UnsignedTx, ) From 62b431eb492f7a59bbd157b5a1e8861482f90f12 Mon Sep 17 00:00:00 2001 From: Jonathan Harvey-Buschel Date: Wed, 3 Jul 2024 21:57:27 -0400 Subject: [PATCH 7/7] itest: check batch equality after a transfer --- itest/assets_test.go | 91 +++++++++++++++++++++++++++++++++++++- itest/test_list_on_test.go | 4 ++ 2 files changed, 94 insertions(+), 1 deletion(-) diff --git a/itest/assets_test.go b/itest/assets_test.go index d30c3b0e8..d4648c9f8 100644 --- a/itest/assets_test.go +++ b/itest/assets_test.go @@ -5,6 +5,8 @@ import ( "context" "crypto/tls" "net/http" + "slices" + "strings" "time" "github.com/btcsuite/btcd/btcec/v2" @@ -24,6 +26,7 @@ import ( "github.com/stretchr/testify/require" "golang.org/x/exp/maps" "golang.org/x/net/http2" + "google.golang.org/protobuf/proto" ) var ( @@ -438,7 +441,6 @@ func testMintAssetsWithTapscriptSibling(t *harnessTest) { rpcIssuableAssets := MintAssetsConfirmBatch( t.t, t.lndHarness.Miner.Client, t.tapd, issuableAssets, ) - AssertAssetBalances(t.t, t.tapd, rpcSimpleAssets, rpcIssuableAssets) // Filter the managed UTXOs to select the genesis UTXO with the @@ -528,3 +530,90 @@ func testMintAssetsWithTapscriptSibling(t *harnessTest) { t.lndHarness.MineBlocksAndAssertNumTxes(1, 1) t.lndHarness.AssertNumUTXOsWithConf(t.lndHarness.Bob, 1, 1, 1) } + +// testMintBatchAndTransfer tests that we can mint a batch of assets, observe +// the finalized batch state, and observe the same batch state after a transfer +// of an asset from the batch. +func testMintBatchAndTransfer(t *harnessTest) { + ctxb := context.Background() + rpcSimpleAssets := MintAssetsConfirmBatch( + t.t, t.lndHarness.Miner.Client, t.tapd, simpleAssets, + ) + + // List the batch right after minting. + originalBatches, err := t.tapd.ListBatches( + ctxb, &mintrpc.ListBatchRequest{}, + ) + require.NoError(t.t, err) + + // We'll make a second node now that'll be the receiver of all the + // assets made above. + secondTapd := setupTapdHarness( + t.t, t, t.lndHarness.Bob, t.universeServer, + ) + defer func() { + require.NoError(t.t, secondTapd.stop(!*noDelete)) + }() + + // In order to force a split, we don't try to send the full first asset. + a := rpcSimpleAssets[0] + addr, events := NewAddrWithEventStream( + t.t, secondTapd, &taprpc.NewAddrRequest{ + AssetId: a.AssetGenesis.AssetId, + Amt: a.Amount - 1, + AssetVersion: a.Version, + }, + ) + + AssertAddrCreated(t.t, secondTapd, a, addr) + + sendResp, sendEvents := sendAssetsToAddr(t, t.tapd, addr) + sendRespJSON, err := formatProtoJSON(sendResp) + require.NoError(t.t, err) + + t.Logf("Got response from sending assets: %v", sendRespJSON) + + // Make sure that eventually we see a single event for the + // address. + AssertAddrEvent(t.t, secondTapd, addr, 1, statusDetected) + + // Mine a block to make sure the events are marked as confirmed. + MineBlocks(t.t, t.lndHarness.Miner.Client, 1, 1) + + // Eventually the event should be marked as confirmed. + AssertAddrEvent(t.t, secondTapd, addr, 1, statusConfirmed) + + // Make sure we have imported and finalized all proofs. + AssertNonInteractiveRecvComplete(t.t, secondTapd, 1) + AssertSendEventsComplete(t.t, addr.ScriptKey, sendEvents) + + // Make sure the receiver has received all events in order for + // the address. + AssertReceiveEvents(t.t, addr, events) + + afterBatches, err := t.tapd.ListBatches( + ctxb, &mintrpc.ListBatchRequest{}, + ) + require.NoError(t.t, err) + + // The batch listed after the transfer should be identical to the batch + // listed before the transfer. + require.Equal( + t.t, len(originalBatches.Batches), len(afterBatches.Batches), + ) + + originalBatch := originalBatches.Batches[0].Batch + afterBatch := afterBatches.Batches[0].Batch + + // Sort the assets from the listed batch before comparison. + slices.SortFunc(originalBatch.Assets, + func(a, b *mintrpc.PendingAsset) int { + return strings.Compare(a.Name, b.Name) + }) + slices.SortFunc(afterBatch.Assets, + func(a, b *mintrpc.PendingAsset) int { + return strings.Compare(a.Name, b.Name) + }) + + require.True(t.t, proto.Equal(originalBatch, afterBatch)) +} diff --git a/itest/test_list_on_test.go b/itest/test_list_on_test.go index e3b3585f5..7f6c2c731 100644 --- a/itest/test_list_on_test.go +++ b/itest/test_list_on_test.go @@ -13,6 +13,10 @@ var testCases = []*testCase{ name: "mint batch resume", test: testMintBatchResume, }, + { + name: "mint batch and transfer", + test: testMintBatchAndTransfer, + }, { name: "asset meta validation", test: testAssetMeta,