diff --git a/tapgarden/batch.go b/tapgarden/batch.go index c439bd84f..df8b4e7a4 100644 --- a/tapgarden/batch.go +++ b/tapgarden/batch.go @@ -50,9 +50,6 @@ type MintingBatch struct { // GenesisPacket is the funded genesis packet that may or may not be // fully signed. When broadcast, this will create all assets stored // within this batch. - // - // NOTE: This field is only set if the state is beyond - // BatchStateCommitted. GenesisPacket *tapsend.FundedPsbt // RootAssetCommitment is the root Taproot Asset commitment for all the diff --git a/tapgarden/planter.go b/tapgarden/planter.go index 667630a84..be61507ff 100644 --- a/tapgarden/planter.go +++ b/tapgarden/planter.go @@ -861,7 +861,13 @@ func (c *ChainPlanter) gardener() { req.Resolve(batches) case reqTypeFundBatch: - log.Infof("Funding batch") + if c.pendingBatch != nil && + c.pendingBatch.IsFunded() { + + req.Error(fmt.Errorf("batch already " + + "funded")) + break + } fundReqParams, err := typedParam[FundParams](req) @@ -880,6 +886,8 @@ func (c *ChainPlanter) gardener() { break } + req.Resolve(c.pendingBatch) + // TODO(jhb): follow-up PR: Implement SealBatch command case reqTypeSealBatch: req.Error(fmt.Errorf("not yet implemented")) @@ -1172,6 +1180,15 @@ func (c *ChainPlanter) finalizeBatch(params FinalizeParams) (*BatchCaretaker, err error ) + // Before modifying the pending batch, check if the batch was already + // funded. If so, reject any provided parameters, as they would conflict + // with those previously used for batch funding. + haveParams := params.FeeRate.IsSome() || params.SiblingTapTree.IsSome() + if haveParams && c.pendingBatch.IsFunded() { + return nil, fmt.Errorf("cannot provide finalize parameters " + + "if batch already funded") + } + // Process the finalize parameters. feeRate = params.FeeRate.UnwrapToPtr() @@ -1409,7 +1426,7 @@ func (c *ChainPlanter) prepAssetSeedling(ctx context.Context, req.ScriptKey = asset.NewScriptKeyBip86(scriptKey) } - // Now that we know the field are valid, we'll check to see if a batch + // Now that we know the seedling is valid, we'll check to see if a batch // already exists. switch { // No batch, so we'll create a new one with only this seedling as part diff --git a/tapgarden/planter_test.go b/tapgarden/planter_test.go index 850c80158..8b6705ef9 100644 --- a/tapgarden/planter_test.go +++ b/tapgarden/planter_test.go @@ -47,6 +47,19 @@ var ( tapgarden.BatchStateSeedlingCancelled, tapgarden.BatchStateSproutCancelled, ) + batchFrozenStates = fn.NewSet( + tapgarden.BatchStateFrozen, + tapgarden.BatchStateCommitted, + tapgarden.BatchStateBroadcast, + tapgarden.BatchStateConfirmed, + tapgarden.BatchStateFinalized, + ) + batchCommittedStates = fn.NewSet( + tapgarden.BatchStateCommitted, + tapgarden.BatchStateBroadcast, + tapgarden.BatchStateConfirmed, + tapgarden.BatchStateFinalized, + ) ) // newMintingStore creates a new instance of the TapAddressBook book. @@ -83,8 +96,6 @@ type mintingTestHarness struct { planter *tapgarden.ChainPlanter - batchKey *keychain.KeyDescriptor - proofFiles *tapgarden.MockProofArchive proofWatcher *tapgarden.MockProofWatcher @@ -152,10 +163,7 @@ func (t *mintingTestHarness) newRandSeedlings(numSeedlings int) []*tapgarden.See seedlings := make([]*tapgarden.Seedling, numSeedlings) for i := 0; i < numSeedlings; i++ { var n [32]byte - if _, err := rand.Read(n[:]); err != nil { - t.Fatalf("unable to read str: %v", err) - } - + test.RandRead(t, n[:]) assetName := hex.EncodeToString(n[:]) seedlings[i] = &tapgarden.Seedling{ AssetVersion: asset.Version(rand.Int31n(2)), @@ -187,11 +195,30 @@ func (t *mintingTestHarness) assertKeyDerived() *keychain.KeyDescriptor { // queueSeedlingsInBatch adds the series of seedlings to the batch, an error is // raised if any of the seedlings aren't accepted. -func (t *mintingTestHarness) queueSeedlingsInBatch( +func (t *mintingTestHarness) queueSeedlingsInBatch(isFunded bool, seedlings ...*tapgarden.Seedling) { for i, seedling := range seedlings { seedling := seedling + keyCount := 0 + + // For the first seedling sent, we should get a new request, + // representing the batch internal key. + if i == 0 && !isFunded { + keyCount++ + } + + // Seedlings without an external script key will have one + // derived. + if seedling.ScriptKey.PubKey == nil { + keyCount++ + } + + // Seedlings with emission enabled and without an external + // group internal key will have one derived. + if seedling.EnableEmission && seedling.GroupInternalKey == nil { + keyCount++ + } // Queue the new seedling for a batch. // @@ -199,18 +226,9 @@ func (t *mintingTestHarness) queueSeedlingsInBatch( updates, err := t.planter.QueueNewSeedling(seedling) require.NoError(t, err) - // For the first seedlings sent, we should get a new request - if i == 0 { - t.batchKey = t.assertKeyDerived() - } - - // For each seedling queued, we expect a new set of keys to be - // created for the asset script key and an additional key if - // emission was enabled. - t.assertKeyDerived() - - if seedling.EnableEmission { + for keyCount != 0 { t.assertKeyDerived() + keyCount-- } // We should get an update from the update channel that the @@ -309,14 +327,70 @@ func (t *mintingTestHarness) assertFinalizeBatch(wg *sync.WaitGroup, } } +type FundBatchResp = FinalizeBatchResp + +// fundBatch uses the public FundBatch planter call to fund a minting batch. +// The caller must wait for the planter call to complete. +func (t *mintingTestHarness) fundBatch(wg *sync.WaitGroup, + respChan chan *FundBatchResp, params *tapgarden.FundParams) { + + t.Helper() + + wg.Add(1) + go func() { + defer wg.Done() + + fundParams := tapgarden.FundParams{ + FeeRate: fn.None[chainfee.SatPerKWeight](), + SiblingTapTree: fn.None[asset.TapscriptTreeNodes](), + } + + if params != nil { + fundParams = *params + } + + fundedBatch, fundErr := t.planter.FundBatch( + fundParams, + ) + resp := &FundBatchResp{ + Batch: fundedBatch, + Err: fundErr, + } + + respChan <- resp + }() +} + +func (t *mintingTestHarness) assertFundBatch(wg *sync.WaitGroup, + respChan chan *FundBatchResp, + errString string) *tapgarden.MintingBatch { + + t.Helper() + + wg.Wait() + fundResp := <-respChan + + switch { + case errString == "": + require.NoError(t, fundResp.Err) + return fundResp.Batch + + default: + require.ErrorContains(t, fundResp.Err, errString) + return nil + } +} + // progressCaretaker uses the mock interfaces to progress a caretaker from start // to TX confirmation. -func (t *mintingTestHarness) progressCaretaker( +func (t *mintingTestHarness) progressCaretaker(isFunded bool, batchSibling *commitment.TapscriptPreimage, feeRate *chainfee.SatPerKWeight) func() { // Assert that the caretaker has requested a genesis TX to be funded. - _ = t.assertGenesisTxFunded(feeRate) + if !isFunded { + _ = t.assertGenesisTxFunded(feeRate) + } // We should now transition to the next state where we'll attempt to // sign this PSBT packet generated above. @@ -409,7 +483,7 @@ func (t *mintingTestHarness) assertNewBatchFrozen( if len(currentBatches) > len(existingBatches) { for _, batch := range currentBatches { - if batch.State() != tapgarden.BatchStateFrozen { + if !batchFrozenStates.Contains(batch.State()) { continue } @@ -572,6 +646,35 @@ func (t *mintingTestHarness) assertSeedlingsExist( require.Equal( t, seedling.EnableEmission, batchSeedling.EnableEmission, ) + require.Equal( + t, seedling.GroupAnchor, batchSeedling.GroupAnchor, + ) + require.NotNil(t, batchSeedling.ScriptKey.PubKey) + require.Equal( + t, seedling.GroupTapscriptRoot, + batchSeedling.GroupTapscriptRoot, + ) + + if seedling.ScriptKey.PubKey != nil { + require.True( + t, + seedling.ScriptKey.IsEqual( + &batchSeedling.ScriptKey, + )) + } + + if seedling.GroupInternalKey != nil { + require.True( + t, asset.EqualKeyDescriptors( + *seedling.GroupInternalKey, + *batchSeedling.GroupInternalKey, + ), + ) + } + + if seedling.EnableEmission { + require.NotNil(t, batchSeedling.GroupInternalKey) + } } } @@ -657,6 +760,24 @@ func (t *mintingTestHarness) assertBatchGenesisTx( return genesisTxFee } +// assertMintOutputKey asserts that the genesis output key for the batch was +// computed correctly during minting and includes a tapscript sibling. +func (t *mintingTestHarness) assertMintOutputKey(batch *tapgarden.MintingBatch, + siblingHash *chainhash.Hash) { + + rootCommitment := batch.RootAssetCommitment + require.NotNil(t, rootCommitment) + + scriptRoot := rootCommitment.TapscriptRoot(siblingHash) + expectedOutputKey := txscript.ComputeTaprootOutputKey( + batch.BatchKey.PubKey, scriptRoot[:], + ) + + outputKey, _, err := batch.MintingOutputKey(nil) + require.NoError(t, err) + require.True(t, expectedOutputKey.IsEqual(outputKey)) +} + // assertSeedlingsMatchSprouts asserts that the seedlings were properly matched // into actual assets. func (t *mintingTestHarness) assertSeedlingsMatchSprouts( @@ -675,7 +796,7 @@ func (t *mintingTestHarness) assertSeedlingsMatchSprouts( // Filter out any cancelled batches. isCommittedBatch := func(batch *tapgarden.MintingBatch) bool { - return batch.State() == tapgarden.BatchStateCommitted + return batchCommittedStates.Contains(batch.State()) } batch, err := fn.First(pendingBatches, isCommittedBatch) if err != nil { @@ -712,12 +833,37 @@ func (t *mintingTestHarness) assertSeedlingsMatchSprouts( require.Equal(t, seedling.AssetType, assetSprout.Type) require.Equal(t, seedling.AssetName, assetSprout.Genesis.Tag) require.Equal( - t, seedling.Meta.MetaHash(), assetSprout.Genesis.MetaHash, + t, seedling.Meta.MetaHash(), + assetSprout.Genesis.MetaHash, ) require.Equal(t, seedling.Amount, assetSprout.Amount) - require.Equal( - t, seedling.EnableEmission, assetSprout.GroupKey != nil, + require.True( + t, seedling.ScriptKey.IsEqual(&assetSprout.ScriptKey), ) + + if seedling.EnableEmission { + require.NotNil(t, assetSprout.GroupKey) + } + + if seedling.GroupInternalKey != nil { + require.NotNil(t, assetSprout.GroupKey) + require.True(t, asset.EqualKeyDescriptors( + *seedling.GroupInternalKey, + assetSprout.GroupKey.RawKey, + )) + } + + if seedling.GroupTapscriptRoot != nil { + require.NotNil(t, assetSprout.GroupKey) + require.Equal( + t, seedling.GroupTapscriptRoot, + assetSprout.GroupKey.TapscriptRoot, + ) + } + + if seedling.GroupAnchor != nil || seedling.GroupInfo != nil { + require.NotNil(t, assetSprout.GroupKey) + } } } @@ -802,7 +948,7 @@ func (t *mintingTestHarness) queueInitialBatch( // Next make new random seedlings, and queue each of them up within // the main state machine for batched minting. seedlings := t.newRandSeedlings(numSeedlings) - t.queueSeedlingsInBatch(seedlings...) + t.queueSeedlingsInBatch(false, seedlings...) // At this point, there should be a single pending batch with 5 // seedlings. The batch stored in the log should also match up exactly. @@ -949,7 +1095,7 @@ func testMintingTicker(t *mintingTestHarness) { // One seedling is a duplicate of a seedling from the cancelled batch, // to ensure that we can store multiple versions of the same seedling. seedlings := t.newRandSeedlings(numSeedlings) - t.queueSeedlingsInBatch(seedlings...) + t.queueSeedlingsInBatch(false, seedlings...) // Next, finalize the pending batch to continue with minting. _ = t.finalizeBatchAssertFrozen(false) @@ -1038,7 +1184,7 @@ func testMintingCancelFinalize(t *mintingTestHarness) { if seedlings[0].EnableEmission { seedlings[0].GroupInternalKey = nil } - t.queueSeedlingsInBatch(seedlings...) + t.queueSeedlingsInBatch(false, seedlings...) t.assertPendingBatchExists(numSeedlings) t.assertSeedlingsExist(seedlings, nil) @@ -1184,7 +1330,7 @@ func testFinalizeBatch(t *mintingTestHarness) { t.finalizeBatch(&wg, respChan, nil) batchCount++ - _ = t.progressCaretaker(nil, nil) + _ = t.progressCaretaker(false, nil, nil) caretakerCount++ t.assertFinalizeBatch(&wg, respChan, "") @@ -1208,7 +1354,7 @@ func testFinalizeBatch(t *mintingTestHarness) { t.finalizeBatch(&wg, respChan, nil) batchCount++ - sendConfNtfn := t.progressCaretaker(nil, nil) + sendConfNtfn := t.progressCaretaker(false, nil, nil) caretakerCount++ // Trigger the confirmation event, which should cause the caretaker to @@ -1243,7 +1389,7 @@ func testFinalizeBatch(t *mintingTestHarness) { t.finalizeBatch(&wg, respChan, &finalizeReq) batchCount++ - sendConfNtfn = t.progressCaretaker(nil, &manualFeeRate) + sendConfNtfn = t.progressCaretaker(false, nil, &manualFeeRate) sendConfNtfn() t.assertFinalizeBatch(&wg, respChan, "") @@ -1356,7 +1502,7 @@ func testFinalizeWithTapscriptTree(t *mintingTestHarness) { // Verify that the final genesis TX uses the correct Taproot output key. treeRootChildren := test.BuildTapscriptTreeNoReveal(t.T, sigLockKey) siblingPreimage := commitment.NewPreimageFromBranch(treeRootChildren) - sendConfNtfn := t.progressCaretaker(&siblingPreimage, nil) + sendConfNtfn := t.progressCaretaker(false, &siblingPreimage, nil) sendConfNtfn() // Once the TX is broadcast, the caretaker should run to completion, @@ -1370,20 +1516,150 @@ func testFinalizeWithTapscriptTree(t *mintingTestHarness) { // Verify that the final minting output key matches what we would derive // manually. - batchRootCommitment := batchWithSibling.RootAssetCommitment - require.NotNil(t, batchRootCommitment) siblingHash, err := siblingPreimage.TapHash() require.NoError(t, err) - batchScriptRoot := batchRootCommitment.TapscriptRoot(siblingHash) - batchOutputKeyExpected := txscript.ComputeTaprootOutputKey( - batchWithSibling.BatchKey.PubKey, batchScriptRoot[:], + t.assertMintOutputKey(batchWithSibling, siblingHash) +} + +func testFundBeforeFinalize(t *mintingTestHarness) { + // First, create a new chain planter instance using the supplied test + // harness. + t.refreshChainPlanter() + + var ( + wg sync.WaitGroup + respChan = make(chan *FundBatchResp, 1) + finalizeRespChan = make(chan *FinalizeBatchResp, 1) + fundReq tapgarden.FundParams ) - batchOutputKey, _, err := batchWithSibling.MintingOutputKey(nil) - require.NoError(t, err) - require.Equal( - t, batchOutputKeyExpected.SerializeCompressed(), - batchOutputKey.SerializeCompressed(), + + // Derive a set of keys that we'll supply for specific seedlings. First, + // a non-BIP86 script key. + scriptKeyInternalKey := test.RandPubKey(t) + scriptKeyTapTweak := test.RandBytes(32) + tweakedScriptKey := txscript.ComputeTaprootOutputKey( + scriptKeyInternalKey, scriptKeyTapTweak, + ) + scriptTweakedKey := asset.ScriptKey{ + PubKey: tweakedScriptKey, + TweakedScriptKey: &asset.TweakedScriptKey{ + RawKey: keychain.KeyDescriptor{ + PubKey: scriptKeyInternalKey, + }, + Tweak: scriptKeyTapTweak, + }, + } + + // Let's also make an internal key for an asset group. We need to supply + // the private key so that the planter can produce an asset group + // witness during batch sealing. + groupInternalKeyDesc, groupInternalKeyPriv := test.RandKeyDesc(t) + t.keyRing.Keys[groupInternalKeyDesc.KeyLocator] = groupInternalKeyPriv + + // We'll use the default test tapscript tree for both the batch + // tapscript sibling and a tapscript root for one asset group. + defaultTapBranch := test.BuildTapscriptTreeNoReveal( + t.T, groupInternalKeyDesc.PubKey, ) + defaultTapTree := asset.TapTreeNodesFromBranch(defaultTapBranch) + defaultPreimage := commitment.NewPreimageFromBranch(defaultTapBranch) + defaultTapHash := defaultTapBranch.TapHash() + + // Make a set of 5 seedlings, which we'll modify manually. + const numSeedlings = 5 + seedlings := t.newRandSeedlings(numSeedlings) + + // Set an external script key for the first seedling. + seedlings[0].ScriptKey = scriptTweakedKey + seedlings[0].EnableEmission = false + + // Set an external group key for the second seedling. + seedlings[1].EnableEmission = true + seedlings[1].GroupInternalKey = &groupInternalKeyDesc + + // Set a group tapscript root for the third seedling. + seedlings[2].EnableEmission = true + seedlings[2].GroupTapscriptRoot = defaultTapHash[:] + + // Set the fourth seedling to be a member of the second seedling's + // asset group. + seedlings[3].EnableEmission = false + seedlings[3].GroupAnchor = &seedlings[1].AssetName + seedlings[3].AssetType = seedlings[1].AssetType + seedlings[3].Amount = 1 + + // Set the final seedling to be ungrouped. + seedlings[4].EnableEmission = false + + // Fund a batch with a tapscript sibling and a manual feerate. This + // should create a new batch. + manualFee := chainfee.FeePerKwFloor * 2 + fundReq = tapgarden.FundParams{ + SiblingTapTree: fn.Some(defaultTapTree), + FeeRate: fn.Some(manualFee), + } + t.fundBatch(&wg, respChan, &fundReq) + + t.assertKeyDerived() + t.assertGenesisTxFunded(&manualFee) + t.assertFundBatch(&wg, respChan, "") + + // After funding, the planter should have persisted the batch. The new + // batch should be funded but have no seedlings. + fundedBatches, err := t.planter.ListBatches(nil) + require.NoError(t, err) + require.Len(t, fundedBatches, 1) + + fundedBatch := fundedBatches[0] + require.Len(t, fundedBatch.Seedlings, 0) + require.NotNil(t, fundedBatch.GenesisPacket) + t.assertBatchGenesisTx(fundedBatch.GenesisPacket) + require.Equal(t, defaultTapHash[:], fundedBatch.TapSibling()) + require.True(t, fundedBatch.State() == tapgarden.BatchStatePending) + + // Trying to fund a batch again should fail, as there is a pending batch + // that is already funded. + fundReq = tapgarden.FundParams{} + t.fundBatch(&wg, respChan, &fundReq) + t.assertFundBatch(&wg, respChan, "batch already funded") + + // Trying to finalize the batch with finalize parameters should also + // fail, as those parameters should have been provided during batch + // funding. + finalizeReq := tapgarden.FinalizeParams{ + SiblingTapTree: fn.Some(defaultTapTree), + FeeRate: fn.Some(manualFee), + } + t.finalizeBatch(&wg, finalizeRespChan, &finalizeReq) + t.assertFinalizeBatch(&wg, finalizeRespChan, "batch already funded") + + // This finalize error is also sent on the main error channel, so drain + // that before continuing. + caretakerErr := <-t.errChan + require.ErrorContains(t, caretakerErr, "batch already funded") + + // Add the seedlings modified earlier to the batch, and check that they + // were added correctly. + t.queueSeedlingsInBatch(true, seedlings...) + t.assertPendingBatchExists(numSeedlings) + t.assertSeedlingsExist(seedlings, nil) + + // Finally, finalize the batch and check that the resulting assets match + // the seedlings. + t.finalizeBatch(&wg, finalizeRespChan, nil) + t.assertBatchProgressing() + t.assertNoPendingBatch() + + sendConfNtfn := t.progressCaretaker(true, &defaultPreimage, &manualFee) + mintedBatch := t.assertFinalizeBatch(&wg, finalizeRespChan, "") + + t.assertSeedlingsMatchSprouts(seedlings) + + sendConfNtfn() + + t.assertNumCaretakersActive(0) + t.assertLastBatchState(1, tapgarden.BatchStateFinalized) + t.assertMintOutputKey(mintedBatch, &defaultTapHash) } // mintingStoreTestCase is used to programmatically run a series of test cases @@ -1415,6 +1691,10 @@ var testCases = []mintingStoreTestCase{ name: "finalize_with_tapscript_tree", testFunc: testFinalizeWithTapscriptTree, }, + { + name: "fund_before_finalize", + testFunc: testFundBeforeFinalize, + }, } // TestBatchedAssetIssuance runs a test of tests to ensure that the set of