diff --git a/asset/asset.go b/asset/asset.go index 25afc69c6..6a927b833 100644 --- a/asset/asset.go +++ b/asset/asset.go @@ -695,6 +695,30 @@ type GroupKeyRequest struct { NewAsset *Asset } +// GroupVirtualTx contains all the information needed to produce an asset group +// witness, except for the group internal key descriptor (or private key). A +// GroupVirtualTx is constructed from a GroupKeyRequest. +type GroupVirtualTx struct { + // Tx is a virtual transaction that represents the genesis state + // transition of a grouped asset. + Tx wire.MsgTx + + // PrevOut is a transaction output that represents a grouped asset. + // PrevOut uses the tweaked group key as its PkScript. This is used in + // combination with GroupVirtualTx.Tx as input for a GenesisSigner. + PrevOut wire.TxOut + + // GenID is the asset ID of the grouped asset in a GroupKeyRequest. This + // ID is needed to construct a sign descriptor for a GenesisSigner, as + // it is the single tweak for the group internal key. + GenID ID + + // TweakedKey is the tweaked group key for the given GroupKeyRequest. + // This is later used to construct a complete GroupKey, after a + // GenesisSigner has produced an asset group witness. + TweakedKey btcec.PublicKey +} + // GroupKeyReveal is a type for representing the data used to derive the tweaked // key used to identify an asset group. The final tweaked key is the result of: // TapTweak(groupInternalKey, tapscriptRoot) @@ -959,6 +983,50 @@ func (s ScriptKey) IsUnSpendable() (bool, error) { return NUMSPubKey.IsEqual(s.PubKey), nil } +// IsEqual returns true is this tweaked script key is exactly equivalent to the +// passed other tweaked script key. +func (ts *TweakedScriptKey) IsEqual(other *TweakedScriptKey) bool { + if ts == nil { + return other == nil + } + + if other == nil { + return false + } + + if !bytes.Equal(ts.Tweak, other.Tweak) { + return false + } + + return EqualKeyDescriptors(ts.RawKey, other.RawKey) +} + +// IsEqual returns true is this script key is exactly equivalent to the passed +// other script key. +func (s *ScriptKey) IsEqual(otherScriptKey *ScriptKey) bool { + if s == nil { + return otherScriptKey == nil + } + + if otherScriptKey == nil { + return false + } + + if s.PubKey == nil { + return otherScriptKey.PubKey == nil + } + + if otherScriptKey.PubKey == nil { + return false + } + + if !s.TweakedScriptKey.IsEqual(otherScriptKey.TweakedScriptKey) { + return false + } + + return s.PubKey.IsEqual(otherScriptKey.PubKey) +} + // NewScriptKey constructs a ScriptKey with only the publicly available // information. This resulting key may or may not have a tweak applied to it. func NewScriptKey(key *btcec.PublicKey) ScriptKey { @@ -1045,14 +1113,20 @@ func (req *GroupKeyRequest) Validate() error { return fmt.Errorf("missing group internal key") } + tapscriptRootSize := len(req.TapscriptRoot) + if tapscriptRootSize != 0 && tapscriptRootSize != sha256.Size { + return fmt.Errorf("tapscript root must be %d bytes", + sha256.Size) + } + return nil } -// DeriveGroupKey derives an asset's group key based on an internal public -// key descriptor, the original group asset genesis, and the asset's genesis. -func DeriveGroupKey(genSigner GenesisSigner, genBuilder GenesisTxBuilder, - req GroupKeyRequest) (*GroupKey, error) { - +// BuildGroupVirtualTx derives the tweaked group key for group key request, +// and constructs the group virtual TX needed to construct a sign descriptor and +// produce an asset group witness. +func (req *GroupKeyRequest) BuildGroupVirtualTx(genBuilder GenesisTxBuilder) ( + *GroupVirtualTx, error) { // First, perform the final checks on the asset being authorized for // group membership. err := req.Validate() @@ -1064,7 +1138,7 @@ func DeriveGroupKey(genSigner GenesisSigner, genBuilder GenesisTxBuilder, // creating the virtual minting transaction. genesisTweak := req.AnchorGen.ID() tweakedGroupKey, err := GroupPubKey( - req.RawKey.PubKey, genesisTweak[:], nil, + req.RawKey.PubKey, genesisTweak[:], req.TapscriptRoot, ) if err != nil { return nil, fmt.Errorf("cannot tweak group key: %w", err) @@ -1082,91 +1156,57 @@ func DeriveGroupKey(genSigner GenesisSigner, genBuilder GenesisTxBuilder, return nil, fmt.Errorf("cannot build virtual tx: %w", err) } - // Build the static signing descriptor needed to sign the virtual - // minting transaction. This is restricted to group keys with an empty - // tapscript root and key path spends. - signDesc := &lndclient.SignDescriptor{ - KeyDesc: req.RawKey, - SingleTweak: genesisTweak[:], - SignMethod: input.TaprootKeySpendBIP0086SignMethod, - Output: prevOut, - HashType: txscript.SigHashDefault, - InputIndex: 0, - } - sig, err := genSigner.SignVirtualTx(signDesc, genesisTx, prevOut) - if err != nil { - return nil, err - } - - return &GroupKey{ - RawKey: req.RawKey, - GroupPubKey: *tweakedGroupKey, - Witness: wire.TxWitness{sig.Serialize()}, + return &GroupVirtualTx{ + Tx: *genesisTx, + PrevOut: *prevOut, + GenID: genesisTweak, + TweakedKey: *tweakedGroupKey, }, nil } -// DeriveCustomGroupKey derives an asset's group key based on a signing -// descriptor, the original group asset genesis, and the asset's genesis. -func DeriveCustomGroupKey(genSigner GenesisSigner, genBuilder GenesisTxBuilder, - req GroupKeyRequest, tapLeaf *psbt.TaprootTapLeafScript, - scriptWitness []byte) (*GroupKey, error) { +// AssembleGroupKeyFromWitness constructs a group key given a group witness +// generated externally. +func AssembleGroupKeyFromWitness(genTx GroupVirtualTx, req GroupKeyRequest, + tapLeaf *psbt.TaprootTapLeafScript, scriptWitness []byte) (*GroupKey, + error) { - // First, perform the final checks on the asset being authorized for - // group membership. - err := req.Validate() - if err != nil { - return nil, err + if scriptWitness == nil { + return nil, fmt.Errorf("script witness cannot be nil") } - // Compute the tweaked group key and set it in the asset before - // creating the virtual minting transaction. - genesisTweak := req.AnchorGen.ID() - tweakedGroupKey, err := GroupPubKey( - req.RawKey.PubKey, genesisTweak[:], req.TapscriptRoot, - ) - if err != nil { - return nil, fmt.Errorf("cannot tweak group key: %w", err) + groupKey := &GroupKey{ + RawKey: req.RawKey, + GroupPubKey: genTx.TweakedKey, + TapscriptRoot: req.TapscriptRoot, + Witness: wire.TxWitness{scriptWitness}, } - assetWithGroup := req.NewAsset.Copy() - assetWithGroup.GroupKey = &GroupKey{ - GroupPubKey: *tweakedGroupKey, - } - - // Exit early if a group witness is already given, since we don't need - // to construct a virtual TX nor produce a signature. - if scriptWitness != nil { - if tapLeaf == nil { - return nil, fmt.Errorf("need tap leaf with group " + - "script witness") + if tapLeaf != nil { + if tapLeaf.LeafVersion != txscript.BaseLeafVersion { + return nil, fmt.Errorf("unsupported script version") } - witness := wire.TxWitness{ - scriptWitness, tapLeaf.Script, tapLeaf.ControlBlock, - } - - return &GroupKey{ - RawKey: req.RawKey, - GroupPubKey: *tweakedGroupKey, - TapscriptRoot: req.TapscriptRoot, - Witness: witness, - }, nil + groupKey.Witness = append( + groupKey.Witness, tapLeaf.Script, tapLeaf.ControlBlock, + ) } - // Build the virtual transaction that represents the minting of the new - // asset, which will be signed to generate the group witness. - genesisTx, prevOut, err := genBuilder.BuildGenesisTx(assetWithGroup) - if err != nil { - return nil, fmt.Errorf("cannot build virtual tx: %w", err) - } + return groupKey, nil +} + +// DeriveGroupKey derives an asset's group key based on an internal public key +// descriptor, the original group asset genesis, and the asset's genesis. +func DeriveGroupKey(genSigner GenesisSigner, genTx GroupVirtualTx, + req GroupKeyRequest, tapLeaf *psbt.TaprootTapLeafScript) (*GroupKey, + error) { // Populate the signing descriptor needed to sign the virtual minting // transaction. signDesc := &lndclient.SignDescriptor{ KeyDesc: req.RawKey, - SingleTweak: genesisTweak[:], + SingleTweak: genTx.GenID[:], TapTweak: req.TapscriptRoot, - Output: prevOut, + Output: &genTx.PrevOut, HashType: txscript.SigHashDefault, InputIndex: 0, } @@ -1193,7 +1233,7 @@ func DeriveCustomGroupKey(genSigner GenesisSigner, genBuilder GenesisTxBuilder, return nil, fmt.Errorf("bad sign descriptor for group key") } - sig, err := genSigner.SignVirtualTx(signDesc, genesisTx, prevOut) + sig, err := genSigner.SignVirtualTx(signDesc, &genTx.Tx, &genTx.PrevOut) if err != nil { return nil, err } @@ -1204,13 +1244,14 @@ func DeriveCustomGroupKey(genSigner GenesisSigner, genBuilder GenesisTxBuilder, // the control block to the witness, otherwise the verifier will reject // the generated witness. if signDesc.SignMethod == input.TaprootScriptSpendSignMethod { - witness = append(witness, signDesc.WitnessScript) - witness = append(witness, tapLeaf.ControlBlock) + witness = append( + witness, signDesc.WitnessScript, tapLeaf.ControlBlock, + ) } return &GroupKey{ RawKey: signDesc.KeyDesc, - GroupPubKey: *tweakedGroupKey, + GroupPubKey: genTx.TweakedKey, TapscriptRoot: signDesc.TapTweak, Witness: witness, }, nil diff --git a/asset/asset_test.go b/asset/asset_test.go index 74bc145cc..05a3a3997 100644 --- a/asset/asset_test.go +++ b/asset/asset_test.go @@ -800,7 +800,10 @@ func TestAssetGroupKey(t *testing.T) { // need to provide a copy to arrive at the same result. protoAsset := NewAssetNoErr(t, g, 1, 0, 0, fakeScriptKey, nil) groupReq := NewGroupKeyRequestNoErr(t, fakeKeyDesc, g, protoAsset, nil) - keyGroup, err := DeriveGroupKey(genSigner, &genBuilder, *groupReq) + genTx, err := groupReq.BuildGroupVirtualTx(&genBuilder) + require.NoError(t, err) + + keyGroup, err := DeriveGroupKey(genSigner, *genTx, *groupReq, nil) require.NoError(t, err) require.Equal( @@ -816,9 +819,10 @@ func TestAssetGroupKey(t *testing.T) { groupReq = NewGroupKeyRequestNoErr( t, test.PubToKeyDesc(privKey.PubKey()), g, protoAsset, tapTweak, ) - keyGroup, err = DeriveCustomGroupKey( - genSigner, &genBuilder, *groupReq, nil, nil, - ) + genTx, err = groupReq.BuildGroupVirtualTx(&genBuilder) + require.NoError(t, err) + + keyGroup, err = DeriveGroupKey(genSigner, *genTx, *groupReq, nil) require.NoError(t, err) require.Equal( @@ -873,34 +877,44 @@ func TestDeriveGroupKey(t *testing.T) { } // A prototype asset is required for building the genesis virtual TX. - _, err := DeriveGroupKey(genSigner, &genBuilder, groupReq) + _, err := groupReq.BuildGroupVirtualTx(&genBuilder) require.ErrorContains(t, err, "grouped asset cannot be nil") // The prototype asset must have a genesis witness. groupReq.NewAsset = nonGenProtoAsset - _, err = DeriveGroupKey(genSigner, &genBuilder, groupReq) + _, err = groupReq.BuildGroupVirtualTx(&genBuilder) require.ErrorContains(t, err, "asset is not a genesis asset") // The prototype asset must not have a group key set. groupReq.NewAsset = groupedProtoAsset - _, err = DeriveGroupKey(genSigner, &genBuilder, groupReq) + _, err = groupReq.BuildGroupVirtualTx(&genBuilder) require.ErrorContains(t, err, "asset already has group key") // The anchor genesis used for signing must have the same asset type // as the prototype asset being signed. groupReq.AnchorGen = collectGen groupReq.NewAsset = protoAsset - _, err = DeriveGroupKey(genSigner, &genBuilder, groupReq) + _, err = groupReq.BuildGroupVirtualTx(&genBuilder) require.ErrorContains(t, err, "asset group type mismatch") // The group key request must include an internal key. groupReq.AnchorGen = baseGen groupReq.RawKey.PubKey = nil - _, err = DeriveGroupKey(genSigner, &genBuilder, groupReq) + _, err = groupReq.BuildGroupVirtualTx(&genBuilder) require.ErrorContains(t, err, "missing group internal key") + // The tapscript root in the group key request must be exactly 32 bytes + // if present. groupReq.RawKey = groupKeyDesc - groupKey, err := DeriveGroupKey(genSigner, &genBuilder, groupReq) + groupReq.TapscriptRoot = test.RandBytes(33) + _, err = groupReq.BuildGroupVirtualTx(&genBuilder) + require.ErrorContains(t, err, "tapscript root must be 32 bytes") + + groupReq.TapscriptRoot = test.RandBytes(32) + genTx, err := groupReq.BuildGroupVirtualTx(&genBuilder) + require.NoError(t, err) + + groupKey, err := DeriveGroupKey(genSigner, *genTx, groupReq, nil) require.NoError(t, err) require.NotNil(t, groupKey) } diff --git a/asset/mock.go b/asset/mock.go index 473abd18e..6e5cc9ad8 100644 --- a/asset/mock.go +++ b/asset/mock.go @@ -56,8 +56,10 @@ func RandGroupKeyWithSigner(t testing.TB, genesis Genesis, AnchorGen: genesis, NewAsset: newAsset, } + genTx, err := groupReq.BuildGroupVirtualTx(&genBuilder) + require.NoError(t, err) - groupKey, err := DeriveGroupKey(genSigner, &genBuilder, groupReq) + groupKey, err := DeriveGroupKey(genSigner, *genTx, groupReq, nil) require.NoError(t, err) return groupKey, privateKey.Serialize() @@ -343,10 +345,7 @@ func AssetCustomGroupKey(t *testing.T, useHashLock, BIP86, keySpend, "key types") } - var ( - groupKey *GroupKey - err error - ) + var groupKey *GroupKey genID := gen.ID() scriptKey := RandScriptKey(t) @@ -369,15 +368,16 @@ func AssetCustomGroupKey(t *testing.T, useHashLock, BIP86, keySpend, AnchorGen: gen, NewAsset: protoAsset, } - // Update the group key request and group key derivation arguments // to match the requested group key type. switch { // Use an empty tapscript and script witness. case BIP86: - groupKey, err = DeriveCustomGroupKey( - genSigner, &genBuilder, groupReq, nil, nil, - ) + genTx, err := groupReq.BuildGroupVirtualTx(&genBuilder) + require.NoError(t, err) + + groupKey, err = DeriveGroupKey(genSigner, *genTx, groupReq, nil) + require.NoError(t, err) // Derive a tapscipt root using the default tapscript tree used for // testing, but use a signature as a witness. @@ -388,9 +388,11 @@ func AssetCustomGroupKey(t *testing.T, useHashLock, BIP86, keySpend, treeTapHash := treeRootChildren.TapHash() groupReq.TapscriptRoot = treeTapHash[:] - groupKey, err = DeriveCustomGroupKey( - genSigner, &genBuilder, groupReq, nil, nil, - ) + genTx, err := groupReq.BuildGroupVirtualTx(&genBuilder) + require.NoError(t, err) + + groupKey, err = DeriveGroupKey(genSigner, *genTx, groupReq, nil) + require.NoError(t, err) // For a script spend, we derive a tapscript root, and create the needed // tapscript and script witness. @@ -401,13 +403,23 @@ func AssetCustomGroupKey(t *testing.T, useHashLock, BIP86, keySpend, ) groupReq.TapscriptRoot = tapRootHash - groupKey, err = DeriveCustomGroupKey( - genSigner, &genBuilder, groupReq, tapLeaf, witness, - ) + genTx, err := groupReq.BuildGroupVirtualTx(&genBuilder) + require.NoError(t, err) + + switch { + case witness != nil: + groupKey, err = AssembleGroupKeyFromWitness( + *genTx, groupReq, tapLeaf, witness, + ) + + default: + groupKey, err = DeriveGroupKey( + genSigner, *genTx, groupReq, tapLeaf, + ) + } + require.NoError(t, err) } - require.NoError(t, err) - return NewAssetNoErr( t, gen, protoAsset.Amount, protoAsset.LockTime, protoAsset.RelativeLockTime, scriptKey, groupKey, diff --git a/commitment/commitment_test.go b/commitment/commitment_test.go index 27ba5633b..a86e22a64 100644 --- a/commitment/commitment_test.go +++ b/commitment/commitment_test.go @@ -104,9 +104,14 @@ func TestNewAssetCommitment(t *testing.T) { t, test.PubToKeyDesc(group1Pub), genesis1, genesis2ProtoAsset, nil, ) + group1ReissuedGenTx, err := group1ReissuedGroupReq.BuildGroupVirtualTx( + &genTxBuilder, + ) + require.NoError(t, err) + group1ReissuedGroupKey, err := asset.DeriveGroupKey( - asset.NewMockGenesisSigner(group1Priv), &genTxBuilder, - *group1ReissuedGroupReq, + asset.NewMockGenesisSigner(group1Priv), *group1ReissuedGenTx, + *group1ReissuedGroupReq, nil, ) require.NoError(t, err) group1Reissued = asset.NewAssetNoErr( @@ -958,9 +963,14 @@ func TestUpdateAssetCommitment(t *testing.T) { group1ReissuedGroupReq := asset.NewGroupKeyRequestNoErr( t, test.PubToKeyDesc(group1Pub), genesis1, group1Reissued, nil, ) + group1ReissuedGenTx, err := group1ReissuedGroupReq.BuildGroupVirtualTx( + &genTxBuilder, + ) + require.NoError(t, err) + group1ReissuedGroupKey, err := asset.DeriveGroupKey( - asset.NewMockGenesisSigner(group1Priv), &genTxBuilder, - *group1ReissuedGroupReq, + asset.NewMockGenesisSigner(group1Priv), *group1ReissuedGenTx, + *group1ReissuedGroupReq, nil, ) require.NoError(t, err) group1Reissued = asset.NewAssetNoErr( diff --git a/fn/iter.go b/fn/iter.go index c82d41639..45e0fdc67 100644 --- a/fn/iter.go +++ b/fn/iter.go @@ -28,9 +28,9 @@ func ForEach[T any](items []T, f func(T)) { // ForEachMapItem is a generic implementation of a for-each (map with side // effects). This can be used to ensure that any normal for-loop don't run into // bugs due to loop variable scoping. -func ForEachMapItem[T any, K comparable](items map[K]T, f func(T)) { +func ForEachMapItem[T any, K comparable](items map[K]T, f func(K, T)) { for i := range items { - f(items[i]) + f(i, items[i]) } } diff --git a/go.mod b/go.mod index e7f6c0d83..bc3006bcb 100644 --- a/go.mod +++ b/go.mod @@ -32,7 +32,6 @@ require ( github.com/lightningnetwork/lnd v0.17.0-beta.rc6.0.20240301195848-f61761277f14 github.com/lightningnetwork/lnd/cert v1.2.2 github.com/lightningnetwork/lnd/clock v1.1.1 - github.com/lightningnetwork/lnd/ticker v1.1.1 github.com/lightningnetwork/lnd/tlv v1.2.3 github.com/lightningnetwork/lnd/tor v1.1.2 github.com/ory/dockertest/v3 v3.10.0 @@ -124,6 +123,7 @@ require ( github.com/lightningnetwork/lnd/healthcheck v1.2.3 // indirect github.com/lightningnetwork/lnd/kvdb v1.4.5 // indirect github.com/lightningnetwork/lnd/queue v1.1.1 // indirect + github.com/lightningnetwork/lnd/ticker v1.1.1 // indirect github.com/ltcsuite/ltcd v0.0.0-20190101042124-f37f8bf35796 // indirect github.com/mattn/go-isatty v0.0.17 // indirect github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369 // indirect diff --git a/internal/test/helpers.go b/internal/test/helpers.go index b7a4fcb07..fe747354c 100644 --- a/internal/test/helpers.go +++ b/internal/test/helpers.go @@ -91,6 +91,19 @@ func RandPrivKey(_ testing.TB) *btcec.PrivateKey { return priv } +func RandKeyDesc(t testing.TB) (keychain.KeyDescriptor, *btcec.PrivateKey) { + priv, err := btcec.NewPrivateKey() + require.NoError(t, err) + + return keychain.KeyDescriptor{ + PubKey: priv.PubKey(), + KeyLocator: keychain.KeyLocator{ + Index: RandInt[uint32](), + Family: keychain.KeyFamily(RandInt[uint32]()), + }, + }, priv +} + func SchnorrPubKey(t testing.TB, privKey *btcec.PrivateKey) *btcec.PublicKey { return SchnorrKey(t, privKey.PubKey()) } diff --git a/tapcfg/config.go b/tapcfg/config.go index ebccde50e..54bb0119e 100644 --- a/tapcfg/config.go +++ b/tapcfg/config.go @@ -64,11 +64,6 @@ const ( defaultConfigFileName = "tapd.conf" - // defaultBatchMintingInterval is the default interval used to - // determine when a set of pending assets should be flushed into a new - // batch. - defaultBatchMintingInterval = time.Minute * 10 - // fallbackHashMailAddr is the fallback address we'll use to deliver // proofs for asynchronous sends. fallbackHashMailAddr = "mailbox.terminal.lightning.today:443" @@ -298,8 +293,6 @@ type Config struct { CPUProfile string `long:"cpuprofile" description:"Write CPU profile to the specified file"` Profile string `long:"profile" description:"Enable HTTP profiling on either a port or host:port"` - BatchMintingInterval time.Duration `long:"batch-minting-interval" description:"A duration (1m, 2h, etc) that governs how frequently pending assets are gather into a batch to be minted."` - ReOrgSafeDepth int32 `long:"reorgsafedepth" description:"The number of confirmations we'll wait for before considering a transaction safely buried in the chain."` // The following options are used to configure the proof courier. @@ -380,7 +373,6 @@ func DefaultConfig() Config { }, LogWriter: build.NewRotatingLogWriter(), Prometheus: monitoring.DefaultPrometheusConfig(), - BatchMintingInterval: defaultBatchMintingInterval, ReOrgSafeDepth: defaultReOrgSafeDepth, DefaultProofCourierAddr: defaultProofCourierAddr, HashMailCourier: &proof.HashMailCourierCfg{ diff --git a/tapcfg/server.go b/tapcfg/server.go index 7d1c4bd3d..559c8ce8e 100644 --- a/tapcfg/server.go +++ b/tapcfg/server.go @@ -23,7 +23,6 @@ import ( "github.com/lightningnetwork/lnd" "github.com/lightningnetwork/lnd/clock" "github.com/lightningnetwork/lnd/signal" - "github.com/lightningnetwork/lnd/ticker" ) // databaseBackend is an interface that contains all methods our different @@ -362,7 +361,6 @@ func genServerConfig(cfg *Config, cfgLogger btclog.Logger, ProofWatcher: reOrgWatcher, UniversePushBatchSize: defaultUniverseSyncBatchSize, }, - BatchTicker: ticker.NewForce(cfg.BatchMintingInterval), ProofUpdates: proofArchive, ErrChan: mainErrChan, }), diff --git a/tapdb/asset_minting.go b/tapdb/asset_minting.go index cd86d23f4..88b990bdb 100644 --- a/tapdb/asset_minting.go +++ b/tapdb/asset_minting.go @@ -3,6 +3,7 @@ package tapdb import ( "bytes" "context" + "crypto/sha256" "database/sql" "errors" "fmt" @@ -232,6 +233,23 @@ type PendingAssetStore interface { assetID []byte) (sqlc.FetchAssetMetaForAssetRow, error) } +var ( + // ErrFetchMintingBatches is returned when fetching multiple minting + // batches fails. + ErrFetchMintingBatches = errors.New("unable to fetch minting batches") + + // ErrBatchParsing is returned when parsing a fetching minting batch + // fails. + ErrBatchParsing = errors.New("unable to parse batch") + + // ErrBindBatchTx is returned when binding a tx to a minting batch + // fails. + ErrBindBatchTx = errors.New("unable to bind batch tx") + + // ErrEcodePsbt is returned when serializing a PSBT fails. + ErrEncodePsbt = errors.New("unable to encode psbt") +) + // AssetStoreTxOptions defines the set of db txn options the PendingAssetStore // understands. type AssetStoreTxOptions struct { @@ -278,6 +296,14 @@ func NewAssetMintingStore(db BatchedPendingAssetStore) *AssetMintingStore { } } +// OptionalSeedlingFields contains database IDs for optional seedling fields +// that have been stored on disk. +type OptionalSeedlingFields struct { + GroupInternalKeyID sql.NullInt64 + GroupGenesisID sql.NullInt64 + GroupAnchorID sql.NullInt64 +} + // CommitMintingBatch commits a new minting batch to disk along with any // seedlings specified as part of the batch. A new internal key is also // created, with the batch referencing that internal key. This internal key @@ -298,8 +324,7 @@ func (a *AssetMintingStore) CommitMintingBatch(ctx context.Context, KeyIndex: int32(newBatch.BatchKey.Index), }) if err != nil { - return fmt.Errorf("unable to insert internal "+ - "key: %w", err) + return fmt.Errorf("%w: %w", ErrUpsertInternalKey, err) } // With our internal key inserted, we can now insert a new @@ -328,6 +353,39 @@ func (a *AssetMintingStore) CommitMintingBatch(ctx context.Context, } } + // If the batch is funded, we can also insert the batch TX and + // batch genesis outpoint. + if newBatch.GenesisPacket != nil { + genesisPacket := newBatch.GenesisPacket + genesisTx := genesisPacket.Pkt.UnsignedTx + changeIdx := genesisPacket.ChangeOutputIndex + genesisOutpoint := genesisTx.TxIn[0].PreviousOutPoint + + var psbtBuf bytes.Buffer + err := genesisPacket.Pkt.Serialize(&psbtBuf) + if err != nil { + return fmt.Errorf("%w: %w", ErrEncodePsbt, err) + } + + genesisPointID, err := upsertGenesisPoint( + ctx, q, genesisOutpoint, + ) + if err != nil { + return fmt.Errorf("%w: %w", + ErrUpsertGenesisPoint, err) + } + + err = q.BindMintingBatchWithTx(ctx, BatchChainUpdate{ + RawKey: rawBatchKey, + MintingTxPsbt: psbtBuf.Bytes(), + ChangeOutputIndex: sqlInt32(changeIdx), + GenesisID: sqlInt64(genesisPointID), + }) + if err != nil { + return fmt.Errorf("%w: %w", ErrBindBatchTx, err) + } + } + // Now that our minting batch is in place, which references the // internal key inserted above, we can create the set of new // seedlings. We insert group anchors before other assets. @@ -357,35 +415,38 @@ func (a *AssetMintingStore) CommitMintingBatch(ctx context.Context, EmissionEnabled: seedling.EnableEmission, } - // If this seedling is being issued to an existing - // group, we need to reference the genesis that - // was first used to create the group. - if seedling.HasGroupKey() { - genesisID, err := fetchGenesisID( - ctx, q, *seedling.GroupInfo.Genesis, - ) - if err != nil { - return err - } - - dbSeedling.GroupGenesisID = sqlInt64(genesisID) + scriptKeyID, err := upsertScriptKey( + ctx, seedling.ScriptKey, q, + ) + if err != nil { + return fmt.Errorf("unable to insert seedling "+ + "script key: %w", err) + } + + dbSeedling.ScriptKeyID = sqlInt64(scriptKeyID) + + tapscriptRootSize := len(seedling.GroupTapscriptRoot) + if tapscriptRootSize != 0 && + tapscriptRootSize != sha256.Size { + + return ErrTapscriptRootSize } - // If this seedling is being issued to a group being - // created in this batch, we need to reference the - // anchor seedling for the group. - if seedling.GroupAnchor != nil { - anchorID, err := fetchSeedlingID( - ctx, q, rawBatchKey, - *seedling.GroupAnchor, - ) - if err != nil { - return err - } - - dbSeedling.GroupAnchorID = sqlInt64(anchorID) + dbSeedling.GroupTapscriptRoot = seedling. + GroupTapscriptRoot + + optionalDbIDs, err := insertOptionalSeedlingParams( + ctx, q, rawBatchKey, seedling, + ) + if err != nil { + return err } + dbSeedling.GroupInternalKeyID = + optionalDbIDs.GroupInternalKeyID + dbSeedling.GroupGenesisID = optionalDbIDs.GroupGenesisID + dbSeedling.GroupAnchorID = optionalDbIDs.GroupAnchorID + err = q.InsertAssetSeedling(ctx, dbSeedling) if err != nil { return err @@ -398,6 +459,61 @@ func (a *AssetMintingStore) CommitMintingBatch(ctx context.Context, return err } +func insertOptionalSeedlingParams(ctx context.Context, q PendingAssetStore, + batchKey []byte, seedling *tapgarden.Seedling) (OptionalSeedlingFields, + error) { + + var ( + fieldIDs OptionalSeedlingFields + err error + ) + + if seedling.GroupInternalKey != nil { + rawKeyBytes := seedling.GroupInternalKey.PubKey. + SerializeCompressed() + groupInternalKey := InternalKey{ + RawKey: rawKeyBytes, + KeyFamily: int32(seedling.GroupInternalKey.Family), + KeyIndex: int32(seedling.GroupInternalKey.Index), + } + + internalKeyID, err := q.UpsertInternalKey(ctx, groupInternalKey) + if err != nil { + return fieldIDs, err + } + + fieldIDs.GroupInternalKeyID = sqlInt64(internalKeyID) + } + + // If this seedling is being issued to an existing group, we need to + // reference the genesis that was first used to create the group. + if seedling.HasGroupKey() { + genesisID, err := fetchGenesisID( + ctx, q, *seedling.GroupInfo.Genesis, + ) + if err != nil { + return fieldIDs, err + } + + fieldIDs.GroupGenesisID = sqlInt64(genesisID) + } + + // If this seedling is being issued to a group being created in this + // batch, we need to reference the anchor seedling for the group. + if seedling.GroupAnchor != nil { + anchorID, err := fetchSeedlingID( + ctx, q, batchKey, *seedling.GroupAnchor, + ) + if err != nil { + return fieldIDs, err + } + + fieldIDs.GroupAnchorID = sqlInt64(anchorID) + } + + return fieldIDs, err +} + // AddSeedlingsToBatch adds a new set of seedlings to an existing batch. func (a *AssetMintingStore) AddSeedlingsToBatch(ctx context.Context, batchKey *btcec.PublicKey, seedlings ...*tapgarden.Seedling) error { @@ -431,35 +547,38 @@ func (a *AssetMintingStore) AddSeedlingsToBatch(ctx context.Context, EmissionEnabled: seedling.EnableEmission, } - // If this seedling is being issued to an existing - // group, we need to reference the genesis that - // was first used to create the group. - if seedling.HasGroupKey() { - genesisID, err := fetchGenesisID( - ctx, q, *seedling.GroupInfo.Genesis, - ) - if err != nil { - return err - } - - dbSeedling.GroupGenesisID = sqlInt64(genesisID) + scriptKeyID, err := upsertScriptKey( + ctx, seedling.ScriptKey, q, + ) + if err != nil { + return fmt.Errorf("unable to insert seedling "+ + "script key: %w", err) + } + + dbSeedling.ScriptKeyID = sqlInt64(scriptKeyID) + + tapscriptRootSize := len(seedling.GroupTapscriptRoot) + if tapscriptRootSize != 0 && + tapscriptRootSize != sha256.Size { + + return ErrTapscriptRootSize } - // If this seedling is being issued to a group being - // created in this batch, we need to reference the - // anchor seedling for the group. - if seedling.GroupAnchor != nil { - anchorID, err := fetchSeedlingID( - ctx, q, rawBatchKey, - *seedling.GroupAnchor, - ) - if err != nil { - return err - } - - dbSeedling.GroupAnchorID = sqlInt64(anchorID) + dbSeedling.GroupTapscriptRoot = seedling. + GroupTapscriptRoot + + optionalDbIDs, err := insertOptionalSeedlingParams( + ctx, q, rawBatchKey, seedling, + ) + if err != nil { + return err } + dbSeedling.GroupInternalKeyID = + optionalDbIDs.GroupInternalKeyID + dbSeedling.GroupGenesisID = optionalDbIDs.GroupGenesisID + dbSeedling.GroupAnchorID = optionalDbIDs.GroupAnchorID + err = q.InsertAssetSeedlingIntoBatch(ctx, dbSeedling) if err != nil { return fmt.Errorf("unable to insert "+ @@ -473,8 +592,8 @@ func (a *AssetMintingStore) AddSeedlingsToBatch(ctx context.Context, // fetchSeedlingID attempts to fetch the ID for a seedling from a specific // batch. This is performed within the context of a greater DB transaction. -func fetchSeedlingID(ctx context.Context, q PendingAssetStore, - batchKey []byte, seedlingName string) (int64, error) { +func fetchSeedlingID(ctx context.Context, q PendingAssetStore, batchKey []byte, + seedlingName string) (int64, error) { seedlingParams := AssetSeedlingTuple{ SeedlingName: seedlingName, @@ -512,6 +631,71 @@ func fetchAssetSeedlings(ctx context.Context, q PendingAssetStore, EnableEmission: dbSeedling.EmissionEnabled, } + if dbSeedling.TweakedScriptKey != nil { + tweakedScriptKey, err := btcec.ParsePubKey( + dbSeedling.TweakedScriptKey, + ) + if err != nil { + return nil, err + } + + scriptKeyInternalPub, err := btcec.ParsePubKey( + dbSeedling.ScriptKeyRaw, + ) + if err != nil { + return nil, err + } + + scriptKeyLocator := keychain.KeyLocator{ + Index: extractSqlInt32[uint32]( + dbSeedling.ScriptKeyIndex, + ), + Family: extractSqlInt32[keychain.KeyFamily]( + dbSeedling.ScriptKeyFam, + ), + } + + scriptKeyRawKey := keychain.KeyDescriptor{ + KeyLocator: scriptKeyLocator, + PubKey: scriptKeyInternalPub, + } + seedling.ScriptKey = asset.ScriptKey{ + PubKey: tweakedScriptKey, + TweakedScriptKey: &asset.TweakedScriptKey{ + RawKey: scriptKeyRawKey, + Tweak: dbSeedling.ScriptKeyTweak, + }, + } + } + + if dbSeedling.GroupKeyRaw != nil { + groupKeyPub, err := btcec.ParsePubKey( + dbSeedling.GroupKeyRaw, + ) + if err != nil { + return nil, err + } + + groupKeyLocator := keychain.KeyLocator{ + Index: extractSqlInt32[uint32]( + dbSeedling.GroupKeyIndex, + ), + Family: extractSqlInt32[keychain.KeyFamily]( + dbSeedling.GroupKeyFam, + ), + } + + seedling.GroupInternalKey = &keychain.KeyDescriptor{ + KeyLocator: groupKeyLocator, + PubKey: groupKeyPub, + } + } + + if len(dbSeedling.GroupTapscriptRoot) != 0 { + seedling.GroupTapscriptRoot = dbSeedling. + GroupTapscriptRoot + } + // Fetch the group info for seedlings with a specific group. // There can only be one group per genesis. if dbSeedling.GroupGenesisID.Valid { @@ -580,17 +764,34 @@ func fetchAssetSprouts(ctx context.Context, q PendingAssetStore, for i, sprout := range dbSprout { // First, we'll decode the script key which very asset must // specify, and populate the key locator information - scriptKeyPub, err := btcec.ParsePubKey(sprout.ScriptKeyRaw) + tweakedScriptKey, err := btcec.ParsePubKey( + sprout.TweakedScriptKey, + ) + if err != nil { + return nil, err + } + + internalScriptKey, err := btcec.ParsePubKey( + sprout.ScriptKeyRaw, + ) if err != nil { return nil, err } - scriptKey := keychain.KeyDescriptor{ - PubKey: scriptKeyPub, + + scriptKeyDesc := keychain.KeyDescriptor{ + PubKey: internalScriptKey, KeyLocator: keychain.KeyLocator{ Index: uint32(sprout.ScriptKeyIndex), Family: keychain.KeyFamily(sprout.ScriptKeyFam), }, } + scriptKey := asset.ScriptKey{ + PubKey: tweakedScriptKey, + TweakedScriptKey: &asset.TweakedScriptKey{ + RawKey: scriptKeyDesc, + Tweak: sprout.Tweak, + }, + } // Not all assets have a key group, so we only need to // populate this information for those that signalled the @@ -629,6 +830,10 @@ func fetchAssetSprouts(ctx context.Context, q PendingAssetStore, GroupPubKey: *tweakedGroupKey, Witness: groupWitness, } + + if len(sprout.TapscriptRoot) != 0 { + groupKey.TapscriptRoot = sprout.TapscriptRoot + } } // Next, we'll populate the asset genesis information which @@ -639,8 +844,7 @@ func fetchAssetSprouts(ctx context.Context, q PendingAssetStore, bytes.NewReader(sprout.GenesisPrevOut), 0, 0, &genesisPrevOut, ); err != nil { - return nil, fmt.Errorf("unable to read "+ - "outpoint: %w", err) + return nil, fmt.Errorf("%w: %w", ErrReadOutpoint, err) } assetGenesis := asset.Genesis{ FirstPrevOut: genesisPrevOut, @@ -669,7 +873,7 @@ func fetchAssetSprouts(ctx context.Context, q PendingAssetStore, assetSprout, err := asset.New( assetGenesis, amount, lockTime, relativeLocktime, - asset.NewScriptKeyBip86(scriptKey), groupKey, + scriptKey, groupKey, asset.WithAssetVersion(asset.Version(sprout.Version)), ) if err != nil { @@ -679,7 +883,6 @@ 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 } @@ -736,20 +939,18 @@ func (a *AssetMintingStore) FetchNonFinalBatches( ctx, int16(tapgarden.BatchStateFinalized), ) if err != nil { - return fmt.Errorf("unable to fetch minting "+ - "batches: %w", err) + return fmt.Errorf("%w: %w", ErrFetchMintingBatches, err) } parseBatch := func(batch MintingBatchI) (*tapgarden.MintingBatch, error) { - convBatch := convertMintingBatchI(batch) - return marshalMintingBatch(ctx, q, convBatch) + return marshalMintingBatch(ctx, q, MintingBatchF(batch)) } batches, err = fn.MapErr(dbBatches, parseBatch) if err != nil { - return fmt.Errorf("batch parsing failed: %w", err) + return fmt.Errorf("%w: %w", ErrBatchParsing, err) } return nil @@ -771,20 +972,18 @@ func (a *AssetMintingStore) FetchAllBatches( dbErr := a.db.ExecTx(ctx, &readOpts, func(q PendingAssetStore) error { dbBatches, err := q.AllMintingBatches(ctx) if err != nil { - return fmt.Errorf("unable to fetch minting "+ - "batches: %w", err) + return fmt.Errorf("%w: %w", ErrFetchMintingBatches, err) } parseBatch := func(batch MintingBatchA) (*tapgarden.MintingBatch, error) { - convBatch := convertMintingBatchA(batch) - return marshalMintingBatch(ctx, q, convBatch) + return marshalMintingBatch(ctx, q, MintingBatchF(batch)) } batches, err = fn.MapErr(dbBatches, parseBatch) if err != nil { - return fmt.Errorf("batch parsing failed: %w", err) + return fmt.Errorf("%w: %w", ErrBatchParsing, err) } return nil @@ -816,7 +1015,7 @@ func (a *AssetMintingStore) FetchMintingBatch(ctx context.Context, batch, err = marshalMintingBatch(ctx, q, dbBatch) if err != nil { - return fmt.Errorf("batch parsing failed: %w", err) + return fmt.Errorf("%w: %w", ErrBatchParsing, err) } return nil @@ -831,18 +1030,6 @@ func (a *AssetMintingStore) FetchMintingBatch(ctx context.Context, return batch, nil } -// convertMintingBatchI converts a batch fetched with FetchNonFinalBatches to -// another type so it can be parsed. -func convertMintingBatchI(batch MintingBatchI) MintingBatchF { - return MintingBatchF(batch) -} - -// convertMintingBatchA converts a batch fetched with AllMintingBatches to -// another type so it can be parsed. -func convertMintingBatchA(batch MintingBatchA) MintingBatchF { - return MintingBatchF(batch) -} - // marshalMintingBatch marshals a minting batch into its native type, // and fetches the corresponding seedlings or root Taproot Asset commitment. func marshalMintingBatch(ctx context.Context, q PendingAssetStore, @@ -980,6 +1167,131 @@ func encodeOutpoint(outPoint wire.OutPoint) ([]byte, error) { return b.Bytes(), nil } +// CommitBatchTx updates the genesis transaction of a batch based on the batch +// key. +func (a *AssetMintingStore) CommitBatchTx(ctx context.Context, + batchKey *btcec.PublicKey, genesisPacket *tapsend.FundedPsbt) error { + + genesisOutpoint := genesisPacket.Pkt.UnsignedTx.TxIn[0].PreviousOutPoint + rawBatchKey := batchKey.SerializeCompressed() + + var psbtBuf bytes.Buffer + if err := genesisPacket.Pkt.Serialize(&psbtBuf); err != nil { + return fmt.Errorf("%w: %w", ErrEncodePsbt, err) + } + + var writeTxOpts AssetStoreTxOptions + return a.db.ExecTx(ctx, &writeTxOpts, func(q PendingAssetStore) error { + genesisPointID, err := upsertGenesisPoint( + ctx, q, genesisOutpoint, + ) + if err != nil { + return fmt.Errorf("%w: %w", ErrUpsertGenesisPoint, err) + } + + return q.BindMintingBatchWithTx(ctx, BatchChainUpdate{ + RawKey: rawBatchKey, + MintingTxPsbt: psbtBuf.Bytes(), + ChangeOutputIndex: sqlInt32( + genesisPacket.ChangeOutputIndex, + ), + GenesisID: sqlInt64(genesisPointID), + }) + }) +} + +// AddSeedlingGroups stores the asset groups for seedlings associated with a +// batch. +func (a *AssetMintingStore) AddSeedlingGroups(ctx context.Context, + genesisOutpoint wire.OutPoint, assetGroups []*asset.AssetGroup) error { + + var writeTxOpts AssetStoreTxOptions + return a.db.ExecTx(ctx, &writeTxOpts, func(q PendingAssetStore) error { + // fetch the outpoint ID inserted during funding + genesisPointID, err := upsertGenesisPoint( + ctx, q, genesisOutpoint, + ) + if err != nil { + return fmt.Errorf("%w: %w", ErrUpsertGenesisPoint, err) + } + + // insert genesis and group key + for idx := range assetGroups { + genAssetID, err := upsertGenesis( + ctx, q, genesisPointID, + *assetGroups[idx].Genesis, + ) + if err != nil { + return fmt.Errorf("unable to upsert grouped "+ + "seedling genesis: %w", err) + } + + _, err = upsertGroupKey( + ctx, assetGroups[idx].GroupKey, q, + genesisPointID, genAssetID, + ) + if err != nil { + return fmt.Errorf("unable to upsert group for "+ + "grouped seedling: %w", err) + } + } + + return nil + }) +} + +// 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, + 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() + } + + seedlingGens = append(seedlingGens, gen) + }) + + // 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 { + return err + } + + groupKey, err := fetchGroupByGenesis(ctx, q, genID) + if err != nil { + return err + } + + seedlingGroups = append(seedlingGroups, groupKey) + } + + return nil + }) + + if dbErr != nil { + return nil, dbErr + } + + return seedlingGroups, nil +} + // AddSproutsToBatch updates a batch with the passed batch transaction and also // binds the genesis transaction (which will create the set of assets in the // batch) to the batch itself. @@ -1026,7 +1338,7 @@ func (a *AssetMintingStore) AddSproutsToBatch(ctx context.Context, // the genesis packet, and genesis point information. var psbtBuf bytes.Buffer if err := genesisPacket.Pkt.Serialize(&psbtBuf); err != nil { - return fmt.Errorf("unable to encode psbt: %w", err) + return fmt.Errorf("%w: %w", ErrEncodePsbt, err) } err = q.BindMintingBatchWithTx(ctx, BatchChainUpdate{ RawKey: rawBatchKey, @@ -1037,7 +1349,7 @@ func (a *AssetMintingStore) AddSproutsToBatch(ctx context.Context, GenesisID: sqlInt64(genesisPointID), }) if err != nil { - return fmt.Errorf("unable to add batch tx: %w", err) + return fmt.Errorf("%w: %w", ErrBindBatchTx, err) } // Finally, update the batch state to BatchStateCommitted. @@ -1108,7 +1420,8 @@ func (a *AssetMintingStore) CommitSignedGenesisTx(ctx context.Context, MintingTxPsbt: psbtBuf.Bytes(), }) if err != nil { - return fmt.Errorf("unable to update genesis tx: %w", err) + return fmt.Errorf("unable to update genesis tx: %w", + err) } // Before we can insert a managed UTXO, we'll need to insert a @@ -1136,7 +1449,8 @@ func (a *AssetMintingStore) CommitSignedGenesisTx(ctx context.Context, TxnID: chainTXID, }) if err != nil { - return fmt.Errorf("unable to insert managed utxo: %w", err) + return fmt.Errorf("unable to insert managed utxo: %w", + err) } // With the managed UTXO inserted, we also need to update all @@ -1157,7 +1471,8 @@ func (a *AssetMintingStore) CommitSignedGenesisTx(ctx context.Context, PrevOut: genesisOutpoint, AnchorTxID: sqlInt64(chainTXID), }); err != nil { - return fmt.Errorf("unable to anchor genesis tx: %w", err) + return fmt.Errorf("unable to anchor genesis tx: %w", + err) } // Finally, update the batch state to BatchStateBroadcast. diff --git a/tapdb/asset_minting_test.go b/tapdb/asset_minting_test.go index 1be0f79d7..31484a763 100644 --- a/tapdb/asset_minting_test.go +++ b/tapdb/asset_minting_test.go @@ -82,7 +82,7 @@ func assertBatchEqual(t *testing.T, a, b *tapgarden.MintingBatch) { require.Equal(t, a.TapSibling(), b.TapSibling()) require.Equal(t, a.BatchKey, b.BatchKey) require.Equal(t, a.Seedlings, b.Seedlings) - require.Equal(t, a.GenesisPacket, b.GenesisPacket) + assertPsbtEqual(t, a.GenesisPacket, b.GenesisPacket) require.Equal(t, a.RootAssetCommitment, b.RootAssetCommitment) } @@ -128,9 +128,10 @@ func storeGroupGenesis(t *testing.T, ctx context.Context, initGen asset.Genesis, groupReq := asset.NewGroupKeyRequestNoErr( t, privDesc, initGen, genProtoAsset, nil, ) - groupKey, err := asset.DeriveGroupKey( - genSigner, &genTxBuilder, *groupReq, - ) + genTx, err := groupReq.BuildGroupVirtualTx(&genTxBuilder) + require.NoError(t, err) + + groupKey, err := asset.DeriveGroupKey(genSigner, *genTx, *groupReq, nil) require.NoError(t, err) initialAsset := asset.RandAssetWithValues( @@ -315,7 +316,7 @@ func addRandGroupToBatch(t *testing.T, store *AssetMintingStore, // Generate a random genesis and group to use as a group anchor // for this seedling. - privDesc, groupPriv := randKeyDesc(t) + privDesc, groupPriv := test.RandKeyDesc(t) randGenesis := asset.RandGenesis(t, randAssetType) genesisAmt, groupPriv, group := storeGroupGenesis( t, ctx, randGenesis, nil, store, privDesc, groupPriv, @@ -356,8 +357,8 @@ func addRandSiblingToBatch(t *testing.T, batch *tapgarden.MintingBatch) ( // seedling is being issued into an existing group, and creates a multi-asset // group. Specifically, one seedling will have emission enabled, and the other // seedling will reference the first seedling as its group anchor. -func addMultiAssetGroupToBatch(seedlings map[string]*tapgarden.Seedling) (string, - string) { +func addMultiAssetGroupToBatch(seedlings map[string]*tapgarden.Seedling) ( + string, string) { seedlingNames := maps.Keys(seedlings) seedlingCount := len(seedlingNames) @@ -384,6 +385,7 @@ func addMultiAssetGroupToBatch(seedlings map[string]*tapgarden.Seedling) (string // The anchor asset must have emission enabled, and the second asset // must specify the first as its group anchor. anchorSeedling.EnableEmission = true + anchorSeedling.GroupTapscriptRoot = test.RandBytes(32) groupedSeedling.AssetType = anchorSeedling.AssetType groupedSeedling.EnableEmission = false groupedSeedling.GroupAnchor = &anchorSeedling.AssetName @@ -417,6 +419,7 @@ func TestCommitMintingBatchSeedlings(t *testing.T) { // have it be exactly the same as what we wrote. mintingBatches := noError1(t, assetStore.FetchNonFinalBatches, ctx) assertSeedlingBatchLen(t, mintingBatches, 1, numSeedlings) + require.NotNil(t, mintingBatches[0].GenesisPacket) assertBatchEqual(t, mintingBatch, mintingBatches[0]) assertBatchSibling(t, mintingBatch, randSiblingHash) @@ -485,19 +488,6 @@ func TestCommitMintingBatchSeedlings(t *testing.T) { assertSeedlingBatchLen(t, mintingBatches, 1, numSeedlings) } -func randKeyDesc(t *testing.T) (keychain.KeyDescriptor, *btcec.PrivateKey) { - priv, err := btcec.NewPrivateKey() - require.NoError(t, err) - - return keychain.KeyDescriptor{ - PubKey: priv.PubKey(), - KeyLocator: keychain.KeyLocator{ - Index: uint32(rand.Int31()), - Family: keychain.KeyFamily(rand.Int31()), - }, - }, priv -} - // seedlingsToAssetRoot maps a set of seedlings to an asset root. // // TODO(roasbeef): same func in tapgarden can just re-use? @@ -524,9 +514,6 @@ func seedlingsToAssetRoot(t *testing.T, genesisPoint wire.OutPoint, assetGen.MetaHash = seedling.Meta.MetaHash() } - scriptKey, _ := randKeyDesc(t) - tweakedScriptKey := asset.NewScriptKeyBip86(scriptKey) - var ( genTxBuilder = tapscript.GroupTxBuilder{} groupPriv *btcec.PrivateKey @@ -560,7 +547,7 @@ func seedlingsToAssetRoot(t *testing.T, genesisPoint wire.OutPoint, if groupInfo != nil || seedling.EnableEmission { protoAsset, err = asset.New( - assetGen, amount, 0, 0, tweakedScriptKey, nil, + assetGen, amount, 0, 0, seedling.ScriptKey, nil, ) require.NoError(t, err) } @@ -568,23 +555,37 @@ func seedlingsToAssetRoot(t *testing.T, genesisPoint wire.OutPoint, if groupInfo != nil { groupReq := asset.NewGroupKeyRequestNoErr( t, groupInfo.GroupKey.RawKey, - *groupInfo.Genesis, protoAsset, nil, + *groupInfo.Genesis, protoAsset, + groupInfo.GroupKey.TapscriptRoot, + ) + genTx, err := groupReq.BuildGroupVirtualTx( + &genTxBuilder, ) + require.NoError(t, err) + groupKey, err = asset.DeriveGroupKey( asset.NewMockGenesisSigner(groupPriv), - &genTxBuilder, *groupReq, + *genTx, *groupReq, nil, ) + require.NoError(t, err) } if seedling.EnableEmission { - groupKeyRaw, newGroupPriv := randKeyDesc(t) + groupKeyRaw, newGroupPriv := test.RandKeyDesc(t) genSigner := asset.NewMockGenesisSigner(newGroupPriv) groupReq := asset.NewGroupKeyRequestNoErr( - t, groupKeyRaw, assetGen, protoAsset, nil, + t, groupKeyRaw, assetGen, protoAsset, + seedling.GroupTapscriptRoot, + ) + genTx, err := groupReq.BuildGroupVirtualTx( + &genTxBuilder, ) + require.NoError(t, err) + groupKey, err = asset.DeriveGroupKey( - genSigner, &genTxBuilder, *groupReq, + genSigner, *genTx, *groupReq, nil, ) + require.NoError(t, err) newGroupPrivs[seedling.AssetName] = newGroupPriv newGroupInfo[seedling.AssetName] = &asset.AssetGroup{ Genesis: &assetGen, @@ -595,7 +596,7 @@ func seedlingsToAssetRoot(t *testing.T, genesisPoint wire.OutPoint, require.NoError(t, err) newAsset, err := asset.New( - assetGen, amount, 0, 0, tweakedScriptKey, groupKey, + assetGen, amount, 0, 0, seedling.ScriptKey, groupKey, asset.WithAssetVersion(seedling.AssetVersion), ) require.NoError(t, err) @@ -614,43 +615,9 @@ func seedlingsToAssetRoot(t *testing.T, genesisPoint wire.OutPoint, return tapCommitment } -func randGenesisPacket(t *testing.T) *tapsend.FundedPsbt { - tx := wire.NewMsgTx(2) - - var hash chainhash.Hash - _, err := rand.Read(hash[:]) - require.NoError(t, err) - - tx.AddTxIn(&wire.TxIn{ - PreviousOutPoint: wire.OutPoint{ - Hash: hash, - Index: 1, - }, - }) - tx.AddTxOut(&wire.TxOut{ - PkScript: bytes.Repeat([]byte{0x01}, 34), - Value: 5, - }) - tx.AddTxOut(&wire.TxOut{ - PkScript: bytes.Repeat([]byte{0x02}, 34), - Value: 10, - }) - tx.AddTxOut(&wire.TxOut{ - PkScript: bytes.Repeat([]byte{0x02}, 34), - Value: 15, - }) - - packet, err := psbt.NewFromUnsignedTx(tx) - require.NoError(t, err) - return &tapsend.FundedPsbt{ - Pkt: packet, - ChangeOutputIndex: 1, - ChainFees: 100, - } -} - func assertPsbtEqual(t *testing.T, a, b *tapsend.FundedPsbt) { require.Equal(t, a.ChangeOutputIndex, b.ChangeOutputIndex) + require.Equal(t, a.ChainFees, b.ChainFees) require.Equal(t, a.LockedUTXOs, b.LockedUTXOs) var aBuf, bBuf bytes.Buffer @@ -721,7 +688,7 @@ func TestAddSproutsToBatch(t *testing.T) { // Now that the batch is on disk, we'll map those seedlings to an // actual asset commitment, then insert them into the DB as sprouts. - genesisPacket := randGenesisPacket(t) + genesisPacket := mintingBatch.GenesisPacket assetRoot := seedlingsToAssetRoot( t, genesisPacket.Pkt.UnsignedTx.TxIn[0].PreviousOutPoint, mintingBatch.Seedlings, seedlingGroups, @@ -788,8 +755,7 @@ func addRandAssets(t *testing.T, ctx context.Context, batchKey := mintingBatch.BatchKey.PubKey require.NoError(t, assetStore.CommitMintingBatch(ctx, mintingBatch)) - genesisPacket := randGenesisPacket(t) - + genesisPacket := mintingBatch.GenesisPacket assetRoot := seedlingsToAssetRoot( t, genesisPacket.Pkt.UnsignedTx.TxIn[0].PreviousOutPoint, mintingBatch.Seedlings, seedlingGroups, @@ -848,7 +814,7 @@ func TestCommitBatchChainActions(t *testing.T) { // to disk, along with the Taproot Asset script root that's stored // alongside any managed UTXOs. require.NoError(t, assetStore.CommitSignedGenesisTx( - ctx, randAssetCtx.batchKey, randAssetCtx.genesisPkt, 2, + ctx, randAssetCtx.batchKey, randAssetCtx.genesisPkt, 0, randAssetCtx.merkleRoot, randAssetCtx.scriptRoot, randAssetCtx.tapSiblingBytes, )) @@ -1071,7 +1037,7 @@ func TestDuplicateGroupKey(t *testing.T) { // Now that we have the DB, we'll insert a new random internal key, and // then a key family linked to that internal key. - keyDesc, _ := randKeyDesc(t) + keyDesc, _ := test.RandKeyDesc(t) rawKey := keyDesc.PubKey.SerializeCompressed() keyID, err := db.UpsertInternalKey(ctx, InternalKey{ @@ -1118,12 +1084,12 @@ func TestGroupStore(t *testing.T) { // Now we generate and store one group of two assets, and // a collectible in its own group. - privDesc1, groupPriv1 := randKeyDesc(t) + privDesc1, groupPriv1 := test.RandKeyDesc(t) gen1 := asset.RandGenesis(t, asset.Normal) _, _, group1 := storeGroupGenesis( t, ctx, gen1, nil, assetStore, privDesc1, groupPriv1, ) - privDesc2, groupPriv2 := randKeyDesc(t) + privDesc2, groupPriv2 := test.RandKeyDesc(t) gen2 := asset.RandGenesis(t, asset.Collectible) _, _, group2 := storeGroupGenesis( t, ctx, gen2, nil, assetStore, privDesc2, groupPriv2, @@ -1326,7 +1292,7 @@ func TestGroupAnchors(t *testing.T) { // Now we'll map these seedlings to an asset commitment and insert them // into the DB as sprouts. - genesisPacket := randGenesisPacket(t) + genesisPacket := mintingBatch.GenesisPacket assetRoot := seedlingsToAssetRoot( t, genesisPacket.Pkt.UnsignedTx.TxIn[0].PreviousOutPoint, mintingBatch.Seedlings, seedlingGroups, diff --git a/tapdb/assets_common.go b/tapdb/assets_common.go index 4b7468441..6df7270a9 100644 --- a/tapdb/assets_common.go +++ b/tapdb/assets_common.go @@ -84,6 +84,40 @@ type UpsertAssetStore interface { error) } +var ( + // ErrEncodeGenesisPoint is returned when encoding a genesis point + // before upserting it fails. + ErrEncodeGenesisPoint = errors.New("unable to encode genesis point") + + // ErrReadOutpoint is returned when decoding an outpoint fails. + ErrReadOutpoint = errors.New("unable to read outpoint") + + // ErrUpsertGenesisPoint is returned when upserting a genesis point + // fails. + ErrUpsertGenesisPoint = errors.New("unable to upsert genesis point") + + // ErrUpsertGroupKey is returned when upserting a group key fails. + ErrUpsertGroupKey = errors.New("unable to upsert group key") + + // ErrUpsertInternalKey is returned when upserting an internal key + // fails. + ErrUpsertInternalKey = errors.New("unable to upsert internal key") + + // ErrUpsertScriptKey is returned when upserting a script key fails. + ErrUpsertScriptKey = errors.New("unable to upsert script key") + + // ErrNoAssetGroup is returned when no matching asset group is found. + ErrNoAssetGroup = errors.New("no matching asset group") + + // ErrGroupGenesisInfo is returned when fetching genesis info for an + // asset group fails. + ErrGroupGenesisInfo = errors.New("unable to fetch group genesis info") + + // ErrTapscriptRootSize is returned when the given tapscript root is not + // exactly 32 bytes. + ErrTapscriptRootSize = errors.New("tapscript root invalid: wrong size") +) + // upsertGenesis imports a new genesis point into the database or returns the // existing ID if that point already exists. func upsertGenesisPoint(ctx context.Context, q UpsertAssetStore, @@ -91,14 +125,14 @@ func upsertGenesisPoint(ctx context.Context, q UpsertAssetStore, genesisPoint, err := encodeOutpoint(genesisOutpoint) if err != nil { - return 0, fmt.Errorf("unable to encode genesis point: %w", err) + return 0, fmt.Errorf("%w: %w", ErrEncodeGenesisPoint, err) } // First, we'll insert the component that ties together all the assets // in a batch: the genesis point. genesisPointID, err := q.UpsertGenesisPoint(ctx, genesisPoint) if err != nil { - return 0, fmt.Errorf("unable to insert genesis point: %w", err) + return 0, fmt.Errorf("%w: %w", ErrUpsertGenesisPoint, err) } return genesisPointID, nil @@ -134,7 +168,7 @@ func fetchGenesisID(ctx context.Context, q UpsertAssetStore, genPoint, err := encodeOutpoint(genesis.FirstPrevOut) if err != nil { - return 0, fmt.Errorf("unable to encode genesis point: %w", err) + return 0, fmt.Errorf("%w: %w", ErrEncodeGenesisPoint, err) } assetID := genesis.ID() @@ -189,14 +223,14 @@ func upsertAssetsWithGenesis(ctx context.Context, q UpsertAssetStore, ctx, a.GroupKey, q, genesisPointID, genAssetID, ) if err != nil { - return 0, nil, fmt.Errorf("unable to upsert group "+ - "key: %w", err) + return 0, nil, fmt.Errorf("%w: %w", ErrUpsertGroupKey, + err) } scriptKeyID, err := upsertScriptKey(ctx, a.ScriptKey, q) if err != nil { - return 0, nil, fmt.Errorf("unable to upsert script "+ - "key: %w", err) + return 0, nil, fmt.Errorf("%w: %w", ErrUpsertScriptKey, + err) } // Is the asset anchored already? @@ -280,15 +314,14 @@ func upsertGroupKey(ctx context.Context, groupKey *asset.GroupKey, KeyIndex: int32(groupKey.RawKey.Index), }) if err != nil { - return nullID, fmt.Errorf("unable to insert internal key: %w", - err) + return nullID, fmt.Errorf("%w: %w", ErrUpsertInternalKey, err) } // The only valid size for a non-empty Tapscript root is 32 bytes. if len(groupKey.TapscriptRoot) != 0 && len(groupKey.TapscriptRoot) != sha256.Size { - return nullID, fmt.Errorf("tapscript root invalid: wrong size") + return nullID, ErrTapscriptRootSize } groupID, err := q.UpsertAssetGroupKey(ctx, AssetGroupKey{ @@ -298,8 +331,7 @@ func upsertGroupKey(ctx context.Context, groupKey *asset.GroupKey, GenesisPointID: genesisPointID, }) if err != nil { - return nullID, fmt.Errorf("unable to insert group key: %w", - err) + return nullID, fmt.Errorf("%w: %w", ErrUpsertGroupKey, err) } // With the statement above complete, we'll now insert the @@ -340,8 +372,8 @@ func upsertScriptKey(ctx context.Context, scriptKey asset.ScriptKey, KeyIndex: int32(scriptKey.RawKey.Index), }) if err != nil { - return 0, fmt.Errorf("unable to insert internal key: "+ - "%w", err) + return 0, fmt.Errorf("%w: %w", ErrUpsertInternalKey, + err) } scriptKeyID, err := q.UpsertScriptKey(ctx, NewScriptKey{ InternalKeyID: rawScriptKeyID, @@ -349,8 +381,7 @@ func upsertScriptKey(ctx context.Context, scriptKey asset.ScriptKey, Tweak: scriptKey.Tweak, }) if err != nil { - return 0, fmt.Errorf("unable to insert script key: "+ - "%w", err) + return 0, fmt.Errorf("%w: %w", ErrUpsertScriptKey, err) } return scriptKeyID, nil @@ -373,16 +404,15 @@ func upsertScriptKey(ctx context.Context, scriptKey asset.ScriptKey, RawKey: scriptKey.PubKey.SerializeCompressed(), }) if err != nil { - return 0, fmt.Errorf("unable to insert internal key: "+ - "%w", err) + return 0, fmt.Errorf("%w: %w", ErrUpsertInternalKey, + err) } scriptKeyID, err = q.UpsertScriptKey(ctx, NewScriptKey{ InternalKeyID: rawScriptKeyID, TweakedScriptKey: scriptKey.PubKey.SerializeCompressed(), }) if err != nil { - return 0, fmt.Errorf("unable to insert script key: "+ - "%w", err) + return 0, fmt.Errorf("%w: %w", ErrUpsertScriptKey, err) } } @@ -415,8 +445,8 @@ func fetchGenesis(ctx context.Context, q FetchGenesisStore, var genesisPrevOut wire.OutPoint err = readOutPoint(bytes.NewReader(gen.PrevOut), 0, 0, &genesisPrevOut) if err != nil { - return asset.Genesis{}, fmt.Errorf("unable to read outpoint: "+ - "%w", err) + return asset.Genesis{}, fmt.Errorf("%w: %w", ErrReadOutpoint, + err) } var metaHash [32]byte @@ -454,15 +484,14 @@ func fetchGroupByGenesis(ctx context.Context, q GroupStore, groupInfo, err := q.FetchGroupByGenesis(ctx, genID) switch { case errors.Is(err, sql.ErrNoRows): - return nil, fmt.Errorf("no matching asset group: %w", err) + return nil, fmt.Errorf("%w: %w", ErrNoAssetGroup, err) case err != nil: return nil, err } groupGenesis, err := fetchGenesis(ctx, q, genID) if err != nil { - return nil, fmt.Errorf("unable to fetch group genesis info: "+ - "%w", err) + return nil, fmt.Errorf("%w: %w", ErrGroupGenesisInfo, err) } groupKey, err := parseGroupKeyInfo( @@ -489,15 +518,14 @@ func fetchGroupByGroupKey(ctx context.Context, q GroupStore, groupInfo, err := q.FetchGroupByGroupKey(ctx, groupKeyQuery[:]) switch { case errors.Is(err, sql.ErrNoRows): - return nil, fmt.Errorf("no matching asset group: %w", err) + return nil, fmt.Errorf("%w: %w", ErrNoAssetGroup, err) case err != nil: return nil, err } groupGenesis, err := fetchGenesis(ctx, q, groupInfo.GenAssetID) if err != nil { - return nil, fmt.Errorf("unable to fetch group genesis info: "+ - "%w", err) + return nil, fmt.Errorf("%w: %w", ErrGroupGenesisInfo, err) } groupKey, err := parseGroupKeyInfo( diff --git a/tapdb/assets_store_test.go b/tapdb/assets_store_test.go index 1746ceb11..9660ff591 100644 --- a/tapdb/assets_store_test.go +++ b/tapdb/assets_store_test.go @@ -166,8 +166,11 @@ func randAsset(t *testing.T, genOpts ...assetGenOpt) *asset.Asset { groupReq := asset.NewGroupKeyRequestNoErr( t, groupKeyDesc, initialGen, protoAsset, nil, ) + genTx, err := groupReq.BuildGroupVirtualTx(&genTxBuilder) + require.NoError(t, err) + assetGroupKey, err = asset.DeriveGroupKey( - genSigner, &genTxBuilder, *groupReq, + genSigner, *genTx, *groupReq, nil, ) require.NoError(t, err) diff --git a/tapdb/sqlc/assets.sql.go b/tapdb/sqlc/assets.sql.go index c49554b06..8150a07f9 100644 --- a/tapdb/sqlc/assets.sql.go +++ b/tapdb/sqlc/assets.sql.go @@ -1531,7 +1531,7 @@ func (q *Queries) FetchScriptKeyIDByTweakedKey(ctx context.Context, tweakedScrip } const fetchSeedlingByID = `-- name: FetchSeedlingByID :one -SELECT seedling_id, asset_name, asset_version, asset_type, asset_supply, asset_meta_id, emission_enabled, batch_id, group_genesis_id, group_anchor_id +SELECT seedling_id, asset_name, asset_version, asset_type, asset_supply, asset_meta_id, emission_enabled, batch_id, group_genesis_id, group_anchor_id, script_key_id, group_internal_key_id, group_tapscript_root FROM asset_seedlings WHERE seedling_id = $1 ` @@ -1550,6 +1550,9 @@ func (q *Queries) FetchSeedlingByID(ctx context.Context, seedlingID int64) (Asse &i.BatchID, &i.GroupGenesisID, &i.GroupAnchorID, + &i.ScriptKeyID, + &i.GroupInternalKeyID, + &i.GroupTapscriptRoot, ) return i, err } @@ -1595,26 +1598,49 @@ WITH target_batch(batch_id) AS ( SELECT seedling_id, asset_name, asset_type, asset_version, asset_supply, assets_meta.meta_data_hash, assets_meta.meta_data_type, assets_meta.meta_data_blob, emission_enabled, batch_id, - group_genesis_id, group_anchor_id + group_genesis_id, group_anchor_id, group_tapscript_root, + script_keys.tweak AS script_key_tweak, + script_keys.tweaked_script_key, + internal_keys.raw_key AS script_key_raw, + internal_keys.key_family AS script_key_fam, + internal_keys.key_index AS script_key_index, + group_internal_keys.raw_key AS group_key_raw, + group_internal_keys.key_family AS group_key_fam, + group_internal_keys.key_index AS group_key_index FROM asset_seedlings LEFT JOIN assets_meta ON asset_seedlings.asset_meta_id = assets_meta.meta_id +LEFT JOIN script_keys + ON asset_seedlings.script_key_id = script_keys.script_key_id +LEFT JOIN internal_keys + ON script_keys.internal_key_id = internal_keys.key_id +LEFT JOIN internal_keys group_internal_keys + ON asset_seedlings.group_internal_key_id = group_internal_keys.key_id WHERE asset_seedlings.batch_id in (SELECT batch_id FROM target_batch) ` type FetchSeedlingsForBatchRow struct { - SeedlingID int64 - AssetName string - AssetType int16 - AssetVersion int16 - AssetSupply int64 - MetaDataHash []byte - MetaDataType sql.NullInt16 - MetaDataBlob []byte - EmissionEnabled bool - BatchID int64 - GroupGenesisID sql.NullInt64 - GroupAnchorID sql.NullInt64 + SeedlingID int64 + AssetName string + AssetType int16 + AssetVersion int16 + AssetSupply int64 + MetaDataHash []byte + MetaDataType sql.NullInt16 + MetaDataBlob []byte + EmissionEnabled bool + BatchID int64 + GroupGenesisID sql.NullInt64 + GroupAnchorID sql.NullInt64 + GroupTapscriptRoot []byte + ScriptKeyTweak []byte + TweakedScriptKey []byte + ScriptKeyRaw []byte + ScriptKeyFam sql.NullInt32 + ScriptKeyIndex sql.NullInt32 + GroupKeyRaw []byte + GroupKeyFam sql.NullInt32 + GroupKeyIndex sql.NullInt32 } func (q *Queries) FetchSeedlingsForBatch(ctx context.Context, rawKey []byte) ([]FetchSeedlingsForBatchRow, error) { @@ -1639,6 +1665,15 @@ func (q *Queries) FetchSeedlingsForBatch(ctx context.Context, rawKey []byte) ([] &i.BatchID, &i.GroupGenesisID, &i.GroupAnchorID, + &i.GroupTapscriptRoot, + &i.ScriptKeyTweak, + &i.TweakedScriptKey, + &i.ScriptKeyRaw, + &i.ScriptKeyFam, + &i.ScriptKeyIndex, + &i.GroupKeyRaw, + &i.GroupKeyFam, + &i.GroupKeyIndex, ); err != nil { return nil, err } @@ -1789,23 +1824,30 @@ func (q *Queries) HasAssetProof(ctx context.Context, tweakedScriptKey []byte) (b const insertAssetSeedling = `-- name: InsertAssetSeedling :exec INSERT INTO asset_seedlings ( asset_name, asset_type, asset_version, asset_supply, asset_meta_id, - emission_enabled, batch_id, group_genesis_id, group_anchor_id + emission_enabled, batch_id, group_genesis_id, group_anchor_id, + script_key_id, group_internal_key_id, group_tapscript_root ) VALUES ( - $1, $2, $3, $4, $5, $6, $7, - $8, $9 + $1, $2, $3, $4, + $5, $6, $7, + $8, $9, + $10, $11, + $12 ) ` type InsertAssetSeedlingParams struct { - AssetName string - AssetType int16 - AssetVersion int16 - AssetSupply int64 - AssetMetaID int64 - EmissionEnabled bool - BatchID int64 - GroupGenesisID sql.NullInt64 - GroupAnchorID sql.NullInt64 + AssetName string + AssetType int16 + AssetVersion int16 + AssetSupply int64 + AssetMetaID int64 + EmissionEnabled bool + BatchID int64 + GroupGenesisID sql.NullInt64 + GroupAnchorID sql.NullInt64 + ScriptKeyID sql.NullInt64 + GroupInternalKeyID sql.NullInt64 + GroupTapscriptRoot []byte } func (q *Queries) InsertAssetSeedling(ctx context.Context, arg InsertAssetSeedlingParams) error { @@ -1819,6 +1861,9 @@ func (q *Queries) InsertAssetSeedling(ctx context.Context, arg InsertAssetSeedli arg.BatchID, arg.GroupGenesisID, arg.GroupAnchorID, + arg.ScriptKeyID, + arg.GroupInternalKeyID, + arg.GroupTapscriptRoot, ) return err } @@ -1836,24 +1881,31 @@ WITH target_key_id AS ( ) INSERT INTO asset_seedlings( asset_name, asset_type, asset_version, asset_supply, asset_meta_id, - emission_enabled, batch_id, group_genesis_id, group_anchor_id + emission_enabled, batch_id, group_genesis_id, group_anchor_id, + script_key_id, group_internal_key_id, group_tapscript_root ) VALUES ( - $2, $3, $4, $5, $6, $7, + $2, $3, $4, $5, + $6, $7, (SELECT key_id FROM target_key_id), - $8, $9 + $8, $9, + $10, $11, + $12 ) ` type InsertAssetSeedlingIntoBatchParams struct { - RawKey []byte - AssetName string - AssetType int16 - AssetVersion int16 - AssetSupply int64 - AssetMetaID int64 - EmissionEnabled bool - GroupGenesisID sql.NullInt64 - GroupAnchorID sql.NullInt64 + RawKey []byte + AssetName string + AssetType int16 + AssetVersion int16 + AssetSupply int64 + AssetMetaID int64 + EmissionEnabled bool + GroupGenesisID sql.NullInt64 + GroupAnchorID sql.NullInt64 + ScriptKeyID sql.NullInt64 + GroupInternalKeyID sql.NullInt64 + GroupTapscriptRoot []byte } func (q *Queries) InsertAssetSeedlingIntoBatch(ctx context.Context, arg InsertAssetSeedlingIntoBatchParams) error { @@ -1867,6 +1919,9 @@ func (q *Queries) InsertAssetSeedlingIntoBatch(ctx context.Context, arg InsertAs arg.EmissionEnabled, arg.GroupGenesisID, arg.GroupAnchorID, + arg.ScriptKeyID, + arg.GroupInternalKeyID, + arg.GroupTapscriptRoot, ) return err } diff --git a/tapdb/sqlc/migrations/000017_seedling_script_group_keys.down.sql b/tapdb/sqlc/migrations/000017_seedling_script_group_keys.down.sql new file mode 100644 index 000000000..c89c6ff81 --- /dev/null +++ b/tapdb/sqlc/migrations/000017_seedling_script_group_keys.down.sql @@ -0,0 +1,3 @@ +ALTER TABLE asset_seedlings DROP COLUMN script_key_id; +ALTER TABLE asset_seedlings DROP COLUMN group_internal_key_id; +ALTER TABLE asset_seedlings DROP COLUMN group_tapscript_root; \ No newline at end of file diff --git a/tapdb/sqlc/migrations/000017_seedling_script_group_keys.up.sql b/tapdb/sqlc/migrations/000017_seedling_script_group_keys.up.sql new file mode 100644 index 000000000..ef29f8a8c --- /dev/null +++ b/tapdb/sqlc/migrations/000017_seedling_script_group_keys.up.sql @@ -0,0 +1,12 @@ +-- According to SQLite docs, a column added via ALTER TABLE cannot be both +-- a REFERENCE and NOT NULL, so we'll have to enforce non-nilness outside of the DB. +ALTER TABLE asset_seedlings ADD COLUMN script_key_id BIGINT REFERENCES script_keys(script_key_id); + +-- For a group anchor, we derive the internal key for the future group key early, +-- to allow use of custom group witnesses. +ALTER TABLE asset_seedlings ADD COLUMN group_internal_key_id BIGINT REFERENCES internal_keys(key_id); + +-- For a group key, the internal key can also be tweaked to commit to a +-- tapscript tree. Once we finalize the batch, this tweak will also be stored +-- as part of the asset group itself. +ALTER TABLE asset_seedlings ADD COLUMN group_tapscript_root BLOB; \ No newline at end of file diff --git a/tapdb/sqlc/models.go b/tapdb/sqlc/models.go index 5393b0fb2..80fb27b94 100644 --- a/tapdb/sqlc/models.go +++ b/tapdb/sqlc/models.go @@ -87,16 +87,19 @@ type AssetProof struct { } type AssetSeedling struct { - SeedlingID int64 - AssetName string - AssetVersion int16 - AssetType int16 - AssetSupply int64 - AssetMetaID int64 - EmissionEnabled bool - BatchID int64 - GroupGenesisID sql.NullInt64 - GroupAnchorID sql.NullInt64 + SeedlingID int64 + AssetName string + AssetVersion int16 + AssetType int16 + AssetSupply int64 + AssetMetaID int64 + EmissionEnabled bool + BatchID int64 + GroupGenesisID sql.NullInt64 + GroupAnchorID sql.NullInt64 + ScriptKeyID sql.NullInt64 + GroupInternalKeyID sql.NullInt64 + GroupTapscriptRoot []byte } type AssetTransfer struct { diff --git a/tapdb/sqlc/queries/assets.sql b/tapdb/sqlc/queries/assets.sql index 2c94cf8d2..430ca97d9 100644 --- a/tapdb/sqlc/queries/assets.sql +++ b/tapdb/sqlc/queries/assets.sql @@ -57,10 +57,14 @@ WHERE batch_id in (SELECT batch_id FROM target_batch); -- name: InsertAssetSeedling :exec INSERT INTO asset_seedlings ( asset_name, asset_type, asset_version, asset_supply, asset_meta_id, - emission_enabled, batch_id, group_genesis_id, group_anchor_id + emission_enabled, batch_id, group_genesis_id, group_anchor_id, + script_key_id, group_internal_key_id, group_tapscript_root ) VALUES ( - $1, $2, $3, $4, $5, $6, $7, - sqlc.narg('group_genesis_id'), sqlc.narg('group_anchor_id') + @asset_name, @asset_type, @asset_version, @asset_supply, + @asset_meta_id, @emission_enabled, @batch_id, + sqlc.narg('group_genesis_id'), sqlc.narg('group_anchor_id'), + sqlc.narg('script_key_id'), sqlc.narg('group_internal_key_id'), + @group_tapscript_root ); -- name: FetchSeedlingID :one @@ -108,11 +112,15 @@ WITH target_key_id AS ( ) INSERT INTO asset_seedlings( asset_name, asset_type, asset_version, asset_supply, asset_meta_id, - emission_enabled, batch_id, group_genesis_id, group_anchor_id + emission_enabled, batch_id, group_genesis_id, group_anchor_id, + script_key_id, group_internal_key_id, group_tapscript_root ) VALUES ( - $2, $3, $4, $5, $6, $7, + @asset_name, @asset_type, @asset_version, @asset_supply, + @asset_meta_id, @emission_enabled, (SELECT key_id FROM target_key_id), - sqlc.narg('group_genesis_id'), sqlc.narg('group_anchor_id') + sqlc.narg('group_genesis_id'), sqlc.narg('group_anchor_id'), + sqlc.narg('script_key_id'), sqlc.narg('group_internal_key_id'), + @group_tapscript_root ); -- name: FetchSeedlingsForBatch :many @@ -126,10 +134,24 @@ WITH target_batch(batch_id) AS ( SELECT seedling_id, asset_name, asset_type, asset_version, asset_supply, assets_meta.meta_data_hash, assets_meta.meta_data_type, assets_meta.meta_data_blob, emission_enabled, batch_id, - group_genesis_id, group_anchor_id + group_genesis_id, group_anchor_id, group_tapscript_root, + script_keys.tweak AS script_key_tweak, + script_keys.tweaked_script_key, + internal_keys.raw_key AS script_key_raw, + internal_keys.key_family AS script_key_fam, + internal_keys.key_index AS script_key_index, + group_internal_keys.raw_key AS group_key_raw, + group_internal_keys.key_family AS group_key_fam, + group_internal_keys.key_index AS group_key_index FROM asset_seedlings LEFT JOIN assets_meta ON asset_seedlings.asset_meta_id = assets_meta.meta_id +LEFT JOIN script_keys + ON asset_seedlings.script_key_id = script_keys.script_key_id +LEFT JOIN internal_keys + ON script_keys.internal_key_id = internal_keys.key_id +LEFT JOIN internal_keys group_internal_keys + ON asset_seedlings.group_internal_key_id = group_internal_keys.key_id WHERE asset_seedlings.batch_id in (SELECT batch_id FROM target_batch); -- name: UpsertGenesisPoint :one diff --git a/tapgarden/batch.go b/tapgarden/batch.go index 8e004d8a9..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 @@ -122,19 +119,6 @@ func (m *MintingBatch) Copy() *MintingBatch { return batchCopy } -// TODO(roasbeef): add batch validate method re unique names? - -// AddSeedling adds a new seedling to the batch. -func (m *MintingBatch) addSeedling(s *Seedling) error { - if _, ok := m.Seedlings[s.AssetName]; ok { - return fmt.Errorf("asset with name %v already in batch", - s.AssetName) - } - - m.Seedlings[s.AssetName] = s - return nil -} - // validateGroupAnchor checks if the group anchor for a seedling is valid. // A valid anchor must already be part of the batch and have emission enabled. func (m *MintingBatch) validateGroupAnchor(s *Seedling) error { @@ -232,3 +216,7 @@ func (m *MintingBatch) TapSibling() []byte { func (m *MintingBatch) UpdateTapSibling(sibling *chainhash.Hash) { m.tapSibling = sibling } + +func (m *MintingBatch) IsFunded() bool { + return m.GenesisPacket != nil +} diff --git a/tapgarden/caretaker.go b/tapgarden/caretaker.go index 20390d336..1d8bc5f1a 100644 --- a/tapgarden/caretaker.go +++ b/tapgarden/caretaker.go @@ -412,64 +412,6 @@ func (b *BatchCaretaker) assetCultivator() { } } -// fundGenesisPsbt generates a PSBT packet we'll use to create an asset. In -// order to be able to create an asset, we need an initial genesis outpoint. To -// obtain this we'll ask the wallet to fund a PSBT template for GenesisAmtSats -// (all outputs need to hold some BTC to not be dust), and with a dummy script. -// We need to use a dummy script as we can't know the actual script key since -// that's dependent on the genesis outpoint. -func (b *BatchCaretaker) fundGenesisPsbt( - ctx context.Context) (*tapsend.FundedPsbt, error) { - - log.Infof("BatchCaretaker(%x): attempting to fund GenesisPacket", - b.batchKey[:]) - - txTemplate := wire.NewMsgTx(2) - txTemplate.AddTxOut(tapsend.CreateDummyOutput()) - genesisPkt, err := psbt.NewFromUnsignedTx(txTemplate) - if err != nil { - return nil, fmt.Errorf("unable to make psbt packet: %w", err) - } - - log.Infof("BatchCaretaker(%x): creating skeleton PSBT", b.batchKey[:]) - log.Tracef("PSBT: %v", spew.Sdump(genesisPkt)) - - var feeRate chainfee.SatPerKWeight - switch { - // If a fee rate was manually assigned for this batch, use that instead - // of a fee rate estimate. - case b.cfg.BatchFeeRate != nil: - feeRate = *b.cfg.BatchFeeRate - log.Infof("BatchCaretaker(%x): using manual fee rate: %s, %d "+ - "sat/vB", b.batchKey[:], feeRate.String(), - feeRate.FeePerKVByte()/1000) - - default: - feeRate, err = b.cfg.ChainBridge.EstimateFee( - ctx, GenesisConfTarget, - ) - if err != nil { - return nil, fmt.Errorf("unable to estimate fee: %w", - err) - } - - log.Infof("BatchCaretaker(%x): estimated fee rate: %s", - b.batchKey[:], feeRate.FeePerKVByte().String()) - } - - fundedGenesisPkt, err := b.cfg.Wallet.FundPsbt( - ctx, genesisPkt, 1, feeRate, - ) - if err != nil { - return nil, fmt.Errorf("unable to fund psbt: %w", err) - } - - log.Infof("BatchCaretaker(%x): funded GenesisPacket", b.batchKey[:]) - log.Tracef("GenesisPacket: %v", spew.Sdump(fundedGenesisPkt)) - - return fundedGenesisPkt, nil -} - // extractGenesisOutpoint extracts the genesis point (the first output from the // genesis transaction). func extractGenesisOutpoint(tx *wire.MsgTx) wire.OutPoint { @@ -489,15 +431,71 @@ func (b *BatchCaretaker) seedlingsToAssetSprouts(ctx context.Context, newAssets := make([]*asset.Asset, 0, len(b.cfg.Batch.Seedlings)) - // Seedlings that anchor a group may be referenced by other seedlings, - // and therefore need to be mapped to sprouts first so that we derive - // the initial tweaked group key early. - orderedSeedlings := SortSeedlings(maps.Values(b.cfg.Batch.Seedlings)) - newGroups := make(map[string]*asset.AssetGroup, len(orderedSeedlings)) + // separate grouped assets from ungrouped + groupedSeedlings, ungroupedSeedlings := filterSeedlingsWithGroup( + 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, + maps.Values(groupedSeedlings), + ) + if err != nil { + return nil, err + } + seedlingGroupCount := len(seedlingGroups) + + if groupedSeedlingCount != seedlingGroupCount { + return nil, fmt.Errorf("wrong number of grouped assets and "+ + "asset groups: %d, %d", groupedSeedlingCount, + seedlingGroupCount) + } + + for i := 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") + } + + seedling, ok := groupedSeedlings[seedlingGroup.Tag] + if !ok { + groupTweakedKey := seedlingGroup.GroupKey.GroupPubKey. + SerializeCompressed() + return nil, fmt.Errorf("no seedling with tag matching "+ + "group: %v, %x", seedlingGroup.Tag, + groupTweakedKey) + } + + // build assets for grouped seedlings + var amount uint64 + switch seedling.AssetType { + case asset.Normal: + amount = seedling.Amount + case asset.Collectible: + amount = 1 + } + + newAsset, err := asset.New( + *seedlingGroup.Genesis, amount, 0, 0, + seedling.ScriptKey, seedlingGroup.GroupKey, + asset.WithAssetVersion(seedling.AssetVersion), + ) + if err != nil { + return nil, fmt.Errorf("unable to create new asset: %w", + err) + } - for _, seedlingName := range orderedSeedlings { - seedling := b.cfg.Batch.Seedlings[seedlingName] + newAssets = append(newAssets, newAsset) + } + // build assets for ungrouped seedlings + for seedlingName := range ungroupedSeedlings { + seedling := ungroupedSeedlings[seedlingName] assetGen := asset.Genesis{ FirstPrevOut: genesisPoint, Tag: seedling.AssetName, @@ -512,23 +510,8 @@ func (b *BatchCaretaker) seedlingsToAssetSprouts(ctx context.Context, assetGen.MetaHash = seedling.Meta.MetaHash() } - scriptKey, err := b.cfg.KeyRing.DeriveNextKey( - ctx, asset.TaprootAssetsKeyFamily, - ) - if err != nil { - return nil, fmt.Errorf("unable to obtain script "+ - "key: %w", err) - } - tweakedScriptKey := asset.NewScriptKeyBip86(scriptKey) - - var ( - amount uint64 - groupInfo *asset.AssetGroup - protoAsset *asset.Asset - sproutGroupKey *asset.GroupKey - ) - // Determine the amount for the actual asset. + var amount uint64 switch seedling.AssetType { case asset.Normal: amount = seedling.Amount @@ -536,93 +519,10 @@ func (b *BatchCaretaker) seedlingsToAssetSprouts(ctx context.Context, amount = 1 } - // If the seedling has a group key specified, - // that group key was validated earlier. We need to - // sign the new genesis with that group key. - if seedling.HasGroupKey() { - groupInfo = seedling.GroupInfo - } - - // If the seedling has a group anchor specified, that anchor - // was validated earlier and the corresponding group has already - // been created. We need to look up the group key and sign - // the asset genesis with that key. - if seedling.GroupAnchor != nil { - groupInfo = newGroups[*seedling.GroupAnchor] - } - - // If a group witness needs to be produced, then we will need a - // partially filled asset as part of the signing process. - if groupInfo != nil || seedling.EnableEmission { - protoAsset, err = asset.New( - assetGen, amount, 0, 0, tweakedScriptKey, nil, - asset.WithAssetVersion(seedling.AssetVersion), - ) - if err != nil { - return nil, fmt.Errorf("unable to create "+ - "asset for group key signing: %w", err) - } - } - - if groupInfo != nil { - groupReq, err := asset.NewGroupKeyRequest( - groupInfo.GroupKey.RawKey, *groupInfo.Genesis, - protoAsset, nil, - ) - if err != nil { - return nil, fmt.Errorf("unable to request "+ - "asset group membership: %w", err) - } - - sproutGroupKey, err = asset.DeriveGroupKey( - b.cfg.GenSigner, b.cfg.GenTxBuilder, *groupReq, - ) - if err != nil { - return nil, fmt.Errorf("unable to tweak group "+ - "key: %w", err) - } - } - - // If emission is enabled without a group key specified, - // then we'll need to generate another public key, - // then use that to derive the key group signature - // along with the tweaked key group. - if seedling.EnableEmission { - rawGroupKey, err := b.cfg.KeyRing.DeriveNextKey( - ctx, asset.TaprootAssetsKeyFamily, - ) - if err != nil { - return nil, fmt.Errorf("unable to derive "+ - "group key: %w", err) - } - - groupReq, err := asset.NewGroupKeyRequest( - rawGroupKey, assetGen, protoAsset, nil, - ) - if err != nil { - return nil, fmt.Errorf("unable to request "+ - "asset group creation: %w", err) - } - - sproutGroupKey, err = asset.DeriveGroupKey( - b.cfg.GenSigner, b.cfg.GenTxBuilder, *groupReq, - ) - if err != nil { - return nil, fmt.Errorf("unable to tweak group "+ - "key: %w", err) - } - - newGroups[seedlingName] = &asset.AssetGroup{ - Genesis: &assetGen, - GroupKey: sproutGroupKey, - } - } - // With the necessary keys components assembled, we'll create // the actual asset now. newAsset, err := asset.New( - assetGen, amount, 0, 0, tweakedScriptKey, - sproutGroupKey, + assetGen, amount, 0, 0, seedling.ScriptKey, nil, asset.WithAssetVersion(seedling.AssetVersion), ) if err != nil { @@ -630,15 +530,6 @@ func (b *BatchCaretaker) seedlingsToAssetSprouts(ctx context.Context, err) } - // Verify the group witness if present. - if sproutGroupKey != nil { - err := b.cfg.TxValidator.Execute(newAsset, nil, nil) - if err != nil { - return nil, fmt.Errorf("unable to verify "+ - "asset group witness: %w", err) - } - } - newAssets = append(newAssets, newAsset) } @@ -675,30 +566,38 @@ func (b *BatchCaretaker) stateStep(currentState BatchState) (BatchState, error) // batch, so we'll use the batch key as the internal key for the // genesis transaction that'll create the batch. case BatchStateFrozen: - // First, we'll fund a PSBT packet with enough coins allocated - // as inputs to be able to create our genesis output for the - // asset and also pay for fees. - // // TODO(roasbeef): need to invalidate asset creation if on // restart leases are gone ctx, cancel := b.WithCtxQuitNoTimeout() defer cancel() - genesisTxPkt, err := b.fundGenesisPsbt(ctx) + + // Make a copy of the batch PSBT, which we'll modify and then + // update the batch with. + var psbtBuf bytes.Buffer + err := b.cfg.Batch.GenesisPacket.Pkt.Serialize(&psbtBuf) if err != nil { - return 0, err + return 0, fmt.Errorf("unable to serialize genesis "+ + "PSBT: %w", err) } - genesisPoint := extractGenesisOutpoint( - genesisTxPkt.Pkt.UnsignedTx, - ) + genesisTxPkt, err := psbt.NewFromRawBytes(&psbtBuf, false) + if err != nil { + return 0, fmt.Errorf("unable to deserialize genesis "+ + "PSBT: %w", err) + } + changeOutputIndex := b.cfg.Batch.GenesisPacket.ChangeOutputIndex // If the change output is first, then our commitment is second, // and vice versa. + // TODO(jhb): return the anchor index instead of change? or both + // so this works for N outputs b.anchorOutputIndex = 0 - if genesisTxPkt.ChangeOutputIndex == 0 { + if changeOutputIndex == 0 { b.anchorOutputIndex = 1 } + genesisPoint := extractGenesisOutpoint(genesisTxPkt.UnsignedTx) + // First, we'll turn all the seedlings into actual taproot assets. tapCommitment, err := b.seedlingsToAssetSprouts( ctx, genesisPoint, b.anchorOutputIndex, @@ -737,23 +636,28 @@ func (b *BatchCaretaker) stateStep(currentState BatchState) (BatchState, error) "script: %w", err) } - genesisTxPkt.Pkt.UnsignedTx.TxOut[b.anchorOutputIndex].PkScript = genesisScript + genesisTxPkt.UnsignedTx. + TxOut[b.anchorOutputIndex].PkScript = genesisScript log.Infof("BatchCaretaker(%x): committing sprouts to disk", b.batchKey[:]) + fundedGenesisPsbt := tapsend.FundedPsbt{ + Pkt: genesisTxPkt, + ChangeOutputIndex: changeOutputIndex, + } // With all our commitments created, we'll commit them to disk, // replacing the existing seedlings we had created for each of // these assets. err = b.cfg.Log.AddSproutsToBatch( ctx, b.cfg.Batch.BatchKey.PubKey, - genesisTxPkt, b.cfg.Batch.RootAssetCommitment, + &fundedGenesisPsbt, b.cfg.Batch.RootAssetCommitment, ) if err != nil { return 0, fmt.Errorf("unable to commit batch: %w", err) } - b.cfg.Batch.GenesisPacket = genesisTxPkt + b.cfg.Batch.GenesisPacket.Pkt = genesisTxPkt // Now that we know the script key for all the assets, we'll // populate the asset metas map as we need that to create the @@ -1635,10 +1539,10 @@ func GenRawGroupAnchorVerifier(ctx context.Context) func(*asset.Genesis, assetGroupKey := asset.ToSerialized(&groupKey.GroupPubKey) groupAnchor, err := groupAnchors.Get(assetGroupKey) if err != nil { - // TODO(jhb): add tapscript root support singleTweak := gen.ID() tweakedGroupKey, err := asset.GroupPubKey( - groupKey.RawKey.PubKey, singleTweak[:], nil, + groupKey.RawKey.PubKey, singleTweak[:], + groupKey.TapscriptRoot, ) if err != nil { return err diff --git a/tapgarden/interface.go b/tapgarden/interface.go index 8515aa6bd..1958bc2c7 100644 --- a/tapgarden/interface.go +++ b/tapgarden/interface.go @@ -40,6 +40,14 @@ type Planter interface { // returned. CancelSeedling() error + // FundBatch attempts to provide a genesis point for the current batch, + // or create a new funded batch. + FundBatch(params FundParams) (*MintingBatch, error) + + // SealBatch attempts to seal the current batch, by providing or + // deriving all witnesses necessary to create the final genesis TX. + SealBatch(params SealParams) (*MintingBatch, error) + // FinalizeBatch signals that the asset minter should finalize // the current batch, if one exists. FinalizeBatch(params FinalizeParams) (*MintingBatch, error) @@ -201,6 +209,17 @@ type MintingStore interface { FetchMintingBatch(ctx context.Context, batchKey *btcec.PublicKey) (*MintingBatch, error) + // AddSeedlingGroups stores the asset groups for seedlings associated + // with a batch. + AddSeedlingGroups(ctx context.Context, genesisOutpoint wire.OutPoint, + assetGroups []*asset.AssetGroup) error + + // FetchSeedlingGroups is used to fetch the asset groups for seedlings + // associated with a funded batch. + FetchSeedlingGroups(ctx context.Context, genesisOutpoint wire.OutPoint, + anchorOutputIndex uint32, + seedlings []*Seedling) ([]*asset.AssetGroup, error) + // AddSproutsToBatch adds a new set of sprouts to the batch, along with // a GenesisPacket, that once signed and broadcast with create the // set of assets on chain. @@ -249,6 +268,11 @@ type MintingStore interface { // be committed to disk. CommitBatchTapSibling(ctx context.Context, batchKey *btcec.PublicKey, rootHash *chainhash.Hash) error + + // CommitBatchTx adds a funded transaction to the batch, which also sets + // the genesis point for the batch. + CommitBatchTx(ctx context.Context, batchKey *btcec.PublicKey, + genesisTx *tapsend.FundedPsbt) error } // ChainBridge is our bridge to the target chain. It's used to get confirmation diff --git a/tapgarden/mock.go b/tapgarden/mock.go index d98c93271..6f9c39cb6 100644 --- a/tapgarden/mock.go +++ b/tapgarden/mock.go @@ -5,7 +5,6 @@ import ( "context" "encoding/hex" "fmt" - "math/rand" "testing" "time" @@ -22,11 +21,13 @@ import ( "github.com/lightninglabs/taproot-assets/asset" "github.com/lightninglabs/taproot-assets/internal/test" "github.com/lightninglabs/taproot-assets/proof" + "github.com/lightninglabs/taproot-assets/tapscript" "github.com/lightninglabs/taproot-assets/tapsend" "github.com/lightningnetwork/lnd/chainntnfs" "github.com/lightningnetwork/lnd/keychain" "github.com/lightningnetwork/lnd/lnwallet" "github.com/lightningnetwork/lnd/lnwallet/chainfee" + "github.com/stretchr/testify/require" ) // RandSeedlings creates a new set of random seedlings for testing. @@ -35,15 +36,17 @@ func RandSeedlings(t testing.TB, numSeedlings int) map[string]*Seedling { for i := 0; i < numSeedlings; i++ { metaBlob := test.RandBytes(32) assetName := hex.EncodeToString(test.RandBytes(32)) + scriptKey, _ := test.RandKeyDesc(t) seedlings[assetName] = &Seedling{ // For now, we only test the v0 and v1 versions. - AssetVersion: asset.Version(rand.Int31n(2)), - AssetType: asset.Type(rand.Int31n(2)), + AssetVersion: asset.Version(test.RandIntn(2)), + AssetType: asset.Type(test.RandIntn(2)), AssetName: assetName, Meta: &proof.MetaReveal{ Data: metaBlob, }, - Amount: uint64(rand.Int31()), + Amount: uint64(test.RandInt[uint32]()), + ScriptKey: asset.NewScriptKeyBip86(scriptKey), EnableEmission: test.RandBool(), } } @@ -54,17 +57,17 @@ func RandSeedlings(t testing.TB, numSeedlings int) map[string]*Seedling { // RandSeedlingMintingBatch creates a new minting batch with only random // seedlings populated for testing. func RandSeedlingMintingBatch(t testing.TB, numSeedlings int) *MintingBatch { + genesisTx := NewGenesisTx(t, chainfee.FeePerKwFloor) + BatchKey, _ := test.RandKeyDesc(t) return &MintingBatch{ - BatchKey: keychain.KeyDescriptor{ - PubKey: test.RandPubKey(t), - KeyLocator: keychain.KeyLocator{ - Index: uint32(rand.Int31()), - Family: keychain.KeyFamily(rand.Int31()), - }, - }, + BatchKey: BatchKey, Seedlings: RandSeedlings(t, numSeedlings), - HeightHint: rand.Uint32(), + HeightHint: test.RandInt[uint32](), CreationTime: time.Now(), + GenesisPacket: &tapsend.FundedPsbt{ + Pkt: &genesisTx, + ChangeOutputIndex: 1, + }, } } @@ -93,21 +96,33 @@ func NewMockWalletAnchor() *MockWalletAnchor { } } -func (m *MockWalletAnchor) FundPsbt(_ context.Context, packet *psbt.Packet, - _ uint32, _ chainfee.SatPerKWeight) (*tapsend.FundedPsbt, error) { +// NewGenesisTx creates a funded genesis PSBT with the given fee rate. +func NewGenesisTx(t testing.TB, feeRate chainfee.SatPerKWeight) psbt.Packet { + txTemplate := wire.NewMsgTx(2) + txTemplate.AddTxOut(tapsend.CreateDummyOutput()) + genesisPkt, err := psbt.NewFromUnsignedTx(txTemplate) + require.NoError(t, err) + + FundGenesisTx(genesisPkt, feeRate) + return *genesisPkt +} + +// FundGenesisTx add a genesis input and change output to a 1-output TX. +func FundGenesisTx(packet *psbt.Packet, feeRate chainfee.SatPerKWeight) { + const anchorBalance = int64(100000) // Take the PSBT packet and add an additional input and output to // simulate the wallet funding the transaction. packet.UnsignedTx.AddTxIn(&wire.TxIn{ PreviousOutPoint: wire.OutPoint{ - Index: rand.Uint32(), + Index: test.RandInt[uint32](), }, }) // Use a P2TR input by default. anchorInput := psbt.PInput{ WitnessUtxo: &wire.TxOut{ - Value: 100000, + Value: anchorBalance, PkScript: bytes.Clone(tapsend.GenesisDummyScript), }, SighashType: txscript.SigHashDefault, @@ -117,13 +132,26 @@ func (m *MockWalletAnchor) FundPsbt(_ context.Context, packet *psbt.Packet, // Use a non-P2TR change output by default so we avoid generating // exclusion proofs. changeOutput := wire.TxOut{ - Value: 50000, + Value: anchorBalance - packet.UnsignedTx.TxOut[0].Value, PkScript: bytes.Clone(tapsend.GenesisDummyScript), } changeOutput.PkScript[0] = txscript.OP_0 packet.UnsignedTx.AddTxOut(&changeOutput) packet.Outputs = append(packet.Outputs, psbt.POutput{}) + // Set a realistic change value. + _, fee := tapscript.EstimateFee( + [][]byte{tapsend.GenesisDummyScript}, packet.UnsignedTx.TxOut, + feeRate, + ) + packet.UnsignedTx.TxOut[1].Value -= int64(fee) +} + +func (m *MockWalletAnchor) FundPsbt(_ context.Context, packet *psbt.Packet, + _ uint32, feeRate chainfee.SatPerKWeight) (*tapsend.FundedPsbt, error) { + + FundGenesisTx(packet, feeRate) + // We always have the change output be the second output, so this means // the Taproot Asset commitment will live in the first output. pkt := &tapsend.FundedPsbt{ @@ -131,7 +159,15 @@ func (m *MockWalletAnchor) FundPsbt(_ context.Context, packet *psbt.Packet, ChangeOutputIndex: 1, } - m.FundPsbtSignal <- pkt + // Return a copy of the packet to the test harness. + var packetBuf bytes.Buffer + _ = packet.Serialize(&packetBuf) + packetCopy, _ := psbt.NewFromRawBytes(&packetBuf, false) + + m.FundPsbtSignal <- &tapsend.FundedPsbt{ + Pkt: packetCopy, + ChangeOutputIndex: 1, + } return pkt, nil } @@ -402,7 +438,7 @@ func (m *MockChainBridge) EstimateFee(ctx context.Context, return 0, fmt.Errorf("failed to estimate fee") } - return 253, nil + return chainfee.FeePerKwFloor, nil } func GenMockGroupVerifier() func(*btcec.PublicKey) error { diff --git a/tapgarden/planter.go b/tapgarden/planter.go index d819953ee..341f7dcb2 100644 --- a/tapgarden/planter.go +++ b/tapgarden/planter.go @@ -8,6 +8,7 @@ import ( "time" "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcutil/psbt" "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/wire" "github.com/davecgh/go-spew/spew" @@ -15,9 +16,9 @@ import ( "github.com/lightninglabs/taproot-assets/fn" "github.com/lightninglabs/taproot-assets/proof" "github.com/lightninglabs/taproot-assets/tapscript" + "github.com/lightninglabs/taproot-assets/tapsend" "github.com/lightninglabs/taproot-assets/universe" "github.com/lightningnetwork/lnd/lnwallet/chainfee" - "github.com/lightningnetwork/lnd/ticker" "golang.org/x/exp/maps" ) @@ -77,10 +78,6 @@ type GardenKit struct { type PlanterConfig struct { GardenKit - // BatchTicker is used to notify the planter than it should assemble - // all asset requests into a new batch. - BatchTicker *ticker.Force - // ProofUpdates is the storage backend for updated proofs. ProofUpdates proof.Archiver @@ -135,6 +132,27 @@ type FinalizeParams struct { SiblingTapTree fn.Option[asset.TapscriptTreeNodes] } +// FundParams are the options available to change how a batch is funded, and how +// the genesis TX is constructed. +type FundParams struct { + FeeRate fn.Option[chainfee.SatPerKWeight] + SiblingTapTree fn.Option[asset.TapscriptTreeNodes] + // TODO(jhb): follow-up PR: accept a PSBT here +} + +// groupSeal specifies the group witness for a seedling in a funded batch. +type groupSeal struct { + GroupMember asset.ID + GroupWitness []wire.TxWitness +} + +// SealParams change how asset groups in a minting batch are created. +type SealParams struct { + GroupWitnesses []groupSeal + // TODO(jhb): follow-up PR: accept a witness for the genesis point here + // to enable script-path spends +} + func newStateParamReq[T, S any](req reqType, param S) *stateParamReq[T, S] { return &stateParamReq[T, S]{ stateReq: *newStateReq[T](req), @@ -185,6 +203,8 @@ const ( reqTypeListBatches reqTypeFinalizeBatch reqTypeCancelBatch + reqTypeFundBatch + reqTypeSealBatch ) // ChainPlanter is responsible for accepting new incoming requests to create @@ -381,6 +401,261 @@ func (c *ChainPlanter) stopCaretakers() { } } +// newBatch creates a new minting batch, which includes deriving a new internal +// key. The batch is not written to disk nor set as the pending batch. +func (c *ChainPlanter) newBatch() (*MintingBatch, error) { + ctx, cancel := c.WithCtxQuit() + defer cancel() + + // To create a new batch we'll first need to grab a new internal key, + // which will be used in the output we create, and also will serve as + // the primary identifier for a batch. + log.Infof("Creating new MintingBatch") + newInternalKey, err := c.cfg.KeyRing.DeriveNextKey( + ctx, asset.TaprootAssetsKeyFamily, + ) + if err != nil { + return nil, err + } + + currentHeight, err := c.cfg.ChainBridge.CurrentHeight(ctx) + if err != nil { + return nil, fmt.Errorf("unable to get current height: %w", err) + } + + // Create the new batch. + newBatch := &MintingBatch{ + CreationTime: time.Now(), + HeightHint: currentHeight, + BatchKey: newInternalKey, + Seedlings: make(map[string]*Seedling), + AssetMetas: make(AssetMetas), + } + newBatch.UpdateState(BatchStatePending) + return newBatch, nil +} + +// fundGenesisPsbt generates a PSBT packet we'll use to create an asset. In +// order to be able to create an asset, we need an initial genesis outpoint. To +// obtain this we'll ask the wallet to fund a PSBT template for GenesisAmtSats +// (all outputs need to hold some BTC to not be dust), and with a dummy script. +// We need to use a dummy script as we can't know the actual script key since +// that's dependent on the genesis outpoint. +func (c *ChainPlanter) fundGenesisPsbt(ctx context.Context, + batchKey asset.SerializedKey, + manualFeeRate *chainfee.SatPerKWeight) (*tapsend.FundedPsbt, error) { + + log.Infof("Attempting to fund batch: %x", batchKey) + + // Construct a 1-output TX as a template for our genesis TX, which the + // backing wallet will fund. + txTemplate := wire.NewMsgTx(2) + txTemplate.AddTxOut(tapsend.CreateDummyOutput()) + genesisPkt, err := psbt.NewFromUnsignedTx(txTemplate) + if err != nil { + return nil, fmt.Errorf("unable to make psbt packet: %w", err) + } + + log.Infof("creating skeleton PSBT for batch: %x", batchKey) + log.Tracef("PSBT: %v", spew.Sdump(genesisPkt)) + + var feeRate chainfee.SatPerKWeight + switch { + // If a fee rate was manually assigned for this batch, use that instead + // of a fee rate estimate. + case manualFeeRate != nil: + feeRate = *manualFeeRate + log.Infof("using manual fee rate for batch: %x, %s, %d sat/vB", + batchKey[:], feeRate.String(), + feeRate.FeePerKVByte()/1000) + + default: + feeRate, err = c.cfg.ChainBridge.EstimateFee( + ctx, GenesisConfTarget, + ) + if err != nil { + return nil, fmt.Errorf("unable to estimate fee: %w", + err) + } + + log.Infof("estimated fee rate for batch: %x, %s", + batchKey[:], feeRate.FeePerKVByte().String()) + } + + fundedGenesisPkt, err := c.cfg.Wallet.FundPsbt( + ctx, genesisPkt, 1, feeRate, + ) + if err != nil { + return nil, fmt.Errorf("unable to fund psbt: %w", err) + } + + log.Infof("Funded GenesisPacket for batch: %x", batchKey) + log.Tracef("GenesisPacket: %v", spew.Sdump(fundedGenesisPkt)) + + return fundedGenesisPkt, nil +} + +// filterSeedlingsWithGroup separates a set of seedlings into two sets based on +// their relation to an asset group, which has not been constructed yet. +func filterSeedlingsWithGroup( + seedlings map[string]*Seedling) (map[string]*Seedling, + map[string]*Seedling) { + + withGroup := make(map[string]*Seedling) + withoutGroup := make(map[string]*Seedling) + fn.ForEachMapItem(seedlings, func(name string, seedling *Seedling) { + switch { + case seedling.GroupInfo != nil || seedling.GroupAnchor != nil || + seedling.EnableEmission: + + withGroup[name] = seedling + + default: + withoutGroup[name] = seedling + } + }) + + return withGroup, withoutGroup +} + +// buildGroupReqs creates group key requests and asset group genesis TXs for +// seedlings that are part of a funded batch. +func (c *ChainPlanter) buildGroupReqs(genesisPoint wire.OutPoint, + assetOutputIndex uint32, + groupSeedlings map[string]*Seedling) ([]asset.GroupKeyRequest, + []asset.GroupVirtualTx, error) { + + // Seedlings that anchor a group may be referenced by other seedlings, + // and therefore need to be mapped to sprouts first so that we derive + // the initial tweaked group key early. + orderedSeedlings := SortSeedlings(maps.Values(groupSeedlings)) + newGroups := make(map[string]*asset.AssetGroup) + groupReqs := make([]asset.GroupKeyRequest, 0, len(orderedSeedlings)) + genTXs := make([]asset.GroupVirtualTx, 0, len(orderedSeedlings)) + + for _, seedlingName := range orderedSeedlings { + seedling := groupSeedlings[seedlingName] + + assetGen := asset.Genesis{ + FirstPrevOut: genesisPoint, + Tag: seedling.AssetName, + OutputIndex: assetOutputIndex, + Type: seedling.AssetType, + } + + // 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 + // genesis. + if seedling.Meta != nil { + assetGen.MetaHash = seedling.Meta.MetaHash() + } + + var ( + amount uint64 + groupInfo *asset.AssetGroup + protoAsset *asset.Asset + err error + ) + + // Determine the amount for the actual asset. + switch seedling.AssetType { + case asset.Normal: + amount = seedling.Amount + case asset.Collectible: + amount = 1 + } + + // If the seedling has a group key specified, + // that group key was validated earlier. We need to + // sign the new genesis with that group key. + if seedling.HasGroupKey() { + groupInfo = seedling.GroupInfo + } + + // If the seedling has a group anchor specified, that anchor + // was validated earlier and the corresponding group has already + // been created. We need to look up the group key and sign + // the asset genesis with that key. + if seedling.GroupAnchor != nil { + groupInfo = newGroups[*seedling.GroupAnchor] + } + + // If a group witness needs to be produced, then we will need a + // partially filled asset as part of the signing process. + if groupInfo != nil || seedling.EnableEmission { + protoAsset, err = asset.New( + assetGen, amount, 0, 0, seedling.ScriptKey, + nil, + asset.WithAssetVersion(seedling.AssetVersion), + ) + if err != nil { + return nil, nil, fmt.Errorf("unable to create "+ + "asset for group key signing: %w", err) + } + } + + if groupInfo != nil { + groupReq, err := asset.NewGroupKeyRequest( + groupInfo.GroupKey.RawKey, *groupInfo.Genesis, + protoAsset, groupInfo.GroupKey.TapscriptRoot, + ) + if err != nil { + return nil, nil, fmt.Errorf("unable to "+ + "request asset group membership: %w", + err) + } + + genTx, err := groupReq.BuildGroupVirtualTx( + c.cfg.GenTxBuilder, + ) + if err != nil { + return nil, nil, err + } + + groupReqs = append(groupReqs, *groupReq) + genTXs = append(genTXs, *genTx) + } + + // If emission is enabled, an internal key for the group should + // already be specified. Use that to derive the key group + // signature along with the tweaked key group. + if seedling.EnableEmission { + if seedling.GroupInternalKey == nil { + return nil, nil, fmt.Errorf("unable to " + + "derive group key") + } + + groupReq, err := asset.NewGroupKeyRequest( + *seedling.GroupInternalKey, assetGen, + protoAsset, seedling.GroupTapscriptRoot, + ) + if err != nil { + return nil, nil, fmt.Errorf("unable to "+ + "request asset group creation: %w", err) + } + + genTx, err := groupReq.BuildGroupVirtualTx( + c.cfg.GenTxBuilder, + ) + if err != nil { + return nil, nil, err + } + + groupReqs = append(groupReqs, *groupReq) + genTXs = append(genTXs, *genTx) + + newGroups[seedlingName] = &asset.AssetGroup{ + Genesis: &assetGen, + GroupKey: &asset.GroupKey{ + RawKey: *seedling.GroupInternalKey, + }, + } + } + } + + return groupReqs, genTXs, nil +} + // freezeMintingBatch freezes a target minting batch which means that no new // assets can be added to the batch. func freezeMintingBatch(ctx context.Context, batchStore MintingStore, @@ -392,7 +667,7 @@ func freezeMintingBatch(ctx context.Context, batchStore MintingStore, batchKey.SerializeCompressed(), len(batch.Seedlings)) // In order to freeze a batch, we need to update the state of the batch - // to BatchStateFinalized, meaning that no other changes can happen. + // to BatchStateFrozen, meaning that no other changes can happen. // // TODO(roasbeef): assert not in some other state first? return batchStore.UpdateBatchState( @@ -400,17 +675,6 @@ func freezeMintingBatch(ctx context.Context, batchStore MintingStore, ) } -func commitBatchSibling(ctx context.Context, batchStore MintingStore, - batch *MintingBatch, sibling *chainhash.Hash) error { - - batchKey := batch.BatchKey.PubKey - - log.Infof("Committing tapscript sibling hash(batch_key=%x, sibling=%x)", - batchKey.SerializeCompressed(), sibling[:]) - - return batchStore.CommitBatchTapSibling(ctx, batchKey, sibling) -} - // 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, @@ -530,31 +794,6 @@ func (c *ChainPlanter) gardener() { for { select { - case <-c.cfg.BatchTicker.Ticks(): - // There is no pending batch, so we can just abort. - if c.pendingBatch == nil { - log.Debugf("No batches pending...doing nothing") - continue - } - - defaultFeeRate := fn.None[chainfee.SatPerKWeight]() - emptyTapSibling := fn.None[asset.TapscriptTreeNodes]() - - defaultFinalizeParams := FinalizeParams{ - FeeRate: defaultFeeRate, - SiblingTapTree: emptyTapSibling, - } - _, err := c.finalizeBatch(defaultFinalizeParams) - if err != nil { - c.cfg.ErrChan <- fmt.Errorf("unable to freeze "+ - "minting batch: %w", err) - continue - } - - // Now that we have a caretaker launched for this - // batch, we'll set the pending batch to nil - c.pendingBatch = nil - // A request for new asset issuance just arrived, add this to // the pending batch and acknowledge the receipt back to the // caller. @@ -639,6 +878,38 @@ func (c *ChainPlanter) gardener() { req.Resolve(batches) + case reqTypeFundBatch: + if c.pendingBatch != nil && + c.pendingBatch.IsFunded() { + + req.Error(fmt.Errorf("batch already " + + "funded")) + break + } + + fundReqParams, err := + typedParam[FundParams](req) + if err != nil { + req.Error(fmt.Errorf("bad fund "+ + "params: %w", err)) + break + } + + ctx, cancel := c.WithCtxQuit() + err = c.fundBatch(ctx, *fundReqParams) + cancel() + if err != nil { + req.Error(fmt.Errorf("unable to fund "+ + "minting batch: %w", err)) + break + } + + req.Resolve(c.pendingBatch) + + // TODO(jhb): follow-up PR: Implement SealBatch command + case reqTypeSealBatch: + req.Error(fmt.Errorf("not yet implemented")) + case reqTypeFinalizeBatch: if c.pendingBatch == nil { req.Error(fmt.Errorf("no pending batch")) @@ -722,56 +993,259 @@ func (c *ChainPlanter) gardener() { } } -// finalizeBatch creates a new caretaker for the batch and starts it. -func (c *ChainPlanter) finalizeBatch(params FinalizeParams) (*BatchCaretaker, - error) { - +// fundBatch attempts to fund a minting batch and create a funded genesis PSBT. +// This PSBT is a template that the caretaker will modify when finalizing the +// batch. If a feerate or tapscript sibling are provided, those will be used +// when funding the batch. If no pending batch exists, a batch will be created +// with the funded genesis PSBT. After funding, the pending batch will be +// saved to disk and updated in memory. +func (c *ChainPlanter) fundBatch(ctx context.Context, params FundParams) error { var ( feeRate *chainfee.SatPerKWeight rootHash *chainhash.Hash err error ) - ctx, cancel := c.WithCtxQuit() - defer cancel() // If a tapscript tree was specified for this batch, we'll store it on // disk. The caretaker we start for this batch will use it when deriving // the final Taproot output key. - params.FeeRate.WhenSome(func(fr chainfee.SatPerKWeight) { - feeRate = &fr - }) + feeRate = params.FeeRate.UnwrapToPtr() params.SiblingTapTree.WhenSome(func(tn asset.TapscriptTreeNodes) { - rootHash, err = c.cfg.TreeStore. - StoreTapscriptTree(ctx, tn) + rootHash, err = c.cfg.TreeStore.StoreTapscriptTree(ctx, tn) }) if err != nil { - return nil, fmt.Errorf("unable to store tapscript "+ - "tree for minting batch: %w", err) + return fmt.Errorf("unable to store tapscript tree for minting "+ + "batch: %w", err) } - if rootHash != nil { - ctx, cancel = c.WithCtxQuit() - defer cancel() - err = commitBatchSibling( - ctx, c.cfg.Log, c.pendingBatch, rootHash, + // Update the batch by adding the sibling root hash and genesis TX. + updateBatch := func(batch *MintingBatch) error { + // Add the batch sibling root hash if present. + if rootHash != nil { + batch.tapSibling = rootHash + } + + // Fund the batch with the specified fee rate. + batchKey := asset.ToSerialized(batch.BatchKey.PubKey) + batchTX, err := c.fundGenesisPsbt(ctx, batchKey, feeRate) + if err != nil { + return fmt.Errorf("unable to fund minting PSBT for "+ + "batch: %x %w", batchKey[:], err) + } + + batch.GenesisPacket = batchTX + + return nil + } + + switch { + // If we don't have a batch, we'll create an empty batch before funding + // and writing to disk. + case c.pendingBatch == nil: + newBatch, err := c.newBatch() + if err != nil { + return fmt.Errorf("unable to create new batch: %w", err) + } + + err = updateBatch(newBatch) + if err != nil { + return err + } + + // Now that we're done populating parts of the batch, write it + // to disk. + err = c.cfg.Log.CommitMintingBatch(ctx, newBatch) + if err != nil { + return err + } + + c.pendingBatch = newBatch + + // If we already have a batch, we need to attach the optional sibling + // root hash and fund the batch. + case c.pendingBatch != nil: + err = updateBatch(c.pendingBatch) + if err != nil { + return err + } + + // Write the associated sibling root hash and TX to disk. + if c.pendingBatch.tapSibling != nil { + err = c.cfg.Log.CommitBatchTapSibling( + ctx, c.pendingBatch.BatchKey.PubKey, rootHash, + ) + if err != nil { + return fmt.Errorf("unable to commit tapscript "+ + "sibling for minting batch %w", err) + } + } + + err = c.cfg.Log.CommitBatchTx( + ctx, c.pendingBatch.BatchKey.PubKey, + c.pendingBatch.GenesisPacket, + ) + if err != nil { + return err + } + } + + return nil +} + +// sealBatch will verify that each grouped asset in the pending batch has an +// asset group witness, and will attempt to create asset group witnesses when +// possible if they are not provided. After all asset group witnesses have been +// validated, they are saved to disk to be used by the caretaker during batch +// finalization. +func (c *ChainPlanter) sealBatch(ctx context.Context, _ SealParams) error { + // A batch should exist with 1+ seedlings and be funded before being + // sealed. + if c.pendingBatch == nil { + return fmt.Errorf("no pending batch") + } + + if len(c.pendingBatch.Seedlings) == 0 { + return fmt.Errorf("no seedlings in batch") + } + + if !c.pendingBatch.IsFunded() { + return fmt.Errorf("batch is not funded") + } + + // Filter the batch seedlings to only consider those that will become + // grouped assets. If there are no such seedlings, then there is nothing + // to seal and no action is needed. + groupSeedlings, _ := filterSeedlingsWithGroup(c.pendingBatch.Seedlings) + if len(groupSeedlings) == 0 { + return nil + } + + // 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 c.pendingBatch.GenesisPacket.ChangeOutputIndex == 0 { + anchorOutputIndex = 1 + } + + genesisPoint := extractGenesisOutpoint( + c.pendingBatch.GenesisPacket.Pkt.UnsignedTx, + ) + + // Construct the group key requests and group virtual TXs for each + // seedling. With these we can verify provided asset group witnesses, + // or attempt to derive asset group witnesses if needed. + groupReqs, genTXs, err := c.buildGroupReqs( + genesisPoint, anchorOutputIndex, groupSeedlings, + ) + if err != nil { + return fmt.Errorf("unable to build group requests: %w", err) + } + + assetGroups := make([]*asset.AssetGroup, 0, len(groupReqs)) + for i := 0; i < len(groupReqs); i++ { + // Derive the asset group witness. + groupKey, err := asset.DeriveGroupKey( + c.cfg.GenSigner, genTXs[i], groupReqs[i], nil, ) if err != nil { - return nil, fmt.Errorf("unable to commit tapscript "+ - "sibling for minting batch %w", err) + return err } + + // Recreate the asset with the populated group key and validate + // the asset group witness. + protoAsset := groupReqs[i].NewAsset + groupedAsset, err := asset.New( + protoAsset.Genesis, protoAsset.Amount, + protoAsset.LockTime, protoAsset.RelativeLockTime, + protoAsset.ScriptKey, groupKey, + asset.WithAssetVersion(protoAsset.Version), + ) + if err != nil { + return err + } + + err = c.cfg.TxValidator.Execute(groupedAsset, nil, nil) + if err != nil { + return fmt.Errorf("unable to verify asset "+ + "group witness: %w", err) + } + + newGroup := &asset.AssetGroup{ + Genesis: &groupReqs[i].NewAsset.Genesis, + GroupKey: groupKey, + } + + assetGroups = append(assetGroups, newGroup) + } + + // With all the asset group witnesses validated, we can now save them + // to disk. + err = c.cfg.Log.AddSeedlingGroups(ctx, genesisPoint, assetGroups) + if err != nil { + return fmt.Errorf("unable to write seedling groups: %w", err) + } + + return nil +} + +// finalizeBatch creates a new caretaker for the batch and starts it. +func (c *ChainPlanter) finalizeBatch(params FinalizeParams) (*BatchCaretaker, + error) { + + var ( + feeRate *chainfee.SatPerKWeight + 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") } - c.pendingBatch.tapSibling = rootHash + // Process the finalize parameters. + feeRate = params.FeeRate.UnwrapToPtr() + + ctx, cancel := c.WithCtxQuit() + defer cancel() + + params.SiblingTapTree.WhenSome(func(tn asset.TapscriptTreeNodes) { + _, err = c.cfg.TreeStore.StoreTapscriptTree(ctx, tn) + }) + if err != nil { + return nil, fmt.Errorf("unable to store tapscript tree for "+ + "minting batch: %w", err) + } // At this point, we have a non-empty batch, so we'll first finalize it // on disk. This means no further seedlings can be added to this batch. - ctx, cancel = c.WithCtxQuit() err = freezeMintingBatch(ctx, c.cfg.Log, c.pendingBatch) - cancel() if err != nil { - return nil, fmt.Errorf("unable to freeze minting batch: %w", - err) + return nil, err + } + + // If the batch already has a funded TX, we can skip funding the batch. + if !c.pendingBatch.IsFunded() { + // Fund the batch before starting the caretaker. If funding + // fails, we can't start a caretaker for the batch, so we'll + // clear the pending batch. The batch will exist on disk for + // the user to recreate it if necessary. + // TODO(jhb): Don't clear pending batch here + err = c.fundBatch(ctx, FundParams(params)) + if err != nil { + c.pendingBatch = nil + return nil, err + } + } + + // TODO(jhb): follow-up PR: detect batches that were already sealed + err = c.sealBatch(ctx, SealParams{}) + if err != nil { + return nil, err } // Now that the batch has been frozen on disk, we can update the batch @@ -824,6 +1298,30 @@ func (c *ChainPlanter) ListBatches(batchKey *btcec.PublicKey) ([]*MintingBatch, return <-req.resp, <-req.err } +// FundBatch sends a signal to the planter to fund the current batch, or create +// a funded batch. +func (c *ChainPlanter) FundBatch(params FundParams) (*MintingBatch, error) { + req := newStateParamReq[*MintingBatch](reqTypeFundBatch, params) + + if !fn.SendOrQuit[stateRequest](c.stateReqs, req, c.Quit) { + return nil, fmt.Errorf("chain planter shutting down") + } + + return <-req.resp, <-req.err +} + +// SealBatch attempts to seal the current batch, by providing or deriving all +// witnesses necessary to create the final genesis TX. +func (c *ChainPlanter) SealBatch(params SealParams) (*MintingBatch, error) { + req := newStateParamReq[*MintingBatch](reqTypeSealBatch, params) + + if !fn.SendOrQuit[stateRequest](c.stateReqs, req, c.Quit) { + return nil, fmt.Errorf("chain planter shutting down") + } + + return <-req.resp, <-req.err +} + // FinalizeBatch sends a signal to the planter to finalize the current batch. func (c *ChainPlanter) FinalizeBatch(params FinalizeParams) (*MintingBatch, error) { @@ -859,6 +1357,14 @@ func (c *ChainPlanter) prepAssetSeedling(ctx context.Context, return err } + // The seedling name must be unique within the pending batch. + if c.pendingBatch != nil { + if _, ok := c.pendingBatch.Seedlings[req.AssetName]; ok { + return fmt.Errorf("asset with name %v already in batch", + req.AssetName) + } + } + // If emission is enabled and a group key is specified, we need to // make sure the asset types match and that we can sign with that key. if req.HasGroupKey() { @@ -894,45 +1400,66 @@ func (c *ChainPlanter) prepAssetSeedling(ctx context.Context, } } - // Now that we know the field are 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 - // of the batch. - case c.pendingBatch == nil: - log.Infof("Creating new MintingBatch w/ %v", req) + // If a group internal key or tapscript root is specified, emission must + // also be enabled. + if !req.EnableEmission { + if req.GroupInternalKey != nil { + return fmt.Errorf("cannot specify group internal key " + + "without enabling emission") + } - // To create a new batch we'll first need to grab a new - // internal key, which'll be used in the output we create, and - // also will serve as the primary identifier for a batch. - newInternalKey, err := c.cfg.KeyRing.DeriveNextKey( + if req.GroupTapscriptRoot != nil { + return fmt.Errorf("cannot specify group tapscript " + + "root without enabling emission") + } + } + + // For group anchors, derive an internal key for the future group key if + // none was provided. + if req.EnableEmission && req.GroupInternalKey == nil { + groupInternalKey, err := c.cfg.KeyRing.DeriveNextKey( ctx, asset.TaprootAssetsKeyFamily, ) if err != nil { - return err + return fmt.Errorf("unable to obtain internal key for "+ + "group key for seedling: %s %w", req.AssetName, + err) } - ctx, cancel := c.WithCtxQuit() - defer cancel() - currentHeight, err := c.cfg.ChainBridge.CurrentHeight(ctx) + req.GroupInternalKey = &groupInternalKey + } + + // Now that we've validated the seedling, we can derive a script key to + // be used for this asset, if an external script key was not provided. + if req.ScriptKey.PubKey == nil { + scriptKey, err := c.cfg.KeyRing.DeriveNextKey( + ctx, asset.TaprootAssetsKeyFamily, + ) if err != nil { - return fmt.Errorf("unable to get current height: %w", - err) + return fmt.Errorf("unable to obtain script key for "+ + "seedling: %s %w", req.AssetName, err) } - // Create a new batch and commit it to disk so we can pick up - // where we left off upon restart. - newBatch := &MintingBatch{ - CreationTime: time.Now(), - HeightHint: currentHeight, - BatchKey: newInternalKey, - Seedlings: map[string]*Seedling{ - req.AssetName: req, - }, - AssetMetas: make(AssetMetas), + // Default to BIP86 for the script key tweaking method. + req.ScriptKey = asset.NewScriptKeyBip86(scriptKey) + } + + // 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 + // of the batch. + case c.pendingBatch == nil: + newBatch, err := c.newBatch() + if err != nil { + return err } - newBatch.UpdateState(BatchStatePending) - ctx, cancel = c.WithCtxQuit() + + log.Infof("Adding %v to new MintingBatch", req) + + newBatch.Seedlings[req.AssetName] = req + + ctx, cancel := c.WithCtxQuit() defer cancel() err = c.cfg.Log.CommitMintingBatch(ctx, newBatch) if err != nil { @@ -946,15 +1473,7 @@ func (c *ChainPlanter) prepAssetSeedling(ctx context.Context, case c.pendingBatch != nil: log.Infof("Adding %v to existing MintingBatch", req) - // First attempt to add the seedling to our pending batch, if - // this name is already taken (in the batch), then an error - // will be returned. - // - // TODO(roasbeef): unique constraint below? will trigger on the - // name? - if err := c.pendingBatch.addSeedling(req); err != nil { - return err - } + c.pendingBatch.Seedlings[req.AssetName] = req // Now that we know the seedling is ok, we'll write it to disk. ctx, cancel := c.WithCtxQuit() diff --git a/tapgarden/planter_test.go b/tapgarden/planter_test.go index 3327f425b..0aa45023c 100644 --- a/tapgarden/planter_test.go +++ b/tapgarden/planter_test.go @@ -35,16 +35,31 @@ import ( "github.com/lightningnetwork/lnd/keychain" "github.com/lightningnetwork/lnd/lntest/wait" "github.com/lightningnetwork/lnd/lnwallet/chainfee" - "github.com/lightningnetwork/lnd/ticker" "github.com/stretchr/testify/require" ) // Default to a large interval so the planter never actually ticks and only // rely on our manual ticks. var ( - defaultInterval = time.Hour * 24 - defaultTimeout = time.Second * 5 - minterInterval = time.Millisecond * 250 + defaultTimeout = time.Second * 5 + noCaretakerStates = fn.NewSet( + tapgarden.BatchStatePending, + 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. @@ -79,12 +94,8 @@ type mintingTestHarness struct { txValidator tapscript.TxValidator - ticker *ticker.Force - planter *tapgarden.ChainPlanter - batchKey *keychain.KeyDescriptor - proofFiles *tapgarden.MockProofArchive proofWatcher *tapgarden.MockProofWatcher @@ -96,8 +107,8 @@ type mintingTestHarness struct { // newMintingTestHarness creates a new test harness from an active minting // store and an existing testing context. -func newMintingTestHarness(t *testing.T, store tapgarden.MintingStore, - interval time.Duration) *mintingTestHarness { +func newMintingTestHarness(t *testing.T, + store tapgarden.MintingStore) *mintingTestHarness { keyRing := tapgarden.NewMockKeyRing() genSigner := tapgarden.NewMockGenSigner(keyRing) @@ -107,7 +118,6 @@ func newMintingTestHarness(t *testing.T, store tapgarden.MintingStore, T: t, store: store, treeStore: &treeMgr, - ticker: ticker.NewForce(interval), wallet: tapgarden.NewMockWalletAnchor(), chain: tapgarden.NewMockChainBridge(), proofFiles: &tapgarden.MockProofArchive{}, @@ -141,7 +151,6 @@ func (t *mintingTestHarness) refreshChainPlanter() { ProofFiles: t.proofFiles, ProofWatcher: t.proofWatcher, }, - BatchTicker: t.ticker, ProofUpdates: t.proofFiles, ErrChan: t.errChan, }) @@ -154,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)), @@ -189,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. // @@ -201,9 +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 keyCount != 0 { + t.assertKeyDerived() + keyCount-- } // We should get an update from the update channel that the @@ -234,9 +259,15 @@ func (t *mintingTestHarness) assertPendingBatchExists(numSeedlings int) { func (t *mintingTestHarness) assertNoPendingBatch() { t.Helper() - batch, err := t.planter.PendingBatch() + batches, err := t.store.FetchAllBatches(context.Background()) require.NoError(t, err) - require.Nil(t, batch) + + // Filter out batches still pending or already cancelled. + require.Zero(t, fn.Count(batches, + func(batch *tapgarden.MintingBatch) bool { + return batch.State() == tapgarden.BatchStatePending + }, + )) } type FinalizeBatchResp struct { @@ -296,23 +327,69 @@ func (t *mintingTestHarness) assertFinalizeBatch(wg *sync.WaitGroup, } } -// progressCaretaker uses the mock interfaces to progress a caretaker from start -// to TX confirmation. -func (t *mintingTestHarness) progressCaretaker(seedlings []*tapgarden.Seedling, - batchSibling *commitment.TapscriptPreimage) func() { +type FundBatchResp = FinalizeBatchResp - // Assert that the caretaker has requested a genesis TX to be funded. - _ = t.assertGenesisTxFunded() +// 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) { - // For each seedling created above, we expect a new set of keys to be - // created for the asset script key and an additional key if emission - // was enabled. - for i := 0; i < len(seedlings); i++ { - t.assertKeyDerived() + t.Helper() - if seedlings[i].EnableEmission { - t.assertKeyDerived() + 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(isFunded bool, + batchSibling *commitment.TapscriptPreimage, + feeRate *chainfee.SatPerKWeight) func() { + + // Assert that the caretaker has requested a genesis TX to be funded. + if !isFunded { + _ = t.assertGenesisTxFunded(feeRate) } // We should now transition to the next state where we'll attempt to @@ -342,9 +419,9 @@ func (t *mintingTestHarness) progressCaretaker(seedlings []*tapgarden.Seedling, return t.assertConfReqSent(tx, block) } -// tickMintingBatch fires the ticker that forces the planter to create a new -// batch. -func (t *mintingTestHarness) tickMintingBatch( +// finalizeBatchAssertFrozen fires the ticker that forces the planter to create +// a new batch. +func (t *mintingTestHarness) finalizeBatchAssertFrozen( noBatch bool) *tapgarden.MintingBatch { t.Helper() @@ -362,15 +439,24 @@ func (t *mintingTestHarness) tickMintingBatch( }, ) - // We now trigger the ticker to tick the batch. - t.ticker.Force <- time.Now() + var ( + wg sync.WaitGroup + respChan = make(chan *FinalizeBatchResp, 1) + ) + + t.finalizeBatch(&wg, respChan, nil) if noBatch { t.assertNoPendingBatch() return nil } - return t.assertNewBatchFrozen(existingBatches) + // Check that the batch was frozen and then funded. + newBatch := t.assertNewBatchFrozen(existingBatches) + _ = t.assertGenesisTxFunded(nil) + + // Fetch the batch again after funding. + return t.fetchSingleBatch(newBatch.BatchKey.PubKey) } func (t *mintingTestHarness) assertNewBatchFrozen( @@ -397,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 } @@ -443,6 +529,29 @@ func (t *mintingTestHarness) cancelMintingBatch(noBatch bool) *btcec.PublicKey { return batchKey } +func (t *mintingTestHarness) assertBatchProgressing() *tapgarden.MintingBatch { + // Exclude all states that the batch should not have when progressing + // from frozen to finalized. + var progressingBatches []*tapgarden.MintingBatch + err := wait.Predicate(func() bool { + batches, err := t.store.FetchAllBatches(context.Background()) + require.NoError(t, err) + + // Filter out batches still pending or already cancelled. + progressingBatches = fn.Filter(batches, + func(batch *tapgarden.MintingBatch) bool { + return !noCaretakerStates.Contains( + batch.State(), + ) + }) + + return len(progressingBatches) == 1 + }, defaultTimeout) + require.NoError(t, err) + + return progressingBatches[0] +} + // assertNumCaretakersActive asserts that the specified number of caretakers // are active. func (t *mintingTestHarness) assertNumCaretakersActive(n int) { @@ -458,40 +567,31 @@ func (t *mintingTestHarness) assertNumCaretakersActive(n int) { // assertGenesisTxFunded asserts that a caretaker attempted to fund a new // genesis transaction. -func (t *mintingTestHarness) assertGenesisTxFunded() *tapsend.FundedPsbt { +func (t *mintingTestHarness) assertGenesisTxFunded( + manualFee *chainfee.SatPerKWeight) *tapsend.FundedPsbt { + // In order to fund a transaction, we expect a call to estimate the // fee, followed by a request to fund a new PSBT packet. - _, err := fn.RecvOrTimeout( - t.chain.FeeEstimateSignal, defaultTimeout, - ) - require.NoError(t, err) + if manualFee == nil { + _, err := fn.RecvOrTimeout( + t.chain.FeeEstimateSignal, defaultTimeout, + ) + require.NoError(t, err) + } pkt, err := fn.RecvOrTimeout( t.wallet.FundPsbtSignal, defaultTimeout, ) require.NoError(t, err) - - // Finally, we'll assert that the dummy output or a valid P2TR output - // is found in the packet. - var found bool - for _, txOut := range (*pkt).Pkt.UnsignedTx.TxOut { - txOut := txOut - - if txOut.Value == int64(tapgarden.GenesisAmtSats) { - isP2TR := txscript.IsPayToTaproot(txOut.PkScript) - isDummyScript := bytes.Equal( - txOut.PkScript, tapsend.GenesisDummyScript[:], - ) - - if isP2TR || isDummyScript { - found = true - break - } - } - } - if !found { - t.Fatalf("unable to find dummy tx out in genesis tx: %v", - spew.Sdump(pkt)) + require.NotNil(t, pkt) + + // Our genesis TX in unit tests is always 1 P2TR in, 1 P2TR out & + // 1 P2WSH out. This has a fixed size of 155 vB. + const mintTxSize = 155 + txFee := t.assertBatchGenesisTx(*pkt) + if manualFee != nil { + expectedFee := manualFee.FeePerKVByte().FeeForVSize(mintTxSize) + require.GreaterOrEqual(t, txFee, expectedFee) } return *pkt @@ -548,6 +648,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) + } } } @@ -560,13 +689,7 @@ func isCancelledBatch(batch *tapgarden.MintingBatch) bool { func (t *mintingTestHarness) assertBatchState(batchKey *btcec.PublicKey, batchState tapgarden.BatchState) { - t.Helper() - - batches, err := t.planter.ListBatches(batchKey) - require.NoError(t, err) - require.Len(t, batches, 1) - - batch := batches[0] + batch := t.fetchSingleBatch(batchKey) require.Equal(t, batchState, batch.State()) } @@ -574,7 +697,6 @@ func (t *mintingTestHarness) assertLastBatchState(numBatches int, batchState tapgarden.BatchState) { t.Helper() - batches, err := t.planter.ListBatches(nil) require.NoError(t, err) @@ -582,6 +704,82 @@ func (t *mintingTestHarness) assertLastBatchState(numBatches int, require.Equal(t, batchState, batches[len(batches)-1].State()) } +func (t *mintingTestHarness) fetchSingleBatch( + batchKey *btcec.PublicKey) *tapgarden.MintingBatch { + + t.Helper() + if batchKey == nil { + return t.assertBatchProgressing() + } + + batch, err := t.store.FetchMintingBatch(context.Background(), batchKey) + require.NoError(t, err) + require.NotNil(t, batch) + + return batch +} + +func (t *mintingTestHarness) fetchLastBatch() *tapgarden.MintingBatch { + t.Helper() + batches, err := t.store.FetchAllBatches(context.Background()) + require.NoError(t, err) + require.NotEmpty(t, batches) + + return batches[len(batches)-1] +} + +func (t *mintingTestHarness) assertBatchGenesisTx( + pkt *tapsend.FundedPsbt) btcutil.Amount { + + t.Helper() + + // Finally, we'll assert that the dummy output or a valid P2TR output + // is found in the packet. + var found bool + for _, txOut := range pkt.Pkt.UnsignedTx.TxOut { + txOut := txOut + + if txOut.Value == int64(tapgarden.GenesisAmtSats) { + isP2TR := txscript.IsPayToTaproot(txOut.PkScript) + isDummyScript := bytes.Equal( + txOut.PkScript, tapsend.GenesisDummyScript[:], + ) + + if isP2TR || isDummyScript { + found = true + break + } + } + } + if !found { + t.Fatalf("unable to find dummy tx out in genesis tx: %v", + spew.Sdump(pkt)) + } + + genesisTxFee, err := pkt.Pkt.GetTxFee() + require.NoError(t, err) + + 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( @@ -600,7 +798,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 { @@ -637,12 +835,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) + } } } @@ -653,7 +876,7 @@ func (t *mintingTestHarness) assertGenesisPsbtFinalized( t.Helper() - // Ensure that a request to finalize the PSBt has come across. + // Ensure that a request to finalize the PSBT has come across. _, err := fn.RecvOrTimeout( t.wallet.SignPsbtSignal, defaultTimeout, ) @@ -727,7 +950,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. @@ -769,7 +992,7 @@ func testBasicAssetCreation(t *mintingTestHarness) { // Now we'll force a batch tick which should kick off a new caretaker // that starts to progress the batch all the way to broadcast. - t.tickMintingBatch(false) + t.finalizeBatchAssertFrozen(false) // We'll now restart the planter to ensure that it's able to properly // resume all the caretakers. We need to sleep for a small amount to @@ -778,27 +1001,16 @@ func testBasicAssetCreation(t *mintingTestHarness) { t.refreshChainPlanter() // Now that the planter is back up, a single caretaker should have been - // launched as well. Next, assert that the caretaker has requested a - // genesis tx to be funded. - _ = t.assertGenesisTxFunded() + // launched as well. The batch should already be funded. + batch := t.fetchSingleBatch(nil) + t.assertBatchGenesisTx(batch.GenesisPacket) t.assertNumCaretakersActive(1) // We'll now force yet another restart to ensure correctness of the - // state machine, we expect the PSBT packet to be funded again as well, - // since we didn't get a chance to write it to disk. + // state machine, we expect the PSBT packet to still be funded. t.refreshChainPlanter() - _ = t.assertGenesisTxFunded() - - // For each seedling created above, we expect a new set of keys to be - // created for the asset script key and an additional key if emission - // was enabled. - for i := 0; i < numSeedlings; i++ { - t.assertKeyDerived() - - if seedlings[i].EnableEmission { - t.assertKeyDerived() - } - } + batch = t.fetchSingleBatch(nil) + t.assertBatchGenesisTx(batch.GenesisPacket) // Now that the batch has been ticked, and the caretaker started, there // should no longer be a pending batch. @@ -885,26 +1097,16 @@ 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.tickMintingBatch(false) + _ = t.finalizeBatchAssertFrozen(false) // A single caretaker should have been launched as well. Next, assert - // that the caretaker has requested a genesis tx to be funded. - _ = t.assertGenesisTxFunded() - t.assertNumCaretakersActive(1) - - // For each seedling created above, we expect a new set of keys to be - // created for the asset script key and an additional key if emission - // was enabled. - for i := 0; i < numSeedlings; i++ { - t.assertKeyDerived() - - if seedlings[i].EnableEmission { - t.assertKeyDerived() - } - } + // that the batch is already funded. + t.assertBatchProgressing() + currentBatch := t.fetchLastBatch() + t.assertBatchGenesisTx(currentBatch.GenesisPacket) // Now that the batch has been ticked, and the caretaker started, there // should no longer be a pending batch. @@ -974,13 +1176,17 @@ func testMintingCancelFinalize(t *mintingTestHarness) { // Requesting batch finalization or cancellation with no pending batch // should return an error without crashing the planter. - t.tickMintingBatch(true) + t.finalizeBatchAssertFrozen(true) t.cancelMintingBatch(true) // Next, make another 5 random seedlings and continue with minting. seedlings = t.newRandSeedlings(numSeedlings) seedlings[0] = firstSeedling - t.queueSeedlingsInBatch(seedlings...) + seedlings[0].ScriptKey = asset.ScriptKey{} + if seedlings[0].EnableEmission { + seedlings[0].GroupInternalKey = nil + } + t.queueSeedlingsInBatch(false, seedlings...) t.assertPendingBatchExists(numSeedlings) t.assertSeedlingsExist(seedlings, nil) @@ -994,53 +1200,14 @@ func testMintingCancelFinalize(t *mintingTestHarness) { require.ErrorContains(t, planterErr.Error, "already in batch") // Now, finalize the pending batch to continue with minting. - t.tickMintingBatch(false) - - // A single caretaker should have been launched as well. Next, assert - // that the caretaker has requested a genesis tx to be funded. - _ = t.assertGenesisTxFunded() - t.assertNumCaretakersActive(1) - - // For each seedling created above, we expect a new set of keys to be - // created for the asset script key and an additional key if emission - // was enabled. - for i := 0; i < numSeedlings; i++ { - t.assertKeyDerived() - - if seedlings[i].EnableEmission { - t.assertKeyDerived() - } - } - - // We should be able to cancel the batch even after it has a caretaker, - // and at this point the minting transaction is still being made. - secondBatchKey := t.cancelMintingBatch(false) - t.assertNoPendingBatch() - t.assertBatchState(secondBatchKey, tapgarden.BatchStateSproutCancelled) - - // We can make another 5 random seedlings and continue with minting. - seedlings = t.newRandSeedlings(numSeedlings) - t.queueSeedlingsInBatch(seedlings...) - - t.assertPendingBatchExists(numSeedlings) - t.assertSeedlingsExist(seedlings, nil) - - // Now, finalize the pending batch to continue with minting. - thirdBatch := t.tickMintingBatch(false) + thirdBatch := t.finalizeBatchAssertFrozen(false) require.NotNil(t, thirdBatch) require.NotNil(t, thirdBatch.BatchKey.PubKey) thirdBatchKey := thirdBatch.BatchKey.PubKey - _ = t.assertGenesisTxFunded() - t.assertNumCaretakersActive(1) - - for i := 0; i < numSeedlings; i++ { - t.assertKeyDerived() - - if seedlings[i].EnableEmission { - t.assertKeyDerived() - } - } + t.assertBatchProgressing() + thirdBatch = t.fetchLastBatch() + t.assertBatchGenesisTx(thirdBatch.GenesisPacket) // Now that the batch has been ticked, and the caretaker started, there // should no longer be a pending batch. @@ -1140,17 +1307,21 @@ func testFinalizeBatch(t *mintingTestHarness) { ) require.NoError(t, err) - // If the caretaker failed, there should be no active caretakers nor - // pending batch. The caretaker error should be propagated to the caller - // of finalize. + // The planter should fail to finalize the batch, so there should be no + // active caretakers nor pending batch. t.assertNoPendingBatch() t.assertNumCaretakersActive(caretakerCount) t.assertLastBatchState(batchCount, tapgarden.BatchStateFrozen) t.assertFinalizeBatch(&wg, respChan, "unable to estimate fee") + // This funding error is also sent on the main error channel, so drain + // that before continuing. + caretakerErr := <-t.errChan + require.ErrorContains(t, caretakerErr, "unable to fund minting PSBT") + // Queue another batch, reset fee estimation behavior, and set TX // confirmation registration to fail. - seedlings := t.queueInitialBatch(numSeedlings) + t.queueInitialBatch(numSeedlings) t.chain.FailFeeEstimates(false) t.chain.FailConf(true) @@ -1161,11 +1332,11 @@ func testFinalizeBatch(t *mintingTestHarness) { t.finalizeBatch(&wg, respChan, nil) batchCount++ - _ = t.progressCaretaker(seedlings, nil) + _ = t.progressCaretaker(false, nil, nil) caretakerCount++ t.assertFinalizeBatch(&wg, respChan, "") - caretakerErr := <-t.errChan + caretakerErr = <-t.errChan require.ErrorContains(t, caretakerErr, "error getting confirmation") // The stopped caretaker will still exist but there should be no pending @@ -1177,7 +1348,7 @@ func testFinalizeBatch(t *mintingTestHarness) { // Queue another batch, set TX confirmation to succeed, and set the // confirmation event to be empty. - seedlings = t.queueInitialBatch(numSeedlings) + t.queueInitialBatch(numSeedlings) t.chain.FailConf(false) t.chain.EmptyConf(true) @@ -1185,7 +1356,7 @@ func testFinalizeBatch(t *mintingTestHarness) { t.finalizeBatch(&wg, respChan, nil) batchCount++ - sendConfNtfn := t.progressCaretaker(seedlings, nil) + sendConfNtfn := t.progressCaretaker(false, nil, nil) caretakerCount++ // Trigger the confirmation event, which should cause the caretaker to @@ -1209,13 +1380,18 @@ func testFinalizeBatch(t *mintingTestHarness) { t.assertNumCaretakersActive(caretakerCount) // Queue another batch and drive the caretaker to a successful minting. - seedlings = t.queueInitialBatch(numSeedlings) + t.queueInitialBatch(numSeedlings) t.chain.EmptyConf(false) - t.finalizeBatch(&wg, respChan, nil) + // Use a custom feerate and verify that the TX uses that feerate. + manualFeeRate := chainfee.FeePerKwFloor * 2 + finalizeReq := tapgarden.FinalizeParams{ + FeeRate: fn.Some(manualFeeRate), + } + t.finalizeBatch(&wg, respChan, &finalizeReq) batchCount++ - sendConfNtfn = t.progressCaretaker(seedlings, nil) + sendConfNtfn = t.progressCaretaker(false, nil, &manualFeeRate) sendConfNtfn() t.assertFinalizeBatch(&wg, respChan, "") @@ -1232,7 +1408,7 @@ func testFinalizeWithTapscriptTree(t *mintingTestHarness) { // Create an initial batch of 5 seedlings. const numSeedlings = 5 - seedlings := t.queueInitialBatch(numSeedlings) + t.queueInitialBatch(numSeedlings) var ( wg sync.WaitGroup @@ -1274,29 +1450,12 @@ func testFinalizeWithTapscriptTree(t *mintingTestHarness) { t.treeStore.FailStore = false t.treeStore.FailLoad = true - // Receive all the signals needed to progress the caretaker through - // the batch sprouting, which is when the sibling tapscript tree is - // used. - progressCaretakerToTxSigning := func( - currentSeedlings []*tapgarden.Seedling) { - - _ = t.assertGenesisTxFunded() - - for i := 0; i < len(currentSeedlings); i++ { - t.assertKeyDerived() - - if currentSeedlings[i].EnableEmission { - t.assertKeyDerived() - } - } - } - // Finalize the batch with a tapscript tree sibling. t.finalizeBatch(&wg, respChan, &finalizeReq) batchCount++ // The caretaker should fail when computing the Taproot output key. - progressCaretakerToTxSigning(seedlings) + _ = t.assertGenesisTxFunded(nil) t.assertFinalizeBatch(&wg, respChan, "failed to load tapscript tree") t.assertLastBatchState(batchCount, tapgarden.BatchStateFrozen) t.assertNoPendingBatch() @@ -1326,18 +1485,18 @@ func testFinalizeWithTapscriptTree(t *mintingTestHarness) { // Queue another batch, and try to finalize with a sibling that is also // a Taproot asset commitment. - seedlings = t.queueInitialBatch(numSeedlings) + t.queueInitialBatch(numSeedlings) t.finalizeBatch(&wg, respChan, &finalizeReq) batchCount++ - progressCaretakerToTxSigning(seedlings) + _ = t.assertGenesisTxFunded(nil) t.assertFinalizeBatch( &wg, respChan, "preimage is a Taproot Asset commitment", ) t.assertNoPendingBatch() // Queue another batch, and provide a valid sibling tapscript tree. - seedlings = t.queueInitialBatch(numSeedlings) + t.queueInitialBatch(numSeedlings) finalizeReq.SiblingTapTree = fn.Some(*tapTreePreimage) t.finalizeBatch(&wg, respChan, &finalizeReq) batchCount++ @@ -1345,7 +1504,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(seedlings, &siblingPreimage) + sendConfNtfn := t.progressCaretaker(false, &siblingPreimage, nil) sendConfNtfn() // Once the TX is broadcast, the caretaker should run to completion, @@ -1359,27 +1518,156 @@ 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 // that are parametrized based on a fresh minting store. type mintingStoreTestCase struct { name string - interval time.Duration testFunc func(t *mintingTestHarness) } @@ -1387,29 +1675,28 @@ type mintingStoreTestCase struct { var testCases = []mintingStoreTestCase{ { name: "basic_asset_creation", - interval: defaultInterval, testFunc: testBasicAssetCreation, }, { name: "creation_by_minting_ticker", - interval: minterInterval, testFunc: testMintingTicker, }, { name: "minting_with_cancellation", - interval: minterInterval, testFunc: testMintingCancelFinalize, }, { name: "finalize_batch", - interval: minterInterval, testFunc: testFinalizeBatch, }, { name: "finalize_with_tapscript_tree", - interval: minterInterval, testFunc: testFinalizeWithTapscriptTree, }, + { + name: "fund_before_finalize", + testFunc: testFundBeforeFinalize, + }, } // TestBatchedAssetIssuance runs a test of tests to ensure that the set of @@ -1423,9 +1710,7 @@ func TestBatchedAssetIssuance(t *testing.T) { testCase := testCase t.Run(testCase.name, func(t *testing.T) { - mintTest := newMintingTestHarness( - t, mintingStore, testCase.interval, - ) + mintTest := newMintingTestHarness(t, mintingStore) testCase.testFunc(mintTest) }) } diff --git a/tapgarden/seedling.go b/tapgarden/seedling.go index 0298d5ba7..8b83b6117 100644 --- a/tapgarden/seedling.go +++ b/tapgarden/seedling.go @@ -1,10 +1,12 @@ package tapgarden import ( + "crypto/sha256" "fmt" "github.com/lightninglabs/taproot-assets/asset" "github.com/lightninglabs/taproot-assets/proof" + "github.com/lightningnetwork/lnd/keychain" ) var ( @@ -95,6 +97,22 @@ type Seedling struct { // update is used to send updates w.r.t the state of the batch. updates SeedlingUpdates + + // ScriptKey is the tweaked Taproot key that will be used to spend the + // asset after minting. By default, this key is constructed with a + // BIP-0086 style tweak. + ScriptKey asset.ScriptKey + + // GroupInternalKey is the raw group key before the tweak with the + // genesis point or tapscript root has been applied. + GroupInternalKey *keychain.KeyDescriptor + + // GroupTapscriptRoot is the root of the Tapscript tree that commits to + // all script spend conditions for the group key. Instead of spending an + // asset, these scripts are used to define witnesses more complex than + // a Schnorr signature for reissuing assets. A group key with an empty + // Tapscript root can only authorize reissuance with a signature. + GroupTapscriptRoot []byte } // validateFields attempts to validate the set of input fields for the passed @@ -122,6 +140,13 @@ func (c Seedling) validateFields() error { return ErrInvalidAssetAmt } + // The group tapscript root must be 32 bytes. + tapscriptRootSize := len(c.GroupTapscriptRoot) + if tapscriptRootSize != 0 && tapscriptRootSize != sha256.Size { + return fmt.Errorf("tapscript root must be %d bytes", + sha256.Size) + } + return nil }