diff --git a/proof/meta.go b/proof/meta.go index 63ed61130..10aba50d0 100644 --- a/proof/meta.go +++ b/proof/meta.go @@ -174,12 +174,6 @@ type MetaReveal struct { UnknownOddTypes tlv.TypeMap } -// SizableInteger is a subset of Integer that excludes int8, since we never use -// it in practice. -type SizableInteger interface { - constraints.Unsigned | ~int | ~int16 | ~int32 | ~int64 -} - // Validate validates the meta reveal. func (m *MetaReveal) Validate() error { // A meta reveal is allowed to be nil. @@ -234,6 +228,13 @@ func (m *MetaReveal) Validate() error { return err } + // The asset metadata is invalid when the universe commitments feature + // is enabled but no delegation key is specified. + if m.UniverseCommitments && m.DelegationKey.IsNone() { + return fmt.Errorf("universe commitments enabled in asset " + + "metadata but delegation key is unspecified") + } + return fn.MapOptionZ(m.DelegationKey, func(key btcec.PublicKey) error { if key == emptyKey { return ErrDelegationKeyEmpty @@ -247,6 +248,12 @@ func (m *MetaReveal) Validate() error { }) } +// SizableInteger is a subset of Integer that excludes int8, since we never use +// it in practice. +type SizableInteger interface { + constraints.Unsigned | ~int | ~int16 | ~int32 | ~int64 +} + // IsValidMetaType checks if the passed value is a valid meta type. func IsValidMetaType[T SizableInteger](num T) (MetaType, error) { switch { @@ -288,6 +295,8 @@ func IsValidDecDisplay(decDisplay uint32) error { // DecodeMetaJSON decodes bytes as a JSON object, after checking that the bytes // could be valid metadata. +// +// TODO(ffranr): Add unit test for `jBytes := []byte{}`. func DecodeMetaJSON(jBytes []byte) (map[string]interface{}, error) { jMeta := make(map[string]interface{}) diff --git a/rpcserver.go b/rpcserver.go index 685dfa402..50a24d238 100644 --- a/rpcserver.go +++ b/rpcserver.go @@ -508,7 +508,10 @@ func (r *rpcServer) MintAsset(ctx context.Context, "collectibles") } - var seedlingMeta *proof.MetaReveal + // TODO(ffranr): Move seedling MetaReveal construction into + // ChainPlanter. This will allow us to simplify delegation key + // management. + var seedlingMeta proof.MetaReveal switch { // If we have an explicit asset meta field, we parse the content. case req.Asset.AssetMeta != nil: @@ -520,7 +523,7 @@ func (r *rpcServer) MintAsset(ctx context.Context, // If the asset meta field was specified, then the data inside // must be valid. Let's check that now. - seedlingMeta = &proof.MetaReveal{ + seedlingMeta = proof.MetaReveal{ Data: req.Asset.AssetMeta.Data, Type: metaType, } @@ -542,15 +545,10 @@ func (r *rpcServer) MintAsset(ctx context.Context, return nil, err } - err = seedlingMeta.Validate() - if err != nil { - return nil, err - } - // If no asset meta field was specified, we create a default meta // reveal with the decimal display set. default: - seedlingMeta = &proof.MetaReveal{ + seedlingMeta = proof.MetaReveal{ Type: proof.MetaOpaque, } @@ -560,11 +558,6 @@ func (r *rpcServer) MintAsset(ctx context.Context, if err != nil { return nil, err } - - err = seedlingMeta.Validate() - if err != nil { - return nil, err - } } // Parse the optional script key and group internal key. The group @@ -607,7 +600,7 @@ func (r *rpcServer) MintAsset(ctx context.Context, AssetName: req.Asset.Name, Amount: req.Asset.Amount, EnableEmission: req.Asset.NewGroupedAsset, - Meta: seedlingMeta, + Meta: &seedlingMeta, UniverseCommitments: req.Asset.UniverseCommitments, } diff --git a/tapcfg/server.go b/tapcfg/server.go index a91d51a75..11ee0311e 100644 --- a/tapcfg/server.go +++ b/tapcfg/server.go @@ -534,10 +534,8 @@ func genServerConfig(cfg *Config, cfgLogger btclog.Logger, RuntimeID: runtimeID, EnableChannelFeatures: enableChannelFeatures, Lnd: lndServices, - ChainParams: address.ParamsForChain( - cfg.ActiveNetParams.Name, - ), - ReOrgWatcher: reOrgWatcher, + ChainParams: tapChainParams, + ReOrgWatcher: reOrgWatcher, AssetMinter: tapgarden.NewChainPlanter(tapgarden.PlanterConfig{ GardenKit: tapgarden.GardenKit{ Wallet: walletAnchor, @@ -553,6 +551,7 @@ func genServerConfig(cfg *Config, cfgLogger btclog.Logger, ProofWatcher: reOrgWatcher, UniversePushBatchSize: defaultUniverseSyncBatchSize, }, + ChainParams: tapChainParams, ProofUpdates: proofArchive, ErrChan: mainErrChan, }), diff --git a/tapdb/asset_minting.go b/tapdb/asset_minting.go index a0d54c244..075ebc0c7 100644 --- a/tapdb/asset_minting.go +++ b/tapdb/asset_minting.go @@ -127,6 +127,10 @@ type ( // NewAssetMeta wraps the params needed to insert a new asset meta on // disk. NewAssetMeta = sqlc.UpsertAssetMetaParams + + // MintAnchorUniCommitParams wraps the params needed to insert a new + // mint anchor uni commitment on disk. + MintAnchorUniCommitParams = sqlc.UpsertMintAnchorUniCommitmentParams ) // PendingAssetStore is a sub-set of the main sqlc.Querier interface that @@ -199,7 +203,8 @@ type PendingAssetStore interface { // BindMintingBatchWithTx adds the minting transaction to an existing // batch. - BindMintingBatchWithTx(ctx context.Context, arg BatchChainUpdate) error + BindMintingBatchWithTx(ctx context.Context, + arg BatchChainUpdate) (int64, error) // UpdateBatchGenesisTx updates the batch tx attached to an existing // batch. @@ -238,6 +243,16 @@ type PendingAssetStore interface { // FetchAssetMetaForAsset fetches the asset meta for a given asset. FetchAssetMetaForAsset(ctx context.Context, assetID []byte) (sqlc.FetchAssetMetaForAssetRow, error) + + // FetchMintAnchorUniCommitment fetches the mint anchor uni commitment + // for a given batch. + FetchMintAnchorUniCommitment(ctx context.Context, + batchID int32) (sqlc.MintAnchorUniCommitment, error) + + // UpsertMintAnchorUniCommitment inserts a new or updates an existing + // mint anchor uni commitment on disk. + UpsertMintAnchorUniCommitment(ctx context.Context, + arg MintAnchorUniCommitParams) (int64, error) } var ( @@ -311,6 +326,77 @@ type OptionalSeedlingFields struct { GroupAnchorID sql.NullInt64 } +// insertMintAnchorTx inserts a mint anchor transaction into the database. +func insertMintAnchorTx(ctx context.Context, q PendingAssetStore, + anchorPackage tapgarden.FundedMintAnchorPsbt, + batchKey btcec.PublicKey, genesisOutpoint wire.OutPoint) error { + + // Ensure that the genesis point is in the database. + genesisPointDbID, err := upsertGenesisPoint( + ctx, q, genesisOutpoint, + ) + if err != nil { + return fmt.Errorf("%w: %w", ErrUpsertGenesisPoint, err) + } + + var psbtBuf bytes.Buffer + err = anchorPackage.Pkt.Serialize(&psbtBuf) + if err != nil { + return fmt.Errorf("%w: %w", ErrEncodePsbt, err) + } + + rawBatchKey := batchKey.SerializeCompressed() + enableUniverseCommitments := anchorPackage.PreCommitmentOutput.IsSome() + + batchID, err := q.BindMintingBatchWithTx(ctx, BatchChainUpdate{ + RawKey: rawBatchKey, + MintingTxPsbt: psbtBuf.Bytes(), + ChangeOutputIndex: sqlInt32(anchorPackage.ChangeOutputIndex), + AssetsOutputIndex: sqlInt32(anchorPackage.AssetAnchorOutIdx), + GenesisID: sqlInt64(genesisPointDbID), + UniverseCommitments: enableUniverseCommitments, + }) + if err != nil { + return fmt.Errorf("%w: %w", ErrBindBatchTx, err) + } + + // If universe commitments are not enabled for this batch, we can + // return early. + if !enableUniverseCommitments { + return nil + } + + // At this point, universe commitments are enabled for this batch, so + // we'll insert the mint anchor uni commitment record. + preCommitOut, err := anchorPackage.PreCommitmentOutput.UnwrapOrErr( + fmt.Errorf("pre-commitment outpoint bundle not set"), + ) + if err != nil { + return err + } + + // Serialize internal key. + internalKey := preCommitOut.InternalKey.SerializeCompressed() + + // Serialize group key. + groupPubKey := preCommitOut.GroupPubKey.SerializeCompressed() + + _, err = q.UpsertMintAnchorUniCommitment( + ctx, MintAnchorUniCommitParams{ + BatchID: int32(batchID), + TxOutputIndex: int32(preCommitOut.OutIdx), + TaprootInternalKey: internalKey, + GroupKey: groupPubKey, + }, + ) + if err != nil { + return fmt.Errorf("unable to insert mint anchor uni "+ + "commitment: %w", err) + } + + return nil +} + // 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 @@ -365,31 +451,16 @@ func (a *AssetMintingStore) CommitMintingBatch(ctx context.Context, 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, + // Insert the batch transaction. + err = insertMintAnchorTx( + ctx, q, *genesisPacket, + *newBatch.BatchKey.PubKey, 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) + return fmt.Errorf("unable to insert mint "+ + "anchor tx: %w", err) } } @@ -1163,11 +1234,59 @@ func marshalMintingBatch(ctx context.Context, q PendingAssetStore, if err != nil { return nil, err } - batch.GenesisPacket = &tapsend.FundedPsbt{ - Pkt: genesisPkt, - ChangeOutputIndex: extractSqlInt32[int32]( - dbBatch.ChangeOutputIndex, - ), + + if !dbBatch.AssetsOutputIndex.Valid { + return nil, fmt.Errorf("missing asset anchor output " + + "index") + } + assetAnchorOutIdx := dbBatch.AssetsOutputIndex.Int32 + + // If the batch has universe commitments, we will retrieve + // the pre-commitment output index from the database. + var preCommitOut fn.Option[tapgarden.PreCommitmentOutput] + if dbBatch.UniverseCommitments { + res, err := q.FetchMintAnchorUniCommitment( + ctx, int32(dbBatch.BatchID), + ) + if err != nil { + return nil, fmt.Errorf("unable to fetch mint "+ + "anchor uni commitment: %w", err) + } + + // Parse the internal key from the database. + internalKey, err := btcec.ParsePubKey( + res.TaprootInternalKey, + ) + if err != nil { + return nil, fmt.Errorf("error parsing "+ + "taproot internal key: %w", err) + } + + // Parse the group public key from the database. + groupPubKey, err := btcec.ParsePubKey(res.GroupKey) + if err != nil { + return nil, fmt.Errorf("error parsing "+ + "group public key: %w", err) + } + + preCommitOut = fn.Some( + tapgarden.PreCommitmentOutput{ + OutIdx: uint32(res.TxOutputIndex), + InternalKey: *internalKey, + GroupPubKey: *groupPubKey, + }, + ) + } + + batch.GenesisPacket = &tapgarden.FundedMintAnchorPsbt{ + FundedPsbt: tapsend.FundedPsbt{ + Pkt: genesisPkt, + ChangeOutputIndex: extractSqlInt32[int32]( + dbBatch.ChangeOutputIndex, + ), + }, + AssetAnchorOutIdx: uint32(assetAnchorOutIdx), + PreCommitmentOutput: preCommitOut, } } @@ -1282,33 +1401,23 @@ func encodeOutpoint(outPoint wire.OutPoint) ([]byte, error) { // 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 { + batchKey *btcec.PublicKey, + genesisPacket tapgarden.FundedMintAnchorPsbt) 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, + // Insert the batch transaction. + err := insertMintAnchorTx( + ctx, q, genesisPacket, *batchKey, genesisOutpoint, ) if err != nil { - return fmt.Errorf("%w: %w", ErrUpsertGenesisPoint, err) + return fmt.Errorf("unable to insert mint anchor "+ + "tx: %w", err) } - return q.BindMintingBatchWithTx(ctx, BatchChainUpdate{ - RawKey: rawBatchKey, - MintingTxPsbt: psbtBuf.Bytes(), - ChangeOutputIndex: sqlInt32( - genesisPacket.ChangeOutputIndex, - ), - GenesisID: sqlInt64(genesisPointID), - }) + return nil }) } @@ -1415,7 +1524,8 @@ func fetchSeedlingGroups(ctx context.Context, q PendingAssetStore, // binds the genesis transaction (which will create the set of assets in the // batch) to the batch itself. func (a *AssetMintingStore) AddSproutsToBatch(ctx context.Context, - batchKey *btcec.PublicKey, genesisPacket *tapsend.FundedPsbt, + batchKey *btcec.PublicKey, + genesisPacket *tapgarden.FundedMintAnchorPsbt, assetRoot *commitment.TapCommitment) error { // Before we open the DB transaction below, we'll fetch the set of @@ -1444,7 +1554,8 @@ func (a *AssetMintingStore) AddSproutsToBatch(ctx context.Context, var writeTxOpts AssetStoreTxOptions return a.db.ExecTx(ctx, &writeTxOpts, func(q PendingAssetStore) error { - genesisPointID, _, err := upsertAssetsWithGenesis( + // Upsert the assets with genesis. + _, _, err := upsertAssetsWithGenesis( ctx, q, genesisOutpoint, sortedAssets, nil, ) if err != nil { @@ -1452,23 +1563,13 @@ func (a *AssetMintingStore) AddSproutsToBatch(ctx context.Context, "genesis: %w", err) } - // With all the assets inserted, we'll now update the - // corresponding batch that references all these assets with - // the genesis packet, and genesis point information. - var psbtBuf bytes.Buffer - if err := genesisPacket.Pkt.Serialize(&psbtBuf); err != nil { - return fmt.Errorf("%w: %w", ErrEncodePsbt, err) - } - err = q.BindMintingBatchWithTx(ctx, BatchChainUpdate{ - RawKey: rawBatchKey, - MintingTxPsbt: psbtBuf.Bytes(), - ChangeOutputIndex: sqlInt32( - genesisPacket.ChangeOutputIndex, - ), - GenesisID: sqlInt64(genesisPointID), - }) + // Insert the batch transaction. + err = insertMintAnchorTx( + ctx, q, *genesisPacket, *batchKey, genesisOutpoint, + ) if err != nil { - return fmt.Errorf("%w: %w", ErrBindBatchTx, err) + return fmt.Errorf("unable to insert mint anchor "+ + "tx: %w", err) } // Finally, update the batch state to BatchStateCommitted. diff --git a/tapdb/asset_minting_test.go b/tapdb/asset_minting_test.go index 8d84f4d4d..b4493210a 100644 --- a/tapdb/asset_minting_test.go +++ b/tapdb/asset_minting_test.go @@ -88,7 +88,9 @@ 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) - assertPsbtEqual(t, a.GenesisPacket, b.GenesisPacket) + assertPsbtEqual( + t, &a.GenesisPacket.FundedPsbt, &b.GenesisPacket.FundedPsbt, + ) require.Equal(t, a.RootAssetCommitment, b.RootAssetCommitment) } @@ -497,7 +499,9 @@ func TestCommitMintingBatchSeedlings(t *testing.T) { // First, we'll write a new minting batch to disk, including an // internal key and a set of seedlings. One random seedling will // be a reissuance into a specific group. - mintingBatch := tapgarden.RandSeedlingMintingBatch(t, numSeedlings) + mintingBatch := tapgarden.RandMintingBatch( + t, tapgarden.WithTotalSeedlings(numSeedlings), + ) _, randGroup, _ := addRandGroupToBatch( t, assetStore, ctx, mintingBatch.Seedlings, ) @@ -598,7 +602,9 @@ func TestCommitMintingBatchSeedlings(t *testing.T) { // Insert another normal batch into the database. We should get this // batch back if we query for the set of non-final batches. - mintingBatch = tapgarden.RandSeedlingMintingBatch(t, numSeedlings) + mintingBatch = tapgarden.RandMintingBatch( + t, tapgarden.WithTotalSeedlings(numSeedlings), + ) err = assetStore.CommitMintingBatch(ctx, mintingBatch) require.NoError(t, err) mintingBatches = noError1(t, assetStore.FetchNonFinalBatches, ctx) @@ -789,7 +795,9 @@ func TestAddSproutsToBatch(t *testing.T) { // First, we'll create a new batch, then add some sample seedlings. // One random seedling will be a reissuance into a specific group. - mintingBatch := tapgarden.RandSeedlingMintingBatch(t, numSeedlings) + mintingBatch := tapgarden.RandMintingBatch( + t, tapgarden.WithTotalSeedlings(numSeedlings), + ) _, seedlingGroups, _ := addRandGroupToBatch( t, assetStore, ctx, mintingBatch.Seedlings, ) @@ -842,7 +850,10 @@ func TestAddSproutsToBatch(t *testing.T) { // state. assertSeedlingBatchLen(t, mintingBatches, 1, 0) assertBatchState(t, mintingBatches[0], tapgarden.BatchStateCommitted) - assertPsbtEqual(t, genesisPacket, mintingBatches[0].GenesisPacket) + assertPsbtEqual( + t, &genesisPacket.FundedPsbt, + &mintingBatches[0].GenesisPacket.FundedPsbt, + ) assertAssetsEqual(t, assetRoot, mintingBatches[0].RootAssetCommitment) // We also expect that for each of the assets we created above, we're @@ -884,7 +895,9 @@ type randAssetCtx struct { func addRandAssets(t *testing.T, ctx context.Context, assetStore *AssetMintingStore, numAssets int) randAssetCtx { - mintingBatch := tapgarden.RandSeedlingMintingBatch(t, numAssets) + mintingBatch := tapgarden.RandMintingBatch( + t, tapgarden.WithTotalSeedlings(numAssets), + ) genAmt, seedlingGroups, group := addRandGroupToBatch( t, assetStore, ctx, mintingBatch.Seedlings, ) @@ -930,7 +943,7 @@ func addRandAssets(t *testing.T, ctx context.Context, batchKey: batchKey, groupKey: &group.GroupKey.GroupPubKey, groupGenAmt: genAmt, - genesisPkt: genesisPacket, + genesisPkt: &genesisPacket.FundedPsbt, assetRoot: assetRoot, merkleRoot: merkleRoot[:], scriptRoot: scriptRoot[:], @@ -981,7 +994,8 @@ func TestCommitBatchChainActions(t *testing.T) { t, mintingBatches[0], tapgarden.BatchStateBroadcast, ) assertPsbtEqual( - t, randAssetCtx.genesisPkt, mintingBatches[0].GenesisPacket, + t, randAssetCtx.genesisPkt, + &mintingBatches[0].GenesisPacket.FundedPsbt, ) assertBatchSibling(t, mintingBatches[0], randAssetCtx.tapSiblingHash) @@ -1391,7 +1405,9 @@ func TestGroupAnchors(t *testing.T) { // internal key and a set of seedlings. One random seedling will // be a reissuance into a specific group. Two other seedlings will form // a multi-asset group. - mintingBatch := tapgarden.RandSeedlingMintingBatch(t, numSeedlings) + mintingBatch := tapgarden.RandMintingBatch( + t, tapgarden.WithTotalSeedlings(numSeedlings), + ) _, seedlingGroups, _ := addRandGroupToBatch( t, assetStore, ctx, mintingBatch.Seedlings, ) @@ -1486,7 +1502,10 @@ func TestGroupAnchors(t *testing.T) { // state. assertSeedlingBatchLen(t, mintingBatches, 1, 0) assertBatchState(t, mintingBatches[0], tapgarden.BatchStateCommitted) - assertPsbtEqual(t, genesisPacket, mintingBatches[0].GenesisPacket) + assertPsbtEqual( + t, &genesisPacket.FundedPsbt, + &mintingBatches[0].GenesisPacket.FundedPsbt, + ) assertAssetsEqual(t, assetRoot, mintingBatches[0].RootAssetCommitment) // Check that the number of group anchors and members matches the batch @@ -1761,6 +1780,173 @@ func TestTapscriptTreeManager(t *testing.T) { loadTapscriptTreeChecked(t, ctx, assetStore, tree5, tree5Hash) } +// storeMintAnchorUniCommitment stores a mint anchor commitment in the DB. +func storeMintAnchorUniCommitment(t *testing.T, assetStore AssetMintingStore, + batchID int32, txOutputIndex int32, taprootInternalKey []byte, + groupKey []byte) { + + ctx := context.Background() + + var writeTxOpts AssetStoreTxOptions + upsertMintAnchorPreCommit := func(q PendingAssetStore) error { + _, err := q.UpsertMintAnchorUniCommitment( + ctx, sqlc.UpsertMintAnchorUniCommitmentParams{ + BatchID: batchID, + TxOutputIndex: txOutputIndex, + TaprootInternalKey: taprootInternalKey, + GroupKey: groupKey, + }, + ) + require.NoError(t, err) + + return nil + } + _ = assetStore.db.ExecTx(ctx, &writeTxOpts, upsertMintAnchorPreCommit) +} + +// assertMintAnchorUniCommitment is a helper function that reads a mint anchor +// commitment from the DB and asserts that it matches the expected values. +func assertMintAnchorUniCommitment(t *testing.T, assetStore AssetMintingStore, + batchID int32, txOutputIndex int32, preCommitInternalKeyBytes, + groupPubKeyBytes []byte) { + + ctx := context.Background() + readOpts := NewAssetStoreReadTx() + + var mintAnchorCommitment *sqlc.MintAnchorUniCommitment + readMintAnchorCommitment := func(q PendingAssetStore) error { + res, err := q.FetchMintAnchorUniCommitment(ctx, batchID) + require.NoError(t, err) + + mintAnchorCommitment = &res + return nil + } + _ = assetStore.db.ExecTx(ctx, &readOpts, readMintAnchorCommitment) + + // Ensure the mint anchor commitment matches the one we inserted. + require.NotNil(t, mintAnchorCommitment) + require.Equal(t, batchID, mintAnchorCommitment.BatchID) + require.Equal(t, txOutputIndex, mintAnchorCommitment.TxOutputIndex) + require.Equal( + t, preCommitInternalKeyBytes, + mintAnchorCommitment.TaprootInternalKey, + ) + require.Equal(t, groupPubKeyBytes, mintAnchorCommitment.GroupKey) +} + +// TestUpsertMintAnchorUniCommitment tests the UpsertMintAnchorUniCommitment +// FetchMintAnchorUniCommitment and SQL queries. In particular, it tests that +// upsert works correctly. +func TestUpsertMintAnchorUniCommitment(t *testing.T) { + t.Parallel() + + ctx := context.Background() + assetStore, _, _ := newAssetStore(t) + + // Create a new batch with one asset group seedling. + mintingBatch := tapgarden.RandMintingBatch( + t, tapgarden.WithTotalSeedlings(1), + ) + mintingBatch.UniverseCommitments = true + + _, _, group := addRandGroupToBatch( + t, assetStore, ctx, mintingBatch.Seedlings, + ) + + // Commit batch. + require.NoError(t, assetStore.CommitMintingBatch(ctx, mintingBatch)) + + // Retrieve the batch ID of the batch we just inserted. + var batchID int32 + readOpts := NewAssetStoreReadTx() + _ = assetStore.db.ExecTx( + ctx, &readOpts, func(q PendingAssetStore) error { + batches, err := q.AllMintingBatches(ctx) + require.NoError(t, err) + require.Len(t, batches, 1) + + batchID = int32(batches[0].BatchID) + return nil + }, + ) + + // Serialize keys into bytes for easier handling. + preCommitInternalKey := test.RandPubKey(t) + preCommitInternalKeyBytes := preCommitInternalKey.SerializeCompressed() + + groupPubKeyBytes := group.GroupPubKey.SerializeCompressed() + + // Upsert a mint anchor commitment for the batch. + txOutputIndex := int32(2) + storeMintAnchorUniCommitment( + t, *assetStore, batchID, txOutputIndex, + preCommitInternalKeyBytes, groupPubKeyBytes, + ) + + // Retrieve and inspect the mint anchor commitment we just inserted. + assertMintAnchorUniCommitment( + t, *assetStore, batchID, txOutputIndex, + preCommitInternalKeyBytes, groupPubKeyBytes, + ) + + // Upsert-ing a new taproot internal key for the same batch should + // overwrite the existing one. + internalKey2 := test.RandPubKey(t) + internalKey2Bytes := internalKey2.SerializeCompressed() + + storeMintAnchorUniCommitment( + t, *assetStore, batchID, txOutputIndex, internalKey2Bytes, + groupPubKeyBytes, + ) + + assertMintAnchorUniCommitment( + t, *assetStore, batchID, txOutputIndex, internalKey2Bytes, + groupPubKeyBytes, + ) + + // Upsert-ing a new group key for the same batch should overwrite the + // existing one. + groupPubKey2 := test.RandPubKey(t) + groupPubKey2Bytes := groupPubKey2.SerializeCompressed() + + storeMintAnchorUniCommitment( + t, *assetStore, batchID, txOutputIndex, internalKey2Bytes, + groupPubKey2Bytes, + ) + + assertMintAnchorUniCommitment( + t, *assetStore, batchID, txOutputIndex, internalKey2Bytes, + groupPubKey2Bytes, + ) +} + +// TestCommitMintingBatchSeedlings tests that we're able to properly write and +// read a base minting batch on disk. This test covers the state when a batch +// only has seedlings, without any fully formed assets. +func TestBlah(t *testing.T) { + t.Parallel() + + assetStore, _, _ := newAssetStore(t) + + ctx := context.Background() + const numSeedlings = 5 + + // First, we'll write a new minting batch to disk, including an + // internal key and a set of seedlings. One random seedling will + // be a reissuance into a specific group. + mintingBatch := tapgarden.RandMintingBatch( + t, tapgarden.WithTotalSeedlings(1), + tapgarden.WithTotalGroups([]int{1}), + tapgarden.WithUniverseCommitments(true), + ) + //_, randGroup, _ := addRandGroupToBatch( + // t, assetStore, ctx, mintingBatch.Seedlings, + //) + //_, randSiblingHash := addRandSiblingToBatch(t, mintingBatch) + err := assetStore.CommitMintingBatch(ctx, mintingBatch) + require.NoError(t, err) +} + func init() { rand.Seed(time.Now().Unix()) diff --git a/tapdb/migrations.go b/tapdb/migrations.go index abc2b3400..fbe40715f 100644 --- a/tapdb/migrations.go +++ b/tapdb/migrations.go @@ -22,7 +22,7 @@ const ( // daemon. // // NOTE: This MUST be updated when a new migration is added. - LatestMigrationVersion = 29 + LatestMigrationVersion = 30 ) // MigrationTarget is a functional option that can be passed to applyMigrations diff --git a/tapdb/sqlc/assets.sql.go b/tapdb/sqlc/assets.sql.go index 769c1ad7b..5d46b3212 100644 --- a/tapdb/sqlc/assets.sql.go +++ b/tapdb/sqlc/assets.sql.go @@ -87,25 +87,27 @@ func (q *Queries) AllInternalKeys(ctx context.Context) ([]InternalKey, error) { } const AllMintingBatches = `-- name: AllMintingBatches :many -SELECT batch_id, batch_state, minting_tx_psbt, change_output_index, genesis_id, height_hint, creation_time_unix, tapscript_sibling, key_id, raw_key, key_family, key_index +SELECT batch_id, batch_state, minting_tx_psbt, change_output_index, genesis_id, height_hint, creation_time_unix, tapscript_sibling, assets_output_index, universe_commitments, key_id, raw_key, key_family, key_index FROM asset_minting_batches JOIN internal_keys ON asset_minting_batches.batch_id = internal_keys.key_id ` type AllMintingBatchesRow struct { - BatchID int64 - BatchState int16 - MintingTxPsbt []byte - ChangeOutputIndex sql.NullInt32 - GenesisID sql.NullInt64 - HeightHint int32 - CreationTimeUnix time.Time - TapscriptSibling []byte - KeyID int64 - RawKey []byte - KeyFamily int32 - KeyIndex int32 + BatchID int64 + BatchState int16 + MintingTxPsbt []byte + ChangeOutputIndex sql.NullInt32 + GenesisID sql.NullInt64 + HeightHint int32 + CreationTimeUnix time.Time + TapscriptSibling []byte + AssetsOutputIndex sql.NullInt32 + UniverseCommitments bool + KeyID int64 + RawKey []byte + KeyFamily int32 + KeyIndex int32 } func (q *Queries) AllMintingBatches(ctx context.Context) ([]AllMintingBatchesRow, error) { @@ -126,6 +128,8 @@ func (q *Queries) AllMintingBatches(ctx context.Context) ([]AllMintingBatchesRow &i.HeightHint, &i.CreationTimeUnix, &i.TapscriptSibling, + &i.AssetsOutputIndex, + &i.UniverseCommitments, &i.KeyID, &i.RawKey, &i.KeyFamily, @@ -353,7 +357,7 @@ func (q *Queries) BindMintingBatchWithTapSibling(ctx context.Context, arg BindMi return err } -const BindMintingBatchWithTx = `-- name: BindMintingBatchWithTx :exec +const BindMintingBatchWithTx = `-- name: BindMintingBatchWithTx :one WITH target_batch AS ( SELECT batch_id FROM asset_minting_batches batches @@ -361,26 +365,34 @@ WITH target_batch AS ( ON batches.batch_id = keys.key_id WHERE keys.raw_key = $1 ) -UPDATE asset_minting_batches -SET minting_tx_psbt = $2, change_output_index = $3, genesis_id = $4 +UPDATE asset_minting_batches +SET minting_tx_psbt = $2, change_output_index = $3, assets_output_index = $4, + genesis_id = $5, universe_commitments = $6 WHERE batch_id IN (SELECT batch_id FROM target_batch) +RETURNING batch_id ` type BindMintingBatchWithTxParams struct { - RawKey []byte - MintingTxPsbt []byte - ChangeOutputIndex sql.NullInt32 - GenesisID sql.NullInt64 + RawKey []byte + MintingTxPsbt []byte + ChangeOutputIndex sql.NullInt32 + AssetsOutputIndex sql.NullInt32 + GenesisID sql.NullInt64 + UniverseCommitments bool } -func (q *Queries) BindMintingBatchWithTx(ctx context.Context, arg BindMintingBatchWithTxParams) error { - _, err := q.db.ExecContext(ctx, BindMintingBatchWithTx, +func (q *Queries) BindMintingBatchWithTx(ctx context.Context, arg BindMintingBatchWithTxParams) (int64, error) { + row := q.db.QueryRowContext(ctx, BindMintingBatchWithTx, arg.RawKey, arg.MintingTxPsbt, arg.ChangeOutputIndex, + arg.AssetsOutputIndex, arg.GenesisID, + arg.UniverseCommitments, ) - return err + var batch_id int64 + err := row.Scan(&batch_id) + return batch_id, err } const ConfirmChainAnchorTx = `-- name: ConfirmChainAnchorTx :exec @@ -1502,6 +1514,26 @@ func (q *Queries) FetchManagedUTXOs(ctx context.Context) ([]FetchManagedUTXOsRow return items, nil } +const FetchMintAnchorUniCommitment = `-- name: FetchMintAnchorUniCommitment :one +SELECT id, batch_id, tx_output_index, taproot_internal_key, group_key +FROM mint_anchor_uni_commitments +WHERE batch_id = $1 +` + +// Fetch a record from the mint_anchor_uni_commitments table by id. +func (q *Queries) FetchMintAnchorUniCommitment(ctx context.Context, batchID int32) (MintAnchorUniCommitment, error) { + row := q.db.QueryRowContext(ctx, FetchMintAnchorUniCommitment, batchID) + var i MintAnchorUniCommitment + err := row.Scan( + &i.ID, + &i.BatchID, + &i.TxOutputIndex, + &i.TaprootInternalKey, + &i.GroupKey, + ) + return i, err +} + const FetchMintingBatch = `-- name: FetchMintingBatch :one WITH target_batch AS ( -- This CTE is used to fetch the ID of a batch, based on the serialized @@ -1514,7 +1546,7 @@ WITH target_batch AS ( ON batches.batch_id = keys.key_id WHERE keys.raw_key = $1 ) -SELECT batch_id, batch_state, minting_tx_psbt, change_output_index, genesis_id, height_hint, creation_time_unix, tapscript_sibling, key_id, raw_key, key_family, key_index +SELECT batch_id, batch_state, minting_tx_psbt, change_output_index, genesis_id, height_hint, creation_time_unix, tapscript_sibling, assets_output_index, universe_commitments, key_id, raw_key, key_family, key_index FROM asset_minting_batches batches JOIN internal_keys keys ON batches.batch_id = keys.key_id @@ -1522,18 +1554,20 @@ WHERE batch_id in (SELECT batch_id FROM target_batch) ` type FetchMintingBatchRow struct { - BatchID int64 - BatchState int16 - MintingTxPsbt []byte - ChangeOutputIndex sql.NullInt32 - GenesisID sql.NullInt64 - HeightHint int32 - CreationTimeUnix time.Time - TapscriptSibling []byte - KeyID int64 - RawKey []byte - KeyFamily int32 - KeyIndex int32 + BatchID int64 + BatchState int16 + MintingTxPsbt []byte + ChangeOutputIndex sql.NullInt32 + GenesisID sql.NullInt64 + HeightHint int32 + CreationTimeUnix time.Time + TapscriptSibling []byte + AssetsOutputIndex sql.NullInt32 + UniverseCommitments bool + KeyID int64 + RawKey []byte + KeyFamily int32 + KeyIndex int32 } func (q *Queries) FetchMintingBatch(ctx context.Context, rawKey []byte) (FetchMintingBatchRow, error) { @@ -1548,6 +1582,8 @@ func (q *Queries) FetchMintingBatch(ctx context.Context, rawKey []byte) (FetchMi &i.HeightHint, &i.CreationTimeUnix, &i.TapscriptSibling, + &i.AssetsOutputIndex, + &i.UniverseCommitments, &i.KeyID, &i.RawKey, &i.KeyFamily, @@ -1557,7 +1593,7 @@ func (q *Queries) FetchMintingBatch(ctx context.Context, rawKey []byte) (FetchMi } const FetchMintingBatchesByInverseState = `-- name: FetchMintingBatchesByInverseState :many -SELECT batch_id, batch_state, minting_tx_psbt, change_output_index, genesis_id, height_hint, creation_time_unix, tapscript_sibling, key_id, raw_key, key_family, key_index +SELECT batch_id, batch_state, minting_tx_psbt, change_output_index, genesis_id, height_hint, creation_time_unix, tapscript_sibling, assets_output_index, universe_commitments, key_id, raw_key, key_family, key_index FROM asset_minting_batches batches JOIN internal_keys keys ON batches.batch_id = keys.key_id @@ -1565,18 +1601,20 @@ WHERE batches.batch_state != $1 ` type FetchMintingBatchesByInverseStateRow struct { - BatchID int64 - BatchState int16 - MintingTxPsbt []byte - ChangeOutputIndex sql.NullInt32 - GenesisID sql.NullInt64 - HeightHint int32 - CreationTimeUnix time.Time - TapscriptSibling []byte - KeyID int64 - RawKey []byte - KeyFamily int32 - KeyIndex int32 + BatchID int64 + BatchState int16 + MintingTxPsbt []byte + ChangeOutputIndex sql.NullInt32 + GenesisID sql.NullInt64 + HeightHint int32 + CreationTimeUnix time.Time + TapscriptSibling []byte + AssetsOutputIndex sql.NullInt32 + UniverseCommitments bool + KeyID int64 + RawKey []byte + KeyFamily int32 + KeyIndex int32 } func (q *Queries) FetchMintingBatchesByInverseState(ctx context.Context, batchState int16) ([]FetchMintingBatchesByInverseStateRow, error) { @@ -1597,6 +1635,8 @@ func (q *Queries) FetchMintingBatchesByInverseState(ctx context.Context, batchSt &i.HeightHint, &i.CreationTimeUnix, &i.TapscriptSibling, + &i.AssetsOutputIndex, + &i.UniverseCommitments, &i.KeyID, &i.RawKey, &i.KeyFamily, @@ -2936,6 +2976,42 @@ func (q *Queries) UpsertManagedUTXO(ctx context.Context, arg UpsertManagedUTXOPa return utxo_id, err } +const UpsertMintAnchorUniCommitment = `-- name: UpsertMintAnchorUniCommitment :one +INSERT INTO mint_anchor_uni_commitments ( + id, batch_id, tx_output_index, taproot_internal_key, group_key +) +VALUES ($1, $2, $3, $4, $5) +ON CONFLICT(batch_id, tx_output_index) DO UPDATE SET + -- The following fields are updated if a conflict occurs. + taproot_internal_key = EXCLUDED.taproot_internal_key, + group_key = EXCLUDED.group_key +RETURNING id +` + +type UpsertMintAnchorUniCommitmentParams struct { + ID int64 + BatchID int32 + TxOutputIndex int32 + TaprootInternalKey []byte + GroupKey []byte +} + +// Upsert a record into the mint_anchor_uni_commitments table. +// If a record with the same batch_id and group_key already exists, update the +// existing record. Otherwise, insert a new record. +func (q *Queries) UpsertMintAnchorUniCommitment(ctx context.Context, arg UpsertMintAnchorUniCommitmentParams) (int64, error) { + row := q.db.QueryRowContext(ctx, UpsertMintAnchorUniCommitment, + arg.ID, + arg.BatchID, + arg.TxOutputIndex, + arg.TaprootInternalKey, + arg.GroupKey, + ) + var id int64 + err := row.Scan(&id) + return id, err +} + const UpsertScriptKey = `-- name: UpsertScriptKey :one INSERT INTO script_keys ( internal_key_id, tweaked_script_key, tweak, declared_known diff --git a/tapdb/sqlc/migrations/000030_mint_anchor_uni_commitments.down.sql b/tapdb/sqlc/migrations/000030_mint_anchor_uni_commitments.down.sql new file mode 100644 index 000000000..09d43dd60 --- /dev/null +++ b/tapdb/sqlc/migrations/000030_mint_anchor_uni_commitments.down.sql @@ -0,0 +1,11 @@ +-- Drop the mint_anchor_uni_commitments table and its unique index. +DROP INDEX IF EXISTS mint_anchor_uni_commitments_unique; + +-- Drop the table mint_anchor_uni_commitments. +DROP TABLE IF EXISTS mint_anchor_uni_commitments; + +-- Drop the universe_commitments column from the asset_minting_batches table. +ALTER TABLE asset_minting_batches DROP COLUMN universe_commitments; + +-- Drop the assets output index column from the asset_minting_batches table. +ALTER TABLE asset_minting_batches DROP COLUMN assets_output_index; \ No newline at end of file diff --git a/tapdb/sqlc/migrations/000030_mint_anchor_uni_commitments.up.sql b/tapdb/sqlc/migrations/000030_mint_anchor_uni_commitments.up.sql new file mode 100644 index 000000000..512d11056 --- /dev/null +++ b/tapdb/sqlc/migrations/000030_mint_anchor_uni_commitments.up.sql @@ -0,0 +1,44 @@ +-- Add a column to the asset_minting_batches table which stores the output index +-- of the asset anchor transaction output. +ALTER TABLE asset_minting_batches ADD COLUMN assets_output_index INTEGER; + +-- Existing minting anchor transactions have exactly two outputs: the asset +-- commitment and the change output. We can therefore infer the asset anchor +-- output index from the change output index. +UPDATE asset_minting_batches +SET assets_output_index = CASE + WHEN change_output_index = 1 THEN 0 + WHEN change_output_index = 0 THEN 1 + -- If change_output_index is neither 0 nor 1, just set the asset anchor + -- output index to NULL. + ELSE NULL +END; + +-- Add a flag column which indicates if the universe commitments are enabled for +-- this minting batch. This should default to false for all existing minting +-- batches. +ALTER TABLE asset_minting_batches + ADD COLUMN universe_commitments BOOLEAN NOT NULL DEFAULT FALSE; + +-- Create a table to relate a mint batch anchor transaction to its universe +-- commitments. +CREATE TABLE IF NOT EXISTS mint_anchor_uni_commitments ( + id INTEGER PRIMARY KEY, + + -- The ID of the minting batch this universe commitment relates to. + batch_id INTEGER NOT NULL REFERENCES asset_minting_batches(batch_id), + + -- The index of the mint batch anchor transaction pre-commitment output. + tx_output_index INTEGER NOT NULL, + + -- The Taproot output internal key for the pre-commitment output. + taproot_internal_key BLOB, + + -- The asset group key associated with the universe commitment. + group_key BLOB +); + +-- Create a unique index on the mint_anchor_uni_commitments table to enforce the +-- uniqueness of (batch_id, tx_output_index) pairs. +CREATE UNIQUE INDEX mint_anchor_uni_commitments_unique + ON mint_anchor_uni_commitments (batch_id, tx_output_index); diff --git a/tapdb/sqlc/models.go b/tapdb/sqlc/models.go index a0ffe55fe..17555cec5 100644 --- a/tapdb/sqlc/models.go +++ b/tapdb/sqlc/models.go @@ -81,14 +81,16 @@ type AssetGroupWitness struct { } type AssetMintingBatch struct { - BatchID int64 - BatchState int16 - MintingTxPsbt []byte - ChangeOutputIndex sql.NullInt32 - GenesisID sql.NullInt64 - HeightHint int32 - CreationTimeUnix time.Time - TapscriptSibling []byte + BatchID int64 + BatchState int16 + MintingTxPsbt []byte + ChangeOutputIndex sql.NullInt32 + GenesisID sql.NullInt64 + HeightHint int32 + CreationTimeUnix time.Time + TapscriptSibling []byte + AssetsOutputIndex sql.NullInt32 + UniverseCommitments bool } type AssetProof struct { @@ -276,6 +278,14 @@ type ManagedUtxo struct { RootVersion sql.NullInt16 } +type MintAnchorUniCommitment struct { + ID int64 + BatchID int32 + TxOutputIndex int32 + TaprootInternalKey []byte + GroupKey []byte +} + type MssmtNode struct { HashKey []byte LHashKey []byte diff --git a/tapdb/sqlc/querier.go b/tapdb/sqlc/querier.go index ef76b396c..de2823b2d 100644 --- a/tapdb/sqlc/querier.go +++ b/tapdb/sqlc/querier.go @@ -22,7 +22,7 @@ type Querier interface { AssetsDBSizeSqlite(ctx context.Context) (int32, error) AssetsInBatch(ctx context.Context, rawKey []byte) ([]AssetsInBatchRow, error) BindMintingBatchWithTapSibling(ctx context.Context, arg BindMintingBatchWithTapSiblingParams) error - BindMintingBatchWithTx(ctx context.Context, arg BindMintingBatchWithTxParams) error + BindMintingBatchWithTx(ctx context.Context, arg BindMintingBatchWithTxParams) (int64, error) ConfirmChainAnchorTx(ctx context.Context, arg ConfirmChainAnchorTxParams) error ConfirmChainTx(ctx context.Context, arg ConfirmChainTxParams) error DeleteAllNodes(ctx context.Context, namespace string) (int64, error) @@ -76,6 +76,8 @@ type Querier interface { FetchInternalKeyLocator(ctx context.Context, rawKey []byte) (FetchInternalKeyLocatorRow, error) FetchManagedUTXO(ctx context.Context, arg FetchManagedUTXOParams) (FetchManagedUTXORow, error) FetchManagedUTXOs(ctx context.Context) ([]FetchManagedUTXOsRow, error) + // Fetch a record from the mint_anchor_uni_commitments table by id. + FetchMintAnchorUniCommitment(ctx context.Context, batchID int32) (MintAnchorUniCommitment, error) FetchMintingBatch(ctx context.Context, rawKey []byte) (FetchMintingBatchRow, error) FetchMintingBatchesByInverseState(ctx context.Context, batchState int16) ([]FetchMintingBatchesByInverseStateRow, error) FetchMultiverseRoot(ctx context.Context, namespaceRoot string) (FetchMultiverseRootRow, error) @@ -172,6 +174,10 @@ type Querier interface { UpsertGenesisPoint(ctx context.Context, prevOut []byte) (int64, error) UpsertInternalKey(ctx context.Context, arg UpsertInternalKeyParams) (int64, error) UpsertManagedUTXO(ctx context.Context, arg UpsertManagedUTXOParams) (int64, error) + // Upsert a record into the mint_anchor_uni_commitments table. + // If a record with the same batch_id and group_key already exists, update the + // existing record. Otherwise, insert a new record. + UpsertMintAnchorUniCommitment(ctx context.Context, arg UpsertMintAnchorUniCommitmentParams) (int64, error) UpsertMultiverseLeaf(ctx context.Context, arg UpsertMultiverseLeafParams) (int64, error) UpsertMultiverseRoot(ctx context.Context, arg UpsertMultiverseRootParams) (int64, error) UpsertRootNode(ctx context.Context, arg UpsertRootNodeParams) error diff --git a/tapdb/sqlc/queries/assets.sql b/tapdb/sqlc/queries/assets.sql index 1b7a03d3c..c3252bab8 100644 --- a/tapdb/sqlc/queries/assets.sql +++ b/tapdb/sqlc/queries/assets.sql @@ -530,7 +530,7 @@ JOIN internal_keys keys ON keys.key_id = batches.batch_id WHERE keys.raw_key = $1; --- name: BindMintingBatchWithTx :exec +-- name: BindMintingBatchWithTx :one WITH target_batch AS ( SELECT batch_id FROM asset_minting_batches batches @@ -538,9 +538,11 @@ WITH target_batch AS ( ON batches.batch_id = keys.key_id WHERE keys.raw_key = $1 ) -UPDATE asset_minting_batches -SET minting_tx_psbt = $2, change_output_index = $3, genesis_id = $4 -WHERE batch_id IN (SELECT batch_id FROM target_batch); +UPDATE asset_minting_batches +SET minting_tx_psbt = $2, change_output_index = $3, assets_output_index = $4, + genesis_id = $5, universe_commitments = $6 +WHERE batch_id IN (SELECT batch_id FROM target_batch) +RETURNING batch_id; -- name: BindMintingBatchWithTapSibling :exec WITH target_batch AS ( @@ -1015,3 +1017,23 @@ FROM genesis_assets assets JOIN assets_meta ON assets.meta_data_id = assets_meta.meta_id WHERE assets.asset_id = $1; + +-- Upsert a record into the mint_anchor_uni_commitments table. +-- If a record with the same batch_id and group_key already exists, update the +-- existing record. Otherwise, insert a new record. +-- name: UpsertMintAnchorUniCommitment :one +INSERT INTO mint_anchor_uni_commitments ( + id, batch_id, tx_output_index, taproot_internal_key, group_key +) +VALUES ($1, $2, $3, $4, $5) +ON CONFLICT(batch_id, tx_output_index) DO UPDATE SET + -- The following fields are updated if a conflict occurs. + taproot_internal_key = EXCLUDED.taproot_internal_key, + group_key = EXCLUDED.group_key +RETURNING id; + +-- Fetch a record from the mint_anchor_uni_commitments table by id. +-- name: FetchMintAnchorUniCommitment :one +SELECT id, batch_id, tx_output_index, taproot_internal_key, group_key +FROM mint_anchor_uni_commitments +WHERE batch_id = $1; \ No newline at end of file diff --git a/tapdb/sqlc/schemas/generated_schema.sql b/tapdb/sqlc/schemas/generated_schema.sql index ceb81fea4..c9701eef9 100644 --- a/tapdb/sqlc/schemas/generated_schema.sql +++ b/tapdb/sqlc/schemas/generated_schema.sql @@ -152,7 +152,7 @@ CREATE TABLE asset_minting_batches ( height_hint INTEGER NOT NULL, creation_time_unix TIMESTAMP NOT NULL -, tapscript_sibling BLOB); +, tapscript_sibling BLOB, assets_output_index INTEGER, universe_commitments BOOLEAN NOT NULL DEFAULT FALSE); CREATE TABLE asset_proofs ( proof_id INTEGER PRIMARY KEY, @@ -510,6 +510,22 @@ CREATE TABLE managed_utxos ( lease_expiry TIMESTAMP , root_version SMALLINT); +CREATE TABLE mint_anchor_uni_commitments ( + id INTEGER PRIMARY KEY, + + -- The ID of the minting batch this universe commitment relates to. + batch_id INTEGER NOT NULL REFERENCES asset_minting_batches(batch_id), + + -- The index of the mint batch anchor transaction pre-commitment output. + tx_output_index INTEGER NOT NULL, + + -- The Taproot output internal key for the pre-commitment output. + taproot_internal_key BLOB, + + -- The asset group key associated with the universe commitment. + group_key BLOB +); + CREATE TABLE mssmt_nodes ( -- hash_key is the hash key by which we reference all nodes. hash_key BLOB NOT NULL, diff --git a/tapgarden/batch.go b/tapgarden/batch.go index 7aa0c95cc..a2a6563fd 100644 --- a/tapgarden/batch.go +++ b/tapgarden/batch.go @@ -14,7 +14,6 @@ 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/lightningnetwork/lnd/keychain" ) @@ -51,7 +50,7 @@ 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. - GenesisPacket *tapsend.FundedPsbt + GenesisPacket *FundedMintAnchorPsbt // RootAssetCommitment is the root Taproot Asset commitment for all the // assets contained in this batch. @@ -64,6 +63,14 @@ type MintingBatch struct { // reveal for that asset, if it has one. AssetMetas AssetMetas + // UniverseCommitments is a flag that determines whether the minting + // event supports universe commitments. When set to true, the batch must + // include only assets that share the same asset group key. + // + // Universe commitments are minter-controlled, on-chain anchored + // attestations regarding the state of the universe. + UniverseCommitments bool + // mintingPubKey is the top-level Taproot output key that will be used // to commit to the Taproot Asset commitment above. mintingPubKey *btcec.PublicKey @@ -301,6 +308,196 @@ func (m *MintingBatch) HasSeedlings() bool { return len(m.Seedlings) != 0 } +// validateDelegationKey ensures that the delegation key is valid for a seedling +// being considered for inclusion in the batch. +func (m *MintingBatch) validateDelegationKey(newSeedling Seedling) error { + // If the universe commitment flag is disabled, then the delegation key + // should not be set. + if !newSeedling.UniverseCommitments { + if newSeedling.DelegationKey.IsSome() { + return fmt.Errorf("delegation key must not be set " + + "for seedling without universe commitments") + } + + // If the universe commitment flag is disabled and the + // delegation key is correctly unset, no further checks are + // needed. + return nil + } + + // At this point, we know that the universe commitment flag is enabled + // for the seedling. Therefore, the delegation key must be set. + delegationKey, err := newSeedling.DelegationKey.UnwrapOrErr( + fmt.Errorf("delegation key must be set for seedling with " + + "universe commitments"), + ) + if err != nil { + return err + } + + // validateKeyDesc is a helper function to validate a key descriptor. + validateKeyDesc := func(keyDesc keychain.KeyDescriptor) error { + if keyDesc.PubKey == nil { + return fmt.Errorf("pubkey is nil") + } + + if !keyDesc.PubKey.IsOnCurve() { + return fmt.Errorf("pubkey is not on curve") + } + + return nil + } + + // Ensure that the delegation key is valid. + err = validateKeyDesc(delegationKey) + if err != nil { + return fmt.Errorf("candidate seedling delegation "+ + "key validation failed: %w", err) + } + + // Ensure that the delegation key is the same for all seedlings in the + // batch. + for _, seedling := range m.Seedlings { + // Ensure that the delegation key matches that of the candidate + // seedling. + keyDesc, err := seedling.DelegationKey.UnwrapOrErr( + fmt.Errorf("delegation key must be set for seedling " + + "with universe commitments"), + ) + if err != nil { + return err + } + + if !delegationKey.PubKey.IsEqual(keyDesc.PubKey) { + return fmt.Errorf("delegation key mismatch") + } + } + + return nil +} + +// validateUniCommitment verifies that the seedling adheres to the universe +// commitment feature restrictions in the context of the current batch state. +func (m *MintingBatch) validateUniCommitment(newSeedling Seedling) error { + // If the batch is empty, the first seedling will set the universe + // commitment flag for the batch. + if !m.HasSeedlings() { + // If there are no seedlings in the batch, and the first + // (subject) seedling doesn't enable universe commitment, we can + // accept it without further checks. + if !newSeedling.UniverseCommitments { + return nil + } + + // At this point, the given seedling is the first to be added to + // the batch, and it has the universe commitment flag enabled. + // + // The minting batch funding step records the genesis + // transaction in the database. Additionally, the uni-commitment + // feature requires the change output to be locked, ensuring it + // can only be spent by `tapd`. Therefore, to leverage the + // uni-commitment feature, the batch must be populated with + // seedlings, with the uni-commitment flag correctly set before + // any funding attempt is made. + // + // As such, when adding the first seedling with uni-commitment + // support to the batch, it is essential to verify that the + // batch has not yet been funded. + if m.IsFunded() { + return fmt.Errorf("attempting to add first seedling " + + "with universe commitment flag enabled to " + + "funded batch") + } + + // At this point, we know the batch is empty, and the candidate + // seedling will be the first to be added. Consequently, if the + // seedling has the universe commitment flag enabled, it must + // specify a re-issuable asset group key. + if !newSeedling.EnableEmission { + return fmt.Errorf("the emission flag must be enabled " + + "for the first asset in a batch with the " + + "universe commitment flag enabled") + } + + if !newSeedling.HasGroupKey() { + return fmt.Errorf("a group key must be specified " + + "for the first seedling in the batch when " + + "the universe commitment flag is enabled") + } + + // No further checks are required for the first seedling in the + // batch. + return nil + } + + // At this stage, it is confirmed that the batch contains seedlings, and + // the universe commitment flag for the batch should have been correctly + // updated when the existing seedlings were added. + // + // Therefore, when evaluating this new candidate seedling for inclusion + // in the batch, we must ensure that its universe commitment flag state + // matches the flag state of the batch. + if m.UniverseCommitments != newSeedling.UniverseCommitments { + return fmt.Errorf("seedling universe commitment flag does " + + "not match batch") + } + + // If the universe commitment flag is disabled for both the seedling and + // the batch, no additional checks are required. + if !m.UniverseCommitments && !newSeedling.UniverseCommitments { + return nil + } + + // At this stage, the universe commitment flag is enabled for both the + // seedling and the batch, and the batch contains at least one seedling. + // + // As a result, the candidate seedling must have a group anchor that is + // already part of the batch. The group anchor must have been added to + // the batch before the candidate seedling. + if newSeedling.GroupAnchor == nil { + return fmt.Errorf("group anchor unspecified for seedling " + + "with universe commitment flag enabled") + } + + err := m.validateGroupAnchor(&newSeedling) + if err != nil { + return fmt.Errorf("group anchor validation failed: %w", err) + } + + return nil +} + +// AddSeedling adds a new seedling to the batch. +func (m *MintingBatch) AddSeedling(newSeedling Seedling) error { + // Ensure that the seedling adheres to the universe commitment feature + // restrictions in relation to the current batch state. + err := m.validateUniCommitment(newSeedling) + if err != nil { + return fmt.Errorf("seedling does not comply with universe "+ + "commitment feature: %w", err) + } + + // At this stage, the seedling has been confirmed to comply with the + // universe commitment feature restrictions. If this is the first + // seedling being added to the batch, the batch universe commitment flag + // can be set to match the seedling's flag state. + if !m.HasSeedlings() { + m.UniverseCommitments = newSeedling.UniverseCommitments + } + + // Ensure that the delegation key is valid for the seedling being + // considered for inclusion in the batch. + err = m.validateDelegationKey(newSeedling) + if err != nil { + return fmt.Errorf("delegation key validation failed: %w", err) + } + + // Add the seedling to the batch. + m.Seedlings[newSeedling.AssetName] = &newSeedling + + return nil +} + // ToMintingBatch creates a new MintingBatch from a VerboseBatch. func (v *VerboseBatch) ToMintingBatch() *MintingBatch { newBatch := v.MintingBatch.Copy() diff --git a/tapgarden/caretaker.go b/tapgarden/caretaker.go index 633c958e6..a12159ea9 100644 --- a/tapgarden/caretaker.go +++ b/tapgarden/caretaker.go @@ -412,24 +412,6 @@ func (b *BatchCaretaker) assetCultivator() { } } -// extractAnchorOutputIndex extracts the anchor output index from a funded -// genesis packet. -func extractAnchorOutputIndex(genesisPkt *tapsend.FundedPsbt) (uint32, error) { - if len(genesisPkt.Pkt.UnsignedTx.TxOut) != 2 { - return 0, fmt.Errorf("funded genesis packet has unexpected "+ - "number of outputs, expected 2 (txout_len=%d)", - len(genesisPkt.Pkt.UnsignedTx.TxOut)) - } - - anchorOutputIndex := uint32(0) - - if genesisPkt.ChangeOutputIndex == 0 { - anchorOutputIndex = 1 - } - - return anchorOutputIndex, nil -} - // extractGenesisOutpoint extracts the genesis point (the first input from the // genesis transaction). func extractGenesisOutpoint(tx *wire.MsgTx) wire.OutPoint { @@ -612,18 +594,12 @@ func (b *BatchCaretaker) stateStep(currentState BatchState) (BatchState, error) 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, err = extractAnchorOutputIndex( - b.cfg.Batch.GenesisPacket, - ) - if err != nil { - return 0, err - } + + // Unpack output indexes. + genesisPacket := b.cfg.Batch.GenesisPacket + + changeOutputIndex := genesisPacket.ChangeOutputIndex + b.anchorOutputIndex = genesisPacket.AssetAnchorOutIdx genesisPoint := extractGenesisOutpoint(genesisTxPkt.UnsignedTx) @@ -671,10 +647,15 @@ func (b *BatchCaretaker) stateStep(currentState BatchState) (BatchState, error) log.Infof("BatchCaretaker(%x): committing sprouts to disk", b.batchKey[:]) - fundedGenesisPsbt := tapsend.FundedPsbt{ - Pkt: genesisTxPkt, - ChangeOutputIndex: changeOutputIndex, + fundedGenesisPsbt := FundedMintAnchorPsbt{ + FundedPsbt: tapsend.FundedPsbt{ + Pkt: genesisTxPkt, + ChangeOutputIndex: changeOutputIndex, + }, + AssetAnchorOutIdx: b.anchorOutputIndex, + PreCommitmentOutput: genesisPacket.PreCommitmentOutput, } + // With all our commitments created, we'll commit them to disk, // replacing the existing seedlings we had created for each of // these assets. @@ -801,8 +782,9 @@ func (b *BatchCaretaker) stateStep(currentState BatchState) (BatchState, error) err = b.cfg.Log.CommitSignedGenesisTx( ctx, b.cfg.Batch.BatchKey.PubKey, - b.cfg.Batch.GenesisPacket, b.anchorOutputIndex, - merkleRoot, tapCommitmentRoot[:], siblingBytes, + &b.cfg.Batch.GenesisPacket.FundedPsbt, + b.anchorOutputIndex, merkleRoot, tapCommitmentRoot[:], + siblingBytes, ) if err != nil { return 0, fmt.Errorf("unable to commit genesis "+ diff --git a/tapgarden/interface.go b/tapgarden/interface.go index d2d3fbb8a..9a972fe67 100644 --- a/tapgarden/interface.go +++ b/tapgarden/interface.go @@ -234,7 +234,7 @@ type MintingStore interface { // NOTE: The BatchState should transition to BatchStateCommitted upon a // successful call. AddSproutsToBatch(ctx context.Context, batchKey *btcec.PublicKey, - genesisPacket *tapsend.FundedPsbt, + genesisPacket *FundedMintAnchorPsbt, assets *commitment.TapCommitment) error // CommitSignedGenesisTx adds a fully signed genesis transaction to the @@ -288,7 +288,7 @@ type MintingStore interface { // 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 + genesisTx FundedMintAnchorPsbt) 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 94cfa9712..2de935973 100644 --- a/tapgarden/mock.go +++ b/tapgarden/mock.go @@ -21,6 +21,7 @@ import ( "github.com/btcsuite/btcwallet/waddrmgr" "github.com/lightninglabs/lndclient" "github.com/lightninglabs/taproot-assets/asset" + "github.com/lightninglabs/taproot-assets/fn" "github.com/lightninglabs/taproot-assets/internal/test" "github.com/lightninglabs/taproot-assets/proof" "github.com/lightninglabs/taproot-assets/tapscript" @@ -56,21 +57,217 @@ func RandSeedlings(t testing.TB, numSeedlings int) map[string]*Seedling { return seedlings } -// 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: BatchKey, - Seedlings: RandSeedlings(t, numSeedlings), - HeightHint: test.RandInt[uint32](), - CreationTime: time.Now(), - GenesisPacket: &tapsend.FundedPsbt{ - Pkt: &genesisTx, - ChangeOutputIndex: 1, +// RandGroupSeedlings generates a random set of seedlings for a single asset +// group. +func RandGroupSeedlings(t testing.TB, numSeedlings int, + uniCommitments bool) map[string]*Seedling { + + seedlings := make(map[string]*Seedling) + + // Formulate group anchor seedling. + metaBlob := test.RandBytes(32) + groupAnchorName := hex.EncodeToString(test.RandBytes(32)) + assetType := asset.Normal + + // For now, we only test the v0 and v1 versions. + assetVersion := asset.Version(test.RandIntn(2)) + + scriptKey, _ := test.RandKeyDesc(t) + + // If universe commitments are enabled, we generate a random key + // descriptor to use as the delegation key. + var delegationKey fn.Option[keychain.KeyDescriptor] + if uniCommitments { + keyDesc, _ := test.RandKeyDesc(t) + delegationKey = fn.Some(keyDesc) + } + + assetGenesis := asset.RandGenesis(t, assetType) + + // Create asset group key. + groupPrivateDesc, groupPrivateKey := test.RandKeyDesc(t) + + // Generate the signature for our group genesis asset. + genSigner := asset.NewMockGenesisSigner(groupPrivateKey) + genTxBuilder := asset.MockGroupTxBuilder{} + + genProtoAsset := asset.RandAssetWithValues( + t, assetGenesis, nil, asset.RandScriptKey(t), + ) + groupKeyRequest := asset.NewGroupKeyRequestNoErr( + t, groupPrivateDesc, fn.None[asset.ExternalKey](), assetGenesis, + genProtoAsset, nil, fn.None[chainhash.Hash](), + ) + genTx, err := groupKeyRequest.BuildGroupVirtualTx(&genTxBuilder) + require.NoError(t, err) + + groupKey, err := asset.DeriveGroupKey( + genSigner, *genTx, *groupKeyRequest, nil, + ) + require.NoError(t, err) + + seedlings[groupAnchorName] = &Seedling{ + AssetVersion: assetVersion, + AssetType: assetType, + AssetName: groupAnchorName, + Meta: &proof.MetaReveal{ + Data: metaBlob, + }, + Amount: uint64(test.RandInt[uint32]()), + GroupInfo: &asset.AssetGroup{ + Genesis: &assetGenesis, + GroupKey: groupKey, }, + ScriptKey: asset.NewScriptKeyBip86(scriptKey), + EnableEmission: true, + UniverseCommitments: uniCommitments, + DelegationKey: delegationKey, + } + + // Formulate non-anchor group seedlings. + for i := 0; i < numSeedlings-1; i++ { + seedlingName := hex.EncodeToString(test.RandBytes(32)) + + seedlings[groupAnchorName] = &Seedling{ + AssetVersion: assetVersion, + AssetType: assetType, + AssetName: seedlingName, + GroupAnchor: &groupAnchorName, + Meta: &proof.MetaReveal{ + Data: metaBlob, + }, + Amount: uint64(test.RandInt[uint32]()), + ScriptKey: asset.NewScriptKeyBip86(scriptKey), + EnableEmission: true, + UniverseCommitments: uniCommitments, + } } + + return seedlings +} + +// MintBatchOptions is a set of options for creating a new minting batch. +type MintBatchOptions struct { + // totalSeedlings specifies the number of seedlings to generate in this + // minting batch. The seedlings are randomly assigned as grouped or + // ungrouped. + totalSeedlings int + + // totalGroups specifies the number of asset groups to generate in this + // minting batch. Each element in the slice specifies the number of + // seedlings to generate for the corresponding asset group. + totalGroups []int + + // universeCommitments specifies whether to generate universe + // commitments for the asset groups in this minting batch. + universeCommitments bool +} + +// MintBatchOption is a functional option for creating a new minting batch. +type MintBatchOption func(*MintBatchOptions) + +// DefaultMintBatchOptions returns a new set of default minting batch options. +func DefaultMintBatchOptions() MintBatchOptions { + return MintBatchOptions{} +} + +// WithTotalSeedlings sets the total number of seedlings to populate in the +// minting batch. +func WithTotalSeedlings(count int) MintBatchOption { + return func(options *MintBatchOptions) { + options.totalSeedlings = count + } +} + +// WithTotalGroups sets the total number of asset groups to populate in the +// minting batch. Each element in the slice specifies the number of seedlings +// to generate for the corresponding asset group. +func WithTotalGroups(counts []int) MintBatchOption { + return func(options *MintBatchOptions) { + options.totalGroups = counts + } +} + +// WithUniverseCommitments specifies whether to generate universe commitments +// for the asset groups in the minting batch. +func WithUniverseCommitments(enabled bool) MintBatchOption { + return func(options *MintBatchOptions) { + options.universeCommitments = enabled + } +} + +// RandMintingBatch creates a new minting batch with only random seedlings +// populated for testing. +func RandMintingBatch(t testing.TB, opts ...MintBatchOption) *MintingBatch { + // Construct options. + options := DefaultMintBatchOptions() + for _, opt := range opts { + opt(&options) + } + + // Formulate batch seedlings. + seedlings := make(map[string]*Seedling) + + // Generate seedlings for each asset group. + for idx := range options.totalGroups { + countSeedlingsInGroup := options.totalGroups[idx] + + groupSeedlings := RandGroupSeedlings( + t, countSeedlingsInGroup, options.universeCommitments, + ) + + // Add the seedlings to the total seedlings map. + for name, seedling := range groupSeedlings { + seedlings[name] = seedling + } + } + + // If the total number of seedlings generated so far is less than the + // total number of seedlings requested, we generate the remaining + // seedlings at random. + if len(seedlings) < options.totalSeedlings { + remaining := options.totalSeedlings - len(seedlings) + randSeedlings := RandSeedlings(t, remaining) + + // Add the seedlings to the total seedlings map. + for name, seedling := range randSeedlings { + seedlings[name] = seedling + } + } + + // Randomly generating seedlings may result in overlaps with existing + // ones, leading to fewer seedlings than intended. Sanity check to + // ensure that the total number of seedlings generated matches the + // requested amount. This check might help debug flakes in tests. + require.Equal(t, options.totalSeedlings, len(seedlings)) + + batchKey, _ := test.RandKeyDesc(t) + batch := MintingBatch{ + BatchKey: batchKey, + Seedlings: seedlings, + HeightHint: test.RandInt[uint32](), + CreationTime: time.Now(), + UniverseCommitments: options.universeCommitments, + } + + walletFundPsbt := func(ctx context.Context, + anchorPkt psbt.Packet) (tapsend.FundedPsbt, error) { + + FundGenesisTx(&anchorPkt, chainfee.FeePerKwFloor) + + return tapsend.FundedPsbt{ + Pkt: &anchorPkt, + ChangeOutputIndex: 1, + }, nil + } + + // Fund genesis packet. + ctx := context.Background() + fundedPsbt, err := fundGenesisPsbt(ctx, &batch, walletFundPsbt) + require.NoError(t, err) + + batch.GenesisPacket = &fundedPsbt + return &batch } type MockWalletAnchor struct { @@ -182,6 +379,10 @@ func (m *MockWalletAnchor) FundPsbt(_ context.Context, packet *psbt.Packet, packet.UnsignedTx.AddTxOut(&changeOutput) packet.Outputs = append(packet.Outputs, psbt.POutput{}) + // The change output was added last, so it will be the last output in + // the list. Update the change index to reflect this. + changeIdx = int32(len(packet.Outputs) - 1) + // 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{ diff --git a/tapgarden/planter.go b/tapgarden/planter.go index 435ecbb01..d9b4fa442 100644 --- a/tapgarden/planter.go +++ b/tapgarden/planter.go @@ -14,8 +14,10 @@ import ( "github.com/btcsuite/btcd/btcutil/psbt" "github.com/btcsuite/btcd/chaincfg" "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcd/wire" "github.com/davecgh/go-spew/spew" + "github.com/lightninglabs/taproot-assets/address" "github.com/lightninglabs/taproot-assets/asset" "github.com/lightninglabs/taproot-assets/fn" "github.com/lightninglabs/taproot-assets/proof" @@ -23,6 +25,7 @@ import ( "github.com/lightninglabs/taproot-assets/tapscript" "github.com/lightninglabs/taproot-assets/tapsend" "github.com/lightninglabs/taproot-assets/universe" + lfn "github.com/lightningnetwork/lnd/fn" "github.com/lightningnetwork/lnd/lnwallet/chainfee" "golang.org/x/exp/maps" ) @@ -83,6 +86,11 @@ type GardenKit struct { type PlanterConfig struct { GardenKit + // ChainParams defines the chain parameters for the target blockchain + // network. It specifies whether the network is Bitcoin mainnet or + // testnet. + ChainParams address.ChainParams + // ProofUpdates is the storage backend for updated proofs. ProofUpdates proof.Archiver @@ -663,56 +671,236 @@ func (c *ChainPlanter) newBatch() (*MintingBatch, error) { 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) { +// preCommitmentOutput creates the pre-commitment output for a batch that uses +// universe commitments. +func preCommitmentOutput(pendingBatch *MintingBatch) (PreCommitmentOutput, + error) { + + var zero PreCommitmentOutput + + // Ensure that a pending batch is provided. + if pendingBatch == nil { + return zero, fmt.Errorf("no pending batch provided when " + + "creating pre-commitment output") + } + + // Ensure that the universe commitments feature is enabled for the + // batch. + if !pendingBatch.UniverseCommitments { + return zero, fmt.Errorf("code error: universe commitments " + + "should be enabled before calling " + + "preCommitmentOutput") + } + + // Ensure that the batch has at least one seedling. + if len(pendingBatch.Seedlings) == 0 { + return zero, fmt.Errorf("uni commitment enabled for funded " + + "batch but no seedlings in batch") + } + + // Retrieve batch anchor seedling. + var groupAnchorSeedling *Seedling + for _, seedling := range pendingBatch.Seedlings { + if seedling.GroupAnchor == nil { + groupAnchorSeedling = seedling + break + } + + groupAnchorSeedling = + pendingBatch.Seedlings[*seedling.GroupAnchor] + break + } + + // Ensure that the group anchor seedling is found. + if groupAnchorSeedling == nil { + return zero, fmt.Errorf("no group anchor seedling found") + } + + // Extract delegated key from the group anchor seedling. + delegationKey, err := groupAnchorSeedling.DelegationKey.UnwrapOrErr( + fmt.Errorf("no delegation key found in seedling"), + ) + if err != nil { + return zero, err + } + + // Extract group pub key from group anchor seedling. + if groupAnchorSeedling.GroupInfo == nil { + return zero, fmt.Errorf("no group info found in seedling") + } + groupPubKey := groupAnchorSeedling.GroupInfo.GroupPubKey - log.Infof("Attempting to fund batch: %x", batchKey) + // Use a placeholder output index for the pre-commitment output. This + // will be revised after funding. + var placeholderOutIdx uint32 = 0 - // Construct a 1-output TX as a template for our genesis TX, which the - // backing wallet will fund. + // Formulate the pre-commitment output bundle. + preCommitOut := NewPreCommitmentOutput( + placeholderOutIdx, *delegationKey.PubKey, groupPubKey, + ) + return preCommitOut, nil +} + +// unfundedAnchorPsbt creates an unfunded PSBT packet for the minting anchor +// transaction. +func unfundedAnchorPsbt(preCommitmentTxOut fn.Option[wire.TxOut]) (psbt.Packet, + error) { + + var zero psbt.Packet + + // Construct a template transaction for our minting anchor transaction. txTemplate := wire.NewMsgTx(2) + + // Add one output to anchor all assets which are being minted. txTemplate.AddTxOut(tapsend.CreateDummyOutput()) + + // If universe commitments are enabled, we add an output to the + // transaction which will be used as the pre-commitment output. + // This output is spent by the universe commitment transaction. + preCommitmentTxOut.WhenSome(func(txOut wire.TxOut) { + txTemplate.AddTxOut(&txOut) + }) + + // Formulate the PSBT packet from the template transaction. genesisPkt, err := psbt.NewFromUnsignedTx(txTemplate) if err != nil { - return nil, fmt.Errorf("unable to make psbt packet: %w", err) + return zero, fmt.Errorf("unable to make psbt packet: %w", err) + } + + return *genesisPkt, nil +} + +// AnchorTxOutputIndexes specifies the output indexes of the batch mint anchor +// transaction. +type AnchorTxOutputIndexes struct { + // AssetAnchorOutIdx is the index of the asset anchor output in the + // transaction. + AssetAnchorOutIdx uint32 + + // ChangeOutIdx is the index of the change output in the transaction. + ChangeOutIdx uint32 + + // PreCommitOutIdx is the index of the pre-commitment output in the + // transaction. This field is only set if universe commitments are + // enabled for the batch. + PreCommitOutIdx fn.Option[uint32] +} + +// anchorTxOutputIndexes specifies the output indexes of the anchor transaction. +func anchorTxOutputIndexes(fundedPsbt tapsend.FundedPsbt, + preCommitmentTxOut fn.Option[wire.TxOut]) (AnchorTxOutputIndexes, + error) { + + var ( + zero AnchorTxOutputIndexes + + // assetAnchorOutIdxOpt will contain the index of the asset + // anchor output in the transaction. + assetAnchorOutIdxOpt fn.Option[uint32] + + // preCommitOutIdx will contain the index of the pre-commitment + // output in the transaction. This field is only + // set if universe commitments are enabled for the batch. + preCommitOutIdx fn.Option[uint32] + ) + + // Formulate the expected asset anchor output that we will use to + // identify the asset anchor output in the transaction. + expectedAssetAnchorOutput := tapsend.CreateDummyOutput() + expectedAssetAnchorPkScript := expectedAssetAnchorOutput.PkScript + + // Inspect each output in the transaction to determine the output + // indexes. + for idx := range fundedPsbt.Pkt.UnsignedTx.TxOut { + // Skip the change output based on its index. + if int32(idx) == fundedPsbt.ChangeOutputIndex { + continue + } + + // We will inspect the output script pubkey to determine whether + // it is the asset anchor output or the pre-commitment output. + txOut := fundedPsbt.Pkt.UnsignedTx.TxOut[idx] + + // If the output script pubkey matches the expected asset anchor + // output script pubkey, we have found the asset anchor output. + if bytes.Equal(txOut.PkScript, expectedAssetAnchorPkScript) { + assetAnchorOutIdxOpt = fn.Some(uint32(idx)) + continue + } + + // If universe commitments are enabled, we will inspect the + // output script pubkey to determine whether it is the + // pre-commitment output. + preCommitmentTxOut.WhenSome( + func(preCommitTxOut wire.TxOut) { + // If the output script pubkey matches the + // pre-commitment output script pubkey, we have + // found the pre-commitment output. + outputMatch := bytes.Equal( + txOut.PkScript, preCommitTxOut.PkScript, + ) + if outputMatch { + preCommitOutIdx = fn.Some(uint32(idx)) + } + }, + ) + } + + // Unpack the asset anchor output index. Return an error if the output + // index is not found. + assetAnchorOutIdx, err := assetAnchorOutIdxOpt.UnwrapOrErr( + fmt.Errorf("asset anchor output index not found"), + ) + if err != nil { + return zero, err } - log.Infof("creating skeleton PSBT for batch: %x", batchKey) - log.Tracef("PSBT: %v", spew.Sdump(genesisPkt)) + // If the pre-commitment output is expected, but not found, we return an + // error. + if preCommitmentTxOut.IsSome() && !preCommitOutIdx.IsSome() { + return zero, fmt.Errorf("pre-commitment output index not found") + } + + return AnchorTxOutputIndexes{ + AssetAnchorOutIdx: assetAnchorOutIdx, + ChangeOutIdx: uint32(fundedPsbt.ChangeOutputIndex), + PreCommitOutIdx: preCommitOutIdx, + }, nil +} +// anchorTxFeeRate computes the fee rate for the anchor transaction. If a fee +// rate is manually assigned for the batch, it is used. Otherwise, the fee rate +// is estimated based on the current network conditions. +func (c *ChainPlanter) anchorTxFeeRate(ctx context.Context, + manualFeeRate *chainfee.SatPerKWeight) (chainfee.SatPerKWeight, error) { + + // Compute the anchor transaction fee rate. 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(), + log.Infof("using manual fee rate for batch: %s, %d sat/vB", + feeRate.String(), feeRate.FeePerKVByte()/1000) default: - feeRate, err = c.cfg.ChainBridge.EstimateFee( + feeRate, err := c.cfg.ChainBridge.EstimateFee( ctx, GenesisConfTarget, ) if err != nil { - return nil, fmt.Errorf("unable to estimate fee: %w", + return 0, fmt.Errorf("unable to estimate fee: %w", err) } - log.Infof("estimated fee rate for batch: %x, %s", - batchKey[:], feeRate.FeePerKVByte().String()) + log.Infof("estimated fee rate for batch: %s", + feeRate.FeePerKVByte().String()) } minRelayFee, err := c.cfg.Wallet.MinRelayFee(ctx) if err != nil { - return nil, fmt.Errorf("unable to obtain minrelayfee: %w", err) + return 0, fmt.Errorf("unable to obtain minrelayfee: %w", err) } // If the fee rate is below the minimum relay fee, we'll @@ -725,28 +913,134 @@ func (c *ChainPlanter) fundGenesisPsbt(ctx context.Context, // This case should already have been handled by the // `checkFeeRateSanity` of `rpcserver.go`. We check here // again to be safe. - return nil, fmt.Errorf("feerate does not meet "+ + return 0, fmt.Errorf("feerate does not meet "+ "minrelayfee: (fee_rate=%s, minrelayfee=%s)", feeRate.String(), minRelayFee.String()) default: - log.Infof("Bump fee rate for batch %x to meet "+ - "minrelayfee from %s to %s", batchKey[:], + log.Infof("Bump fee rate for batch to meet "+ + "minrelayfee from %s to %s", feeRate.String(), minRelayFee.String()) feeRate = minRelayFee } } - fundedGenesisPkt, err := c.cfg.Wallet.FundPsbt( - ctx, genesisPkt, 1, feeRate, -1, - ) + return feeRate, nil +} + +// WalletFundPsbt is a function that funds a PSBT packet. +type WalletFundPsbt = func(ctx context.Context, + anchorPkt psbt.Packet) (tapsend.FundedPsbt, error) + +// 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 fundGenesisPsbt(ctx context.Context, pendingBatch *MintingBatch, + walletFundPsbt WalletFundPsbt) (FundedMintAnchorPsbt, error) { + + var zero FundedMintAnchorPsbt + + // If universe commitments are enabled, we formulate a pre-commitment + // output. This output is spent by the universe commitment transaction. + var preCommitmentOut fn.Option[PreCommitmentOutput] + if pendingBatch != nil && pendingBatch.UniverseCommitments { + out, err := preCommitmentOutput(pendingBatch) + if err != nil { + return zero, fmt.Errorf("unable to create "+ + "pre-commitment output: %w", err) + } + + preCommitmentOut = fn.Some(out) + } + + // Derive wire.TxOut from the pre-commitment output, if available. + var preCommitmentTxOut fn.Option[wire.TxOut] + if preCommitmentOut.IsSome() { + txOut, err := fn.MapOptionZ( + preCommitmentOut, + func(p PreCommitmentOutput) lfn.Result[wire.TxOut] { + txOut, err := p.TxOut() + return lfn.NewResult(txOut, err) + }, + ).Unpack() + if err != nil { + return zero, err + } + + preCommitmentTxOut = fn.Some(txOut) + } + + // Construct an unfunded anchor PSBT which will eventually become a + // funded minting anchor transaction. + genesisPkt, err := unfundedAnchorPsbt(preCommitmentTxOut) if err != nil { - return nil, fmt.Errorf("unable to fund psbt: %w", err) + return zero, fmt.Errorf("unable to create anchor template tx: "+ + "%w", err) + } + log.Tracef("Unfunded batch anchor PSBT: %v", spew.Sdump(genesisPkt)) + + fundedGenesisPkt, err := walletFundPsbt(ctx, genesisPkt) + if err != nil { + return zero, fmt.Errorf("unable to fund psbt: %w", err) + } + + // Sanity check the funded PSBT. + if fundedGenesisPkt.ChangeOutputIndex == -1 { + return zero, fmt.Errorf("undefined change output index in " + + "funded anchor transaction") } - log.Infof("Funded GenesisPacket for batch: %x", batchKey) log.Tracef("GenesisPacket: %v", spew.Sdump(fundedGenesisPkt)) - return fundedGenesisPkt, nil + // Classify anchor transaction output indexes. + anchorOutIndexes, err := anchorTxOutputIndexes( + fundedGenesisPkt, preCommitmentTxOut, + ) + if err != nil { + return zero, fmt.Errorf("unable to determine output indexes: "+ + "%w", err) + } + + // Sanity check that the pre-commitment output index was found if + // expected. + if preCommitmentOut.IsSome() && + anchorOutIndexes.PreCommitOutIdx.IsNone() { + + return zero, fmt.Errorf("pre-commitment output index not found") + } + + // If pre-commitment output is some, assign the output index to the + // pre-commitment output. + if preCommitmentOut.IsSome() { + // Ensure that a pre-commitment output index is found. + outIdx, err := anchorOutIndexes.PreCommitOutIdx.UnwrapOrErr( + fmt.Errorf("pre-commitment output index not found"), + ) + if err != nil { + return zero, err + } + + // Assign output index to the pre-commitment output. + preCommitmentOut = fn.MapOption( + func(out PreCommitmentOutput) PreCommitmentOutput { + out.OutIdx = outIdx + return out + }, + )(preCommitmentOut) + } + + // Formulate a funded minting anchor PSBT from the funded PSBT. + fundedMintAnchorPsbt, err := NewFundedMintAnchorPsbt( + fundedGenesisPkt, anchorOutIndexes, preCommitmentOut, + ) + if err != nil { + return zero, fmt.Errorf("unable to create funded minting "+ + "anchor PSBT: %w", err) + } + + return fundedMintAnchorPsbt, nil } // filterSeedlingsWithGroup separates a set of seedlings into two sets based on @@ -1037,12 +1331,13 @@ func filterFinalizedBatches(batches []*MintingBatch) ([]*MintingBatch, func fetchFinalizedBatch(ctx context.Context, batchStore MintingStore, archiver proof.Archiver, batch *MintingBatch) (*MintingBatch, error) { + if batch.GenesisPacket == nil { + return nil, fmt.Errorf("batch is missing anchor tx packet") + } + // Collect genesis TX information from the batch to build the proof // locators. - anchorOutputIndex, err := extractAnchorOutputIndex(batch.GenesisPacket) - if err != nil { - return nil, err - } + anchorOutputIndex := batch.GenesisPacket.AssetAnchorOutIdx signedTx, err := psbt.Extract(batch.GenesisPacket.Pkt) if err != nil { @@ -1279,12 +1574,7 @@ func newVerboseBatch(currentBatch *MintingBatch, // Before we can build the group key requests for each seedling, we must // fetch the genesis point and anchor index for the batch. - anchorOutputIndex, err := extractAnchorOutputIndex( - currentBatch.GenesisPacket, - ) - if err != nil { - return nil, err - } + anchorOutputIndex := currentBatch.GenesisPacket.AssetAnchorOutIdx genesisPoint := extractGenesisOutpoint( currentBatch.GenesisPacket.Pkt.UnsignedTx, @@ -1700,15 +1990,15 @@ func (c *ChainPlanter) fundBatch(ctx context.Context, params FundParams, workingBatch *MintingBatch) error { var ( - feeRate *chainfee.SatPerKWeight - rootHash *chainhash.Hash - err error + manualFeeRate *chainfee.SatPerKWeight + rootHash *chainhash.Hash + err error ) // 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. - feeRate = params.FeeRate.UnwrapToPtr() + manualFeeRate = params.FeeRate.UnwrapToPtr() params.SiblingTapTree.WhenSome(func(tn asset.TapscriptTreeNodes) { rootHash, err = c.cfg.TreeStore.StoreTapscriptTree(ctx, tn) }) @@ -1726,14 +2016,42 @@ func (c *ChainPlanter) fundBatch(ctx context.Context, params FundParams, } // Fund the batch with the specified fee rate. + feeRate, err := c.anchorTxFeeRate(ctx, manualFeeRate) + if err != nil { + return fmt.Errorf("unable to determine anchor TX "+ + "fee rate: %w", err) + } + batchKey := asset.ToSerialized(batch.BatchKey.PubKey) - batchTX, err := c.fundGenesisPsbt(ctx, batchKey, feeRate) + + // walletFundPsbt is a closure that will be used to fund the + // batch with the specified fee rate. + walletFundPsbt := func(ctx context.Context, + anchorPkt psbt.Packet) (tapsend.FundedPsbt, error) { + + var zero tapsend.FundedPsbt + + fundedPkt, err := c.cfg.Wallet.FundPsbt( + ctx, &anchorPkt, 1, feeRate, -1, + ) + if err != nil { + return zero, err + } + + return *fundedPkt, nil + } + + log.Infof("Attempting to fund batch: %x", batchKey) + mintAnchorTx, err := fundGenesisPsbt( + ctx, c.pendingBatch, walletFundPsbt, + ) if err != nil { return fmt.Errorf("unable to fund minting PSBT for "+ "batch: %x %w", batchKey[:], err) } - batch.GenesisPacket = batchTX + log.Infof("Funded GenesisPacket for batch: %x", batchKey) + batch.GenesisPacket = &mintAnchorTx return nil } @@ -1781,7 +2099,7 @@ func (c *ChainPlanter) fundBatch(ctx context.Context, params FundParams, } err = c.cfg.Log.CommitBatchTx( - ctx, workingBatch.BatchKey.PubKey, workingBatch.GenesisPacket, + ctx, workingBatch.BatchKey.PubKey, *workingBatch.GenesisPacket, ) if err != nil { return err @@ -1862,12 +2180,7 @@ func (c *ChainPlanter) sealBatch(ctx context.Context, params SealParams, // Before we can build the group key requests for each seedling, we must // fetch the genesis point and anchor index for the batch. - anchorOutputIndex, err := extractAnchorOutputIndex( - workingBatch.GenesisPacket, - ) - if err != nil { - return nil, err - } + anchorOutputIndex := workingBatch.GenesisPacket.AssetAnchorOutIdx genesisPoint := extractGenesisOutpoint( workingBatch.GenesisPacket.Pkt.UnsignedTx, @@ -2245,12 +2558,93 @@ func (c *ChainPlanter) CancelBatch() (*btcec.PublicKey, error) { return <-req.resp, <-req.err } +// prepSeedlingDelegationKey finalizes the seedling delegation key. +func (c *ChainPlanter) prepSeedlingDelegationKey(ctx context.Context, + req *Seedling) error { + + // If the universe commitments feature is disabled for this seedling, + // we can skip any further delegation key considerations. + if !req.UniverseCommitments { + return nil + } + + // At this point, we know that the universe commitments feature is + // enabled for the seedling. If a group anchor seedling is specified + // we will use its delegation key. + if req.GroupAnchor != nil { + // Retrieve the group anchor seedling from the pending batch. + anchorSeedlingName := *req.GroupAnchor + + anchor, ok := c.pendingBatch.Seedlings[anchorSeedlingName] + if anchor == nil || !ok { + return fmt.Errorf("group anchor seedling not present "+ + "in batch (anchor_seedling_name=%s)", + anchorSeedlingName) + } + + if anchor.DelegationKey.IsNone() { + return fmt.Errorf("group anchor seedling has no "+ + "delegation key (anchor_seedling_name=%s)", + anchorSeedlingName) + } + + // Set the delegation key for the seedling to the delegation key + // of the group anchor seedling. + req.DelegationKey = anchor.DelegationKey + + // Return early, no further seedling prep required for universe + // commitments feature. + return nil + } + + // On the other hand, if we're handling the group anchor seedling, we + // and the delegation key is unset, we must generate a new one. + if req.EnableEmission && req.GroupAnchor == nil { + newKey, err := c.cfg.KeyRing.DeriveNextKey( + ctx, asset.TaprootAssetsKeyFamily, + ) + if err != nil { + return fmt.Errorf("unable to derive pre-commitment "+ + "output key: %w", err) + } + + req.DelegationKey = fn.Some(newKey) + } + + return nil +} + // prepAssetSeedling performs some basic validation for the Seedling, then // either adds it to an existing pending batch or creates a new batch for it. func (c *ChainPlanter) prepAssetSeedling(ctx context.Context, req *Seedling) error { - // First, we'll perform some basic validation for the seedling. + // Finalise the seedling delegation key. + err := c.prepSeedlingDelegationKey(ctx, req) + if err != nil { + return err + } + + // Set seedling asset metadata fields. + req.Meta.UniverseCommitments = req.UniverseCommitments + + if req.DelegationKey.IsSome() { + keyDesc, err := req.DelegationKey.UnwrapOrErr( + fmt.Errorf("delegation key is not set"), + ) + if err != nil { + return err + } + + if keyDesc.PubKey == nil { + return fmt.Errorf("delegation key has no public key") + } + + req.Meta.DelegationKey = fn.Some(*keyDesc.PubKey) + } + + // We will perform basic validation on the seedling, including metadata + // validation. if err := req.validateFields(); err != nil { return err } @@ -2363,30 +2757,40 @@ func (c *ChainPlanter) prepAssetSeedling(ctx context.Context, return err } - log.Infof("Adding %v to new MintingBatch", req) + c.pendingBatch = newBatch - newBatch.Seedlings[req.AssetName] = req + log.Infof("Attempting to add a seedling to a new batch "+ + "(seedling=%v)", req) + + err = c.pendingBatch.AddSeedling(*req) + if err != nil { + return fmt.Errorf("failed to add seedling to batch: %w", + err) + } ctx, cancel := c.WithCtxQuit() defer cancel() - err = c.cfg.Log.CommitMintingBatch(ctx, newBatch) + err = c.cfg.Log.CommitMintingBatch(ctx, c.pendingBatch) if err != nil { return err } - c.pendingBatch = newBatch - // A batch already exists, so we'll add this seedling to the batch, // committing it to disk fully before we move on. case c.pendingBatch != nil: - log.Infof("Adding %v to existing MintingBatch", req) + log.Infof("Attempting to add a seedling to batch (seedling=%v)", + req) - c.pendingBatch.Seedlings[req.AssetName] = req + err := c.pendingBatch.AddSeedling(*req) + if err != nil { + return fmt.Errorf("failed to add seedling to batch: %w", + err) + } // Now that we know the seedling is ok, we'll write it to disk. ctx, cancel := c.WithCtxQuit() defer cancel() - err := c.cfg.Log.AddSeedlingsToBatch( + err = c.cfg.Log.AddSeedlingsToBatch( ctx, c.pendingBatch.BatchKey.PubKey, req, ) if err != nil { @@ -2559,3 +2963,117 @@ var _ Planter = (*ChainPlanter)(nil) // A compile-time assertion to make sure BatchCaretaker satisfies the // fn.EventPublisher interface. var _ fn.EventPublisher[fn.Event, bool] = (*ChainPlanter)(nil) + +// PreCommitmentOutput provides metadata related to the pre-commitment output +// of a mint anchor transaction. This output serves as an intermediate step +// before being spent by the universe commitment transaction. +type PreCommitmentOutput struct { + // OutIdx specifies the index of the pre-commitment output within the + // batch mint anchor transaction. + OutIdx uint32 + + // InternalKey is the Taproot internal public key associated with the + // pre-commitment output. + InternalKey btcec.PublicKey + + // GroupPubKey is the asset group public key associated with this + // pre-commitment output. + GroupPubKey btcec.PublicKey +} + +// NewPreCommitmentOutput creates a new PreCommitmentOutput instance. +func NewPreCommitmentOutput(outIdx uint32, internalKey, + groupPubKey btcec.PublicKey) PreCommitmentOutput { + + return PreCommitmentOutput{ + OutIdx: outIdx, + InternalKey: internalKey, + GroupPubKey: groupPubKey, + } +} + +// TxOut returns the pre-commitment output as a wire.TxOut instance. +func (p *PreCommitmentOutput) TxOut() (wire.TxOut, error) { + var zero wire.TxOut + + // Formulate a taproot output key from the taproot internal key. + taprootOutputKey := txscript.ComputeTaprootKeyNoScript(&p.InternalKey) + + // Create a new pay-to-taproot pk script from the taproot output key. + pkScript, err := txscript.PayToTaprootScript(taprootOutputKey) + if err != nil { + return zero, fmt.Errorf("unable to create pre-commitment "+ + "output pk script: %w", err) + } + + // Return the minting anchor transaction pre-commitment output. + return wire.TxOut{ + Value: int64(tapsend.DummyAmtSats), + PkScript: pkScript, + }, nil +} + +// FundedMintAnchorPsbt is a struct that contains a funded minting anchor +// transaction PSBT. +type FundedMintAnchorPsbt struct { + // FundedPsbt is the PSBT packet that has been funded by the wallet. + tapsend.FundedPsbt + + // AssetAnchorOutIdx is the index of the asset anchor output in the + // transaction. + AssetAnchorOutIdx uint32 + + // PreCommitmentOutput contains metadata describing the pre-commitment + // output. + // + // This field is set only if the pre-commitment output exists in the + // transaction. The pre-commitment output is later spent by the universe + // commitment transaction. + PreCommitmentOutput fn.Option[PreCommitmentOutput] +} + +// NewFundedMintAnchorPsbt creates a new funded minting anchor PSBT package from +// a funded PSBT. +func NewFundedMintAnchorPsbt( + fundedPsbt tapsend.FundedPsbt, anchorOutIndexes AnchorTxOutputIndexes, + preCommitOut fn.Option[PreCommitmentOutput]) (FundedMintAnchorPsbt, + error) { + + var zero FundedMintAnchorPsbt + + // Sanity check pre-commitment output arguments. + if anchorOutIndexes.PreCommitOutIdx.IsSome() != preCommitOut.IsSome() { + return zero, fmt.Errorf("pre-commitment output index and " + + "pre-commitment output must be both set or both unset") + } + + return FundedMintAnchorPsbt{ + FundedPsbt: fundedPsbt, + AssetAnchorOutIdx: anchorOutIndexes.AssetAnchorOutIdx, + PreCommitmentOutput: preCommitOut, + }, nil +} + +// Copy creates a deep copy of FundedMintAnchorPsbt. +func (f *FundedMintAnchorPsbt) Copy() *FundedMintAnchorPsbt { + newMintAnchorPsbt := &FundedMintAnchorPsbt{ + FundedPsbt: tapsend.FundedPsbt{ + ChangeOutputIndex: f.ChangeOutputIndex, + ChainFees: f.ChainFees, + LockedUTXOs: fn.CopySlice(f.LockedUTXOs), + }, + AssetAnchorOutIdx: f.AssetAnchorOutIdx, + PreCommitmentOutput: f.PreCommitmentOutput, + } + + if f.Pkt != nil { + newMintAnchorPsbt.Pkt = &psbt.Packet{ + UnsignedTx: f.Pkt.UnsignedTx.Copy(), + Inputs: fn.CopySlice(f.Pkt.Inputs), + Outputs: fn.CopySlice(f.Pkt.Outputs), + Unknowns: fn.CopySlice(f.Pkt.Unknowns), + } + } + + return newMintAnchorPsbt +} diff --git a/tapgarden/planter_test.go b/tapgarden/planter_test.go index a5c0905c0..52275699f 100644 --- a/tapgarden/planter_test.go +++ b/tapgarden/planter_test.go @@ -156,6 +156,7 @@ func (t *mintingTestHarness) refreshChainPlanter() { ProofFiles: t.proofFiles, ProofWatcher: t.proofWatcher, }, + ChainParams: *chainParams, ProofUpdates: t.proofFiles, ErrChan: t.errChan, }) @@ -1147,14 +1148,14 @@ func testBasicAssetCreation(t *mintingTestHarness) { // Now that the planter is back up, a single caretaker should have been // launched as well. The batch should already be funded. batch := t.fetchSingleBatch(nil) - t.assertBatchGenesisTx(batch.GenesisPacket) + t.assertBatchGenesisTx(&batch.GenesisPacket.FundedPsbt) t.assertNumCaretakersActive(1) // We'll now force yet another restart to ensure correctness of the // state machine. We expect the PSBT packet to still be funded. t.refreshChainPlanter() batch = t.fetchSingleBatch(nil) - t.assertBatchGenesisTx(batch.GenesisPacket) + t.assertBatchGenesisTx(&batch.GenesisPacket.FundedPsbt) // Now that the batch has been ticked, and the caretaker started, there // should no longer be a pending batch. @@ -1250,7 +1251,7 @@ func testMintingTicker(t *mintingTestHarness) { // that the batch is already funded. t.assertBatchProgressing() currentBatch := t.fetchLastBatch() - t.assertBatchGenesisTx(currentBatch.GenesisPacket) + t.assertBatchGenesisTx(¤tBatch.GenesisPacket.FundedPsbt) // Now that the batch has been ticked, and the caretaker started, there // should no longer be a pending batch. @@ -1351,7 +1352,7 @@ func testMintingCancelFinalize(t *mintingTestHarness) { t.assertBatchProgressing() thirdBatch = t.fetchLastBatch() - t.assertBatchGenesisTx(thirdBatch.GenesisPacket) + t.assertBatchGenesisTx(&thirdBatch.GenesisPacket.FundedPsbt) // Now that the batch has been ticked, and the caretaker started, there // should no longer be a pending batch. @@ -1760,7 +1761,7 @@ func testFundSealBeforeFinalize(t *mintingTestHarness) { fundedEmptyBatch := fundedBatches[0] require.Len(t, fundedEmptyBatch.Seedlings, 0) require.NotNil(t, fundedEmptyBatch.GenesisPacket) - t.assertBatchGenesisTx(fundedEmptyBatch.GenesisPacket) + t.assertBatchGenesisTx(&fundedEmptyBatch.GenesisPacket.FundedPsbt) require.Equal(t, defaultTapHash[:], fundedEmptyBatch.TapSibling()) require.True(t, fundedEmptyBatch.State() == tapgarden.BatchStatePending) diff --git a/tapgarden/seedling.go b/tapgarden/seedling.go index 6394234ef..214e742fe 100644 --- a/tapgarden/seedling.go +++ b/tapgarden/seedling.go @@ -102,6 +102,10 @@ type Seedling struct { // attestations regarding the state of the universe. UniverseCommitments bool + // DelegationKey is the public key that is used to verify universe + // commitment related on-chain outputs and proofs. + DelegationKey fn.Option[keychain.KeyDescriptor] + // GroupAnchor is the name of another seedling in the pending batch that // will anchor an asset group. This seedling will be minted with the // same group key as the anchor asset. @@ -233,15 +237,16 @@ func validateAnchorMeta(seedlingMeta *proof.MetaReveal, anchorUniverseCommitments = true } - if seedlingUniverseCommitments != anchorUniverseCommitments { - return fmt.Errorf("seedling universe commitments flag does "+ - "not match group anchor: %v, %v", - seedlingUniverseCommitments, anchorUniverseCommitments) + // If the anchor asset has universe commitment feature turned on, then + // the same must be true for the seedling. + if anchorUniverseCommitments && !seedlingUniverseCommitments { + return fmt.Errorf("seedling universe commitments flag is " + + "false but must be true since the group anchor's " + + "flag is true") } // For now, we simply require a delegation key to be set when universe - // commitments are turned on. In the future, we could allow this to be - // empty and the group internal key to be used for signing. + // commitments are turned on. if seedlingUniverseCommitments && seedlingMeta.DelegationKey.IsNone() { return fmt.Errorf("delegation key must be set for universe " + "commitments flag")